From 657f84aef07fb490e653b56b6a8d501c3f77d9fe Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 16:14:50 +0200 Subject: [PATCH 1/4] feat(cloudwire): x carries a watch's exchange through FTW Cloud The relay stopped on 17 October 2026, and with it the socket that carried a watch's exchange with its box (ADR 0009). This is the wire that carries it through FTW Cloud instead: a watch's one-shot request carries x {d}, the service sends the box x {dev, id, d}, and the box answers under the same dev and id. The service reads neither d and keeps nothing after the answer. - MsgX, CapX, X, AppX, XDataMax, MaxXFrame, XAnswerWithin, XInFlight. - Downlink and OnceMessages gain x; the allowlist lists x unheaded. - Golden vectors: testdata/cloud-once-x-vectors.json. - protocol-cloud.md "Exchange", and ADR 0009 "Through FTW Cloud" (proposed): the phone adds its watch by key, and a watch has tun. The box's half is the paired PR stacked on #1691; srcfl/ftw-cloud and srcfl/ftw-app pin this commit. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0009-watch-exchange.md | 42 ++++- docs/protocol-cloud.md | 88 +++++++++- go/protocol/cloudwire/allowlist.go | 4 + go/protocol/cloudwire/exchange.go | 69 ++++++++ go/protocol/cloudwire/messages.go | 6 +- go/protocol/cloudwire/once.go | 7 +- go/protocol/cloudwire/once_vectors_test.go | 162 ++++++++++++++++++ .../testdata/cloud-once-x-vectors.json | 23 +++ go/protocol/cloudwire/tunnel_test.go | 7 +- 9 files changed, 387 insertions(+), 21 deletions(-) create mode 100644 go/protocol/cloudwire/exchange.go create mode 100644 go/protocol/cloudwire/testdata/cloud-once-x-vectors.json diff --git a/docs/adr/0009-watch-exchange.md b/docs/adr/0009-watch-exchange.md index 13acbadb..13617c80 100644 --- a/docs/adr/0009-watch-exchange.md +++ b/docs/adr/0009-watch-exchange.md @@ -2,10 +2,11 @@ - Status: accepted. This repository has milestones 1, 2, 3, 5 and 6: the command log on the box, the registry names, the watch role with its - enrolment, the exchange socket with its sessions, so the box says - `cmd.exchange`, and the wire for FTW Cloud's one-shot read. The relay's - half is milestone 4, in srcfl/ftw-webapp. Nothing else described here has - shipped. + enrolment, the exchange sessions, and the wire for FTW Cloud's one-shot + read. The relay stopped on 17 October 2026, and its exchange socket went + with it. [Through FTW Cloud](#through-ftw-cloud) (23 October 2026, + proposed) moves the exchange onto FTW Cloud's one-shot request, where the + box says `cmd.exchange` again. - Date: 2026-10-14 - Decided by: Fredrik on 14 October 2026. Before the design: a watch controls the box directly as well as through the phone; an unlocked watch on the @@ -186,6 +187,39 @@ to remove it.** it when the box returns or when the roster runs out after 24 hours. An owner can suspend the watch at FTW Cloud from the phone until then. +## Through FTW Cloud + +Proposed on 23 October 2026, after Fredrik asked to finish FTW Cloud. The +relay was stopped on 17 October 2026, and since the one-door decision of 15 +October every phone reaches its box through FTW Cloud's tunnel. FTW Cloud +carries ciphertext and never chooses an action; that is the line this ADR +keeps, and the watch's commands now sit on the same side of it as the +phone's. + +1. **The exchange rides the one-shot request.** A watch's exchange message + goes to `POST /v1/app/once` as `x {d}`. The service sends the box + `x {dev, id, d}` on the uplink and returns the box's `d` in message 2. + Decisions 1 and 2 stand, apart from the socket: the sessions, their + limits, the shapes and the 400 ms floor are the box's, unchanged. The + wire is in [protocol-cloud.md](../protocol-cloud.md#exchange). +2. **The phone adds its watch by key.** Decision 4's code had to cross the + service inside a handshake from a device the roster does not list yet. + Instead the owner's phone sends the watch's public key over its own + session, with the same step-up: `POST /api/app-link/pairing + {"role": "watch", "key": …}`. The box lists the watch in its next + roster. The code path stays for now and no client uses it. +3. **A watch has `tun`.** The roster gives a watch `tun` whenever it gives + a phone `tun`, so the service admits its exchange with the readable copy + off. The box answers a watch's key on the exchange alone. +4. **Reads stay as decision 7 says**, with one change: with the copy off a + watch on its own shows what it holds and its age. Reading `now` through + the exchange is not built. + +The service learns that a watch of the home sent a request and when, and +when the box answered; the relay learned the same. One `cmdId` sent by the +phone and through FTW Cloud still acts once, because the command log keys +it by the phone that added the watch. + ## What does not change - Every exchange command takes the same `onCmd` path as the stream: expiry, diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index d5330cd9..a0548846 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -6,7 +6,9 @@ or the phone. The bytes live in `go/protocol/cloudwire`, with golden vectors in its `testdata`: `cloud-vectors.json` for the uplink, `cloud-app-vectors.json` for one whole app session, its [tunnel](#tunnel) messages and a pairing proof, and -`cloud-once-vectors.json` for one [one-shot read](#one-shot-read), and +`cloud-once-vectors.json` for one [one-shot read](#one-shot-read), +`cloud-once-x-vectors.json` for one request of the [exchange](#exchange) +through the service, and `cloud-shard-vectors.json` for which hosts a client may follow and how it reads a restart's spread ([Shards and restarts](#shards-and-restarts)). `srcfl/ftw-cloud`, the phone and the watch replay them. `cloudwire.View` is the phone's @@ -43,7 +45,7 @@ for both live in `go/protocol`, under Apache-2.0 |---|---|---|---| | 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. Inside it, the phone's own Noise session with its box rides as [`tun`](#tunnel) | -| 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 | +| 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. Inside it, a watch's [exchange](#exchange) with its box rides as `x` | | 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 | @@ -898,6 +900,9 @@ as it decides for the same session over the relay. - **Effective scope** of a device is its `scopes` intersected with `grant.scopes`. It is empty while the readable copy is off, and a device with `tun` still opens a session for its [tunnel](#tunnel). +- A watch has `tun` whenever a phone may have it. For a watch it opens the + [exchange](#exchange) and nothing else: the box answers a watch's key on + the stream with silence. - A device that leaves the roster loses its sessions (4403), tokens and APNs registrations within a second of the service receiving the roster, which needs the uplink to be up. A device whose `epoch`, `role` or effective scope @@ -1219,12 +1224,15 @@ exactly as `/v1/app` admits a phone and keeps nothing after the answer. 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. + `push.register`, `push.unregister` or `x` (see [Exchange](#exchange)). + 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, and a - non-empty effective scope, so the readable copy must be on. A refusal is 403 with an empty body, before message 2, and does - not say which check failed. + non-empty effective scope, so the readable copy must be on. For `x` the + roster entry's `tun` stands in for the effective scope, as it does for a + phone's tunnel, so commands work with the copy off. 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 @@ -1249,8 +1257,71 @@ has stored the change. 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. +With the readable copy off, a watch on its own shows what it holds and the +age of it, and prices and the plan wait for the phone. + +## Exchange + +A watch's commands go to its box through the box's exchange +([ADR 0009](adr/0009-watch-exchange.md), decisions 1 and 2): short Noise +sessions, `ftw.exchange.v1`, IK to the box's static key, which live on the +box and answer one request at a time. The relay that first carried them is +gone (17 October 2026), so each request rides one +[one-shot request](#one-shot-read) to the service, and the service hands it +to the box on the uplink. It reads neither the request nor the answer. + +1. The watch posts a one-shot request whose message 1 carries `x {d}`. `d` + is one exchange message: handshake message 1 with the watch's `hello` + (608 bytes), or a request of one lane 0 frame, `cmd` or `now` (536). +2. The service admits the watch as step 3 of the one-shot read says, with + `tun` in place of the effective scope. +3. It sends `x {dev, id, d}` down the uplink: `dev` the roster id its own + handshake proved, `id` a number of its own for this request. It never + takes either from the watch. +4. The box answers `x {dev, id, d}` with the same `dev` and `id`. `d` is the + exchange's answer: message 2 with `hello_ok` (1,072 bytes), or two lane + 0 frames (1,072), `cmd.ack` and `cmd.result`, or `now` and a `tick`. An + empty `d` is the box's silence: a stranger, a key that is not the device + `dev` names, a spent session, or a message it could not read. +5. The service answers 200 with message 2 carrying `x {d}`, the box's `d` + unchanged, and keeps nothing. + +**`x`** + +| Field | Type | Meaning | +|---|---|---| +| `dev` | text | The roster device id of the watch. On the uplink only; the service sets it | +| `id` | uint | The service's number for one request, below 2³², which the box copies into its answer. On the uplink only | +| `d` | bstr | One exchange message, or empty for no answer | + +- **No answer.** The service answers `x` with an empty `d` at once when the + box is not connected, did not say `x` in `hello.caps`, or has 8 requests + open; and after 5 s without the box's answer. The watch cannot tell + these apart, and does not need to: an exchange session that got no + answer is spent, and a command that got none is sent again under its + `cmdId`, which the box's command log answers with the stored outcome. +- **The box checks it again.** It runs the handshake and admits the watch + by its key, exactly as ADR 0009 says, and answers only if the key the + handshake proved is the device `dev` names. A request is tried only + against that device's sessions. +- **What the service can do.** Drop, delay or replay a request. It cannot + read or forge `d`. A replayed message 1 opens a session nobody can use; a + replayed request fails in its session's transport, and a replayed `cmd` + in a new session meets the command log. Every `cmd` carries its own + expiry, in box uptime. +- **What the service learns.** That a watch of this home sent a request + and when, and when the answer came. Every request and every answer after + the handshake has one length, and the box answers a `cmd` no sooner than + 400 ms after it arrived, applied or refused, so the service cannot tell a + read from a command, or a refusal from a success. +- **Limits.** The one-shot read's: one request at a time per device and 30 + a minute. A command takes two (the handshake and the `cmd`) and a third + when its result had not settled. `d` is at most 2,048 bytes either way. +- **Enrolment.** An owner's phone adds a watch over its own box session, + with a passkey step-up, by the watch's public key: + `POST /api/app-link/pairing {"role": "watch", "key": ""}`. The + box lists it in the next roster, so its first request is admitted like + any other. No code crosses the service. ## Extension token @@ -1665,6 +1736,7 @@ constants, so `srcfl/ftw-cloud` need not spell them by hand. | 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 | +| Exchange through the service | Answered within 5 s or with an empty `d`; 8 open per box; `d` at most 2,048 bytes | | Roster `ttlS`, renewal | 24 hours, every 6 hours | | Extension token | 7 days | | Alert age for buttons | 10 minutes | diff --git a/go/protocol/cloudwire/allowlist.go b/go/protocol/cloudwire/allowlist.go index f4b21e66..3c189569 100644 --- a/go/protocol/cloudwire/allowlist.go +++ b/go/protocol/cloudwire/allowlist.go @@ -63,6 +63,9 @@ var Fields = map[string][]string{ // tun carries no header and no signature: d is Noise ciphertext its two // ends authenticate, and the service reads only dev and sid. MsgTun: {"dev", "sid", "d"}, + // x is the same: d is a watch's exchange with the box, and the service + // reads only dev and id. + MsgX: {"dev", "id", "d"}, } // Bodies maps each class the box sends to its body type. Classes the box does @@ -86,4 +89,5 @@ var Bodies = map[string]any{ // Unheaded are the box's messages without the common header. var Unheaded = map[string]any{ MsgTun: Tun{}, + MsgX: X{}, } diff --git a/go/protocol/cloudwire/exchange.go b/go/protocol/cloudwire/exchange.go new file mode 100644 index 00000000..dbfa1ba8 --- /dev/null +++ b/go/protocol/cloudwire/exchange.go @@ -0,0 +1,69 @@ +package cloudwire + +import "time" + +// The exchange through FTW Cloud: a watch's commands to its box (ADR 0009, +// decisions 1 and 2). docs/protocol-cloud.md, "Exchange", is the normative +// text. +// +// A watch has HTTPS and nothing lower, so it cannot hold a box session the +// way a phone holds one in the tunnel. It runs the box's exchange instead: +// short Noise sessions (ftw.exchange.v1, IK to the box's static key) that +// live on the box, one request and one answer at a time. Each request rides +// one one-shot request to the service as x {d}. The service adds the device +// id its own handshake proved and the request's number, sends +// x {dev, id, d} down /v1/box, and waits for the box's x {dev, id, d} with +// the same number. Its d goes back to the watch in message 2, as x {d}. +// The service keeps nothing after the answer, and reads neither d. +// +// An empty d from the service means no answer came: the box is not +// connected, does not take x, kept silent, or did not answer in time. An +// empty d from the box is its silence: a stranger, a spent session or a +// message it could not read. + +// MsgX is the exchange message on /v1/box and in a one-shot request. +const MsgX = "x" + +// CapX in hello.caps says the box answers x. The service sends x to no +// other box: an older one closes the uplink on a type it does not know. +const CapX = MsgX + +// Sizes. An exchange message is a handshake message 1 with one lane 0 frame +// (608 bytes), a request of one lane 0 frame (536) or an answer of two +// (1,072). XDataMax leaves room above the largest without letting d carry +// anything else. +const ( + XDataMax = 2048 + // MaxXFrame bounds one x frame on the uplink: d, the device id, the + // number and the envelope around them. + MaxXFrame = XDataMax + 256 +) + +// XAnswerWithin is how long the service waits for the box's answer. The box +// answers a command once its result settles, within 3 s, and no sooner than +// 400 ms after it arrived; a read and a handshake at once. +const XAnswerWithin = 5 * time.Second + +// XInFlight is how many exchanges the service has open at one box at a time, +// as many as the box answers at once. Past it the service answers an empty +// d at once. +const XInFlight = 8 + +// X is the exchange message on /v1/box, both ways. +type X struct { + // Dev is the roster device id of the watch. The service sets it from + // the key its own handshake with the watch proved. + Dev string `cbor:"dev"` + // ID is the service's number for one request, which the box copies + // into its answer. It means nothing else. + ID uint32 `cbor:"id"` + // D is one exchange message, or empty for no answer. + D []byte `cbor:"d"` +} + +// AppX is the exchange message in a one-shot request and its answer. It +// has no device id and no number: the service knows the device from its +// handshake, and one request has one answer. +type AppX struct { + D []byte `cbor:"d"` +} diff --git a/go/protocol/cloudwire/messages.go b/go/protocol/cloudwire/messages.go index 8ace59fe..335ea26a 100644 --- a/go/protocol/cloudwire/messages.go +++ b/go/protocol/cloudwire/messages.go @@ -42,9 +42,9 @@ const ( ) // Downlink is the closed set of types the service may send the box. None of -// the first five asks the box to act. tun carries ciphertext the box decrypts -// itself and judges as it judges the same session over the relay. -var Downlink = []string{MsgChallenge, MsgHelloOK, MsgWatch, MsgResync, MsgPurged, MsgTun} +// the first five asks the box to act. tun and x carry ciphertext the box +// decrypts itself and judges as it judges the same session anywhere else. +var Downlink = []string{MsgChallenge, MsgHelloOK, MsgWatch, MsgResync, MsgPurged, MsgTun, MsgX} // ClassScope is the scope a device needs to read each class the service // forwards. hb maps to "" because every device in the roster gets it: it diff --git a/go/protocol/cloudwire/once.go b/go/protocol/cloudwire/once.go index e76fce0d..39334bf5 100644 --- a/go/protocol/cloudwire/once.go +++ b/go/protocol/cloudwire/once.go @@ -15,8 +15,9 @@ import ( // 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 +// unsigned envelope: read, token.get, push.register, push.unregister or x +// (exchange.go). 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. @@ -48,7 +49,7 @@ const ( // 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} +var OnceMessages = []string{MsgRead, MsgTokenGet, MsgPushRegister, MsgPushUnregister, MsgX} // Read asks for the snapshot, less what the watch already holds. type Read struct { diff --git a/go/protocol/cloudwire/once_vectors_test.go b/go/protocol/cloudwire/once_vectors_test.go index d2e63831..445a1f20 100644 --- a/go/protocol/cloudwire/once_vectors_test.go +++ b/go/protocol/cloudwire/once_vectors_test.go @@ -320,3 +320,165 @@ func TestPushRegisterNamesTheApp(t *testing.T) { t.Fatalf("a watch registration read app %q: %v", back.App, err) } } + +// testdata/cloud-once-x-vectors.json freezes one exchange request through +// the service byte for byte: the watch's x {d} in message 1, the x {dev, id, +// d} the service sends the box and the one the box sends back, and message 2 +// with the box's d as the service writes it. d is filler of the exchange's +// real lengths; the exchange's own bytes are in srcfl/ftw +// go/internal/appuplink/testdata/exchange-vectors.json. +const onceXVectorsPath = "testdata/cloud-once-x-vectors.json" + +type onceXVectorFile 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"` + Prologue string `json:"prologue"` + Msg1Payload string `json:"msg1Payload"` + Request string `json:"request"` + Down string `json:"down"` + Up string `json:"up"` + Msg2Payload string `json:"msg2Payload"` + Answer string `json:"answer"` + Notes map[string]string `json:"notes"` +} + +func TestOnceExchangeVectors(t *testing.T) { + labels := map[string]string{ + "serviceStatic": "ftw.cloud once vector service static", + "serviceEphemeral": "ftw.cloud once x vector service ephemeral", + "watchStatic": "ftw.cloud once vector watch static", + "watchEphemeral": "ftw.cloud once x vector watch ephemeral", + } + serviceStatic := fixedX25519(t, labels["serviceStatic"]) + serviceEph := fixedX25519(t, labels["serviceEphemeral"]) + watchStatic := fixedX25519(t, labels["watchStatic"]) + watchEph := fixedX25519(t, labels["watchEphemeral"]) + 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) + } + home := signed.BoxKey.Home + prologue := OncePrologue(home) + dev := DeviceID(watchStatic.Public()) + + // An exchange handshake's message 1, and its message 2 from the box. + request := bytes.Repeat([]byte{0x5a}, appwire.Message1Overhead+512) + answer := bytes.Repeat([]byte{0xa5}, appwire.Message2Overhead+1024) + msg1Payload, err := Unsigned(MsgX, AppX{D: request}) + if err != nil { + t.Fatal(err) + } + msg2Payload, err := Unsigned(MsgX, AppX{D: answer}) + if err != nil { + t.Fatal(err) + } + frame := func(x X) []byte { + body, err := Marshal(x) + if err != nil { + t.Fatal(err) + } + raw, err := Envelope{T: MsgX, B: body}.Encode() + if err != nil { + t.Fatal(err) + } + f, err := EncodeFrame(raw) + if err != nil { + t.Fatal(err) + } + if len(f) > MaxXFrame { + t.Fatalf("an x frame is %d bytes, over %d", len(f), MaxXFrame) + } + return f + } + down := frame(X{Dev: dev, ID: 1, D: request}) + up := frame(X{Dev: dev, ID: 1, D: answer}) + + 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) + } + body, err := OnceAnswer(msg2) + if err != nil { + t.Fatal(err) + } + parts, err := SplitOnceAnswer(body) + if err != nil || len(parts) != 1 { + t.Fatalf("split %d parts: %v", len(parts), err) + } + got2, err := watch.ReadMessage(parts[0]) + if err != nil { + t.Fatal(err) + } + env, err := DecodeEnvelope(got2) + var x AppX + if err != nil || env.T != MsgX || len(env.Sig) != 0 || Unmarshal(env.B, &x) != nil || !bytes.Equal(x.D, answer) { + t.Fatalf("the watch read message 2 as %+v: %v", env, err) + } + + out := onceXVectorFile{ + 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, Prologue: hex.EncodeToString(prologue), + Msg1Payload: hex.EncodeToString(msg1Payload), Request: hex.EncodeToString(msg1), + Down: hex.EncodeToString(down), Up: hex.EncodeToString(up), + Msg2Payload: hex.EncodeToString(msg2Payload), Answer: hex.EncodeToString(body), + 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, which carries x {d}: one exchange message.", + "down": "The frame the service sends the box: x {dev, id, d}, dev from its own handshake, id its own number.", + "up": "The box's answer under the same dev and id; d goes to the watch unchanged.", + "answer": "Message 2 after its length as u16 big endian. An empty d means no answer came.", + }, + } + gotJSON, err := json.MarshalIndent(out, "", " ") + if err != nil { + t.Fatal(err) + } + gotJSON = append(gotJSON, '\n') + if *update { + if err := os.WriteFile(onceXVectorsPath, gotJSON, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(onceXVectorsPath) + if err != nil { + t.Fatalf("%v (run with -update to create it)", err) + } + if !bytes.Equal(gotJSON, 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", onceXVectorsPath) + } +} diff --git a/go/protocol/cloudwire/testdata/cloud-once-x-vectors.json b/go/protocol/cloudwire/testdata/cloud-once-x-vectors.json new file mode 100644 index 00000000..248deab7 --- /dev/null +++ b/go/protocol/cloudwire/testdata/cloud-once-x-vectors.json @@ -0,0 +1,23 @@ +{ + "serviceStatic": "88b1d5e4491b3de125574d2eaa27a47229fc2c51d19fcb5dc6435b5ef61c380f", + "servicePublic": "0e9bd61ff213665b5e31282e6f2098d467245d224b7b4ad374a6ab4a8e5b594d", + "serviceEphemeral": "609f1c22cad0a21854fb399b775dbd1fad0939359bdd90e8cee3e801e762bd65", + "watchStatic": "8a87ac4f7d277626a8c36f7311c2bcd833e9abacefd9c0df90d39a916f19da94", + "watchPublic": "7ef44864cb0a1f0b998b0ad161d41dac007454a5717c2447c4021449a7873819", + "watchEphemeral": "bec12c753582d65382ef12695e6a90e1557c5d7f39b31f6d50fbf790bc6da93b", + "home": "fjzyr67buzpyncqmjliwgezvde", + "prologue": "6674772e636c6f75642e6f6e63652e76313a666a7a7972363762757a70796e63716d6a6c697767657a766465", + "msg1Payload": "a36162590266a161645902605a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a617461786373696740", + "request": "81f81f71eb08d9d76b6bd377987ba10cee30e50e899e9e3a41c892c69502c67736aadede071b5f30e77532d7ddea315bf2a3ff9fc054728a1cd79efd33d728eda7fa37dca20c64da5ff63d3508c222ef9b3d8f0fae16ff1a690ed5c62e44be646212200ec59c8312dca2b150a78708a30066978db0498c4eb38bce8d1a4cd34a8d114a72ca9776c7c4ffae03edd651f5bb87f9fe87b57b497711e2416cb3b3f9c239c4c56c3475ab46e7fa5a3c79efa74bbcd81b574a9512d071dfddc9afe1267826a5907ef5e9c7d154f85d53a275b9aa4948bc54a85a5acd4ce62080aea614992006a1310ed99e7be28f4d046f63a3bbbea80f6f0346b57962d3152209b6701c939a08bc22edb800bcb6790c2c5721469270f07db2a2f83a3e400922a5fe46c8d653df5e2bfa05c898e0d86dc0ab30fc1626d8a3a7f5a769c872055320f55003ad3bd35c1b6e56f98109091a51cdc8d07526829c0b1b3a4c8b604da85907e458c5664d7b1a0e012fd04a8a3e63038fb2dbf29cf10e367d0965ebcd7eb5bdc4a135c0378e798698de4801209fceb6e5c1fc7bf8eed107663313fdbc29ba233f8ff0087b52046b5a8bcd364068ddd97d345b855c22b020d87c6c6da188a9d04a24e5f9b31dc36cb4461122500b39b54cdbd0a5df53c40e3233f1bdaba63cd5f62b8ce94bf4ba48fb6c533b564b05d6803ae0ff5cbdd0270634372c52ae189a60756f9199f456655557781b13c387ed6d982f1794a8239cb8c4cc1da8992ff6f0b69fc4dacfbeec5539a912b60bfd0965bcc1a4466aacfbc4a73a6ebe09caf61e32cd2e6423d317e25292431fe30b38774982e236cc05f786e42c681287e2cd74a08620c1a409f498c6de7c2d64bb277cd835f65ac92ec404546ab234ff3c909ba442c2cf33870aa55ccce88f229c7c5f8b82b596e0552aec4c88f2e91b1203a0a8f83b09df6d4bd4033a2a9c5348c4fb9cd12e0d44178229f3421371d65ae6ea2686a1a44ba216826284d442a5d2895a789688d706", + "down": "010100000286a36162590277a361645902605a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a626964016364657668667652495a4d734b617461786373696740", + "up": "010100000456a36162590447a36164590430a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5626964016364657668667652495a4d734b617461786373696740", + "msg2Payload": "a36162590436a16164590430a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5617461786373696740", + "answer": "04758694100821300a95e0fb303174248d6fd825e563b58802430522b289579e372b871feab2457830ec66640e595692fef54ef6590377ad4f7c82c47fd877997fac51d04ac4d3af08053ea488b250bcc3ffb74ad54479fa117285210baa02566b7b202a6c7ffc23642f0537954e0ef0fa21fcd651283b7fbeeaa88862cdf2f3024330566ff98cc85a21b3156555bb7a5cc2493649591026392d224190e42159e459b2fa5f2831c7fa21b3a9bdb537fbc265095dfc94da3ee315492966a78ae5c0f5a9d089ee77f9e752df6c44cb96e4bdb76f65cd066e3f27855449a03e49ee1da96b560b5763afc2bb99a097e6bfece8db687fa4fe5c1280d3a8ff7d1365ce79b7762c0d9c74e8679ea232c5d9ea07094a1d17d202b2040cbbcb031056d0f9234052e531dd5707e13fcb1db62c2b2e01ab94858d5fab02bc48809db77b6aad49aa4949b566593ff67c93f79665498ff1d6dbfd5f21f6fd36e831c036a6dd6a4a90c6d970f2fcd152730c3961dbd9949dd7c0f740f660b9a21387b75ca03fc358abe1e2a6f8186b3942b99e4933ffa69b7c4be3ad45e667f13f3218ac2fe135b6c7de6621ac2350a6c065d2d83b8147d6b7e8cf24c7274a7653851940a74841d1365da6d3f63d0e9a343b7ae4c83bcd688ffa77ebac59c48c4f856b17f8a4b04606400a150014ac34e02a862e6e96b58c753f3775c092acba2ac47a8240fe94baf02d289dd7956027af2b1bb08e6ff4d4ff5c2dc8266ea3d1be9aa28f1d6ec74375daf3ffe9bb2c64036a092f7a0cd9bcfa1988c617b1a4c3f059e5601827ff738b88223666349e7a958294b8083a652a8599f5d065d5b16bc8a938038f5664ec6212cc6a739bbbf4de2c4e69dfe9064bc74ad2830636edab24d032cee34a7d3c8fac1d824a47d5577cacfedad4e55bb3615fad6434016e849184964178fea9a515ed0741d8225cbd05b8ee0c2340b7d07536029c7fc1fe00196f4b418ebf0ace805b97afc936688fcfcda5a3677b090f6dac8ff9b632eb312da27b73af219118acf2aeb94b1748c36c94c446b27ccf0fd7419f6fdcd08122572048671c6ed9089577ecba46ff10dde908081aee7f8bf9096ca9f4bb9856274181f8b4bcc2b0f30fe5001f3f0205068090ed9215aa28d1adfcb27fb934ffa66a5f751a1ea6a3c61821855b358fc6eb2672f2cb7d791309be412c5c326ab315b0d744cd593fbf7c46a470ab7d4d0f5a7dc788c928d7e20dfdfeceda97c673c37ffcbcef7d28047623e9a7c48887f9852802db41ccffe2e9b3eae74a31739b754cf4f0cd14cf23116a974245b06dd80ad7802891827507e4c2cf82d06bb4754901db9fbd67a1c64564ade957375718c67ec894c11bcba45c515aafd6d0112e83f2be2d19a907cc40d68e876921eae1c7d0dae4f68b467f141975d2259886f25cac7b51ee5c6b5ab90afd42fd3ce32c1d902a54af485e40c20bb488856119829c42424ab4ef0c0f6e1657aab82ae8735c162f1688317dfb89ae8de6c69a660f2363a96c15042ffa0fed76607c82eebad90d1bc7026d2cf96966eded6cd4e04787c641dd1c9db2f463f63d98b35a0609346f513638b33c82b905b926a44823", + "notes": { + "answer": "Message 2 after its length as u16 big endian. An empty d means no answer came.", + "down": "The frame the service sends the box: x {dev, id, d}, dev from its own handshake, id its own number.", + "keys": "Secrets are SHA-256 of the labels in once_vectors_test.go. X25519 clamps them.", + "request": "The POST body is message 1, which carries x {d}: one exchange message.", + "up": "The box's answer under the same dev and id; d goes to the watch unchanged." + } +} diff --git a/go/protocol/cloudwire/tunnel_test.go b/go/protocol/cloudwire/tunnel_test.go index d817c817..9b23313b 100644 --- a/go/protocol/cloudwire/tunnel_test.go +++ b/go/protocol/cloudwire/tunnel_test.go @@ -37,9 +37,10 @@ func TestTheLargestTunFitsItsFrame(t *testing.T) { } } -// The downlink grows by tun alone. -func TestTheDownlinkIsSixTypes(t *testing.T) { - want := []string{MsgChallenge, MsgHelloOK, MsgWatch, MsgResync, MsgPurged, MsgTun} +// The downlink grows by tun and x alone: the two carry ciphertext the box +// judges itself. +func TestTheDownlinkIsSevenTypes(t *testing.T) { + want := []string{MsgChallenge, MsgHelloOK, MsgWatch, MsgResync, MsgPurged, MsgTun, MsgX} if !slices.Equal(Downlink, want) { t.Fatalf("Downlink is %v, want %v", Downlink, want) } From 8c7c835fdde122bca60ec54296fe43250e494f34 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 17:04:22 +0200 Subject: [PATCH 2/4] chore(changeset): x in FTW Cloud's protocol Co-Authored-By: Claude Opus 5.5 --- .changeset/cloudwire-x.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cloudwire-x.md diff --git a/.changeset/cloudwire-x.md b/.changeset/cloudwire-x.md new file mode 100644 index 00000000..60ef456a --- /dev/null +++ b/.changeset/cloudwire-x.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +FTW Cloud's protocol gains `x`, which carries a watch's exchange with its box through a one-shot request, so a watch can charge the car without its iPhone near. From ec6bcd731b77e900c7be8d23631816a171d2434a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 18:01:41 +0200 Subject: [PATCH 3/4] docs(cloud): the exchange's limits at the box, a restart, and the watch's one op Co-Authored-By: Claude Opus 5.5 --- docs/adr/0009-watch-exchange.md | 12 ++++++++- docs/protocol-cloud.md | 47 +++++++++++++++++++++++++-------- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/docs/adr/0009-watch-exchange.md b/docs/adr/0009-watch-exchange.md index 13617c80..9ae93627 100644 --- a/docs/adr/0009-watch-exchange.md +++ b/docs/adr/0009-watch-exchange.md @@ -207,13 +207,23 @@ phone's. Instead the owner's phone sends the watch's public key over its own session, with the same step-up: `POST /api/app-link/pairing {"role": "watch", "key": …}`. The box lists the watch in its next - roster. The code path stays for now and no client uses it. + roster. The code path is gone: a request for a watch without a key is + refused, and a hello with a code is answered with silence. 3. **A watch has `tun`.** The roster gives a watch `tun` whenever it gives a phone `tun`, so the service admits its exchange with the readable copy off. The box answers a watch's key on the exchange alone. 4. **Reads stay as decision 7 says**, with one change: with the copy off a watch on its own shows what it holds and its age. Reading `now` through the exchange is not built. +5. **The exchange takes the charger's hold alone.** Decision B said a + watch reads and charges. The watch role's `ftw.dispatch.write` reaches + further, so the exchange answers any op but `loadpoint.hold` with + `E_OP_NOT_GRANTED`. +6. **Replays end nothing, and a restart forgets.** A session proves itself + with its first transport message before it writes anything, and a + watch holds two. The command log lives in memory, so a box that + restarted between a command and its retry would act again; the watch + sees the uptime in `hello_ok` go back and does not resend. The service learns that a watch of the home sent a request and when, and when the box answered; the relay learned the same. One `cmdId` sent by the diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index a0548846..a421d5d8 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -1274,7 +1274,8 @@ to the box on the uplink. It reads neither the request nor the answer. is one exchange message: handshake message 1 with the watch's `hello` (608 bytes), or a request of one lane 0 frame, `cmd` or `now` (536). 2. The service admits the watch as step 3 of the one-shot read says, with - `tun` in place of the effective scope. + `tun` in place of the effective scope, and only a roster entry with role + `watch`. 3. It sends `x {dev, id, d}` down the uplink: `dev` the roster id its own handshake proved, `id` a number of its own for this request. It never takes either from the watch. @@ -1299,16 +1300,38 @@ to the box on the uplink. It reads neither the request nor the answer. open; and after 5 s without the box's answer. The watch cannot tell these apart, and does not need to: an exchange session that got no answer is spent, and a command that got none is sent again under its - `cmdId`, which the box's command log answers with the stored outcome. -- **The box checks it again.** It runs the handshake and admits the watch - by its key, exactly as ADR 0009 says, and answers only if the key the - handshake proved is the device `dev` names. A request is tried only - against that device's sessions. + `cmdId`, which the box's command log answers with the stored outcome, + unless the box restarted meanwhile (below). +- **A restart forgets.** The command log lives in the box's memory. A box + that restarted between a command and its retry no longer knows the + `cmdId`, and would act again. `hello_ok.clock.uptimeMs` says so: a new + session's uptime below the one the command was sent in means a restart. + The watch then sends the command no more, and says no answer came, which + may mean it ran. +- **The box checks it again.** `dev` must name a watch of this box; one + that does not costs the box nothing, not even a Diffie-Hellman. The box + runs the handshake and admits the watch by its key, exactly as ADR 0009 + says, and answers only if the key the handshake proved is the device + `dev` names. A request is tried only against that device's sessions. +- **Replays end nothing.** A new session stamps nothing and writes nothing + until its first transport message decrypts. A watch holds at most 2 + sessions and the box 8; past either, the oldest session that has not + proved itself goes first, never the newcomer. The box takes 12 handshakes + a minute from one watch and 60 in all. - **What the service can do.** Drop, delay or replay a request. It cannot - read or forge `d`. A replayed message 1 opens a session nobody can use; a - replayed request fails in its session's transport, and a replayed `cmd` - in a new session meets the command log. Every `cmd` carries its own - expiry, in box uptime. + read or forge `d`. A replayed message 1 opens a session nobody can use + and ends no working one; a replayed request fails in its session's + transport, and a replayed `cmd` in a new session meets the command log. + Every `cmd` carries its own expiry, in box uptime. +- **What a watch may do.** The exchange takes `cmd` with `loadpoint.hold` + alone, which Charge now, Pause and Back to the plan send, and `now`. Any + other op is answered `E_OP_NOT_GRANTED` before it runs, whatever the + watch role's scopes reach (ADR 0009, decision B). +- **The box's share.** `x` answers count against the tunnel's 24-hour + budget and against their own tenth of it, and stop once the tunnel has + spent nine tenths, so phones keep the last of it. The box takes 120 + requests a minute. A request whose answer could not leave is dropped + unanswered and runs nothing. - **What the service learns.** That a watch of this home sent a request and when, and when the answer came. Every request and every answer after the handshake has one length, and the box answers a `cmd` no sooner than @@ -1321,7 +1344,8 @@ to the box on the uplink. It reads neither the request nor the answer. with a passkey step-up, by the watch's public key: `POST /api/app-link/pairing {"role": "watch", "key": ""}`. The box lists it in the next roster, so its first request is admitted like - any other. No code crosses the service. + any other. No code crosses the service, and a request for a watch + without a key is refused. ## Extension token @@ -1737,6 +1761,7 @@ constants, so `srcfl/ftw-cloud` need not spell them by hand. | 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 | | Exchange through the service | Answered within 5 s or with an empty `d`; 8 open per box; `d` at most 2,048 bytes | +| Exchange at the box | 8 sessions, 2 per watch; 12 handshakes a minute per watch, 60 in all; 120 requests a minute; `x` a tenth of the tunnel budget, stopped at nine tenths | | Roster `ttlS`, renewal | 24 hours, every 6 hours | | Extension token | 7 days | | Alert age for buttons | 10 minutes | From 9c31069bce8aaa9e0dbc6560e0c45384e0183fce Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 18:47:46 +0200 Subject: [PATCH 4/4] docs(adr): Fredrik accepted Through FTW Cloud on 23 October 2026 Co-Authored-By: Claude Opus 5.5 --- docs/adr/0009-watch-exchange.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/0009-watch-exchange.md b/docs/adr/0009-watch-exchange.md index 9ae93627..090798be 100644 --- a/docs/adr/0009-watch-exchange.md +++ b/docs/adr/0009-watch-exchange.md @@ -4,8 +4,8 @@ command log on the box, the registry names, the watch role with its enrolment, the exchange sessions, and the wire for FTW Cloud's one-shot read. The relay stopped on 17 October 2026, and its exchange socket went - with it. [Through FTW Cloud](#through-ftw-cloud) (23 October 2026, - proposed) moves the exchange onto FTW Cloud's one-shot request, where the + with it. [Through FTW Cloud](#through-ftw-cloud) (accepted by Fredrik on + 23 October 2026) moves the exchange onto FTW Cloud's one-shot request, where the box says `cmd.exchange` again. - Date: 2026-10-14 - Decided by: Fredrik on 14 October 2026. Before the design: a watch controls @@ -189,7 +189,7 @@ to remove it.** ## Through FTW Cloud -Proposed on 23 October 2026, after Fredrik asked to finish FTW Cloud. The +Accepted by Fredrik on 23 October 2026, after he asked to finish FTW Cloud. The relay was stopped on 17 October 2026, and since the one-door decision of 15 October every phone reaches its box through FTW Cloud's tunnel. FTW Cloud carries ciphertext and never chooses an action; that is the line this ADR