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/watch-cloud-once.md
Original file line number Diff line number Diff line change
@@ -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.
74 changes: 66 additions & 8 deletions docs/protocol-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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=<id>` | 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=<id>` | 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 |

Expand Down Expand Up @@ -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**
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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=<id>` 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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
140 changes: 140 additions & 0 deletions go/protocol/cloudwire/once.go
Original file line number Diff line number Diff line change
@@ -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=<home id>, 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
}
Loading
Loading