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..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-20261016083708-8fdc990b05ac + 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 c844831..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-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-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= 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 {