From 304c9e42f23f5354ae88590b862da15242972e40 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 09:45:40 +0200 Subject: [PATCH 1/2] feat: security notices reach the owners' phones, copy or not, and the car class is held A box's signed notice goes to the home's owner phones in an alert, as the box signed it, with the readable copy on or off, and never to a viewer or a watch. An owner's phone may register for alerts without the copy for this, and a roster keeps that token. A notice holding anything beyond its kind and, on agent.added, the client's name is dropped and counted. Notices have their own hourly cap, counted as they come, reach an owner another owner suspended, and Apple may hold them for a day. Events and box.unreachable go only to devices the newest roster lets read events, and an event of a notice's kind is dropped. An alert over APNs' 4 KB never queues. A box that has sent no roster gets no notice through. The pin moves to srcfl/ftw c41e6e98 (#1661 with master, QUIC included), which also carries master's car class: the service holds car like the other copy classes and serves it under ftw.live.read; a token never reads it. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 + cmd/ftwcloudd/main.go | 1 + go.mod | 2 +- go.sum | 4 +- internal/push/notice_test.go | 291 +++++++++++++++++++++++++++++++++ internal/push/push.go | 178 +++++++++++++++++--- internal/push/push_test.go | 16 +- internal/push/widgets_test.go | 13 +- internal/registry/push.go | 15 +- internal/registry/push_test.go | 2 +- internal/store/store_test.go | 3 +- internal/uplink/car_test.go | 58 +++++++ internal/uplink/hooks_test.go | 49 ++++++ internal/uplink/uplink.go | 17 ++ 14 files changed, 613 insertions(+), 43 deletions(-) create mode 100644 internal/push/notice_test.go create mode 100644 internal/uplink/car_test.go diff --git a/README.md b/README.md index a4f55d0..b5e4aa5 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,13 @@ to it yet. device, and go when the roster drops them or Apple says they are gone. Apple's key comes from Parameter Store ([deploy/apns.md](deploy/apns.md)). +- **Security notices.** A box's signed `notice` (someone gained access, + asks for a change, is about to lose access, can now approve access, or + paired as an owner) goes the same way, unchanged, but to the home's owner + phones only, with the readable copy on or off, and never to a viewer or a + watch. An owner's phone may therefore register for alerts with the copy + off. A notice holding anything beyond its kind and, on `agent.added`, the + client's name is dropped and counted. Notices have their own hourly cap. - **Widget push.** A widget registers its push token with `push.register {kind: widgets}`, which needs `ftw.live.read`. A watch registers its alert and widget tokens with `app: watch`; its pushes go to the watch diff --git a/cmd/ftwcloudd/main.go b/cmd/ftwcloudd/main.go index 7c5c299..0c18d6d 100644 --- a/cmd/ftwcloudd/main.go +++ b/cmd/ftwcloudd/main.go @@ -187,6 +187,7 @@ func serve(args []string) error { pushSvc.WatchApp = *apnsWatch pushSvc.Owns = shards.Ours up.OnHeld = pushSvc.Held + up.OnNotice = pushSvc.Notice up.OnRoster = func(home string, r *cloudwire.Roster) { pushSvc.Roster(home, r) tokens.Roster(home, r) diff --git a/go.mod b/go.mod index 537b92b..473d441 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/fxamacker/cbor/v2 v2.9.4 github.com/gorilla/websocket v1.5.3 github.com/quic-go/quic-go v0.63.0 - github.com/srcfl/ftw/go/protocol v0.0.0-20261016083708-8fdc990b05ac + github.com/srcfl/ftw/go/protocol v0.0.0-20261016090449-c41e6e986336 golang.org/x/crypto v0.57.0 golang.org/x/sys v0.48.0 modernc.org/sqlite v1.59.0 diff --git a/go.sum b/go.sum index c844831..ea08edd 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +52,8 @@ github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78 github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/srcfl/ftw/go/protocol v0.0.0-20261016083708-8fdc990b05ac h1:SrPUWxe26lQYr+UxxRvmm3rCqYmUrMaCpJ3dIQJ8Z4E= -github.com/srcfl/ftw/go/protocol v0.0.0-20261016083708-8fdc990b05ac/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= +github.com/srcfl/ftw/go/protocol v0.0.0-20261016090449-c41e6e986336 h1:IDM+zqGoL/zXgIyOshVNR76exFgw5hciNSZOfG0Pp18= +github.com/srcfl/ftw/go/protocol v0.0.0-20261016090449-c41e6e986336/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= diff --git a/internal/push/notice_test.go b/internal/push/notice_test.go new file mode 100644 index 0000000..132ab13 --- /dev/null +++ b/internal/push/notice_test.go @@ -0,0 +1,291 @@ +package push + +import ( + "bytes" + "encoding/json" + "slices" + "testing" + "time" + + "github.com/srcfl/ftw-cloud/internal/apns" + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// holdRoster keeps a roster as the uplink does: signed in the registry, and +// heard by push. +func (r *rig) holdRoster(t *testing.T, seq uint64, grant cloudwire.Grant, devices ...cloudwire.Device) { + t.Helper() + roster := cloudwire.Roster{RosterSeq: seq, TTLS: 86_400, Devices: devices, Grant: grant} + body, err := cloudwire.Marshal(roster) + if err != nil { + t.Fatal(err) + } + env, err := cloudwire.Envelope{T: cloudwire.MsgRoster, B: body, Sig: make([]byte, 64)}.Encode() + if err != nil { + t.Fatal(err) + } + if err := r.reg.SetRoster(r.home, seq, grant.ID, env, r.clock.time()); err != nil { + t.Fatal(err) + } + r.svc.Roster(r.home, &roster) +} + +// noticeEnvelope is a notice as the box signs it, with fields it should not +// carry when extra is set. +func noticeEnvelope(t *testing.T, kind string, extra map[string]any) []byte { + t.Helper() + body, err := cloudwire.Marshal(cloudwire.Notice{Header: cloudwire.Header{Seq: 7, Boot: bytes.Repeat([]byte{0xb0}, cloudwire.BootBytes), + UptimeMs: 9_000}, Kind: kind}) + if err != nil { + t.Fatal(err) + } + if extra != nil { + var m map[string]any + if err := cloudwire.Unmarshal(body, &m); err != nil { + t.Fatal(err) + } + for k, v := range extra { + m[k] = v + } + if body, err = cloudwire.Marshal(m); err != nil { + t.Fatal(err) + } + } + env, err := cloudwire.Envelope{T: cloudwire.MsgNotice, B: body, Sig: make([]byte, 64)}.Encode() + if err != nil { + t.Fatal(err) + } + return env +} + +func tokens(sent []apns.Notification) []string { + var out []string + for _, n := range sent { + out = append(out, string(n.DeviceToken)) + } + slices.Sort(out) + return out +} + +// With the readable copy off, a notice reaches each owner phone once, as the +// box signed it, and no viewer or watch: they got their tokens while the copy +// carried events, and lost them with it. +func TestANoticeGoesToEachOwnerPhoneWithTheCopyOff(t *testing.T) { + r := newRig(t) + second := cloudwire.Device{ID: "second-owner", Key: bytes.Repeat([]byte{7}, 32), Role: protocol.RoleOwner, Scopes: r.all, Epoch: 3} + viewer := r.viewer + viewer.Scopes = r.all + watch := cloudwire.Device{ID: "owner-watch", Key: bytes.Repeat([]byte{8}, 32), Role: "watch", Scopes: r.all, Epoch: 1} + on := cloudwire.Grant{ID: bytes.Repeat([]byte{5}, 16), Scopes: r.all, Purposes: []string{protocol.CloudPurposeServe}, + Controller: cloudwire.ControllerSourceful} + r.holdRoster(t, 1, on, r.owner, second, viewer, watch) + for dev, token := range map[*cloudwire.Device]string{&r.owner: "owner-token", &second: "second-token", &viewer: "viewer-token"} { + if err := r.register(t, *dev, r.all, token); err != nil { + t.Fatal(err) + } + } + if err := r.svc.Register(r.home, watch, r.all, cloudwire.PushRegister{APNsToken: []byte("watch-token"), + Environment: cloudwire.PushSandbox, Kind: cloudwire.PushKindAlert, App: cloudwire.PushAppWatch}); err != nil { + t.Fatal(err) + } + r.svc.WatchApp = "energy.ftw.app.watchkitapp" + + // With the copy on, a notice is still the owners' alone. + r.svc.Notice(r.home, noticeEnvelope(t, protocol.PushAgentAdded, nil)) + r.drain() + if got := tokens(r.apple.notifications()); !slices.Equal(got, []string{"owner-token", "second-token"}) { + t.Fatalf("with the copy on, the notice went to %v", got) + } + + // The copy goes off. + r.holdRoster(t, 2, cloudwire.Grant{ID: on.ID, Controller: cloudwire.ControllerSourceful}, r.owner, second, viewer, watch) + before := len(r.apple.notifications()) + envelope := noticeEnvelope(t, protocol.PushOwnerAdded, nil) + r.svc.Notice(r.home, envelope) + r.drain() + sent := r.apple.notifications()[before:] + if got := tokens(sent); !slices.Equal(got, []string{"owner-token", "second-token"}) { + t.Fatalf("with the copy off, the notice went to %v", got) + } + want, _ := cloudwire.AlertPayload(cloudwire.Alert{Home: r.home, Event: envelope}) + for _, n := range sent { + var p struct { + FTW cloudwire.Alert `json:"ftw"` + } + if !bytes.Equal(n.Payload, want) || json.Unmarshal(n.Payload, &p) != nil || !bytes.Equal(p.FTW.Event, envelope) || + n.Type != apns.PushAlert || n.App != "" { + t.Fatalf("notification %+v", n) + } + } + // An event that arrives anyway goes to nobody: no device reads events + // now. + r.svc.Event(r.home, []byte{0xa0}) + r.drain() + if len(r.apple.notifications()) != before+2 { + t.Fatal("an event went out with the copy off") + } +} + +// An owner's phone registers for alerts with the copy off, to hear notices; +// a viewer, a watch and an owner's watch app do not. +func TestAnOwnersPhoneRegistersForNoticesWithTheCopyOff(t *testing.T) { + r := newRig(t) + if err := r.register(t, r.owner, nil, "owner-token"); err != nil { + t.Fatalf("an owner's phone: %v", err) + } + if err := r.register(t, r.viewer, nil, "viewer-token"); err != ErrNotForThisDevice { + t.Fatalf("a viewer registered: %v", err) + } + watch := cloudwire.Device{ID: "owner-watch", Key: bytes.Repeat([]byte{8}, 32), Role: "watch", Epoch: 1} + if err := r.register(t, watch, nil, "watch-token"); err != ErrNotForThisDevice { + t.Fatalf("a watch registered: %v", err) + } + if err := r.svc.Register(r.home, r.owner, nil, cloudwire.PushRegister{APNsToken: []byte("owner-watch-app"), + Environment: cloudwire.PushSandbox, Kind: cloudwire.PushKindAlert, App: cloudwire.PushAppWatch}); err != ErrNotForThisDevice { + t.Fatalf("an owner's watch app registered: %v", err) + } + if err := r.svc.Register(r.home, r.owner, nil, cloudwire.PushRegister{APNsToken: []byte("w"), + Environment: cloudwire.PushSandbox, Kind: KindWidgets}); err != ErrNotForThisDevice { + t.Fatalf("an owner registered widgets without the copy: %v", err) + } + // A roster with the copy off keeps the owner's token. + r.holdRoster(t, 1, cloudwire.Grant{Controller: cloudwire.ControllerSourceful}, r.owner, r.viewer) + if regs, _ := r.reg.PushRegs(r.home, cloudwire.PushKindAlert); len(regs) != 1 || regs[0].DeviceID != r.owner.ID { + t.Fatalf("kept %+v", regs) + } +} + +// A notice carries a kind and, on agent.added, the client's name. One that +// carries a reading, or anything that is not a notice, reaches nobody. +func TestANoticeCannotCarryReadings(t *testing.T) { + r := newRig(t) + r.holdRoster(t, 1, cloudwire.Grant{Controller: cloudwire.ControllerSourceful}, r.owner) + if err := r.register(t, r.owner, nil, "owner-token"); err != nil { + t.Fatal(err) + } + refused := noticesRefused.Load() + for name, env := range map[string][]byte{ + "a reading": noticeEnvelope(t, protocol.PushAgentRequest, map[string]any{"fields": map[int]int64{1: 2400}}), + "a source id": noticeEnvelope(t, protocol.PushAgentRequest, map[string]any{"src": "q5Lw0Zb1"}), + "a kind that is no notice": noticeEnvelope(t, protocol.PushChargingConnected, nil), + "not an envelope": {0x01, 0x02}, + } { + r.svc.Notice(r.home, env) + r.drain() + if n := len(r.apple.notifications()); n != 0 { + t.Fatalf("%s: %d alerts", name, n) + } + } + if got := noticesRefused.Load() - refused; got != 4 { + t.Fatalf("counted %d refused notices", got) + } + // An event's envelope handed in as a notice is no notice either. + event, _ := cloudwire.Envelope{T: cloudwire.MsgEvent, B: []byte{0xa0}, Sig: make([]byte, 64)}.Encode() + r.svc.Notice(r.home, event) + r.drain() + if n := len(r.apple.notifications()); n != 0 { + t.Fatalf("an event went as a notice: %d alerts", n) + } +} + +// Notices and events have caps of their own, so a home's events cannot +// crowd out the owners' notices. +func TestNoticesHaveTheirOwnCap(t *testing.T) { + r := newRig(t) + on := cloudwire.Grant{ID: bytes.Repeat([]byte{5}, 16), Scopes: r.all, Purposes: []string{protocol.CloudPurposeServe}, + Controller: cloudwire.ControllerSourceful} + r.holdRoster(t, 1, on, r.owner) + if err := r.register(t, r.owner, r.all, "owner-token"); err != nil { + t.Fatal(err) + } + for range MaxAlertsPerHour + 1 { + r.svc.Event(r.home, []byte{0xa0}) + } + r.drain() + if n := len(r.apple.notifications()); n != MaxAlertsPerHour { + t.Fatalf("%d events went", n) + } + r.svc.Notice(r.home, noticeEnvelope(t, protocol.PushAgentRequest, nil)) + r.drain() + if n := len(r.apple.notifications()); n != MaxAlertsPerHour+1 { + t.Fatal("the events' cap held back a notice") + } +} + +// A box from before notices reached FTW Cloud sends an agent.added both as +// an event and as a notice. The event is dropped: it would reach a viewer, +// and the owners hear the notice. +func TestAnEventOfANoticeKindIsDropped(t *testing.T) { + r := newRig(t) + on := cloudwire.Grant{ID: bytes.Repeat([]byte{5}, 16), Scopes: r.all, Purposes: []string{protocol.CloudPurposeServe}, + Controller: cloudwire.ControllerSourceful} + viewer := r.viewer + viewer.Scopes = r.all + r.holdRoster(t, 1, on, r.owner, viewer) + _ = r.register(t, r.owner, r.all, "owner-token") + _ = r.register(t, viewer, r.all, "viewer-token") + event := func(kind string) []byte { + body, err := cloudwire.Marshal(cloudwire.Event{Header: cloudwire.Header{Seq: 3, Boot: bytes.Repeat([]byte{0xb0}, cloudwire.BootBytes)}, + EventID: "e1", Kind: kind, Severity: cloudwire.SeverityInfo, Args: map[string]string{}}) + if err != nil { + t.Fatal(err) + } + env, _ := cloudwire.Envelope{T: cloudwire.MsgEvent, B: body, Sig: make([]byte, 64)}.Encode() + return env + } + refused := eventsRefused.Load() + for _, kind := range cloudwire.NoticeKinds { + r.svc.Event(r.home, event(kind)) + } + r.drain() + if n := len(r.apple.notifications()); n != 0 || eventsRefused.Load()-refused != int64(len(cloudwire.NoticeKinds)) { + t.Fatalf("%d alerts, %d refused", n, eventsRefused.Load()-refused) + } + r.svc.Event(r.home, event(protocol.PushChargingConnected)) + r.drain() + if got := tokens(r.apple.notifications()); !slices.Equal(got, []string{"owner-token", "viewer-token"}) { + t.Fatalf("an ordinary event went to %v", got) + } +} + +// An owner an owner suspended gets no event, but still every notice: one +// owner must not silence another before adding an agent, a key or an owner. +func TestASuspendedOwnerStillGetsNotices(t *testing.T) { + r := newRig(t) + second := cloudwire.Device{ID: "second-owner", Key: bytes.Repeat([]byte{7}, 32), Role: protocol.RoleOwner, Scopes: r.all, Epoch: 3} + on := cloudwire.Grant{ID: bytes.Repeat([]byte{5}, 16), Scopes: r.all, Purposes: []string{protocol.CloudPurposeServe}, + Controller: cloudwire.ControllerSourceful} + r.holdRoster(t, 1, on, r.owner, second) + _ = r.register(t, r.owner, r.all, "owner-token") + _ = r.register(t, second, r.all, "second-token") + if err := r.reg.Suspend(r.home, second.ID, second.Epoch, r.clock.time()); err != nil { + t.Fatal(err) + } + r.svc.Event(r.home, []byte{0xa0}) + r.drain() + if got := tokens(r.apple.notifications()); !slices.Equal(got, []string{"owner-token"}) { + t.Fatalf("an event went to %v", got) + } + before := len(r.apple.notifications()) + r.svc.Notice(r.home, noticeEnvelope(t, protocol.PushOwnerAdded, nil)) + r.drain() + if got := tokens(r.apple.notifications()[before:]); !slices.Equal(got, []string{"owner-token", "second-token"}) { + t.Fatalf("the notice went to %v", got) + } + if n := r.apple.notifications()[before]; !n.Expiration.Equal(r.clock.time().Add(24 * time.Hour)) { + t.Fatalf("a notice expires at %v", n.Expiration) + } +} + +// Apple takes at most 4 KB; a bigger alert never queues. +func TestAnAlertOverAPNsLimitIsDropped(t *testing.T) { + r := newRig(t) + _ = r.register(t, r.owner, r.all, "owner-token") + big := tooLarge.Load() + r.svc.Event(r.home, bytes.Repeat([]byte{0xa0}, 4000)) + r.drain() + if n := len(r.apple.notifications()); n != 0 || tooLarge.Load()-big != 1 { + t.Fatalf("%d alerts, %d too large", n, tooLarge.Load()-big) + } +} diff --git a/internal/push/push.go b/internal/push/push.go index 55f18fc..6313e18 100644 --- a/internal/push/push.go +++ b/internal/push/push.go @@ -11,15 +11,26 @@ // saying it was restarting on purpose. It carries no arguments and no // buttons. // +// A security notice (docs/agents.md, Push kinds) goes the same way, signed +// by the box and unchanged, but to the home's owner devices only, with the +// readable copy on or off: someone gained access, asks for a change, is +// about to lose access, or can now approve access. It goes to no viewer and +// no watch token, reaches an owner an owner has suspended, and Apple may +// hold it for a day. A notice that carries anything beyond its kind and, on +// agent.added, the client's name is dropped and counted, and so is an event +// of a notice's kind. +// // A widgets push carries no data. It tells WidgetKit to reload the app's // widgets, which then read GET /v1/now with their own token. It goes when // a box's priced hours reach further than before, as when tomorrow's prices // arrive, and when a car is plugged in. // // Tokens are bound to roster entries. A device registers for alerts only if -// its role and the owner's grant both allow ftw.events.read, and for widgets -// only if they allow ftw.live.read. A roster that drops the device, moves -// its epoch or narrows its scope drops its tokens. A device an owner has +// its role and the owner's grant both allow ftw.events.read, or it is an +// owner's phone, which gets notices whatever the grant; and for widgets +// only if they allow ftw.live.read. An event and box.unreachable go only to +// devices the newest roster lets read events. A roster that drops the +// device, moves its epoch or narrows its scope drops its tokens. A device an owner has // suspended gets nothing until the suspension ends. Nothing here logs a // token or a home id. // @@ -52,6 +63,9 @@ const ( // raises a few notifications a day; past this, something is wrong and // the rest are dropped. MaxAlertsPerHour = 20 + // MaxNoticesPerHour is the same for security notices, counted apart, + // so a flood of events cannot hide a new owner and the reverse. + MaxNoticesPerHour = 20 // MaxTokensPerDevice bounds a device's registrations of one kind. A // phone holds one token per kind; a few more cover a reinstall before // Apple says the old token is gone. Past it the oldest goes, so one @@ -109,6 +123,9 @@ var ( alertsQueued = metrics.Default.Gauge("ftwcloud_push_queued", "Pushes waiting for a worker.", "kind", "alert") widgetsQueued = metrics.Default.Gauge("ftwcloud_push_queued", "Pushes waiting for a worker.", "kind", "widgets") alertsDropped = metrics.Drop("push_alert_queue_full") + noticesRefused = metrics.Drop("push_notice_not_a_notice") + eventsRefused = metrics.Drop("push_event_of_a_notice_kind") + tooLarge = metrics.Drop("push_payload_over_apns_limit") widgetsDropped = metrics.Drop("push_widgets_queue_full") widgetsMerged = metrics.Default.Counter("ftwcloud_push_coalesced_total", "Widget pushes merged into one already waiting.") pushCapped = metrics.Drop("push_home_cap") @@ -193,7 +210,7 @@ type Service struct { // alertJob is one alert to a home's phones. On a retry, regs holds the // registrations Apple did not take, and tries how often it went. // unreachable marks box.unreachable, which is recorded as told only once a -// phone took it. +// phone took it. notice marks a security notice, for the owners alone. type alertJob struct { home string payload []byte @@ -201,8 +218,19 @@ type alertJob struct { tries int regs []registry.PushReg unreachable bool + notice bool } +// noticeBucket counts notices against MaxNoticesPerHour, apart from alerts. +const noticeBucket = "notice" + +// maxPayload is the most APNs takes in an alert. +const maxPayload = 4096 + +// noticeExpiry is how long Apple may keep trying to deliver a notice: it +// states a lasting fact, and the phone shows it for that long. +const noticeExpiry = cloudwire.NoticeMaxAgeMs * time.Millisecond + // homeState is one home's box connection, as the dead man's switch sees it, // its recent pushes per kind, and what its widgets show that can change. type homeState struct { @@ -378,7 +406,7 @@ func (s *Service) Register(home string, dev cloudwire.Device, scopes []string, r if app == "" { app = cloudwire.PushAppIPhone } - if !known || !slices.Contains(scopes, k.scope) || + if !known || !(slices.Contains(scopes, k.scope) || getsNotices(dev, r.Kind, app)) || (app != cloudwire.PushAppIPhone && app != cloudwire.PushAppWatch) || len(r.APNsToken) == 0 || len(r.APNsToken) > cloudwire.APNsTokenMaxBytes || (r.Environment != cloudwire.PushSandbox && r.Environment != cloudwire.PushProduction) { @@ -391,6 +419,12 @@ func (s *Service) Register(home string, dev cloudwire.Device, scopes []string, r return s.reg.PushTrim(home, dev.ID, r.Kind, MaxTokensPerDevice) } +// getsNotices reports whether a registration of kind may stand for +// security notices alone: an owner's phone, for alerts. +func getsNotices(dev cloudwire.Device, kindName, app string) bool { + return kindName == cloudwire.PushKindAlert && dev.Role == protocol.RoleOwner && app == cloudwire.PushAppIPhone +} + // Unregister forgets one of the device's tokens. func (s *Service) Unregister(home string, dev cloudwire.Device, r cloudwire.PushUnregister) error { return s.reg.PushUnregister(home, dev.ID, r.APNsToken) @@ -411,10 +445,11 @@ func (s *Service) Roster(home string, r *cloudwire.Roster) { for _, d := range r.Devices { devices[d.ID] = d } - if err := s.reg.PushPrune(home, func(id string, epoch uint64, kindName string) bool { + if err := s.reg.PushPrune(home, func(id string, epoch uint64, kindName, app string) bool { d, ok := devices[id] k, known := kinds[kindName] - return ok && known && d.Epoch == epoch && slices.Contains(d.Scopes, k.scope) && slices.Contains(r.Grant.Scopes, k.scope) + return ok && known && d.Epoch == epoch && (getsNotices(d, kindName, app) || + slices.Contains(d.Scopes, k.scope) && slices.Contains(r.Grant.Scopes, k.scope)) }); err != nil { s.log.Warn("could not drop the tokens a roster dropped", "err", err) } @@ -435,15 +470,62 @@ func (s *Service) Held(home, class string, envelope []byte) { } // Event sends a box-signed event envelope, as it arrived, to the home's -// registered phones. +// registered phones. An event of a notice's kind is dropped: a box from +// before notices reached FTW Cloud sends one as both, and as an event it +// would reach the viewers too. func (s *Service) Event(home string, envelope []byte) { + if env, err := cloudwire.DecodeEnvelope(envelope); err == nil { + var ev struct { + Kind string `cbor:"kind"` + } + if cloudwire.Unmarshal(env.B, &ev) == nil && slices.Contains(cloudwire.NoticeKinds, ev.Kind) { + eventsRefused.Inc() + return + } + } payload, err := cloudwire.AlertPayload(cloudwire.Alert{Home: home, Event: envelope}) - if err != nil { + if err != nil || !fits(payload) { return } s.queueAlert(alertJob{home: home, payload: payload, expires: s.now().Add(eventExpiry)}) } +// fits reports whether Apple takes a payload this size, and counts one it +// would not. +func fits(payload []byte) bool { + if len(payload) > maxPayload { + tooLarge.Inc() + return false + } + return true +} + +// Notice sends a box-signed security notice, as it arrived, to the home's +// owner phones. envelope must hold a notice and nothing more +// (cloudwire.ReadNotice); anything else is dropped and counted. +func (s *Service) Notice(home string, envelope []byte) { + env, err := cloudwire.DecodeEnvelope(envelope) + if err == nil && env.T == cloudwire.MsgNotice { + _, err = cloudwire.ReadNotice(env.B) + } + if err != nil || env.T != cloudwire.MsgNotice { + noticesRefused.Inc() + s.log.Warn("push: a box sent a notice that is not one; dropped") + return + } + payload, err := cloudwire.AlertPayload(cloudwire.Alert{Home: home, Event: envelope}) + if err != nil || !fits(payload) { + return + } + // Counted here, so a box past its cap fills no queue. + if !s.allowIn(home, noticeBucket, MaxNoticesPerHour) { + pushCapped.Inc() + s.log.Warn("a home passed its push cap; push dropped", "kind", noticeBucket, "cap_per_hour", MaxNoticesPerHour) + return + } + s.queueAlert(alertJob{home: home, payload: payload, expires: s.now().Add(noticeExpiry), notice: true}) +} + // widgetsChanged reports whether prices now reach further than before, or a // loadpoint is plugged in that was not. The first of each after a restart // only sets what the next is compared with. @@ -651,38 +733,90 @@ func (s *Service) state(home string) *homeState { // allow counts one push of a kind against the home's hourly cap. func (s *Service) allow(home, kindName string) bool { + return s.allowIn(home, kindName, kinds[kindName].perHour) +} + +// allowIn counts one push against a bucket's hourly cap. +func (s *Service) allowIn(home, bucket string, perHour int) bool { s.mu.Lock() defer s.mu.Unlock() st := s.state(home) now := s.now() var keep []time.Time - for _, at := range st.sent[kindName] { + for _, at := range st.sent[bucket] { if now.Sub(at) < time.Hour { keep = append(keep, at) } } - if len(keep) >= kinds[kindName].perHour { - st.sent[kindName] = keep + if len(keep) >= perHour { + st.sent[bucket] = keep return false } - st.sent[kindName] = append(keep, now) + st.sent[bucket] = append(keep, now) return true } // regs is a home's registrations of a kind that may get a push now. A // device an owner has suspended keeps its tokens, so lifting the suspension -// brings its pushes back, but gets nothing meanwhile. A watch's token waits -// for the watch app's topic. -func (s *Service) regs(home, kindName string) ([]registry.PushReg, error) { +// brings its pushes back, but gets nothing meanwhile, except a notice: one +// owner must not be able to silence another before adding an agent, a key +// or an owner. A watch's token waits for the watch app's topic. +func (s *Service) regs(home, kindName string, notice bool) ([]registry.PushReg, error) { regs, err := s.reg.PushRegs(home, kindName) if err != nil { return nil, err } return slices.DeleteFunc(regs, func(r registry.PushReg) bool { - return s.reg.Suspended(home, r.DeviceID, r.Epoch) || (r.App == cloudwire.PushAppWatch && s.WatchApp == "") + return (!notice && s.reg.Suspended(home, r.DeviceID, r.Epoch)) || (r.App == cloudwire.PushAppWatch && s.WatchApp == "") }), nil } +// audience keeps the registrations an alert may reach, by the newest roster +// the home's box signed: for a notice, its owner phones; for anything else, +// the devices whose effective scope has ftw.events.read. Without a roster +// held, an event goes where the tokens are, as the roster prunes them, and +// a notice goes nowhere. +func (s *Service) audience(home string, notice bool, regs []registry.PushReg) ([]registry.PushReg, error) { + r, err := s.roster(home) + if err != nil || (r == nil && !notice) { + return regs, err + } + devices := map[string]cloudwire.Device{} + if r != nil { + for _, d := range r.Devices { + devices[d.ID] = d + } + } + events := r != nil && slices.Contains(r.Grant.Scopes, protocol.ScopeEventsRead) + return slices.DeleteFunc(regs, func(reg registry.PushReg) bool { + d, ok := devices[reg.DeviceID] + if !ok || d.Epoch != reg.Epoch { + return true + } + if notice { + return !getsNotices(d, cloudwire.PushKindAlert, reg.App) + } + return !events || !slices.Contains(d.Scopes, protocol.ScopeEventsRead) + }), nil +} + +// roster is the newest roster the home's box signed, nil when none is held. +func (s *Service) roster(home string) (*cloudwire.Roster, error) { + h, ok, err := s.reg.Get(home) + if err != nil || !ok || len(h.Roster) == 0 { + return nil, err + } + env, err := cloudwire.DecodeEnvelope(h.Roster) + if err != nil { + return nil, err + } + var r cloudwire.Roster + if err := cloudwire.Unmarshal(env.B, &r); err != nil { + return nil, err + } + return &r, nil +} + // sendAlert posts an alert to the home's registered devices. What Apple or // the network could not take goes again later, while the alert is worth // showing, and only to devices that may still get it; a gone token is @@ -691,7 +825,10 @@ func (s *Service) sendAlert(ctx context.Context, job alertJob) { if s.sender == nil { return } - fresh, err := s.regs(job.home, cloudwire.PushKindAlert) + fresh, err := s.regs(job.home, cloudwire.PushKindAlert, job.notice) + if err == nil && len(fresh) > 0 { + fresh, err = s.audience(job.home, job.notice, fresh) + } if err != nil { s.log.Warn("could not read push registrations; the alert goes again", "err", err) s.retryAlert(job, nil) @@ -703,7 +840,8 @@ func (s *Service) sendAlert(ctx context.Context, job alertJob) { s.told(job) return } - if !s.allow(job.home, cloudwire.PushKindAlert) { + // A notice was counted when it came. + if !job.notice && !s.allow(job.home, cloudwire.PushKindAlert) { pushCapped.Inc() s.log.Warn("a home passed its push cap; push dropped", "kind", cloudwire.PushKindAlert, "cap_per_hour", MaxAlertsPerHour) s.told(job) @@ -771,7 +909,7 @@ func (s *Service) sendWidgets(ctx context.Context, home string) bool { if s.sender == nil { return true } - regs, err := s.regs(home, KindWidgets) + regs, err := s.regs(home, KindWidgets, false) if err != nil { return false } diff --git a/internal/push/push_test.go b/internal/push/push_test.go index 12aaf56..32ff879 100644 --- a/internal/push/push_test.go +++ b/internal/push/push_test.go @@ -175,10 +175,8 @@ func TestOnlyADeviceThatMayReadEventsRegisters(t *testing.T) { t.Fatalf("a viewer registered: %v", err) } // An owner under a grant that leaves events out has no events scope - // in its effective scope. - if err := r.register(t, r.owner, []string{protocol.ScopeLiveRead}, "owner-token"); err != ErrNotForThisDevice { - t.Fatalf("an owner without events registered: %v", err) - } + // in its effective scope, and registers for the notices alone + // (notice_test.go). The rest of this test is about the request. bad := []cloudwire.PushRegister{ {APNsToken: []byte("t"), Environment: "staging", Kind: cloudwire.PushKindAlert}, {APNsToken: []byte("t"), Environment: cloudwire.PushSandbox, Kind: "liveactivity"}, @@ -263,11 +261,15 @@ func TestARosterThatDropsOrNarrowsADeviceDropsItsTokens(t *testing.T) { if regs, _ := r.reg.PushRegs(r.home, cloudwire.PushKindAlert); len(regs) != 0 { t.Fatalf("after a new epoch: %+v", regs) } - // And a grant that drops events drops every token. + // A grant that drops events drops a viewer's token; an owner's phone + // keeps its own for notices. + viewer := r.viewer + viewer.Scopes = r.all _ = r.register(t, again, r.all, "c") + _ = r.register(t, viewer, r.all, "d") grant.Scopes = []string{protocol.ScopeLiveRead} - r.svc.Roster(r.home, &cloudwire.Roster{Devices: []cloudwire.Device{again}, Grant: grant}) - if regs, _ := r.reg.PushRegs(r.home, cloudwire.PushKindAlert); len(regs) != 0 { + r.svc.Roster(r.home, &cloudwire.Roster{Devices: []cloudwire.Device{again, viewer}, Grant: grant}) + if regs, _ := r.reg.PushRegs(r.home, cloudwire.PushKindAlert); len(regs) != 1 || regs[0].DeviceID != again.ID { t.Fatalf("after the grant dropped events: %+v", regs) } } diff --git a/internal/push/widgets_test.go b/internal/push/widgets_test.go index 8bccd14..7aedb14 100644 --- a/internal/push/widgets_test.go +++ b/internal/push/widgets_test.go @@ -190,10 +190,14 @@ func TestWidgetPushesAreCappedPerHome(t *testing.T) { } // A roster prunes each kind by its own scope: a device that loses events -// keeps its widgets, and one that loses live loses them. +// keeps its widgets, and one that loses live loses them. An owner's phone +// keeps its alerts for notices. func TestARosterPrunesEachKindByItsScope(t *testing.T) { r := newRig(t) + viewer := r.viewer + viewer.Scopes = r.all _ = r.register(t, r.owner, r.all, "alerts") + _ = r.register(t, viewer, r.all, "viewer-alerts") _ = r.registerWidgets(t, r.owner, r.all, "widgets") grant := cloudwire.Grant{ID: bytes.Repeat([]byte{4}, 16), Scopes: r.all} count := func(kind string) int { @@ -202,9 +206,10 @@ func TestARosterPrunesEachKindByItsScope(t *testing.T) { } noEvents := r.owner noEvents.Scopes = []string{protocol.ScopeLiveRead} - r.svc.Roster(r.home, &cloudwire.Roster{Devices: []cloudwire.Device{noEvents}, Grant: grant}) - if count(cloudwire.PushKindAlert) != 0 || count(KindWidgets) != 1 { - t.Fatal("losing events did not drop alerts alone") + viewerNoEvents := r.viewer + r.svc.Roster(r.home, &cloudwire.Roster{Devices: []cloudwire.Device{noEvents, viewerNoEvents}, Grant: grant}) + if count(cloudwire.PushKindAlert) != 1 || count(KindWidgets) != 1 { + t.Fatal("losing events did not drop the viewer's alerts alone") } grant.Scopes = []string{protocol.ScopeEventsRead} r.svc.Roster(r.home, &cloudwire.Roster{Devices: []cloudwire.Device{r.owner}, Grant: grant}) diff --git a/internal/registry/push.go b/internal/registry/push.go index aebb827..243a656 100644 --- a/internal/registry/push.go +++ b/internal/registry/push.go @@ -149,9 +149,9 @@ func (r *Registry) PushTrim(home, deviceID, kind string, keep int) error { } // PushPrune keeps only the registrations keep allows: a device at its epoch, -// for one kind, as a new roster says. -func (r *Registry) PushPrune(home string, keep func(deviceID string, epoch uint64, kind string) bool) error { - rows, err := r.rd.Query(`SELECT DISTINCT device_id, epoch, kind FROM push WHERE home = ?`, home) +// for one kind and app, as a new roster says. +func (r *Registry) PushPrune(home string, keep func(deviceID string, epoch uint64, kind, app string) bool) error { + rows, err := r.rd.Query(`SELECT DISTINCT device_id, epoch, kind, app FROM push WHERE home = ?`, home) if err != nil { return err } @@ -159,22 +159,23 @@ func (r *Registry) PushPrune(home string, keep func(deviceID string, epoch uint6 id string epoch int64 kind string + app string } var gone []entry for rows.Next() { var e entry - if err := rows.Scan(&e.id, &e.epoch, &e.kind); err != nil { + if err := rows.Scan(&e.id, &e.epoch, &e.kind, &e.app); err != nil { _ = rows.Close() return err } - if !keep(e.id, uint64(e.epoch), e.kind) { + if !keep(e.id, uint64(e.epoch), e.kind, e.app) { gone = append(gone, e) } } _ = rows.Close() for _, e := range gone { - if _, err := r.db.Exec(`DELETE FROM push WHERE home = ? AND device_id = ? AND epoch = ? AND kind = ?`, - home, e.id, e.epoch, e.kind); err != nil { + if _, err := r.db.Exec(`DELETE FROM push WHERE home = ? AND device_id = ? AND epoch = ? AND kind = ? AND app = ?`, + home, e.id, e.epoch, e.kind, e.app); err != nil { return err } } diff --git a/internal/registry/push_test.go b/internal/registry/push_test.go index 349efc6..e8e1b2d 100644 --- a/internal/registry/push_test.go +++ b/internal/registry/push_test.go @@ -29,7 +29,7 @@ func TestARegistrationBelongsToARosterEntryAndGoesWithIt(t *testing.T) { } // The roster drops phone-b and moves phone-a's epoch: both go. - if err := r.PushPrune("h1", func(id string, epoch uint64, _ string) bool { return id == "phone-a" && epoch == 2 }); err != nil { + if err := r.PushPrune("h1", func(id string, epoch uint64, _, _ string) bool { return id == "phone-a" && epoch == 2 }); err != nil { t.Fatal(err) } if got, _ := r.PushRegs("h1", "alert"); len(got) != 0 { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index fb614da..4dd7de3 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -265,13 +265,14 @@ func TestABoxHandleCannotOpenASession(t *testing.T) { // A token reaches its home through the roster entry it is bound to, at that // epoch, and reads only Now: the newest live, prices and hb, and the newest -// live that carried sources. +// live that carried sources. Never lp, car, plan, health or tiles. func TestATokenReadsOnlyNow(t *testing.T) { s, box, _ := filled(t) _ = box.Put(cloudwire.MsgPrices, []byte("prices"), 8, false) _ = box.Put(cloudwire.MsgLP, []byte("lp"), 9, false) _ = box.Put(cloudwire.MsgHealth, []byte("health"), 10, false) _ = box.Put(cloudwire.MsgTiles, []byte("tiles"), 11, false) + _ = box.Put(cloudwire.MsgCar, []byte("car"), 12, false) box.SetRoster(roster(2, protocol.CloudScopes...)) x, err := s.For(Principal{Kind: Token, Home: "h1", DeviceID: "owner"}) if err != nil { diff --git a/internal/uplink/car_test.go b/internal/uplink/car_test.go new file mode 100644 index 0000000..19dfa09 --- /dev/null +++ b/internal/uplink/car_test.go @@ -0,0 +1,58 @@ +package uplink + +import ( + "bytes" + "testing" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// The car class is held like the other copy classes, as the box signed it, +// and a phone reads it only under ftw.live.read (docs/protocol-cloud.md, +// "car"). +func TestTheCarClassIsHeldAndServedUnderLive(t *testing.T) { + soc, age := int64(640), int64(120) + car := cloudwire.Cars{Cars: []cloudwire.Car{{ID: "c4R1d0x9", SocPermille: &soc, SocSource: protocol.CarSoCVehicle, + SocAgeS: &age, EnergySinceWh: 300, Goal: &cloudwire.CarGoal{TargetPermille: 800, ReadyByMin: 420, Days: 0x7f}}}} + for _, c := range []struct { + name string + scopes []string + served bool + }{ + {"live granted", []string{protocol.ScopeLiveRead}, true}, + {"live not granted", []string{protocol.ScopeEventsRead}, false}, + } { + t.Run(c.name, func(t *testing.T) { + s := newService(t) + b := newBox(t) + if _, err := b.connect(s, nil); err != nil { + t.Fatal(err) + } + b.send(cloudwire.MsgRoster, b.roster(c.scopes...)) + c2 := car + c2.Header = b.header() + sent := b.send(cloudwire.MsgCar, c2) + hb := b.send(cloudwire.MsgHB, cloudwire.HB{Header: b.header(), Clock: cloudwire.ClockNTP}) + wait(t, "the hb", func() bool { + for _, e := range snapshot(t, s, b.home) { + if bytes.Equal(e, hb) { + return true + } + } + return false + }) + got := snapshot(t, s, b.home) + held := false + for _, e := range got { + held = held || bytes.Equal(e, sent) + } + if held != c.served { + t.Fatalf("car served %v, want %v (%d envelopes)", held, c.served, len(got)) + } + if b.closed() { + t.Fatal("the car class closed the uplink") + } + }) + } +} diff --git a/internal/uplink/hooks_test.go b/internal/uplink/hooks_test.go index 8f5ae77..45566ca 100644 --- a/internal/uplink/hooks_test.go +++ b/internal/uplink/hooks_test.go @@ -129,3 +129,52 @@ func TestTheEndOfAnUplinkSaysWhy(t *testing.T) { t.Fatalf("connects %d", conns()) } } + +// A security notice reaches push as the box signed it with the readable copy +// off, and the service holds nothing of it. +func TestANoticeReachesPushWithTheCopyOff(t *testing.T) { + s := newService(t) + h := listen(s) + var notices [][]byte + var mu sync.Mutex + s.up.OnNotice = func(_ string, env []byte) { mu.Lock(); notices = append(notices, env); mu.Unlock() } + b := newBox(t) + _, _ = b.connect(s, nil) + off := b.roster() + off.Grant = cloudwire.Grant{Controller: cloudwire.ControllerSourceful} + b.send(cloudwire.MsgRoster, off) + sent := b.send(cloudwire.MsgNotice, cloudwire.Notice{Header: b.header(), Kind: protocol.PushOwnerAdded}) + wait(t, "the notice", func() bool { mu.Lock(); defer mu.Unlock(); return len(notices) == 1 }) + if !bytes.Equal(notices[0], sent) { + t.Fatal("push got other bytes than the box sent") + } + if classes := h.count(func(h *heard) int { return len(h.classes) }); classes() != 0 { + t.Fatalf("the service held %v with the copy off", h.classes) + } + if b.closed() { + t.Fatal("a notice closed the uplink") + } +} + +// A box that has sent no roster names no owner: its notice goes nowhere and +// costs push nothing. +func TestANoticeBeforeARosterGoesNowhere(t *testing.T) { + s := newService(t) + var mu sync.Mutex + n := 0 + s.up.OnNotice = func(string, []byte) { mu.Lock(); n++; mu.Unlock() } + b := newBox(t) + _, _ = b.connect(s, nil) + b.send(cloudwire.MsgNotice, cloudwire.Notice{Header: b.header(), Kind: protocol.PushOwnerAdded}) + off := b.roster() + off.Grant = cloudwire.Grant{Controller: cloudwire.ControllerSourceful} + b.send(cloudwire.MsgRoster, off) + b.send(cloudwire.MsgNotice, cloudwire.Notice{Header: b.header(), Kind: protocol.PushOwnerAdded}) + wait(t, "the notice after the roster", func() bool { mu.Lock(); defer mu.Unlock(); return n >= 1 }) + time.Sleep(100 * time.Millisecond) + mu.Lock() + defer mu.Unlock() + if n != 1 { + t.Fatalf("push heard %d notices, want the one after the roster", n) + } +} diff --git a/internal/uplink/uplink.go b/internal/uplink/uplink.go index 384032e..096aa17 100644 --- a/internal/uplink/uplink.go +++ b/internal/uplink/uplink.go @@ -136,6 +136,10 @@ type Uplink struct { OnHeld func(home, class string, envelope []byte) OnRoster func(home string, r *cloudwire.Roster) OnGone func(home, why string, conn uint64) + // OnNotice gets each security notice, as the box signed it, with the + // readable copy on or off. The service holds none. It runs on the + // box's goroutine and must not block. Set before serving. + OnNotice func(home string, envelope []byte) // OnPurge hears that a box purged its home, each time it asks, so what // other parts keep for the home goes with it. It runs on the box's // goroutine and must not block. Set before serving. @@ -760,6 +764,19 @@ func (c *boxConn) apply(env cloudwire.Envelope) (stop bool, err error) { return true, nil case cloudwire.MsgHello: return true, fmt.Errorf("%w: a second hello", errProtocol) + case cloudwire.MsgNotice: + // For the owners' phones whatever the grant, so not behind + // Serving(), which is the copy; push checks that it holds a notice + // and nothing more. A box that has sent no roster names no owner, + // and its notice costs nothing. + if r, _ := c.handle.Roster(); r != nil && c.u.OnNotice != nil { + raw, err := env.Encode() + if err != nil { + return true, err + } + c.u.OnNotice(c.home, raw) + } + return false, nil } if _, forwarded := cloudwire.ClassScope[env.T]; !forwarded { From deeaa1c1a022237bf8f24e1c4a07afa0c6435abd Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 12:25:27 +0200 Subject: [PATCH 2/2] chore: pin the protocol to Core master 7fb84d6 (#1661 merged) Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 473d441..54ebcc8 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/fxamacker/cbor/v2 v2.9.4 github.com/gorilla/websocket v1.5.3 github.com/quic-go/quic-go v0.63.0 - github.com/srcfl/ftw/go/protocol v0.0.0-20261016090449-c41e6e986336 + github.com/srcfl/ftw/go/protocol v0.0.0-20261016102129-7fb84d6ed573 golang.org/x/crypto v0.57.0 golang.org/x/sys v0.48.0 modernc.org/sqlite v1.59.0 diff --git a/go.sum b/go.sum index ea08edd..0398da6 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +52,8 @@ github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78 github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/srcfl/ftw/go/protocol v0.0.0-20261016090449-c41e6e986336 h1:IDM+zqGoL/zXgIyOshVNR76exFgw5hciNSZOfG0Pp18= -github.com/srcfl/ftw/go/protocol v0.0.0-20261016090449-c41e6e986336/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= +github.com/srcfl/ftw/go/protocol v0.0.0-20261016102129-7fb84d6ed573 h1:1OOC4RdeERT9r6cLMTlcmTjg8+ML2kU6hCvhx/x+8xI= +github.com/srcfl/ftw/go/protocol v0.0.0-20261016102129-7fb84d6ed573/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=