From 27d5fc7d21d5b02df1636960d0356c56a683b394 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 12:55:20 +0200 Subject: [PATCH] feat(cloudwire): a watch reads FTW Cloud in one request ADR 0009 milestone 6, decisions 7, 8 and 9. cloudwire gains the one-shot read: POST /v1/app/once?home= with Noise IK message 1 as the body and prologue ftw.cloud.once.v1: plus the home id, so it never stands in for an app session handshake. Message 1 carries read {have, hz}, token.get, push.register or push.unregister. Have.Covers says which box envelopes the watch already holds. The answer is message 2 and the envelopes that did not fit, each message after its length as u16 big endian; message 2 is framed too, since a Noise message cannot be opened without its end. push.register gains app, iphone or watch, so the service picks the APNs topic. docs/protocol-cloud.md specifies the read, its limits, app and role watch in the roster. Golden vectors for one read are in testdata/cloud-once-vectors.json; the other vector files are unchanged. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/watch-cloud-once.md | 5 + docs/protocol-cloud.md | 74 +++- go/protocol/cloudwire/once.go | 140 ++++++++ go/protocol/cloudwire/once_vectors_test.go | 322 ++++++++++++++++++ go/protocol/cloudwire/push.go | 10 + .../testdata/cloud-once-vectors.json | 35 ++ 6 files changed, 578 insertions(+), 8 deletions(-) create mode 100644 .changeset/watch-cloud-once.md create mode 100644 go/protocol/cloudwire/once.go create mode 100644 go/protocol/cloudwire/once_vectors_test.go create mode 100644 go/protocol/cloudwire/testdata/cloud-once-vectors.json diff --git a/.changeset/watch-cloud-once.md b/.changeset/watch-cloud-once.md new file mode 100644 index 000000000..ff1a1cf5f --- /dev/null +++ b/.changeset/watch-cloud-once.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Specify how an Apple Watch reads its home from FTW Cloud in one HTTPS request (ADR 0009). `go/protocol/cloudwire` gains the one-shot read: `POST /v1/app/once`, a Noise IK handshake with prologue `ftw.cloud.once.v1:`, `read {have, hz}` and `token.get` in message 1, and an answer of length-prefixed messages. `push.register` gains `app`, `iphone` or `watch`, so the service picks the APNs topic, and the roster may carry a device with role `watch`. Golden vectors for one read are in `cloudwire/testdata/cloud-once-vectors.json`. Nothing on the box changes; FTW Cloud serves the route in a later step. diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index abd508967..6c75361e2 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -3,9 +3,10 @@ Proposed with [ADR 0008](adr/0008-ftw-cloud.md). Nothing here has shipped. This document is normative: "must" is a requirement on the box, the service or the phone. The bytes live in `go/protocol/cloudwire`, with golden vectors -in its `testdata`: `cloud-vectors.json` for the uplink and -`cloud-app-vectors.json` for one whole app session. `srcfl/ftw-cloud` and the -phone replay them. `cloudwire.View` is the phone's +in its `testdata`: `cloud-vectors.json` for the uplink, +`cloud-app-vectors.json` for one whole app session and +`cloud-once-vectors.json` for one [one-shot read](#one-shot-read). +`srcfl/ftw-cloud`, the phone and the watch replay them. `cloudwire.View` is the phone's [freshness](#freshness) rules in Go, and the cases listed there are its tests. The app protocol, which runs inside Noise between a phone and its box, is @@ -25,11 +26,15 @@ for both live in `go/protocol`, under Apache-2.0 - **Phone:** the iPhone app, paired with the box over Noise as today. - **Extension:** a widget, notification service extension or Live Activity on that phone. +- **Watch:** an Apple Watch that an owner's phone added as a device of its + own ([ADR 0009](adr/0009-watch-exchange.md)). watchOS gives it HTTPS and + nothing lower, so it reads in one request. | Connection | Opened by | Transport | What protects it | |---|---|---|---| | Uplink | Box | `wss://cloud.ftw.energy/v1/box` | TLS 1.3, a handshake bound to the TLS session on both sides, every box message signed | | App session | Phone | `wss://cloud.ftw.energy/v1/app?home=` | TLS 1.3, then Noise IK to the service's static key | +| One-shot read | Watch | `POST https://cloud.ftw.energy/v1/app/once?home=` | TLS 1.3, then Noise IK to the service's static key, inside one request | | Extension read | Extension | `GET https://cloud.ftw.energy/v1/now` | TLS and an extension token; the data inside is box-signed | | Push | Service | APNs | Box-signed payloads where they carry data | @@ -422,8 +427,8 @@ The service never forwards these to a phone. |---|---|---| | `id` | text | The box's device id | | `key` | bstr, 32 B | The device's X25519 Noise static key | -| `role` | text | `owner` or `viewer` | -| `scopes` | [text] | Scopes the box derives from the role. The service holds no role table | +| `role` | text | `owner`, `viewer` or `watch` | +| `scopes` | [text] | Scopes the box derives from the role. For a watch, only those its phone's role also carries now. The service holds no role table | | `epoch` | uint | The device's epoch on the box | **Grant** @@ -582,7 +587,7 @@ the table. | `watch` | Any device | `{hz}`: 1 when the phone looks at the home, 0 when it looks away. It holds until the next `watch` or the end of the session | | `tiles.get` | Devices with `ftw.history.read` | `{have}`, tile id to etag. The service answers with the `tiles` envelopes the phone lacks | | `token.get` | Any device | Answered with `token {token, expiresInS}`. A new token replaces the device's old one | -| `push.register` | Any device | `{apnsToken, environment, kind, activityId}`. One registration per APNs token; a Live Activity and its push-to-start each register their own | +| `push.register` | Any device | `{apnsToken, environment, kind, activityId, app}`. One registration per APNs token; a Live Activity and its push-to-start each register their own. `app` is `iphone`, the default when absent, or `watch`, and picks the APNs topic | | `push.unregister` | Any device | `{apnsToken}` | | `suspend` | Owner | `{deviceId}`. Suspends a device | | `unsuspend` | Owner | `{deviceId}`. Lifts a suspension | @@ -646,6 +651,53 @@ Noise session, in the app protocol's `hello_ok.cloud`. - The box lists `cloud.link` in `hello_ok.caps` when its build can run the uplink. A build that names no service does not. +## One-shot read + +A watch cannot open a WebSocket, so it reads with one HTTPS request +([ADR 0009](adr/0009-watch-exchange.md), decision 7). The service admits it +exactly as `/v1/app` admits a phone and keeps nothing after the answer. + +1. The watch posts to `https://cloud.ftw.energy/v1/app/once?home=` over + TLS 1.3, with `content-type: application/octet-stream` and no `Origin` + header. The body is Noise IK message 1 and nothing else: the watch is the + initiator and a service Noise key the responder, as on the app session. + The prologue is `ftw.cloud.once.v1:` followed by the home id, so a + one-shot handshake and an app session handshake never stand in for each + other. +2. Message 1 carries one unsigned envelope: `read`, `token.get`, + `push.register` or `push.unregister`. The service refuses any other type, + an owner's `suspend` among them, with 400 before message 2. +3. The service admits the watch by the rules of the app session: its key in + the home's roster, a roster that has not run out, no suspension, a grant + that is on. A refusal is 403 with an empty body, before message 2, and does + not say which check failed. +4. The answer is 200 with `content-type: application/octet-stream`. Its body + is message 2 and then, for a `read`, the envelopes that did not fit, as + transport messages. Each message, message 2 included, comes after its + length as u16 big endian. +5. The service keeps no session and no Noise state after the answer. + +**`read`** + +| Field | Type | Meaning | +|---|---|---| +| `have` | map | `{boot, seq}`: the `boot` the watch's envelopes come from, and `seq`, class to the highest `seq` it holds from that boot. Empty means nothing | +| `hz` | uint | 1 keeps the box's `watch` in force for 60 s, as a looking phone does. 0 leaves it as it is | + +Message 2 answers a `read` with the app session's `snapshot`, without the box +envelopes `have` covers: one from the same `boot` with a `seq` no higher than +the one the watch holds for its class. `more` counts the envelopes that follow +message 2. A `token.get` gets `token {token, expiresInS}` in message 2, and +`push.register` and `push.unregister` get an empty payload, once the service +has stored the change. + +**Limits.** One read at a time per device and 30 a minute; message 1 at most +4 KiB; the whole answer within 10 s. Past a limit the service answers 429, a +longer message 1 gets 400, and a service without a Noise key answers 501. + +When FTW Cloud is off, a watch on its own reads `now` on the box's exchange +through the relay instead, and prices and the plan wait for the phone. + ## Extension token - 32 random bytes, base64url. The service stores its SHA-256, never the token. @@ -670,6 +722,10 @@ Noise session, in the app protocol's `hello_ok.cloud`. | `liveactivity` | `home`, the `lp` envelope and the newest `hb` envelope, in base64 | The view shows numbers only if both signatures check, the `hb` covers that `lp`, and the charger source grades `live` by the freshness rules; otherwise "Open FTW". `staleDate` is set to when that grade would lapse | | `box.unreachable` | Generic, from the service | Sent after 600 s without the box's socket. No box signature, no arguments, no buttons | +A watch registers with `app: watch`, and the service sends it to the watch +app's APNs topic. A watch and its phone get byte-identical alert payloads, so +Apple shows one alert when both are near. + Each push goes only to devices whose effective scope covers what it carries: `alert` needs `ftw.events.read`, and `widgets` and `liveactivity` need `ftw.live.read`. Viewers therefore get no alerts. `box.unreachable` goes to @@ -898,8 +954,9 @@ Class names, field names, close codes and the signing tags are not registry names. They live here and in `go/protocol`, held by golden vectors in both repositories. -Role names, `owner` and `viewer`, are registry names, but `go/protocol` does -not generate them yet, so `srcfl/ftw-cloud` spells `owner` by hand. Generating +Role names, `owner`, `viewer` and `watch`, are registry names, but +`go/protocol` does not generate them yet, so `srcfl/ftw-cloud` spells `owner` +by hand. Generating the registry's roles would remove that copy. ## Numbers in one place @@ -927,6 +984,7 @@ the registry's roles would remove that copy. | Envelopes queued per phone | 256 | | Phone messages | 20 in a burst, then 2 a second | | App session message 1 | Within 10 s | +| One-shot reads | One at a time per device, 30 a minute; message 1 at most 4 KiB; answered within 10 s | | Roster `ttlS`, renewal | 24 hours, every 6 hours | | Extension token | 7 days | | Alert age for buttons | 10 minutes | diff --git a/go/protocol/cloudwire/once.go b/go/protocol/cloudwire/once.go new file mode 100644 index 000000000..e76fce0df --- /dev/null +++ b/go/protocol/cloudwire/once.go @@ -0,0 +1,140 @@ +package cloudwire + +import ( + "bytes" + "encoding/binary" + "fmt" + "time" +) + +// The one-shot read: a watch reads its home from FTW Cloud in one HTTPS +// request (ADR 0009, decision 7). watchOS gives an app HTTPS and nothing +// lower, so the app session's handshake runs inside a single POST. +// +// The watch posts to OncePath?home=, with content type +// application/octet-stream. The body is IK message 1 to a service Noise key, +// with OncePrologue(home) as the prologue, so a once handshake and an app +// session handshake can never stand in for each other. Message 1 carries one +// unsigned envelope: read, token.get, push.register or push.unregister. The +// answer body is message 2 with the reply and then, for a read, the +// envelopes that did not fit, each message after its length as u16 big +// endian. The service keeps no session and no Noise state after the answer. + +// OncePath is the route, on cloud.ftw.energy. +const OncePath = "/v1/app/once" + +// OncePrologueTag starts the prologue; the home id follows it. +const OncePrologueTag = "ftw.cloud.once.v1:" + +// OncePrologue is the Noise prologue for one home's one-shot read. +func OncePrologue(home string) []byte { + return append([]byte(OncePrologueTag), home...) +} + +// Watch (or phone) to service, in message 1. Push registration uses +// MsgPushRegister and MsgPushUnregister from push.go. +const ( + // MsgRead asks for the home as the app session's snapshot shows it. + MsgRead = "read" + // MsgTokenGet asks for the device's extension token. + MsgTokenGet = "token.get" +) + +// Service to watch, in message 2. +const ( + // MsgToken answers token.get, as on the app session. + MsgToken = "token" +) + +// OnceMessages are the types message 1 may carry. The service refuses every +// other type, an owner's suspend among them, before message 2. +var OnceMessages = []string{MsgRead, MsgTokenGet, MsgPushRegister, MsgPushUnregister} + +// Read asks for the snapshot, less what the watch already holds. +type Read struct { + Have Have `cbor:"have"` + // Hz 1 keeps the box's watch in force for 60 s, as a looking phone does. + // 0 leaves it as it is. + Hz int `cbor:"hz"` +} + +// Have is what a watch holds: the boot its envelopes come from and, per +// class, the highest seq it holds. Empty means nothing. +type Have struct { + Boot []byte `cbor:"boot"` + Seq map[string]uint64 `cbor:"seq"` +} + +// Covers reports whether the watch already holds a box envelope: one from +// the boot it names, with a seq no higher than the highest it holds for that +// class. An envelope that cannot be read is not covered, so it is sent. +func (h Have) Covers(envelope []byte) bool { + if len(h.Boot) == 0 { + return false + } + env, err := DecodeEnvelope(envelope) + if err != nil { + return false + } + var header Header + if err := Unmarshal(env.B, &header); err != nil { + return false + } + held, ok := h.Seq[env.T] + return ok && bytes.Equal(header.Boot, h.Boot) && header.Seq <= held +} + +// Token is the extension token a device reads GET /v1/now with. +type Token struct { + Token string `cbor:"token"` + ExpiresInS int64 `cbor:"expiresInS"` +} + +// Limits on the one-shot read. Past them the service answers 429, or 400 for +// a message 1 that is too long. +const ( + OnceMessage1MaxBytes = 4096 + OnceReadsPerMinute = 30 + // OnceInFlight is how many reads one device may have open at once. + OnceInFlight = 1 + // OnceAnswerWithin bounds the whole request at the service. + OnceAnswerWithin = 10 * time.Second +) + +// OnceAnswer frames an answer body: message 2, then each transport message, +// every one after its length as u16 big endian. +func OnceAnswer(messages ...[]byte) ([]byte, error) { + if len(messages) == 0 { + return nil, fmt.Errorf("%w: an answer needs message 2", ErrFrame) + } + var out []byte + for i, m := range messages { + if len(m) > MaxPayload { + return nil, fmt.Errorf("%w: message %d is %d bytes", ErrFrame, i, len(m)) + } + out = binary.BigEndian.AppendUint16(out, uint16(len(m))) + out = append(out, m...) + } + return out, nil +} + +// SplitOnceAnswer reads an answer body back into message 2 and the transport +// messages after it. +func SplitOnceAnswer(body []byte) ([][]byte, error) { + var out [][]byte + for len(body) > 0 { + if len(body) < 2 { + return nil, fmt.Errorf("%w: a length cut short", ErrFrame) + } + n := int(binary.BigEndian.Uint16(body)) + if len(body) < 2+n { + return nil, fmt.Errorf("%w: a %d-byte message overruns the body", ErrFrame, n) + } + out = append(out, body[2:2+n]) + body = body[2+n:] + } + if len(out) == 0 { + return nil, fmt.Errorf("%w: an empty answer", ErrFrame) + } + return out, nil +} diff --git a/go/protocol/cloudwire/once_vectors_test.go b/go/protocol/cloudwire/once_vectors_test.go new file mode 100644 index 000000000..d2e638314 --- /dev/null +++ b/go/protocol/cloudwire/once_vectors_test.go @@ -0,0 +1,322 @@ +package cloudwire + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "os" + "testing" + + "github.com/srcfl/ftw/go/protocol/appwire" +) + +// testdata/cloud-once-vectors.json freezes one one-shot read byte for byte +// (ADR 0009, milestone 6): fixed static and ephemeral keys on both ends, a +// read whose have already covers two of the newest envelopes, message 2 with +// the snapshot of what is left, one envelope that did not fit after it, and +// the whole answer body as the service writes it. srcfl/ftw-cloud answers it +// as the service; the watch replays it as the initiator. + +const onceVectorsPath = "testdata/cloud-once-vectors.json" + +type onceVectorFile struct { + ServiceStatic string `json:"serviceStatic"` + ServicePublic string `json:"servicePublic"` + ServiceEphemeral string `json:"serviceEphemeral"` + WatchStatic string `json:"watchStatic"` + WatchPublic string `json:"watchPublic"` + WatchEphemeral string `json:"watchEphemeral"` + Home string `json:"home"` + Path string `json:"path"` + Prologue string `json:"prologue"` + Msg1Payload string `json:"msg1Payload"` + Request string `json:"request"` + Held []string `json:"held"` + Msg2Payload string `json:"msg2Payload"` + Msg2 string `json:"msg2"` + Following []appVectorFrame `json:"following"` + Answer string `json:"answer"` + HandshakeHash string `json:"handshakeHash"` + Notes map[string]string `json:"notes"` +} + +func TestOnceReadVectors(t *testing.T) { + raw, err := os.ReadFile(vectorsPath) + if err != nil { + t.Fatal(err) + } + var signed vectorFile + if err := json.Unmarshal(raw, &signed); err != nil { + t.Fatal(err) + } + envelope := func(name string) []byte { + for _, m := range signed.Messages { + if m.Name == name { + b, _ := hex.DecodeString(m.Envelope) + return b + } + } + t.Fatalf("no signed vector %q", name) + return nil + } + + labels := map[string]string{ + "serviceStatic": "ftw.cloud once vector service static", + "serviceEphemeral": "ftw.cloud once vector service ephemeral", + "watchStatic": "ftw.cloud once vector watch static", + "watchEphemeral": "ftw.cloud once vector watch ephemeral", + } + serviceStatic := fixedX25519(t, labels["serviceStatic"]) + serviceEph := fixedX25519(t, labels["serviceEphemeral"]) + watchStatic := fixedX25519(t, labels["watchStatic"]) + watchEph := fixedX25519(t, labels["watchEphemeral"]) + home := signed.BoxKey.Home + prologue := OncePrologue(home) + + // The watch holds the full live (seq 3) and the lp (seq 5) from an + // earlier read, so the service leaves both out. + have := Have{Boot: bytes.Repeat([]byte{0xb0}, BootBytes), Seq: map[string]uint64{MsgLive: 3, MsgLP: 5}} + msg1Payload, err := Unsigned(MsgRead, Read{Have: have, Hz: HzLooking}) + if err != nil { + t.Fatal(err) + } + + newest := []string{"live full", "live delta", "lp", "prices", "hb"} + var held, send []string + for _, n := range newest { + if have.Covers(envelope(n)) { + held = append(held, n) + } else { + send = append(send, n) + } + } + if len(held) != 2 || held[0] != "live full" || held[1] != "lp" { + t.Fatalf("have covers %v", held) + } + // Message 2 carries the first two; the last follows it, as an envelope + // that did not fit would. + inMsg2, after := send[:2], send[2:] + var envs [][]byte + for _, n := range inMsg2 { + envs = append(envs, envelope(n)) + } + msg2Payload, err := SnapshotPayload(envs, 1_791_820_869_000, true, len(after)) + if err != nil { + t.Fatal(err) + } + + watch, err := appwire.NewInitiator(appwire.InitiatorOptions{ + StaticKey: watchStatic, RemoteStatic: serviceStatic.Public(), Prologue: prologue, Ephemeral: &watchEph, + }) + if err != nil { + t.Fatal(err) + } + service, err := appwire.NewResponder(appwire.HandshakeOptions{StaticKey: serviceStatic, Prologue: prologue, Ephemeral: &serviceEph}) + if err != nil { + t.Fatal(err) + } + msg1, err := watch.WriteMessage(msg1Payload) + if err != nil { + t.Fatal(err) + } + if len(msg1) > OnceMessage1MaxBytes { + t.Fatalf("message 1 is %d bytes", len(msg1)) + } + got1, err := service.ReadMessage(msg1) + if err != nil || !bytes.Equal(got1, msg1Payload) { + t.Fatalf("message 1: %v", err) + } + msg2, err := service.WriteMessage(msg2Payload) + if err != nil { + t.Fatal(err) + } + ss, _ := service.Split() + serviceT := appwire.NewTransport(ss) + + out := onceVectorFile{ + ServiceStatic: secretHex(labels["serviceStatic"]), ServicePublic: hex.EncodeToString(serviceStatic.Public()), + ServiceEphemeral: secretHex(labels["serviceEphemeral"]), + WatchStatic: secretHex(labels["watchStatic"]), WatchPublic: hex.EncodeToString(watchStatic.Public()), + WatchEphemeral: secretHex(labels["watchEphemeral"]), + Home: home, Path: OncePath + "?" + AppHomeParam + "=" + home, + Prologue: hex.EncodeToString(prologue), + Msg1Payload: hex.EncodeToString(msg1Payload), Request: hex.EncodeToString(msg1), + Held: held, + Msg2Payload: hex.EncodeToString(msg2Payload), Msg2: hex.EncodeToString(msg2), + HandshakeHash: hex.EncodeToString(ss.HandshakeHash), + Notes: map[string]string{ + "keys": "Secrets are SHA-256 of the labels in once_vectors_test.go. X25519 clamps them.", + "request": "The POST body is message 1 and nothing else.", + "held": "The cloud-vectors.json envelopes the read's have covers, which the service leaves out.", + "answer": "Message 2, then each following transport message, every one after its length as u16 big endian.", + }, + } + messages := [][]byte{msg2} + for _, n := range after { + frame, err := EncodeFrame(envelope(n)) + if err != nil { + t.Fatal(err) + } + sealed, err := serviceT.Encrypt(frame) + if err != nil { + t.Fatal(err) + } + messages = append(messages, sealed) + out.Following = append(out.Following, appVectorFrame{From: "service", Name: n, + Frame: hex.EncodeToString(frame), Message: hex.EncodeToString(sealed)}) + } + answer, err := OnceAnswer(messages...) + if err != nil { + t.Fatal(err) + } + out.Answer = hex.EncodeToString(answer) + + // The watch's side: split the body, read message 2, open what follows. + parts, err := SplitOnceAnswer(answer) + if err != nil || len(parts) != 1+len(after) { + t.Fatalf("split %d parts: %v", len(parts), err) + } + got2, err := watch.ReadMessage(parts[0]) + if err != nil { + t.Fatal(err) + } + snap, err := ParseSnapshot(got2) + if err != nil || len(snap.Envelopes) != len(inMsg2) || snap.More != len(after) { + t.Fatalf("snapshot: %+v %v", snap, err) + } + ws, _ := watch.Split() + if !bytes.Equal(ws.HandshakeHash, ss.HandshakeHash) { + t.Fatal("the two ends disagree on the handshake hash") + } + watchT := appwire.NewTransport(ws) + for i, n := range after { + frame, err := watchT.Decrypt(parts[1+i]) + if err != nil { + t.Fatal(err) + } + env, err := DecodeFrame(frame) + if err != nil || !bytes.Equal(env, envelope(n)) { + t.Fatalf("%s did not come through as the box's bytes: %v", n, err) + } + } + + got, err := json.MarshalIndent(out, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + if *update { + if err := os.WriteFile(onceVectorsPath, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(onceVectorsPath) + if err != nil { + t.Fatalf("%v (run with -update to create it)", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("the bytes no longer match %s. If the wire change is intended, "+ + "regenerate with -update and pair the change with srcfl/ftw-cloud and the watch", onceVectorsPath) + } +} + +// A once handshake never opens an app session, nor the reverse: the prologues +// differ, so each binds its own door. +func TestOnceAndAppHandshakesStayApart(t *testing.T) { + const home = "fjzyr67buzpyncqmjliwgezvde" + if bytes.Equal(OncePrologue(home), AppPrologue(home)) { + t.Fatal("the once and app prologues are the same") + } + service := fixedX25519(t, "once apart service") + watchKey := fixedX25519(t, "once apart watch") + watch, err := appwire.NewInitiator(appwire.InitiatorOptions{ + StaticKey: watchKey, RemoteStatic: service.Public(), Prologue: OncePrologue(home), + }) + if err != nil { + t.Fatal(err) + } + read, _ := Unsigned(MsgRead, Read{Hz: HzAway}) + msg1, err := watch.WriteMessage(read) + if err != nil { + t.Fatal(err) + } + app, err := appwire.NewResponder(appwire.HandshakeOptions{StaticKey: service, Prologue: AppPrologue(home)}) + if err != nil { + t.Fatal(err) + } + if _, err := app.ReadMessage(msg1); err == nil { + t.Fatal("an app session read a once handshake") + } +} + +func TestHaveCoversOnlyWhatTheWatchHolds(t *testing.T) { + boot := bytes.Repeat([]byte{0xb0}, BootBytes) + env := func(t2 string, seq uint64, b []byte) []byte { + raw, err := Marshal(Header{Seq: seq, Boot: b}) + if err != nil { + t.Fatal(err) + } + e, err := Envelope{T: t2, B: raw, Sig: []byte{1}}.Encode() + if err != nil { + t.Fatal(err) + } + return e + } + have := Have{Boot: boot, Seq: map[string]uint64{MsgLive: 10}} + for _, c := range []struct { + name string + env []byte + want bool + }{ + {"older live", env(MsgLive, 9, boot), true}, + {"the same live", env(MsgLive, 10, boot), true}, + {"newer live", env(MsgLive, 11, boot), false}, + {"a class it holds none of", env(MsgHB, 1, boot), false}, + {"another boot", env(MsgLive, 1, bytes.Repeat([]byte{0xb1}, BootBytes)), false}, + {"not an envelope", []byte{0xa0}, false}, + } { + if got := have.Covers(c.env); got != c.want { + t.Fatalf("%s: covered = %v", c.name, got) + } + } + if (Have{}).Covers(env(MsgLive, 1, boot)) { + t.Fatal("an empty have covered an envelope") + } +} + +func TestOnceAnswerFraming(t *testing.T) { + body, err := OnceAnswer([]byte{1, 2, 3}, []byte{}, bytes.Repeat([]byte{9}, 300)) + if err != nil { + t.Fatal(err) + } + parts, err := SplitOnceAnswer(body) + if err != nil || len(parts) != 3 || len(parts[0]) != 3 || len(parts[1]) != 0 || len(parts[2]) != 300 { + t.Fatalf("split %d parts: %v", len(parts), err) + } + for _, bad := range [][]byte{nil, {0}, {0, 5, 1, 2}} { + if _, err := SplitOnceAnswer(bad); err == nil { + t.Fatalf("%x split", bad) + } + } + if _, err := OnceAnswer(); err == nil { + t.Fatal("an answer with no message 2 framed") + } +} + +// push.register says which app a token belongs to; a phone that leaves it out +// is the iPhone app, as before. +func TestPushRegisterNamesTheApp(t *testing.T) { + old, _ := Marshal(PushRegister{APNsToken: []byte{1}, Environment: PushProduction, Kind: PushKindAlert}) + var back PushRegister + if err := Unmarshal(old, &back); err != nil || back.App != "" { + t.Fatalf("an old registration read app %q: %v", back.App, err) + } + watch, _ := Marshal(PushRegister{APNsToken: []byte{1}, Environment: PushProduction, Kind: PushKindAlert, App: PushAppWatch}) + if bytes.Equal(old, watch) { + t.Fatal("app did not reach the wire") + } + if err := Unmarshal(watch, &back); err != nil || back.App != PushAppWatch { + t.Fatalf("a watch registration read app %q: %v", back.App, err) + } +} diff --git a/go/protocol/cloudwire/push.go b/go/protocol/cloudwire/push.go index 33137865f..39aa7c5dc 100644 --- a/go/protocol/cloudwire/push.go +++ b/go/protocol/cloudwire/push.go @@ -77,8 +77,18 @@ type PushRegister struct { Kind string `cbor:"kind"` // ActivityID names a Live Activity. Unused until its push exists. ActivityID string `cbor:"activityId,omitempty"` + // App is the app the token belongs to, so the service picks the APNs + // topic: PushAppIPhone, the default when absent, or PushAppWatch. + App string `cbor:"app,omitempty"` } +// Apps a push token can belong to (ADR 0009, decision 8). Apple sees for a +// watch what it sees for a phone, and no more. +const ( + PushAppIPhone = "iphone" + PushAppWatch = "watch" +) + // PushUnregister forgets one token. type PushUnregister struct { APNsToken []byte `cbor:"apnsToken"` diff --git a/go/protocol/cloudwire/testdata/cloud-once-vectors.json b/go/protocol/cloudwire/testdata/cloud-once-vectors.json new file mode 100644 index 000000000..10b870716 --- /dev/null +++ b/go/protocol/cloudwire/testdata/cloud-once-vectors.json @@ -0,0 +1,35 @@ +{ + "serviceStatic": "88b1d5e4491b3de125574d2eaa27a47229fc2c51d19fcb5dc6435b5ef61c380f", + "servicePublic": "0e9bd61ff213665b5e31282e6f2098d467245d224b7b4ad374a6ab4a8e5b594d", + "serviceEphemeral": "0a57c2f3e160de718ebdc8541eb682a8326c3fe3195899c91dbf9e6373500339", + "watchStatic": "8a87ac4f7d277626a8c36f7311c2bcd833e9abacefd9c0df90d39a916f19da94", + "watchPublic": "7ef44864cb0a1f0b998b0ad161d41dac007454a5717c2447c4021449a7873819", + "watchEphemeral": "6e5d538f24d3c519afe1a7dcfe695774fc227ca40df30c27952b6191693db724", + "home": "fjzyr67buzpyncqmjliwgezvde", + "path": "/v1/app/once?home=fjzyr67buzpyncqmjliwgezvde", + "prologue": "6674772e636c6f75642e6f6e63652e76313a666a7a7972363762757a70796e63716d6a6c697767657a766465", + "msg1Payload": "a361625830a262687a016468617665a263736571a2626c7005646c6976650364626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0617464726561646373696740", + "request": "9af7906b333c129003b6e48d3089ceb6650835f7d975f57b16e173aefd95bd38ff04ba21d9e3be94ec8ff213b4399614193439bdc572e0c06a7f2d619e84ef5ade20c646aacc2ccd36d2534e9b86edd25fdd64d36a928f4d31e3baef73583ce7033b03161609884db24e5299373e5ddc118da8487312c9c8573b10cc99b94e98ff88895652dd4a5b1500cd5432e615622f78eb94f4e2ddf1e4789cd5ea7cf95849", + "held": [ + "live full", + "lp" + ], + "msg2Payload": "a36162590201a4646d6f726501666f6e6c696e65f569656e76656c6f70657382a3616258a1a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b65644279806174646c6976656373696758400dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6a36162588caa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847617466707269636573637369675840e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e36a6c6173745365656e4d731b000001a130d8ad88617468736e617073686f746373696740", + "msg2": "a2cae50b4966bef1eea596266dfcc3584d94e754455e094c30b0c1c3f3bc4833a46345c2a3d9fb673acb3e7bfbcb42a2825985e009adb5e7175f87221293984c0254ca43900b4dbe564cecab7a425fc525249114b2fbc38abfc6c65ad7a70c1caa1af5dca675b13b9f71ecfc47583e82dd0bd5ccd57ce439044b4050aa3ba57c1845fa4a59faf9dbc01aeabcc1e1f2414558b69c0b18b1c94658a95688d7321b693e12d30b6799f51765f5537d4ca194747ae042f09f7de64562a2ad131379de592d76bd7d8db5659d0b3888100b65fcdce6bbc2eb1ec41b8f1e6af6f5fe0a7730d8f3b99b0e26ed3f9ea3f1b053de1a052e54f0249152adeb39f811c8d0c2fd9c930cf8efbc84dede94f0ef400b7fdf908925998f37e2be06da99ec82975ef31c7c5fbbaf1ec76e67ab73ce28cb35279756276e131aa2737c88be3db111b97187785c81a60ff8b0c6708870273f081ea4ef12922eae00183a71840507dbfe94daadcfdc7a88e93aaaacb1ee916ade56ca7a068a064a631252d0f9f7e91a5d23de54946a8fbfaa35621f3724eee7b53b93a8fd83e3125b976d7fdd0dca9d50cc57dc64548b8ec3a36865a58553573d643182a51d780fd48599626739cb2c1bb4c32e138b94686f6161469965ed34c22e9ff2e28af028e0b7c24094ec33b9247f9e18978e298eb6295bcf485875bc911e189241fbbaeafcdb7c24d9ea31342df1e07f46be4b7149a1ca20cd18af637384fe9d493e6142cce517ffda102996478381d98b29ea2ea666fdbcf85d2ebbf7f6145f83cfa0546c0a94f228926cc654c1a0e6b6a5fd27d8", + "following": [ + { + "from": "service", + "name": "hb", + "frame": "0101000000d0a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8", + "message": "00000000000000007309a15213862d5a4d41fd8d09b9adf139ea8648030e7f80827f420068e713425dae48f4b84a55dc51b876ba59ff2aa62f3b0903d894b10db1c5d0e845fe152e7a7ce4fa9d624d4e82cfbaafa8b34b330bdc2c34c231566c5a31b30f15b54d12d93c49e0c1edbc6eba6d449d72318c401c16ca7578fc07bdf45ceb4b9c980f3abcc47b71d60fbc652bf16d25795007f35161e71e392cbb480845b4adf8a2a6639165294b3100b97b639174dbabcced97f18d861f55de63ff732bb1a742d16f61ba26226841ecbffa33a2d049fac1f23a5002835b7775fd56d3575a1b71b5da650af4b9679316" + } + ], + "answer": "0247a2cae50b4966bef1eea596266dfcc3584d94e754455e094c30b0c1c3f3bc4833a46345c2a3d9fb673acb3e7bfbcb42a2825985e009adb5e7175f87221293984c0254ca43900b4dbe564cecab7a425fc525249114b2fbc38abfc6c65ad7a70c1caa1af5dca675b13b9f71ecfc47583e82dd0bd5ccd57ce439044b4050aa3ba57c1845fa4a59faf9dbc01aeabcc1e1f2414558b69c0b18b1c94658a95688d7321b693e12d30b6799f51765f5537d4ca194747ae042f09f7de64562a2ad131379de592d76bd7d8db5659d0b3888100b65fcdce6bbc2eb1ec41b8f1e6af6f5fe0a7730d8f3b99b0e26ed3f9ea3f1b053de1a052e54f0249152adeb39f811c8d0c2fd9c930cf8efbc84dede94f0ef400b7fdf908925998f37e2be06da99ec82975ef31c7c5fbbaf1ec76e67ab73ce28cb35279756276e131aa2737c88be3db111b97187785c81a60ff8b0c6708870273f081ea4ef12922eae00183a71840507dbfe94daadcfdc7a88e93aaaacb1ee916ade56ca7a068a064a631252d0f9f7e91a5d23de54946a8fbfaa35621f3724eee7b53b93a8fd83e3125b976d7fdd0dca9d50cc57dc64548b8ec3a36865a58553573d643182a51d780fd48599626739cb2c1bb4c32e138b94686f6161469965ed34c22e9ff2e28af028e0b7c24094ec33b9247f9e18978e298eb6295bcf485875bc911e189241fbbaeafcdb7c24d9ea31342df1e07f46be4b7149a1ca20cd18af637384fe9d493e6142cce517ffda102996478381d98b29ea2ea666fdbcf85d2ebbf7f6145f83cfa0546c0a94f228926cc654c1a0e6b6a5fd27d800ee00000000000000007309a15213862d5a4d41fd8d09b9adf139ea8648030e7f80827f420068e713425dae48f4b84a55dc51b876ba59ff2aa62f3b0903d894b10db1c5d0e845fe152e7a7ce4fa9d624d4e82cfbaafa8b34b330bdc2c34c231566c5a31b30f15b54d12d93c49e0c1edbc6eba6d449d72318c401c16ca7578fc07bdf45ceb4b9c980f3abcc47b71d60fbc652bf16d25795007f35161e71e392cbb480845b4adf8a2a6639165294b3100b97b639174dbabcced97f18d861f55de63ff732bb1a742d16f61ba26226841ecbffa33a2d049fac1f23a5002835b7775fd56d3575a1b71b5da650af4b9679316", + "handshakeHash": "d136cb1707c8b872b0a5c81a2fdb871fd5c6df9ea48c6bbbe1c95e96b37d8fd9", + "notes": { + "answer": "Message 2, then each following transport message, every one after its length as u16 big endian.", + "held": "The cloud-vectors.json envelopes the read's have covers, which the service leaves out.", + "keys": "Secrets are SHA-256 of the labels in once_vectors_test.go. X25519 clamps them.", + "request": "The POST body is message 1 and nothing else." + } +}