diff --git a/.changeset/cloud-notices.md b/.changeset/cloud-notices.md new file mode 100644 index 000000000..1a46e0eaa --- /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. 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 0c9e8c5ad..3a563679d 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/docs/adr/0008-ftw-cloud.md b/docs/adr/0008-ftw-cloud.md index a601c2d82..104f17f14 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 2951a14f1..822ec006a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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 diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index f36cdb0aa..dc381bd17 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -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 @@ -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 @@ -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 | @@ -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. diff --git a/go/cmd/ftw/agents.go b/go/cmd/ftw/agents.go index 882f5fbfd..83cb1c101 100644 --- a/go/cmd/ftw/agents.go +++ b/go/cmd/ftw/agents.go @@ -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 @@ -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 @@ -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, "") } } } @@ -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 } @@ -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 } @@ -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 } diff --git a/go/cmd/ftw/agents_notice_test.go b/go/cmd/ftw/agents_notice_test.go new file mode 100644 index 000000000..b2f4efb08 --- /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 550887372..d13d858af 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -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 } diff --git a/go/internal/appproto/agents.go b/go/internal/appproto/agents.go index 2fa826932..649284a83 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 @@ -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}) } @@ -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}) } @@ -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}) } diff --git a/go/internal/appproto/agents_test.go b/go/internal/appproto/agents_test.go index d9acabe53..16e8acd31 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 27cdd55de..597d10aed 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 64f60ac56..aa7c742e1 100644 --- a/go/internal/cloudlink/events.go +++ b/go/internal/cloudlink/events.go @@ -51,9 +51,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 +109,17 @@ 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 name cloudwire.NoticeClientOK takes, as the consent page cut it. +func noticeClient(snap grantSnapshot, n notice) string { + if n.kind != protocol.PushAgentAdded || !snap.copyOn() || !snap.g.has(protocol.ScopeEventsRead) || + !cloudwire.NoticeClientOK(n.client) { + return "" + } + return n.client +} + // projectEvent cuts opaque ids and drops names. func (l *Link) projectEvent(snap grantSnapshot, e EventIn) (cloudwire.Event, error) { id, err := randomBytes(eventIDBytes) @@ -199,11 +214,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 } @@ -212,11 +232,18 @@ func (l *Link) Notice(kind 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, kind) + 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 } @@ -228,8 +255,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 b15c76829..3df6ecee6 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -278,8 +278,11 @@ type Link struct { // again. events []EventIn eventsDropped int64 - // notices wait for the uplink; see Notice. - notices []string + // 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{} @@ -821,6 +824,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. @@ -836,7 +842,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, Transport: l.transport, - 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 c968e8aa5..9b44b13c8 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" @@ -523,15 +524,106 @@ 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.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) { + 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) +} + +// 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) { + 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 e3b0e782d..50a985ffe 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 004c99710..512271f26 100644 --- a/go/protocol/cloudwire/messages.go +++ b/go/protocol/cloudwire/messages.go @@ -1,6 +1,15 @@ package cloudwire -import "github.com/srcfl/ftw/go/protocol" +import ( + "errors" + "slices" + "unicode" + "unicode/utf8" + + "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 +387,72 @@ 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} +// 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 +// 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 +// 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 || 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 +} + // 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 000000000..6c6fa6afb --- /dev/null +++ b/go/protocol/cloudwire/notice_test.go @@ -0,0 +1,49 @@ +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 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}, + {"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 e5a977727..2e2fbe2fb 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" ) @@ -97,9 +98,14 @@ 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. +// 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 @@ -111,8 +117,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 +159,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 +224,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 +236,22 @@ func ReadAlert(payload []byte, boxKey func(home string) []byte, offset func(home return view } var ev Event - if Unmarshal(env.B, &ev) != nil { + 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) + 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 || 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) @@ -239,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 0fd94d6b3..f0ae43420 100644 --- a/go/protocol/cloudwire/push_vectors_test.go +++ b/go/protocol/cloudwire/push_vectors_test.go @@ -121,6 +121,29 @@ 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"} + // 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. @@ -139,11 +162,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 +231,32 @@ 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}, + {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} @@ -215,21 +267,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 +345,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 e6b4ac67c..55b8145a0 100644 --- a/go/protocol/cloudwire/testdata/push-vectors.json +++ b/go/protocol/cloudwire/testdata/push-vectors.json @@ -252,6 +252,125 @@ "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" + } + }, + { + "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" + } } ] } diff --git a/go/protocol/push_gen.go b/go/protocol/push_gen.go index d2249d072..489a71065 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."},