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
5 changes: 5 additions & 0 deletions .changeset/cloudwire-x.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 48 additions & 4 deletions docs/adr/0009-watch-exchange.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) (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
the box directly as well as through the phone; an unlocked watch on the
Expand Down Expand Up @@ -186,6 +187,49 @@ 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

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
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 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
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,
Expand Down
113 changes: 105 additions & 8 deletions docs/protocol-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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=<id>` | 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=<id>` | 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=<id>` | 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 |

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -1249,8 +1257,95 @@ 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, 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.
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,
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
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
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": "<base64url>"}`. The
box lists it in the next roster, so its first request is admitted like
any other. No code crosses the service, and a request for a watch
without a key is refused.

## Extension token

Expand Down Expand Up @@ -1665,6 +1760,8 @@ 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 |
| 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 |
Expand Down
4 changes: 4 additions & 0 deletions go/protocol/cloudwire/allowlist.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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{},
}
69 changes: 69 additions & 0 deletions go/protocol/cloudwire/exchange.go
Original file line number Diff line number Diff line change
@@ -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"`
}
6 changes: 3 additions & 3 deletions go/protocol/cloudwire/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions go/protocol/cloudwire/once.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading