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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions cmd/ftwcloudd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
291 changes: 291 additions & 0 deletions internal/push/notice_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading