Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cloud-notices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

FTW Cloud tells every owner's phone when someone gains access to the box, asks for a change, is about to lose access, can now approve access, or pairs as an owner, with the readable copy on or off. The box's `notice` names the client on `agent.added` only while the copy is on and carries events, and these kinds no longer also go to FTW Cloud as events, so an owner gets one alert and a viewer none. The `agent.added` push now says access was given, not who gave it.
2 changes: 1 addition & 1 deletion contract/push-catalogue.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ events:
# the release check access to the box. Access from outside the house is
# news every owner should hear, as with cloud.granted.
title: New access to your box
body: "You gave an assistant or Sourceful access. See who has access in the app."
body: "An assistant or Sourceful was given access to your box. See who has access in the app."
- kind: agent.request
# To every owner, when an agent asks for a durable change. It waits for
# one of them to confirm it, and runs out after twelve hours.
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0008-ftw-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ rules, the app asks again. A public launch needs C2–C4 and the legal work.
| Signatures cost data on a 4G box, about 150 MB a month | Measure in C2 |
| The network sees message sizes and timing | The privacy notice |
| With the copy off, the service sees from the tunnel's timing when the box reads its devices while a phone watches | The privacy notice; a frame goes on every poll whether or not a value moved, frames keep their size, and a hidden session keeps its 5 s |
| Apple sees the home id, event codes and Live Activity state | The privacy notice; no names in arguments; the M7 spike |
| Apple sees the home id, event codes and Live Activity state, and an agent's client name in `agent.added` while the copy carries events | The privacy notice; no device or person's names in event arguments; a notice carries only its kind and that name; the M7 spike |
| Claims or defaults drift back | Claim tests; a missing grant reads as off |
| A paid app meets App Store purchase rules | Decide the payment path before charging |
| AGPL code in the App Store | An App Store exception in `ftw-app` before outside code arrives |
15 changes: 11 additions & 4 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -682,10 +682,17 @@ old one signed has expired.

These are security notices. They go whatever the owner's notification
settings say, through the box's own transports, and to FTW Cloud as a
`notice {kind}` that carries nothing but the kind, whenever the link is up,
copy or not (docs/protocol-cloud.md). FTW Cloud turns a notice into a push
to the home's phones; until it does, it drops the class, as it drops any
box message it does not know.
`notice {kind}` whenever the link is up, copy or not (docs/protocol-cloud.md).
A notice carries nothing but the kind, except that `agent.added` names the
client while the readable copy is on and carries events. FTW Cloud sends
each notice, as the box signed it, in an alert to the home's owner devices
only, never to a viewer or a watch. These kinds never go to FTW Cloud as an
`event`, so an owner hears of each change once through it.

FTW Cloud knows each agent's client name whatever the copy: the roster lists
it in the agent row (`principal.client`), and the name came from its own
consent page. The copy decides only whether the name also goes to Apple in a
notice.

## The audit log

Expand Down
34 changes: 25 additions & 9 deletions docs/protocol-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -737,11 +737,21 @@ record cannot turn a withdrawn consent back on.
| Field | Type | Meaning |
|---|---|---|
| `kind` | text | One of `agent.added`, `agent.request`, `agent.expiring`, `approval.key.set`, `owner.added` |
| `client` | text, optional | On `agent.added` only, while the readable copy is on and its grant has `ftw.events.read`: the client's name as FTW Cloud's consent page showed it: at most 65 characters (the page keeps 64 and marks a cut name with "…"), and no control or format character. Absent otherwise |

A security notice: someone gained access, asks for a change, is about to
lose access, or can now approve access. The service sends each owner device
registered for alerts a push with no data beyond the kind, which the phone
words from its catalogue. See [Agents](agents.md).
lose access, or can now approve access. The service checks that the body
holds nothing beyond the header, `kind` and `client` (`cloudwire.ReadNotice`)
and sends each owner device registered for alerts the notice as it arrived,
in an `alert` (see [Push](#push)). The phone words it from its catalogue. A
notice of these kinds never goes as an `event`, so an owner gets one alert,
not two; a phone shows an event of these kinds as the generic line. A notice
states a lasting fact, so Apple may hold it and the phone shows it for a day
(`NoticeMaxAgeMs`), where an event lasts 10 minutes. The box keeps at most 16
notices for the uplink and counts any it drops (`noticesDropped` in
`GET /api/cloud`). The name in `client` is not new to the service: the
roster's agent row carries it whatever the copy (`principal.client`); the
copy decides only whether it also reaches Apple. See [Agents](agents.md).

**`bye`** `{why, purge}`. `why` is `revoked`, `shutdown` or `update`.
`purge: true` asks the service to delete the home. `why: "update"` holds
Expand Down Expand Up @@ -804,7 +814,7 @@ as it decides for the same session over the relay.
`live`, `lp` or `event`.
- **Byte budget.** The box counts the bytes it sent over a rolling 24 hours
of uptime. Past 20 MB, four times the measured 5 MB, it sends only `event`,
`roster`, `stats.withdraw` and `bye` until the count falls. It sends no
`notice`, `roster`, `stats.withdraw` and `bye` until the count falls. It sends no
`hb` then, so data on phones ages to stale rather than looking current.
`GET /api/cloud` shows the count and whether the budget is reached.
- **`tun` has its own queues and budget.** `tun` waits for the socket apart
Expand Down Expand Up @@ -1175,7 +1185,7 @@ through the relay instead, and prices and the plan wait for the phone.

| Type | Payload | Phone behaviour |
|---|---|---|
| `alert` | `aps` with the generic title "FTW" and `mutable-content: 1`, plus `home` and the `event` envelope in base64 | The extension checks the signature and drops an already seen `(boot, seq)`. If the event is anchored and at most 10 minutes old, it renders text from the app's catalogue copy and picks the category, and so the buttons, from the verified `kind`. Otherwise it shows a generic line with no buttons |
| `alert` | `aps` with the generic title "FTW" and `mutable-content: 1`, plus `home` and, in its `event` member, the box's `event` or `notice` envelope in base64 | The extension checks the signature and drops an already seen `(boot, seq)`. If the event is anchored and at most 10 minutes old, or the notice at most a day, it renders text from the app's catalogue copy and picks the category, and so the buttons, from the verified `kind`. A notice reads as an event of its kind, with `client` as its one argument. Otherwise it shows a generic line with no buttons |
| `widgets` | Content changed, no data | Widgets fetch with their token |
| `liveactivity` | `home`, the `lp` envelope and the newest `hb` envelope, in base64 | The view shows numbers only if both signatures check, the `hb` covers that `lp`, and the charger source grades `live` by the freshness rules; otherwise "Open FTW". `staleDate` is set to when that grade would lapse |
| `box.unreachable` | Generic, from the service | Sent after 600 s without the box's socket. No box signature, no arguments, no buttons |
Expand All @@ -1185,15 +1195,21 @@ app's APNs topic. A watch and its phone get byte-identical alert payloads, so
Apple shows one alert when both are near.

Each push goes only to devices whose effective scope covers what it carries:
`alert` needs `ftw.events.read`, and `widgets` and `liveactivity` need
`ftw.live.read`. Viewers therefore get no alerts. `box.unreachable` goes to
devices registered for `alert`. The service sends `widgets` when tomorrow's
an event's `alert` needs `ftw.events.read`, and `widgets` and `liveactivity`
need `ftw.live.read`. Viewers therefore get no alerts. `box.unreachable` goes
where an event would. A notice's `alert` goes to every owner device
registered for `alert`, with the readable copy on or off, and to no viewer
and no watch token. An owner may therefore register for `alert` with the copy
off; until the copy carries events, it gets notices only. Notices have their
own hourly cap per home, apart from events, so neither crowds out the other. The service sends `widgets` when tomorrow's
prices arrive and when a car is plugged in. It derives no category, collapse
id or priority from an event's kind. How often a Live Activity can refresh
within Apple's budget is the iOS plan's M7 spike.

**What Apple sees.** Every alert payload carries the home id, the event kind,
its arguments and the box's `boot`, `seq` and send time. A Live Activity
its arguments and the box's `boot`, `seq` and send time. A notice's carries
the home id, the notice kind and the same header, and on `agent.added` the
client's name only while the copy carries events. A Live Activity
payload carries the whole `lp` envelope: charge level, power, ready time and
energy delivered. ADR 0008 lists this as a risk.

Expand Down
20 changes: 11 additions & 9 deletions go/cmd/ftw/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,9 @@ type agentPorts struct {
// home is FTW Cloud's home id for this box, bound once the link exists.
home func() string
// notice sends FTW Cloud a security notice for the owners' phones,
// bound once the link exists. It goes copy or not.
notice func(kind string) bool
// bound once the link exists. It goes copy or not; the link decides
// whether client goes with it.
notice func(kind, client string) bool

// Filled in by startAppLink.
access *appuplink.AgentAccess
Expand All @@ -138,10 +139,11 @@ type agentPorts struct {
}

// push tells every owner about a change to who may reach the box: through
// the box's own transports, and as a data-free notice through FTW Cloud.
func (p *agentPorts) push(kind string) {
// the box's own transports, and as a notice through FTW Cloud. client names
// the client given access, on agent.added.
func (p *agentPorts) push(kind, client string) {
if p.notice != nil {
p.notice(kind)
p.notice(kind, client)
}
if p.notices == nil {
return
Expand Down Expand Up @@ -348,7 +350,7 @@ func (p *agentPorts) warnExpiring(nowMs int64) {
key := row.ID + ":" + strconv.FormatInt(row.ExpiresAtMs, 10)
if row.ExpiresAtMs-nowMs <= 24*60*60*1000 && !p.warned[key] {
p.warned[key] = true
p.push(protocol.PushAgentExpiring)
p.push(protocol.PushAgentExpiring, "")
}
}
}
Expand Down Expand Up @@ -441,7 +443,7 @@ func (b boxPageAgents) ReplaceApprovalKey(by, deviceID string, pub []byte) error
_, err := b.p.access.Enroll.ReplaceApprovalKey(deviceID, pub)
b.record(by, protocol.MsgApprovalKeySet, deviceID, err)
if err == nil {
b.p.push(protocol.PushApprovalKeySet)
b.p.push(protocol.PushApprovalKeySet, "")
}
return err
}
Expand All @@ -468,7 +470,7 @@ func (b boxPageAgents) AddAgent(by string, m protocol.AgentAdd) (protocol.AgentR
row, err := b.p.access.Enroll.AddAgent(appenroll.AtTheBoxPage, m)
b.record(by, protocol.MsgAgentAdd, row.ID, err)
if err == nil {
b.p.push(protocol.PushAgentAdded)
b.p.push(protocol.PushAgentAdded, row.Principal.Client)
}
return row, err
}
Expand All @@ -480,7 +482,7 @@ func (b boxPageAgents) WidenAgent(by string, m protocol.AgentWiden) (protocol.Ag
if !slices.Contains(row.Ops, protocol.OpLoadpointHold) || !slices.Contains(row.Scopes, protocol.ScopeDispatchHold) {
b.p.access.EndHolds(row.ID)
}
b.p.push(protocol.PushAgentAdded)
b.p.push(protocol.PushAgentAdded, row.Principal.Client)
}
return row, err
}
Expand Down
48 changes: 48 additions & 0 deletions go/cmd/ftw/agents_notice_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package main

import (
"bytes"
"path/filepath"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/apiauth"
"github.com/srcfl/ftw/go/internal/appenroll"
"github.com/srcfl/ftw/go/internal/appuplink"
"github.com/srcfl/ftw/go/protocol"
)

// An agent added or widened at the box page reaches FTW Cloud as an
// agent.added notice that names its client; the link decides whether the
// name leaves the box.
func TestAnAgentAddedAtTheBoxPageNamesItsClient(t *testing.T) {
enroll, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "box.key"))
if err != nil {
t.Fatal(err)
}
now := time.UnixMilli(1_792_000_000_000)
clock := func() (time.Time, bool) { return now, true }
enroll.SetClock(clock)
type sent struct{ kind, client string }
var notices []sent
p := &agentPorts{access: &appuplink.AgentAccess{Enroll: enroll, Clock: clock},
notice: func(kind, client string) bool { notices = append(notices, sent{kind, client}); return true }}

row, err := boxPageAgents{p}.AddAgent("owner", protocol.AgentAdd{
Key: bytes.Repeat([]byte{7}, 32), Role: apiauth.RoleAgent, Scopes: []string{protocol.ScopeLiveRead},
ExpiresAtMs: now.UnixMilli() + time.Hour.Milliseconds(),
Principal: protocol.Principal{Client: "Claude", ClientID: "https://claude.ai/oauth/mcp-oauth-client-metadata"}})
if err != nil {
t.Fatal(err)
}
if len(notices) != 1 || notices[0] != (sent{protocol.PushAgentAdded, "Claude"}) {
t.Fatalf("notices %+v", notices)
}
if _, err := (boxPageAgents{p}).WidenAgent("owner", protocol.AgentWiden{ID: row.ID, Epoch: row.Epoch,
Scopes: []string{protocol.ScopeHistoryRead, protocol.ScopeLiveRead}, ExpiresAtMs: row.ExpiresAtMs}); err != nil {
t.Fatal(err)
}
if len(notices) != 2 || notices[1] != (sent{protocol.PushAgentAdded, "Claude"}) {
t.Fatalf("a widened grant: notices %+v", notices)
}
}
2 changes: 1 addition & 1 deletion go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,7 @@ func startAppLink(
agentWall.load(st)
enroll.SetClock(agentClock)
agents.key = agentKey(enroll)
enroll.OnOwnerAdded(func() { agents.push(protocol.PushOwnerAdded) })
enroll.OnOwnerAdded(func() { agents.push(protocol.PushOwnerAdded, "") })
if !enabled {
return enroll, nil, false, nil
}
Expand Down
11 changes: 6 additions & 5 deletions go/internal/appproto/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ type Access interface {
EndHolds(agentID string)
// Wall is the wall clock in ms, and whether NTP has set it.
Wall() (int64, bool)
// Notify pushes a catalogue kind to every owner.
Notify(kind string)
// Notify pushes a catalogue kind to every owner. client names the
// client given access, on agent.added, and is empty otherwise.
Notify(kind, client string)
}

// AgentViews builds the typed reads' bodies: FTW Cloud's class bodies, with
Expand Down Expand Up @@ -486,7 +487,7 @@ func (h *Handler) onAgentRequest(env Envelope, grant Enrolment) error {
return refuse(refusal.Code, refusal.Args)
}
h.audit(env.By, env.T, m.Op+" "+req.ReqID, "ok")
h.cfg.Access.Notify(protocol.PushAgentRequest)
h.cfg.Access.Notify(protocol.PushAgentRequest, "")
return h.agentOK(env.ID, map[string]any{"reqId": req.ReqID, "expiresAtMs": req.ExpiresAtMs})
}

Expand Down Expand Up @@ -526,7 +527,7 @@ func (h *Handler) onApprovalKeySet(env Envelope) error {
return h.refuseErr(env.ID, err)
}
// A new key is a new way to let others in: every owner hears of it.
h.cfg.Access.Notify(protocol.PushApprovalKeySet)
h.cfg.Access.Notify(protocol.PushApprovalKeySet, "")
return h.agentOK(env.ID, map[string]any{"deviceId": h.selfID(), "setAtMs": setAt})
}

Expand Down Expand Up @@ -570,7 +571,7 @@ func (h *Handler) onAgentWiden(env Envelope) error {
if !slices.Contains(row.Ops, OpLoadpointHold) || !slices.Contains(row.Scopes, protocol.ScopeDispatchHold) {
h.cfg.Access.EndHolds(row.ID)
}
h.cfg.Access.Notify(protocol.PushAgentAdded)
h.cfg.Access.Notify(protocol.PushAgentAdded, row.Principal.Client)
return h.agentOK(env.ID, map[string]any{"agent": row})
}

Expand Down
2 changes: 1 addition & 1 deletion go/internal/appproto/agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func (a *testAccess) SetApprovalKey(d string, m protocol.ApprovalKeySet) (int64,
return a.id.SetApprovalKey(d, m)
}
func (a *testAccess) Wall() (int64, bool) { return a.wall() }
func (a *testAccess) Notify(kind string) { a.notified = append(a.notified, kind) }
func (a *testAccess) Notify(kind, _ string) { a.notified = append(a.notified, kind) }

// enrolGrants reads one row the way appuplink's liveGrant does.
type enrolGrants struct {
Expand Down
13 changes: 7 additions & 6 deletions go/internal/appuplink/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ type AgentAccess struct {
Enroll *appenroll.Identity
// Drop ends every session a removed key holds. Nil ends none.
Drop func(key []byte)
// Push sends a catalogue kind to every owner. Nil sends nothing.
Push func(kind string)
// Push sends a catalogue kind to every owner, with the client given
// access on agent.added. Nil sends nothing.
Push func(kind, client string)
// Clock is the wall clock and whether NTP has set it. The same clock
// enrolment expires agent rows by.
Clock func() (time.Time, bool)
Expand All @@ -30,7 +31,7 @@ func approver(deviceID string) appenroll.Approver { return appenroll.Approver{De
func (a *AgentAccess) AddAgent(deviceID string, m protocol.AgentAdd) (protocol.AgentRow, error) {
row, err := a.Enroll.AddAgent(approver(deviceID), m)
if err == nil {
a.notify(protocol.PushAgentAdded)
a.notify(protocol.PushAgentAdded, row.Principal.Client)
}
return row, err
}
Expand Down Expand Up @@ -104,10 +105,10 @@ func (a *AgentAccess) Wall() (int64, bool) {
return now.UnixMilli(), ok
}

func (a *AgentAccess) Notify(kind string) { a.notify(kind) }
func (a *AgentAccess) Notify(kind, client string) { a.notify(kind, client) }

func (a *AgentAccess) notify(kind string) {
func (a *AgentAccess) notify(kind, client string) {
if a.Push != nil {
a.Push(kind)
a.Push(kind, client)
}
}
Loading
Loading