From ec5db08b31813eb6896d91e0a382faf09237b8d7 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 09:39:34 +0200 Subject: [PATCH 1/3] feat(cloud): security notices for the owners' phones, with the client's name only while the copy carries events The box's notice may name the client on agent.added while the readable copy is on and its grant has events. cloudwire.ReadNotice refuses a body with anything beyond the header, kind and client, and ReadAlert reads a signed notice in an alert as an event of its kind. The notice kinds no longer go to FTW Cloud as events, so an owner gets one alert and a viewer none. Push vectors gain six notice cases for the app to replay. Co-Authored-By: Claude Opus 5.5 --- .changeset/cloud-notices.md | 5 ++ docs/agents.md | 10 ++- docs/protocol-cloud.md | 28 +++++-- go/cmd/ftw/agents.go | 20 +++-- go/cmd/ftw/agents_notice_test.go | 48 +++++++++++ go/cmd/ftw/app_link.go | 2 +- go/internal/appproto/agents.go | 11 +-- go/internal/appproto/agents_test.go | 2 +- go/internal/appuplink/agents.go | 13 +-- go/internal/cloudlink/events.go | 42 ++++++++-- go/internal/cloudlink/link.go | 2 +- go/internal/cloudlink/tunnel_test.go | 77 +++++++++++++++-- go/protocol/cloudwire/allowlist.go | 6 +- go/protocol/cloudwire/messages.go | 45 +++++++++- go/protocol/cloudwire/notice_test.go | 43 ++++++++++ go/protocol/cloudwire/push.go | 29 +++++-- go/protocol/cloudwire/push_vectors_test.go | 83 ++++++++++++++++--- .../cloudwire/testdata/push-vectors.json | 75 +++++++++++++++++ 18 files changed, 471 insertions(+), 70 deletions(-) create mode 100644 .changeset/cloud-notices.md create mode 100644 go/cmd/ftw/agents_notice_test.go create mode 100644 go/protocol/cloudwire/notice_test.go diff --git a/.changeset/cloud-notices.md b/.changeset/cloud-notices.md new file mode 100644 index 00000000..d9e84f64 --- /dev/null +++ b/.changeset/cloud-notices.md @@ -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. diff --git a/docs/agents.md b/docs/agents.md index 8afe6d7d..497357e0 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -677,10 +677,12 @@ 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. ## The audit log diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index ce450287..260bf321 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -592,11 +592,15 @@ 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 64 bytes. 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. 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 @@ -659,7 +663,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 @@ -1030,7 +1034,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 or notice 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`. 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 | @@ -1040,15 +1044,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. diff --git a/go/cmd/ftw/agents.go b/go/cmd/ftw/agents.go index f59de4bb..03ccd015 100644 --- a/go/cmd/ftw/agents.go +++ b/go/cmd/ftw/agents.go @@ -117,8 +117,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 @@ -134,10 +135,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 @@ -334,7 +336,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, "") } } } @@ -417,7 +419,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 } @@ -444,7 +446,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 } @@ -456,7 +458,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 } diff --git a/go/cmd/ftw/agents_notice_test.go b/go/cmd/ftw/agents_notice_test.go new file mode 100644 index 00000000..b2f4efb0 --- /dev/null +++ b/go/cmd/ftw/agents_notice_test.go @@ -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) + } +} diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index a5335ef9..7e96b543 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -672,7 +672,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 } diff --git a/go/internal/appproto/agents.go b/go/internal/appproto/agents.go index 9dd0f342..bdcdb9c6 100644 --- a/go/internal/appproto/agents.go +++ b/go/internal/appproto/agents.go @@ -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 @@ -483,7 +484,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}) } @@ -523,7 +524,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}) } @@ -567,7 +568,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}) } diff --git a/go/internal/appproto/agents_test.go b/go/internal/appproto/agents_test.go index d5087bb8..004a271f 100644 --- a/go/internal/appproto/agents_test.go +++ b/go/internal/appproto/agents_test.go @@ -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 { diff --git a/go/internal/appuplink/agents.go b/go/internal/appuplink/agents.go index 27cdd55d..597d10ae 100644 --- a/go/internal/appuplink/agents.go +++ b/go/internal/appuplink/agents.go @@ -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) @@ -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 } @@ -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) } } diff --git a/go/internal/cloudlink/events.go b/go/internal/cloudlink/events.go index 64f60ac5..f5f36732 100644 --- a/go/internal/cloudlink/events.go +++ b/go/internal/cloudlink/events.go @@ -4,7 +4,10 @@ import ( "encoding/base64" "errors" "slices" + "strings" "time" + "unicode" + "unicode/utf8" "github.com/srcfl/ftw/go/protocol" "github.com/srcfl/ftw/go/protocol/cloudwire" @@ -51,9 +54,13 @@ var nameArgs = map[string]bool{"name": true} var errClosed = errors.New("cloudlink: the box is shutting down") // QueueEvent keeps an event for the uplink to sign and send. It never -// blocks. False means FTW Cloud will not carry it: off, being revoked, or a -// grant that leaves events out. +// blocks. False means FTW Cloud will not carry it: off, being revoked, a +// grant that leaves events out, or a security notice's kind, which goes to +// the owners alone as a notice (Notice). func (l *Link) QueueEvent(e EventIn) bool { + if slices.Contains(cloudwire.NoticeKinds, e.Kind) { + return false + } l.mu.Lock() g := l.grant if g == nil || g.Revoked || !g.has(protocol.ScopeEventsRead) || l.closing { @@ -105,6 +112,19 @@ func (w *writer) sendEvents(snap grantSnapshot, now time.Time) error { return nil } +// noticeClient is the client name a notice may carry: on agent.added, while +// the copy is on and its grant has events, as an event's arguments would, +// and only a short, plain name. +func noticeClient(snap grantSnapshot, n notice) string { + c := n.client + if n.kind != protocol.PushAgentAdded || !snap.copyOn() || !snap.g.has(protocol.ScopeEventsRead) || + c == "" || len(c) > cloudwire.NoticeClientMaxBytes || !utf8.ValidString(c) || + strings.ContainsFunc(c, unicode.IsControl) { + return "" + } + return c +} + // projectEvent cuts opaque ids and drops names. func (l *Link) projectEvent(snap grantSnapshot, e EventIn) (cloudwire.Event, error) { id, err := randomBytes(eventIDBytes) @@ -199,11 +219,16 @@ func (w *writer) closeIfAsked(snap grantSnapshot) error { // noticeQueue bounds the notices waiting for the uplink. const noticeQueue = 16 +// notice is one security notice waiting for the uplink. +type notice struct{ kind, client string } + // Notice queues a security notice for the owners' phones (docs/agents.md, // Push kinds). Unlike an event it needs no grant: it says only that access -// to the box changed, and goes whenever the box is linked. False means the -// box is not linked or the kind is not a notice. -func (l *Link) Notice(kind string) bool { +// to the box changed, and goes whenever the box is linked. client is the +// name of the client given access, on agent.added; it leaves the box only +// while the readable copy is on and carries events. False means the box is +// not linked or the kind is not a notice. +func (l *Link) Notice(kind, client string) bool { if !slices.Contains(cloudwire.NoticeKinds, kind) { return false } @@ -215,7 +240,7 @@ func (l *Link) Notice(kind string) bool { if len(l.notices) >= noticeQueue { l.notices = l.notices[1:] } - l.notices = append(l.notices, kind) + l.notices = append(l.notices, notice{kind, client}) l.mu.Unlock() l.signal() return true @@ -228,8 +253,9 @@ func (w *writer) sendNotices(snap grantSnapshot) error { queued := l.notices l.notices = nil l.mu.Unlock() - for i, kind := range queued { - if _, err := w.sendBody(snap, cloudwire.MsgNotice, &cloudwire.Notice{Kind: kind}, true); err != nil { + for i, n := range queued { + body := cloudwire.Notice{Kind: n.kind, Client: noticeClient(snap, n)} + if _, err := w.sendBody(snap, cloudwire.MsgNotice, &body, true); err != nil { l.mu.Lock() l.notices = append(queued[i:len(queued):len(queued)], l.notices...) l.mu.Unlock() diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go index e7068973..e21d49e6 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -260,7 +260,7 @@ type Link struct { events []EventIn eventsDropped int64 // notices wait for the uplink; see Notice. - notices []string + notices []notice closing bool byeWhy string byeDone chan struct{} diff --git a/go/internal/cloudlink/tunnel_test.go b/go/internal/cloudlink/tunnel_test.go index 377b45a6..8e3f21e6 100644 --- a/go/internal/cloudlink/tunnel_test.go +++ b/go/internal/cloudlink/tunnel_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" "time" @@ -524,15 +525,81 @@ func TestANoticeGoesWithTheCopyOff(t *testing.T) { h := newHarness(t, svc) h.setRoster(func(r *fakeRosterState) { r.tun = true }) h.nextRoster(t) - if h.link.Notice("cloud.granted") { + if h.link.Notice("cloud.granted", "") { t.Fatal("a kind that is not a notice was queued") } - if !h.link.Notice(protocol.PushAgentAdded) { + if !h.link.Notice(protocol.PushAgentAdded, "Claude") { t.Fatal("the notice was refused") } env := svc.waitFor(cloudwire.MsgNotice, 3*time.Second) - var n cloudwire.Notice - if err := cloudwire.Unmarshal(env.B, &n); err != nil || n.Kind != protocol.PushAgentAdded { - t.Fatalf("notice %+v %v", n, err) + n, err := cloudwire.ReadNotice(env.B) + if err != nil || n.Kind != protocol.PushAgentAdded || n.Client != "" { + t.Fatalf("notice %+v %v: with the copy off it carries the kind alone", n, err) } } + +// The client's name goes with agent.added only while the copy is on and +// carries events, as an event's arguments would. +func TestANoticeNamesTheClientOnlyWhenTheGrantCarriesEvents(t *testing.T) { + for _, c := range []struct { + name string + scopes []string + kind string + client string + want string + }{ + {"events granted", []string{protocol.ScopeLiveRead, protocol.ScopeEventsRead}, protocol.PushAgentAdded, "Claude", "Claude"}, + {"the copy without events", []string{protocol.ScopeLiveRead}, protocol.PushAgentAdded, "Claude", ""}, + {"another kind", []string{protocol.ScopeEventsRead}, protocol.PushAgentRequest, "Claude", ""}, + {"a name too long", []string{protocol.ScopeEventsRead}, protocol.PushAgentAdded, strings.Repeat("c", cloudwire.NoticeClientMaxBytes+1), ""}, + {"a line break", []string{protocol.ScopeEventsRead}, protocol.PushAgentAdded, "Claude\nPay here", ""}, + } { + t.Run(c.name, func(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t, c.scopes...) + svc.waitFor(cloudwire.MsgRoster, 5*time.Second) + if !h.link.Notice(c.kind, c.client) { + t.Fatal("the notice was refused") + } + n, err := cloudwire.ReadNotice(svc.waitFor(cloudwire.MsgNotice, 3*time.Second).B) + if err != nil || n.Kind != c.kind || n.Client != c.want { + t.Fatalf("notice %+v %v, want client %q", n, err, c.want) + } + }) + } +} + +// A security notice's kind never goes as an event: the owners get it once, +// as a notice, and a viewer reading events gets nothing of it. +func TestANoticeKindIsNoEvent(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead, protocol.ScopeEventsRead) + svc.waitFor(cloudwire.MsgRoster, 5*time.Second) + for _, kind := range cloudwire.NoticeKinds { + if h.link.QueueEvent(EventIn{Kind: kind, Priority: 3}) { + t.Fatalf("%s was queued as an event", kind) + } + } + if !h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected, Priority: 3}) { + t.Fatal("an ordinary event was refused") + } + svc.waitFor(cloudwire.MsgEvent, 3*time.Second) +} + +// A notice goes past the spent byte budget, as an event does: it is how an +// owner hears that someone else can reach the box. +func TestANoticeGoesPastTheBudget(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.link.mu.Lock() + h.link.budget.add(h.link.uptimeMs(), budgetBytes) + h.link.mu.Unlock() + h.grant(t) + svc.waitFor(cloudwire.MsgRoster, 5*time.Second) + if !h.link.Notice(protocol.PushOwnerAdded, "") { + t.Fatal("the notice was refused") + } + svc.waitFor(cloudwire.MsgNotice, 3*time.Second) +} diff --git a/go/protocol/cloudwire/allowlist.go b/go/protocol/cloudwire/allowlist.go index e3b0e782..50a985ff 100644 --- a/go/protocol/cloudwire/allowlist.go +++ b/go/protocol/cloudwire/allowlist.go @@ -53,8 +53,10 @@ var Fields = map[string][]string{ MsgBye: {"why", "purge"}, - // A kind and nothing else: no reading, no name, no id. - MsgNotice: {"kind"}, + // A kind and nothing else: no reading, no id. The one name is the + // client an owner gave access, on agent.added while the copy carries + // events, which FTW Cloud's own consent page showed. + MsgNotice: {"kind", "client"}, // tun carries no header and no signature: d is Noise ciphertext its two // ends authenticate, and the service reads only dev and sid. diff --git a/go/protocol/cloudwire/messages.go b/go/protocol/cloudwire/messages.go index 004c9971..5ad1dc76 100644 --- a/go/protocol/cloudwire/messages.go +++ b/go/protocol/cloudwire/messages.go @@ -1,6 +1,13 @@ package cloudwire -import "github.com/srcfl/ftw/go/protocol" +import ( + "errors" + "slices" + + "github.com/fxamacker/cbor/v2" + + "github.com/srcfl/ftw/go/protocol" +) // Message types. The service sends exactly the first five and tun; anything // else from it closes the uplink. @@ -378,16 +385,50 @@ type Grant struct { // Notice is a security notice for the owners: a push kind from the // catalogue, such as agent.added, and nothing that says more. The service -// sends each owner device registered for alerts a push with no data. +// sends each owner device registered for alerts a push carrying the +// notice, which the phone checks and words from its catalogue. type Notice struct { Header Kind string `cbor:"kind"` + // Client is the name of the client given access, as FTW Cloud's + // consent page showed it: only on agent.added, and only while the + // readable copy is on and carries events. Otherwise absent. + Client string `cbor:"client,omitempty"` } // NoticeKinds are the kinds a notice may carry. var NoticeKinds = []string{protocol.PushAgentAdded, protocol.PushAgentRequest, protocol.PushAgentExpiring, protocol.PushApprovalKeySet, protocol.PushOwnerAdded} +// NoticeClientMaxBytes bounds a notice's client name. A longer name goes +// without the name. +const NoticeClientMaxBytes = 64 + +// ErrNotice refuses a notice body that is not one: an unknown field, a kind +// outside NoticeKinds, or a client name where none may be. +var ErrNotice = errors.New("cloudwire: not a notice") + +// ReadNotice decodes a notice body strictly. A body with any field beyond +// the header, kind and client is refused, so a notice cannot carry a +// reading, a name or an id to a phone or to Apple. +func ReadNotice(body []byte) (Notice, error) { + var keys map[string]cbor.RawMessage + if Unmarshal(body, &keys) != nil { + return Notice{}, ErrNotice + } + for k := range keys { + if !slices.Contains(Fields["header"], k) && !slices.Contains(Fields[MsgNotice], k) { + return Notice{}, ErrNotice + } + } + var n Notice + if Unmarshal(body, &n) != nil || !slices.Contains(NoticeKinds, n.Kind) || + (n.Client != "" && (n.Kind != protocol.PushAgentAdded || len(n.Client) > NoticeClientMaxBytes)) { + return Notice{}, ErrNotice + } + return n, nil +} + // Bye ends the uplink. Purge asks the service to delete the home. type Bye struct { Header diff --git a/go/protocol/cloudwire/notice_test.go b/go/protocol/cloudwire/notice_test.go new file mode 100644 index 00000000..0b5b7900 --- /dev/null +++ b/go/protocol/cloudwire/notice_test.go @@ -0,0 +1,43 @@ +package cloudwire + +import ( + "bytes" + "strings" + "testing" + + "github.com/srcfl/ftw/go/protocol" +) + +// A notice body holds the header, a notice kind and, on agent.added, the +// client's name. Anything else, a reading above all, makes it no notice. +func TestReadNoticeTakesOnlyANotice(t *testing.T) { + header := Header{Seq: 4, Boot: bytes.Repeat([]byte{0xb0}, BootBytes), UptimeMs: 9_000} + body := func(n Notice, extra map[string]any) []byte { + n.Header = header + return noticeBody(t, n, extra) + } + for _, c := range []struct { + name string + body []byte + valid bool + }{ + {"a kind", body(Notice{Kind: protocol.PushOwnerAdded}, nil), true}, + {"agent.added with its client", body(Notice{Kind: protocol.PushAgentAdded, Client: "Claude"}, nil), true}, + {"a client at the limit", body(Notice{Kind: protocol.PushAgentAdded, Client: strings.Repeat("c", NoticeClientMaxBytes)}, nil), true}, + {"a client over the limit", body(Notice{Kind: protocol.PushAgentAdded, Client: strings.Repeat("c", NoticeClientMaxBytes+1)}, nil), false}, + {"a client on another kind", body(Notice{Kind: protocol.PushAgentRequest, Client: "Claude"}, nil), false}, + {"a kind that is not a notice", body(Notice{Kind: protocol.PushChargingConnected}, nil), false}, + {"no kind", body(Notice{}, nil), false}, + {"a reading beside the kind", body(Notice{Kind: protocol.PushAgentRequest}, map[string]any{"fields": map[int]int64{1: 2400}}), false}, + {"a source id beside the kind", body(Notice{Kind: protocol.PushAgentRequest}, map[string]any{"src": "q5Lw0Zb1"}), false}, + {"not a map", []byte{0x01}, false}, + } { + n, err := ReadNotice(c.body) + if (err == nil) != c.valid { + t.Errorf("%s: err %v, want valid %v", c.name, err, c.valid) + } + if c.valid && n.Seq != header.Seq { + t.Errorf("%s: header lost: %+v", c.name, n.Header) + } + } +} diff --git a/go/protocol/cloudwire/push.go b/go/protocol/cloudwire/push.go index e5a97772..dd77b1b4 100644 --- a/go/protocol/cloudwire/push.go +++ b/go/protocol/cloudwire/push.go @@ -97,6 +97,10 @@ type PushUnregister struct { APNsToken []byte `cbor:"apnsToken"` } +// NoticeClientArg is the argument a notice's client name reads as, for the +// phone's words. +const NoticeClientArg = "client" + // AlertMaxAgeMs is how old an event may be and still get its text and // buttons on the phone. An older one, or one the phone cannot anchor in // time, shows the generic line with no buttons. @@ -111,8 +115,9 @@ const ( ) // Alert is the "ftw" member of an APNs alert payload. Event is the box's -// envelope exactly as it arrived; Kind is set only on the alert the service -// writes itself, box.unreachable, and a phone accepts no other kind there. +// envelope exactly as it arrived, an event or a notice; Kind is set only on +// the alert the service writes itself, box.unreachable, and a phone accepts +// no other kind there. type Alert struct { V int `json:"v"` Home string `json:"home"` @@ -152,8 +157,9 @@ func AlertPayload(a Alert) ([]byte, error) { // What a phone makes of an alert. const ( - // AlertEvent: a box-signed event, anchored and fresh, of a kind the - // catalogue has. The phone writes its text and offers its buttons. + // AlertEvent: a box-signed event or notice, anchored and fresh, of a + // kind the catalogue has. The phone writes its text and offers its + // buttons. AlertEvent = "event" // AlertUnreachable: the service's box.unreachable. Its own text, no // buttons. @@ -216,7 +222,7 @@ func ReadAlert(payload []byte, boxKey func(home string) []byte, offset func(home return view } env, err := DecodeEnvelope(a.Event) - if err != nil || env.T != MsgEvent { + if err != nil || (env.T != MsgEvent && env.T != MsgNotice) { return view } pk := boxKey(a.Home) @@ -228,7 +234,18 @@ func ReadAlert(payload []byte, boxKey func(home string) []byte, offset func(home return view } var ev Event - if Unmarshal(env.B, &ev) != nil { + if env.T == MsgNotice { + // A notice reads as an event of its kind, with the client's name + // as its one argument when it carries one. + n, err := ReadNotice(env.B) + if err != nil { + return view + } + ev = Event{Header: n.Header, Kind: n.Kind, Args: map[string]string{}} + if n.Client != "" { + ev.Args[NoticeClientArg] = n.Client + } + } else if Unmarshal(env.B, &ev) != nil { return view } at, ok := alertSendTime(ev, offset(a.Home), nowMs) diff --git a/go/protocol/cloudwire/push_vectors_test.go b/go/protocol/cloudwire/push_vectors_test.go index 0fd94d6b..6ebffaed 100644 --- a/go/protocol/cloudwire/push_vectors_test.go +++ b/go/protocol/cloudwire/push_vectors_test.go @@ -121,6 +121,22 @@ func TestPushVectors(t *testing.T) { } signedAsOther := connected signedAsOther.Seq = 16 + // Notices: a kind, and on agent.added the client's name, nothing else. + notice := func(seq uint64, kind, client string) *Notice { + return &Notice{Header: Header{Seq: seq, Boot: boot, UptimeMs: 75_000 + int64(seq), WallMs: at(75_000 + int64(seq))}, + Kind: kind, Client: client} + } + agentAdded := notice(17, protocol.PushAgentAdded, "") + agentNamed := notice(18, protocol.PushAgentAdded, "Claude") + ownerAdded := notice(19, protocol.PushOwnerAdded, "") + notNotice := notice(20, protocol.PushChargingConnected, "") + nameElsewhere := notice(21, protocol.PushAgentRequest, "Claude") + withReading := notice(22, protocol.PushAgentRequest, "") + asEvent := func(n *Notice) Event { + return Event{Header: n.Header, Kind: n.Kind, Args: map[string]string{}} + } + named := asEvent(agentNamed) + named.Args = map[string]string{NoticeClientArg: "Claude"} // Rule U: the phone learned phone time minus box uptime an hour before // the event, so the anchor carries 0.01 % of that hour as margin. @@ -139,11 +155,14 @@ func TestPushVectors(t *testing.T) { key *ecdsa.PrivateKey // signer; nil for no event signFor string // home the box signs for event *Event - alert Alert // Home and Kind; Event is filled in - mutate func(env []byte) []byte - offset *Offset // rule U's anchor the phone holds for home - nowMs int64 - want pushVectorOut + notice *Notice + // extra are fields a notice body should not carry. + extra map[string]any + alert Alert // Home and Kind; Event is filled in + mutate func(env []byte) []byte + offset *Offset // rule U's anchor the phone holds for home + nowMs int64 + want pushVectorOut } fresh := fresh0 event := func(e Event) pushVectorOut { @@ -205,6 +224,19 @@ func TestPushVectors(t *testing.T) { alert: Alert{Home: home}, offset: otherBoot, nowMs: fresh(connected), want: generic}, {name: "rule U learned over a day ago", key: box, signFor: home, event: &noClock, alert: Alert{Home: home}, offset: dayOld, nowMs: fresh(connected), want: generic}, + {name: "notice: agent added", key: box, signFor: home, notice: agentAdded, alert: Alert{Home: home}, + nowMs: fresh(asEvent(agentAdded)), want: event(asEvent(agentAdded))}, + {name: "notice: agent added, with the client's name", key: box, signFor: home, notice: agentNamed, + alert: Alert{Home: home}, nowMs: fresh(named), want: event(named)}, + {name: "notice: owner added", key: box, signFor: home, notice: ownerAdded, alert: Alert{Home: home}, + nowMs: fresh(asEvent(ownerAdded)), want: event(asEvent(ownerAdded))}, + {name: "notice of a kind that is not a notice", key: box, signFor: home, notice: notNotice, + alert: Alert{Home: home}, nowMs: fresh(asEvent(notNotice)), want: generic}, + {name: "notice with a client's name on another kind", key: box, signFor: home, notice: nameElsewhere, + alert: Alert{Home: home}, nowMs: fresh(asEvent(nameElsewhere)), want: generic}, + {name: "notice that carries a reading", key: box, signFor: home, notice: withReading, + extra: map[string]any{"fields": map[int]int64{1: 2400}}, alert: Alert{Home: home}, + nowMs: fresh(asEvent(withReading)), want: generic}, } keys := map[string][]byte{home: boxPK, otherHome: otherPK} @@ -215,21 +247,27 @@ func TestPushVectors(t *testing.T) { for i, s := range specs { a := s.alert var sig []byte - if s.event != nil { - body, err := Marshal(*s.event) + if s.event != nil || s.notice != nil { + typ, body := MsgEvent, []byte(nil) + var err error + if s.event != nil { + body, err = Marshal(*s.event) + } else { + typ, body = MsgNotice, noticeBody(t, *s.notice, s.extra) + } if err != nil { t.Fatal(err) } sig = storedSig(i, s.name) - if *update && Verify(&s.key.PublicKey, s.signFor, Envelope{T: MsgEvent, B: body, Sig: sig}) != nil { - if sig, err = Sign(rand.Reader, s.key, s.signFor, MsgEvent, body); err != nil { + if *update && Verify(&s.key.PublicKey, s.signFor, Envelope{T: typ, B: body, Sig: sig}) != nil { + if sig, err = Sign(rand.Reader, s.key, s.signFor, typ, body); err != nil { t.Fatal(err) } } - if err := Verify(&s.key.PublicKey, s.signFor, Envelope{T: MsgEvent, B: body, Sig: sig}); err != nil { + if err := Verify(&s.key.PublicKey, s.signFor, Envelope{T: typ, B: body, Sig: sig}); err != nil { t.Fatalf("%s: stored signature does not verify: %v", s.name, err) } - env, err := Envelope{T: MsgEvent, B: body, Sig: sig}.Encode() + env, err := Envelope{T: typ, B: body, Sig: sig}.Encode() if err != nil { t.Fatal(err) } @@ -287,6 +325,29 @@ func TestPushVectors(t *testing.T) { } } +// noticeBody is a notice's body with extra fields beside its own. +func noticeBody(t *testing.T, n Notice, extra map[string]any) []byte { + t.Helper() + body, err := Marshal(n) + if err != nil || len(extra) == 0 { + if err != nil { + t.Fatal(err) + } + return body + } + var m map[string]any + if err := Unmarshal(body, &m); err != nil { + t.Fatal(err) + } + for k, v := range extra { + m[k] = v + } + if body, err = Marshal(m); err != nil { + t.Fatal(err) + } + return body +} + // fresh0 is a phone clock 1.2 s after the event's wallMs. func fresh0(e Event) int64 { return *e.WallMs + 1_200 } diff --git a/go/protocol/cloudwire/testdata/push-vectors.json b/go/protocol/cloudwire/testdata/push-vectors.json index e6b4ac67..bd68bf62 100644 --- a/go/protocol/cloudwire/testdata/push-vectors.json +++ b/go/protocol/cloudwire/testdata/push-vectors.json @@ -252,6 +252,81 @@ "verdict": "generic", "home": "fjzyr67buzpyncqmjliwgezvde" } + }, + { + "name": "notice: agent added", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWEulY3NlcRFkYm9vdFCwsLCwsLCwsLCwsLCwsLCwZGtpbmRrYWdlbnQuYWRkZWRmd2FsbE1zGwAAAaEw19qpaHVwdGltZU1zGgABJQlhdGZub3RpY2Vjc2lnWEAbAyFDum/1cvsa/UZ6W4Ycr9Vi0fi4Za3eybeuZVPnU2TKhbb8isxPSpwOBLfLQVgHhoY+bkI6oQUgQQ2R9+3v\"}}", + "sig": "1b032143ba6ff572fb1afd467a5b861cafd562d1f8b865addec9b7ae6553e75364ca85b6fc8acc4f4a9c0e04b7cb41580786863e6e423aa10520410d91f7edef", + "nowMs": 1791820816217, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "agent.added", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 17, + "atMs": 1791820815017 + } + }, + { + "name": "notice: agent added, with the client's name", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWFmmY3NlcRJkYm9vdFCwsLCwsLCwsLCwsLCwsLCwZGtpbmRrYWdlbnQuYWRkZWRmY2xpZW50ZkNsYXVkZWZ3YWxsTXMbAAABoTDX2qpodXB0aW1lTXMaAAElCmF0Zm5vdGljZWNzaWdYQE4+GuWl/LzZJLZfra214MRS7FcNFraI3uxiFd8KifedDll34e209yxx4ShX4EnuMw3pZ/saTXZbanwGvvq7OwA=\"}}", + "sig": "4e3e1ae5a5fcbcd924b65fadadb5e0c452ec570d16b688deec6215df0a89f79d0e5977e1edb4f72c71e12857e049ee330de967fb1a4d765b6a7c06befabb3b00", + "nowMs": 1791820816218, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "agent.added", + "args": { + "client": "Claude" + }, + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 18, + "atMs": 1791820815018 + } + }, + { + "name": "notice: owner added", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWEulY3NlcRNkYm9vdFCwsLCwsLCwsLCwsLCwsLCwZGtpbmRrb3duZXIuYWRkZWRmd2FsbE1zGwAAAaEw19qraHVwdGltZU1zGgABJQthdGZub3RpY2Vjc2lnWEA1UrmPsIrWKWG9fjxWr25VeJlNItvdxLisM+pnQm6hiBlJP04wSC+YWHHEc7ONCX7RaG9AuXgy9YYIC861ofPC\"}}", + "sig": "3552b98fb08ad62961bd7e3c56af6e5578994d22dbddc4b8ac33ea67426ea18819493f4e30482f985871c473b38d097ed1686f40b97832f586080bceb5a1f3c2", + "nowMs": 1791820816219, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "owner.added", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 19, + "atMs": 1791820815019 + } + }, + { + "name": "notice of a kind that is not a notice", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWFKlY3NlcRRkYm9vdFCwsLCwsLCwsLCwsLCwsLCwZGtpbmRyY2hhcmdpbmcuY29ubmVjdGVkZndhbGxNcxsAAAGhMNfarGh1cHRpbWVNcxoAASUMYXRmbm90aWNlY3NpZ1hAEgkMM7TKQ7T+9OGA0ghvgzsbClOTmAZDKyjx5+2GbmhLfEnPDyrf8j7+/XJtPUpRAYtG08d0fJwFx4N25Fj7ig==\"}}", + "sig": "12090c33b4ca43b4fef4e180d2086f833b1b0a53939806432b28f1e7ed866e684b7c49cf0f2adff23efefd726d3d4a51018b46d3c7747c9c05c78376e458fb8a", + "nowMs": 1791820816220, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "notice with a client's name on another kind", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWFumY3NlcRVkYm9vdFCwsLCwsLCwsLCwsLCwsLCwZGtpbmRtYWdlbnQucmVxdWVzdGZjbGllbnRmQ2xhdWRlZndhbGxNcxsAAAGhMNfarWh1cHRpbWVNcxoAASUNYXRmbm90aWNlY3NpZ1hAs6NjZVEBVln2Oa0fswegEGi/WY4oZE+ttBlos/7klFaXvtNQ0LhlWjs1WsNg6F5A1SSN5nruJ1Djpl7T/SaG8w==\"}}", + "sig": "b3a3636551015659f639ad1fb307a01068bf598e28644fadb41968b3fee4945697bed350d0b8655a3b355ac360e85e40d5248de67aee2750e3a65ed3fd2686f3", + "nowMs": 1791820816221, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "notice that carries a reading", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWFmmY3NlcRZkYm9vdFCwsLCwsLCwsLCwsLCwsLCwZGtpbmRtYWdlbnQucmVxdWVzdGZmaWVsZHOhARkJYGZ3YWxsTXMbAAABoTDX2q5odXB0aW1lTXMaAAElDmF0Zm5vdGljZWNzaWdYQLAkL9JgkAuamo13qXA+F1jPe7kikT0U7zv65Audx/TaPylfZhJ+A5ozM0YLiSoi8bceFQJeYGVP9Qtl/y1WStM=\"}}", + "sig": "b0242fd260900b9a9a8d77a9703e1758cf7bb922913d14ef3bfae40b9dc7f4da3f295f66127e039a3333460b892a22f1b71e15025e60654ff50b65ff2d564ad3", + "nowMs": 1791820816222, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } } ] } From 7dd3e0586175276c926dafd77e7bab5e2c0cc1db Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 11:01:29 +0200 Subject: [PATCH 2/3] fix(cloud): notices last a day, check their header and name as the consent page cuts it, and count what the box drops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #1661: - ReadNotice checks the header (a 16-byte boot) and takes a client name of at most 65 characters, the consent page's 64 and its "…", with no control or format character (NoticeClientOK), which the box applies too. - A notice states a lasting fact: the phone shows it for a day (NoticeMaxAgeMs). ReadAlert reads a notice kind sent as an event as the generic line, since that path reached viewers. - The box counts and logs a notice its full queue drops (noticesDropped). - ADR 0008 and the docs say Apple may see an agent's client name, and that FTW Cloud sees it in the roster whatever the copy. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0008-ftw-cloud.md | 2 +- docs/agents.md | 5 +++ docs/protocol-cloud.md | 12 +++-- go/internal/cloudlink/events.go | 18 ++++---- go/internal/cloudlink/link.go | 12 +++-- go/internal/cloudlink/tunnel_test.go | 27 +++++++++++- go/protocol/cloudwire/messages.go | 38 +++++++++++++--- go/protocol/cloudwire/notice_test.go | 10 ++++- go/protocol/cloudwire/push.go | 14 ++++-- go/protocol/cloudwire/push_vectors_test.go | 20 +++++++++ .../cloudwire/testdata/push-vectors.json | 44 +++++++++++++++++++ 11 files changed, 173 insertions(+), 29 deletions(-) diff --git a/docs/adr/0008-ftw-cloud.md b/docs/adr/0008-ftw-cloud.md index a601c2d8..104f17f1 100644 --- a/docs/adr/0008-ftw-cloud.md +++ b/docs/adr/0008-ftw-cloud.md @@ -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 | diff --git a/docs/agents.md b/docs/agents.md index 00caaca4..822ec006 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -689,6 +689,11 @@ 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 The box writes a row for every call an agent makes and for every change to diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index 260bf321..00ebbe9f 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -592,7 +592,7 @@ 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 64 bytes. Absent otherwise | +| `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 checks that the body @@ -600,7 +600,13 @@ 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. See [Agents](agents.md). +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 @@ -1034,7 +1040,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, 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 or notice 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`. A notice reads as an event of its kind, with `client` as its one argument. 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 | diff --git a/go/internal/cloudlink/events.go b/go/internal/cloudlink/events.go index f5f36732..aa7c742e 100644 --- a/go/internal/cloudlink/events.go +++ b/go/internal/cloudlink/events.go @@ -4,10 +4,7 @@ import ( "encoding/base64" "errors" "slices" - "strings" "time" - "unicode" - "unicode/utf8" "github.com/srcfl/ftw/go/protocol" "github.com/srcfl/ftw/go/protocol/cloudwire" @@ -114,15 +111,13 @@ func (w *writer) sendEvents(snap grantSnapshot, now time.Time) error { // noticeClient is the client name a notice may carry: on agent.added, while // the copy is on and its grant has events, as an event's arguments would, -// and only a short, plain name. +// and only a name cloudwire.NoticeClientOK takes, as the consent page cut it. func noticeClient(snap grantSnapshot, n notice) string { - c := n.client if n.kind != protocol.PushAgentAdded || !snap.copyOn() || !snap.g.has(protocol.ScopeEventsRead) || - c == "" || len(c) > cloudwire.NoticeClientMaxBytes || !utf8.ValidString(c) || - strings.ContainsFunc(c, unicode.IsControl) { + !cloudwire.NoticeClientOK(n.client) { return "" } - return c + return n.client } // projectEvent cuts opaque ids and drops names. @@ -237,11 +232,18 @@ func (l *Link) Notice(kind, client string) bool { l.mu.Unlock() return false } + dropped := "" if len(l.notices) >= noticeQueue { + dropped = l.notices[0].kind l.notices = l.notices[1:] + l.noticesDropped++ } l.notices = append(l.notices, notice{kind, client}) l.mu.Unlock() + if dropped != "" { + // The box's own transports carried it; FTW Cloud never will. + l.log.Warn("cloudlink: the notice queue is full; the oldest notice was dropped", "kind", dropped, "queued", noticeQueue) + } l.signal() return true } diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go index e21d49e6..b1574c1c 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -259,8 +259,11 @@ type Link struct { // again. events []EventIn eventsDropped int64 - // notices wait for the uplink; see Notice. - notices []notice + // notices wait for the uplink; see Notice. noticesDropped counts those + // a full queue pushed out. + notices []notice + noticesDropped int64 + closing bool byeWhy string byeDone chan struct{} @@ -797,6 +800,9 @@ type Status struct { // the queue was full, or the uplink was down for longer than a phone // would still act on them. EventsDropped int64 `json:"eventsDropped"` + // NoticesDropped counts security notices a full queue pushed out + // before the uplink took them. The box's own transports carried them. + NoticesDropped int64 `json:"noticesDropped"` // TunBytesLast24h counts the tunnel's bytes, apart from the readable // copy's, against TunBudgetBytes. TunDropped counts tun messages that // never left: no uplink, a full queue or the budget reached. @@ -812,7 +818,7 @@ func (l *Link) Status() Status { s := Status{ State: "off", Notice: l.notice, Available: len(l.opts.ServiceKeys) > 0, Scopes: []string{}, Purposes: []string{}, Connected: l.connected, - LastSendAgeMs: -1, PurgedAtMs: l.purgedAt, EventsDropped: l.eventsDropped, + LastSendAgeMs: -1, PurgedAtMs: l.purgedAt, EventsDropped: l.eventsDropped, NoticesDropped: l.noticesDropped, BytesLast24h: l.budget.total(l.uptimeMs()), BudgetBytes: budgetBytes, Link: "off", TunBytesLast24h: l.tunBudget.total(l.uptimeMs()), TunBudgetBytes: tunBudgetBytes, TunDropped: l.tunDropped, diff --git a/go/internal/cloudlink/tunnel_test.go b/go/internal/cloudlink/tunnel_test.go index 8e3f21e6..67c5f96a 100644 --- a/go/internal/cloudlink/tunnel_test.go +++ b/go/internal/cloudlink/tunnel_test.go @@ -551,7 +551,9 @@ func TestANoticeNamesTheClientOnlyWhenTheGrantCarriesEvents(t *testing.T) { {"events granted", []string{protocol.ScopeLiveRead, protocol.ScopeEventsRead}, protocol.PushAgentAdded, "Claude", "Claude"}, {"the copy without events", []string{protocol.ScopeLiveRead}, protocol.PushAgentAdded, "Claude", ""}, {"another kind", []string{protocol.ScopeEventsRead}, protocol.PushAgentRequest, "Claude", ""}, - {"a name too long", []string{protocol.ScopeEventsRead}, protocol.PushAgentAdded, strings.Repeat("c", cloudwire.NoticeClientMaxBytes+1), ""}, + {"a name too long", []string{protocol.ScopeEventsRead}, protocol.PushAgentAdded, strings.Repeat("c", cloudwire.NoticeClientMaxRunes+1), ""}, + {"a name the consent page cut", []string{protocol.ScopeEventsRead}, protocol.PushAgentAdded, strings.Repeat("ö", 64) + "…", strings.Repeat("ö", 64) + "…"}, + {"a format character", []string{protocol.ScopeEventsRead}, protocol.PushAgentAdded, "Claude\u202e", ""}, {"a line break", []string{protocol.ScopeEventsRead}, protocol.PushAgentAdded, "Claude\nPay here", ""}, } { t.Run(c.name, func(t *testing.T) { @@ -588,6 +590,29 @@ func TestANoticeKindIsNoEvent(t *testing.T) { svc.waitFor(cloudwire.MsgEvent, 3*time.Second) } +// Notices wait for the uplink in a queue of 16. A seventeenth pushes the +// oldest out, and the box counts it. +func TestAFullNoticeQueueCountsWhatItDrops(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t) + svc.waitFor(cloudwire.MsgRoster, 5*time.Second) + // The same linked box, not running: nothing takes its notices. + idle, err := New(Options{Site: h.site, Roster: fakeRoster{}, KV: h.kv, KeyPath: h.keyPath, + ServiceKeys: h.link.opts.ServiceKeys}) + if err != nil { + t.Fatal(err) + } + for i := range noticeQueue + 1 { + if !idle.Notice(protocol.PushAgentRequest, "") { + t.Fatalf("notice %d was refused", i) + } + } + if st := idle.Status(); st.NoticesDropped != 1 { + t.Fatalf("dropped %d", st.NoticesDropped) + } +} + // A notice goes past the spent byte budget, as an event does: it is how an // owner hears that someone else can reach the box. func TestANoticeGoesPastTheBudget(t *testing.T) { diff --git a/go/protocol/cloudwire/messages.go b/go/protocol/cloudwire/messages.go index 5ad1dc76..512271f2 100644 --- a/go/protocol/cloudwire/messages.go +++ b/go/protocol/cloudwire/messages.go @@ -3,6 +3,8 @@ package cloudwire import ( "errors" "slices" + "unicode" + "unicode/utf8" "github.com/fxamacker/cbor/v2" @@ -400,12 +402,34 @@ type Notice struct { var NoticeKinds = []string{protocol.PushAgentAdded, protocol.PushAgentRequest, protocol.PushAgentExpiring, protocol.PushApprovalKeySet, protocol.PushOwnerAdded} -// NoticeClientMaxBytes bounds a notice's client name. A longer name goes -// without the name. -const NoticeClientMaxBytes = 64 +// NoticeClientMaxRunes bounds a notice's client name in characters: FTW +// Cloud's consent page keeps at most 64 of a name and marks one it cut with +// "…", so every name it shows fits. +const NoticeClientMaxRunes = 65 + +// NoticeMaxAgeMs is how old a notice may be and still get its words on the +// phone, and how long Apple may keep trying to deliver it. A notice states +// a lasting fact, such as a new owner, so it lasts longer than an event. +const NoticeMaxAgeMs = 24 * 60 * 60 * 1000 + +// NoticeClientOK reports whether a client name may go in a notice: valid +// UTF-8, at most NoticeClientMaxRunes characters, and no control or format +// character, which could reorder or hide what a lock screen shows. +func NoticeClientOK(c string) bool { + if c == "" || !utf8.ValidString(c) || utf8.RuneCountInString(c) > NoticeClientMaxRunes { + return false + } + for _, r := range c { + if unicode.Is(unicode.Cc, r) || unicode.Is(unicode.Cf, r) { + return false + } + } + return true +} -// ErrNotice refuses a notice body that is not one: an unknown field, a kind -// outside NoticeKinds, or a client name where none may be. +// ErrNotice refuses a notice body that is not one: an unknown field, a +// header out of shape, a kind outside NoticeKinds, or a client name where +// none may be or that NoticeClientOK refuses. var ErrNotice = errors.New("cloudwire: not a notice") // ReadNotice decodes a notice body strictly. A body with any field beyond @@ -422,8 +446,8 @@ func ReadNotice(body []byte) (Notice, error) { } } var n Notice - if Unmarshal(body, &n) != nil || !slices.Contains(NoticeKinds, n.Kind) || - (n.Client != "" && (n.Kind != protocol.PushAgentAdded || len(n.Client) > NoticeClientMaxBytes)) { + if Unmarshal(body, &n) != nil || len(n.Boot) != BootBytes || n.UptimeMs < 0 || !slices.Contains(NoticeKinds, n.Kind) || + (n.Client != "" && (n.Kind != protocol.PushAgentAdded || !NoticeClientOK(n.Client))) { return Notice{}, ErrNotice } return n, nil diff --git a/go/protocol/cloudwire/notice_test.go b/go/protocol/cloudwire/notice_test.go index 0b5b7900..6c6fa6af 100644 --- a/go/protocol/cloudwire/notice_test.go +++ b/go/protocol/cloudwire/notice_test.go @@ -23,8 +23,14 @@ func TestReadNoticeTakesOnlyANotice(t *testing.T) { }{ {"a kind", body(Notice{Kind: protocol.PushOwnerAdded}, nil), true}, {"agent.added with its client", body(Notice{Kind: protocol.PushAgentAdded, Client: "Claude"}, nil), true}, - {"a client at the limit", body(Notice{Kind: protocol.PushAgentAdded, Client: strings.Repeat("c", NoticeClientMaxBytes)}, nil), true}, - {"a client over the limit", body(Notice{Kind: protocol.PushAgentAdded, Client: strings.Repeat("c", NoticeClientMaxBytes+1)}, nil), false}, + {"a name the consent page cut", body(Notice{Kind: protocol.PushAgentAdded, Client: strings.Repeat("ö", 64) + "…"}, nil), true}, + {"a client over the limit", body(Notice{Kind: protocol.PushAgentAdded, Client: strings.Repeat("c", NoticeClientMaxRunes+1)}, nil), false}, + {"a format character", body(Notice{Kind: protocol.PushAgentAdded, Client: "Claude\u202e"}, nil), false}, + {"a control character", body(Notice{Kind: protocol.PushAgentAdded, Client: "Claude\nPay here"}, nil), false}, + {"a short boot", func() []byte { + b, _ := Marshal(Notice{Header: Header{Seq: 4, Boot: []byte{1, 2}}, Kind: protocol.PushOwnerAdded}) + return b + }(), false}, {"a client on another kind", body(Notice{Kind: protocol.PushAgentRequest, Client: "Claude"}, nil), false}, {"a kind that is not a notice", body(Notice{Kind: protocol.PushChargingConnected}, nil), false}, {"no kind", body(Notice{}, nil), false}, diff --git a/go/protocol/cloudwire/push.go b/go/protocol/cloudwire/push.go index dd77b1b4..2e2fbe2f 100644 --- a/go/protocol/cloudwire/push.go +++ b/go/protocol/cloudwire/push.go @@ -3,6 +3,7 @@ package cloudwire import ( "bytes" "encoding/json" + "slices" "github.com/srcfl/ftw/go/protocol" ) @@ -102,8 +103,9 @@ type PushUnregister struct { const NoticeClientArg = "client" // AlertMaxAgeMs is how old an event may be and still get its text and -// buttons on the phone. An older one, or one the phone cannot anchor in -// time, shows the generic line with no buttons. +// buttons on the phone; a notice may be NoticeMaxAgeMs old. An older one, +// or one the phone cannot anchor in time, shows the generic line with no +// buttons. const AlertMaxAgeMs = 10 * 60 * 1000 // UnreachableAfterMs is how long the service waits without the box's socket @@ -234,7 +236,9 @@ func ReadAlert(payload []byte, boxKey func(home string) []byte, offset func(home return view } var ev Event + maxAge := int64(AlertMaxAgeMs) if env.T == MsgNotice { + maxAge = NoticeMaxAgeMs // A notice reads as an event of its kind, with the client's name // as its one argument when it carries one. n, err := ReadNotice(env.B) @@ -245,7 +249,9 @@ func ReadAlert(payload []byte, boxKey func(home string) []byte, offset func(home if n.Client != "" { ev.Args[NoticeClientArg] = n.Client } - } else if Unmarshal(env.B, &ev) != nil { + } else if Unmarshal(env.B, &ev) != nil || slices.Contains(NoticeKinds, ev.Kind) { + // A notice's kind comes only as a notice, to the owners: as an + // event it could have reached a viewer. return view } at, ok := alertSendTime(ev, offset(a.Home), nowMs) @@ -256,7 +262,7 @@ func ReadAlert(payload []byte, boxKey func(home string) []byte, offset func(home at = min(at, *ev.Ts) } _, known := protocol.PushSentences[ev.Kind] - if !known || ev.Kind == protocol.PushBoxUnreachable || nowMs-at > AlertMaxAgeMs { + if !known || ev.Kind == protocol.PushBoxUnreachable || nowMs-at > maxAge { return view } return AlertView{Verdict: AlertEvent, Home: a.Home, Kind: ev.Kind, Args: ev.Args, Src: ev.Src, LP: ev.LP, diff --git a/go/protocol/cloudwire/push_vectors_test.go b/go/protocol/cloudwire/push_vectors_test.go index 6ebffaed..f0ae4342 100644 --- a/go/protocol/cloudwire/push_vectors_test.go +++ b/go/protocol/cloudwire/push_vectors_test.go @@ -137,6 +137,13 @@ func TestPushVectors(t *testing.T) { } named := asEvent(agentNamed) named.Args = map[string]string{NoticeClientArg: "Claude"} + // A notice kind sent as an event, as a box on #1646 did: it could have + // reached a viewer, so it reads as nothing. + noticeAsEvent := Event{Header: Header{Seq: 23, Boot: boot, UptimeMs: 75_023, WallMs: at(75_023)}, + EventID: "bm90aWNlZXYx", Kind: protocol.PushAgentAdded, Severity: SeverityInfo, Args: map[string]string{}} + dayOldNotice := notice(24, protocol.PushApprovalKeySet, "") + shortBoot := notice(25, protocol.PushOwnerAdded, "") + shortBoot.Boot = boot[:8] // Rule U: the phone learned phone time minus box uptime an hour before // the event, so the anchor carries 0.01 % of that hour as margin. @@ -237,6 +244,19 @@ func TestPushVectors(t *testing.T) { {name: "notice that carries a reading", key: box, signFor: home, notice: withReading, extra: map[string]any{"fields": map[int]int64{1: 2400}}, alert: Alert{Home: home}, nowMs: fresh(asEvent(withReading)), want: generic}, + {name: "a notice kind sent as an event", key: box, signFor: home, event: ¬iceAsEvent, alert: Alert{Home: home}, + nowMs: fresh(noticeAsEvent), want: generic}, + {name: "notice: twenty hours old is still shown", key: box, signFor: home, notice: dayOldNotice, alert: Alert{Home: home}, + nowMs: *dayOldNotice.WallMs + 20*60*60*1000, + want: func() pushVectorOut { + o := event(asEvent(dayOldNotice)) + o.AtMs = *dayOldNotice.WallMs + return o + }()}, + {name: "notice: over a day old", key: box, signFor: home, notice: dayOldNotice, alert: Alert{Home: home}, + nowMs: *dayOldNotice.WallMs + NoticeMaxAgeMs + 1, want: generic}, + {name: "notice with a short boot", key: box, signFor: home, notice: shortBoot, alert: Alert{Home: home}, + nowMs: fresh(asEvent(shortBoot)), want: generic}, } keys := map[string][]byte{home: boxPK, otherHome: otherPK} diff --git a/go/protocol/cloudwire/testdata/push-vectors.json b/go/protocol/cloudwire/testdata/push-vectors.json index bd68bf62..55b8145a 100644 --- a/go/protocol/cloudwire/testdata/push-vectors.json +++ b/go/protocol/cloudwire/testdata/push-vectors.json @@ -327,6 +327,50 @@ "verdict": "generic", "home": "fjzyr67buzpyncqmjliwgezvde" } + }, + { + "name": "a notice kind sent as an event", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWHSoY3NlcRdkYXJnc6BkYm9vdFCwsLCwsLCwsLCwsLCwsLCwZGtpbmRrYWdlbnQuYWRkZWRmd2FsbE1zGwAAAaEw19qvZ2V2ZW50SWRsYm05MGFXTmxaWFl4aHNldmVyaXR5ZGluZm9odXB0aW1lTXMaAAElD2F0ZWV2ZW50Y3NpZ1hAdYhzcPtaXW1REa8z71XlON6LANr0PDuWzx2axo7CkNzZSYNx4OF/juTHPf4LKSjQW+dwwkAeE2vIjFjpLhJjEw==\"}}", + "sig": "75887370fb5a5d6d5111af33ef55e538de8b00daf43c3b96cf1d9ac68ec290dcd9498371e0e17f8ee4c73dfe0b2928d05be770c2401e136bc88c58e92e126313", + "nowMs": 1791820816223, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "notice: twenty hours old is still shown", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWFGlY3NlcRgYZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcGFwcHJvdmFsLmtleS5zZXRmd2FsbE1zGwAAAaEw19qwaHVwdGltZU1zGgABJRBhdGZub3RpY2Vjc2lnWEBslMBAqiznLiaedLlM28EGe661MxcbIVAadoY7xwF5cpHybeQU8pFgluWy6XoaOX7zvtEPOxBLFj0qzgfEaBLE\"}}", + "sig": "6c94c040aa2ce72e269e74b94cdbc1067baeb533171b21501a76863bc701797291f26de414f2916096e5b2e97a1a397ef3bed10f3b104b163d2ace07c46812c4", + "nowMs": 1791892815024, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "approval.key.set", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 24, + "atMs": 1791820815024 + } + }, + { + "name": "notice: over a day old", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWFGlY3NlcRgYZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcGFwcHJvdmFsLmtleS5zZXRmd2FsbE1zGwAAAaEw19qwaHVwdGltZU1zGgABJRBhdGZub3RpY2Vjc2lnWEDhgeh5sDs7qhAO/vie0bMhUWZJeajyBGqqql6TIH2c2i4nNqnWOFdaTnGigUzmM+Vvw13M9baTp+8wqH6gnRcg\"}}", + "sig": "e181e879b03b3baa100efef89ed1b32151664979a8f2046aaaaa5e93207d9cda2e2736a9d638575a4e71a2814ce633e56fc35dccf5b693a7ef30a87ea09d1720", + "nowMs": 1791907215025, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "notice with a short boot", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWESlY3NlcRgZZGJvb3RIsLCwsLCwsLBka2luZGtvd25lci5hZGRlZGZ3YWxsTXMbAAABoTDX2rFodXB0aW1lTXMaAAElEWF0Zm5vdGljZWNzaWdYQB0GZm45ROf59oQ4Kl/xD8+TQaF3B+mNoBN3uHrYHTvqGTD1378qCy/FI34or24G6uAl7HB4/RN3QQTNMdDU0KI=\"}}", + "sig": "1d06666e3944e7f9f684382a5ff10fcf9341a17707e98da01377b87ad81d3bea1930f5dfbf2a0b2fc5237e28af6e06eae025ec7078fd13774104cd31d0d4d0a2", + "nowMs": 1791820816225, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } } ] } From 4498908c912d93d2bde2bfb443dd1699c5efe84f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 12:02:19 +0200 Subject: [PATCH 3/3] push: agent.added says access was given, not who gave it Copied from srcfl/ftw-webapp (claude/agent-added-neutral, 8d80b8ea) and regenerated. The box renders it with no name of its own; the phone names the client from the notice when it has one. Co-Authored-By: Claude Opus 5.5 --- .changeset/cloud-notices.md | 2 +- contract/push-catalogue.yaml | 2 +- go/protocol/push_gen.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/cloud-notices.md b/.changeset/cloud-notices.md index d9e84f64..1a46e0ea 100644 --- a/.changeset/cloud-notices.md +++ b/.changeset/cloud-notices.md @@ -2,4 +2,4 @@ "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. +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. diff --git a/contract/push-catalogue.yaml b/contract/push-catalogue.yaml index 0c9e8c5a..3a563679 100644 --- a/contract/push-catalogue.yaml +++ b/contract/push-catalogue.yaml @@ -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. diff --git a/go/protocol/push_gen.go b/go/protocol/push_gen.go index d2249d07..489a7106 100644 --- a/go/protocol/push_gen.go +++ b/go/protocol/push_gen.go @@ -45,7 +45,7 @@ var PushSentences = map[string]PushSentence{ PushBoxUnreachable: {Title: "Your box is out of reach", Body: "It stopped answering. Power cut, or the internet at home is down."}, PushCloudGranted: {Title: "FTW Cloud is on", Body: "Your box now shares a copy of its readings with FTW Cloud. You can turn it off in the app or on the box."}, PushCloudRevoked: {Title: "FTW Cloud is off", Body: "Your box stopped sharing with FTW Cloud and asked it to delete its copy."}, - PushAgentAdded: {Title: "New access to your box", Body: "You gave an assistant or Sourceful access. See who has access in the app."}, + PushAgentAdded: {Title: "New access to your box", Body: "An assistant or Sourceful was given access to your box. See who has access in the app."}, PushAgentRequest: {Title: "Waiting for your answer", Body: "An assistant asks to change a setting. Open FTW to confirm or decline."}, PushAgentExpiring: {Title: "Access ends tomorrow", Body: "An assistant's or Sourceful's access to your box runs out in a day. Renew it in the app if it should go on."}, PushApprovalKeySet: {Title: "A phone can now approve access", Body: "A phone set up the key it uses to approve access to your box. If that was not you, remove the phone in the app."},