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/relay-watch-exchange.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw-webapp": patch
---

The relay can carry a watch's commands. A box opens a second socket, the exchange socket, and a watch posts one sealed message at a time to `/x/<epoch>/<handle>`. The relay reads only the 16-byte id it puts in front of each message, an exchange never takes a phone's stream place, and Caddy writes no access log line for these requests. A box that opens no exchange socket works as before.
88 changes: 73 additions & 15 deletions relay/README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
# The blind relay

Carries encrypted frames between an FTW box and the browsers allowed to see it.
It is the only remote path that carries commands, and it cannot read a watt.
Carries encrypted frames between an FTW box and the browsers and watches
allowed to reach it. It is the only remote path that carries commands, and it
cannot read a watt.
FTW Cloud, which a box owner may turn on for the iPhone app, is a separate
service that can read what the owner enabled. It never touches the relay
([ADR 0008](https://github.com/srcfl/ftw/blob/master/docs/adr/0008-ftw-cloud.md)).

```
box ──ws──▶ ┌────────────┐ ◀──ws── phone
│ relay │
│ no keys │ ◀──ws── laptop
│ no traffic │
│ on disk │
└────────────┘
box ──ws: uplink────▶ ┌────────────┐ ◀──ws──── phone
──ws: exchange──▶ │ relay │
│ no keys │ ◀──ws──── laptop
│ no traffic │
│ on disk │ ◀──https── watch
└────────────┘
```

## Running it
Expand All @@ -22,7 +23,8 @@ npm run relay # PORT=8787 HOST=0.0.0.0
```

Network settings and two state-file paths come from the environment. There is
no configuration file; every limit stays in `src/server.ts` beside the code.
no configuration file; every limit stays in `src/server.ts` or `src/limits.ts`
beside the code.

## What it does

Expand All @@ -35,6 +37,14 @@ stream a ciphertext belongs to without reading it, and reading it is the one
thing it must not do. At 512 bytes a second on the telemetry lane, four streams
cost two kilobytes a second. That is the right way to spend the bandwidth.

A watch cannot hold a stream: watchOS gives an app HTTPS and nothing lower. So
the box opens a second socket, the **exchange socket**, and a watch posts one
sealed message at a time. The relay draws a random 16-byte id, sends the id and
the body down that socket, and returns the answer that comes back behind the
same id. An exchange never takes one of the four stream places, no stream sees
one, and the exchange socket sees no stream frame.
[docs/watch-exchange.md](../docs/watch-exchange.md) has the wire.

## What it cannot do, and how you can check

Read `src/`. The small files are meant to be read: routing and
Expand All @@ -46,12 +56,13 @@ man's switch in `deadman.ts`.
|---|---|
| No keys, no decryption | Nothing in this directory imports a cipher |
| No parsing of payloads | The only property read off a routed message is `.length` |
| On the exchange socket, only its own id | `#onAnswer()` reads the first 16 bytes, the id the relay drew, and returns the rest unchanged |
| No compression | `perMessageDeflate: false` — a compressed frame's size depends on its content |
| No padding, no trimming | `send()` forwards the received buffer unchanged |
| No handle derivation | Needs a secret that never comes near here; see `src/lib/carrier/rendezvous.ts` |
| No storage of routed traffic | Rooms are a `Map` and are deleted with their last socket |
| No storage of routed traffic | Rooms are a `Map` and are deleted with their last socket. An open exchange leaves its room's map when its answer goes or its timer fires |
| Bounded stored state | `deadman.json` holds sealed dead-man rows; `fleet-stats.json` holds daily counters for 90 days. Neither holds routed traffic |
| No record of who was here | `log` is given counts and statuses; `inspect()` is everything held, and holds counts |
| No record of who was here | `log` is given counts and statuses, and nothing about exchanges; `inspect()` is everything held, and holds counts. Caddy writes no access log line for `/x/` |

**The dead man's switch is the one thing the relay holds**, because it is the
one notification the box cannot push about itself: that it is gone. A box
Expand Down Expand Up @@ -90,8 +101,9 @@ from a real box. The daily count is an operating estimate, not a billing or
security measure.

`tests/relay-blindness.test.ts` runs a real box against a real app across this
server, collects every routed byte, every logged line and everything in memory,
and fails if a message type, a device name or a watt reading is anywhere in it.
server, and a stand-in box and watch through the exchange. It collects every
routed byte, every logged line and everything in memory, and fails if a message
type, an op name, a device name or a watt reading is anywhere in it.
It first checks that the same detector catches the unsealed frames, because a
detector that catches nothing proves nothing. The dead man tests hold the new
claims the same way: the persisted file is byte-audited against the five
Expand Down Expand Up @@ -147,7 +159,8 @@ keeping the list, and it is paid on purpose.
## Wire

```
ws://host/r/<epoch>/<handle>/<box|app>
ws://host/r/<epoch>/<handle>/<box|app|exchange>
POST https://host/x/<epoch>/<handle>
```

Binary messages are routed. Text from a peer is a protocol error and closes the
Expand All @@ -161,7 +174,7 @@ interpret. Text from the relay is one of two words:

| Close code | Meaning |
|---|---|
| 4400 | Bad join, text on a binary channel, or an oversize message |
| 4400 | Bad join, text on a binary channel, an oversize message, or an exchange answer of the wrong size |
| 4409 | Wrong epoch; the reason carries the right one |
| 4410 | The epoch turned over; the reason carries the new one |
| 4429 | Rate limited, room occupied, or the process is at its ceiling |
Expand All @@ -170,6 +183,33 @@ A single timer drives the heartbeat, the reaping of dead sockets and the
rotation check, so nothing the relay emits has a cadence that traffic can
influence.

### The exchange

The box joins with role `exchange`, one per room; the first holds it, as with
the uplink. Each message from the relay is `id(16) ‖ body`, and each answer
from the box is `id(16) ‖ reply`. The relay drops an answer whose id it is not
waiting for, and says no word on this socket: no `ready`, no `gone`.

A watch posts `content-type: application/octet-stream` and gets a status back.
No CORS header is ever sent, so a web page cannot read an answer.

| Status | Meaning |
|---|---|
| 200 | The box replied; the body is its reply, unchanged |
| 400 | The path, the content type or the body size is not one the route takes |
| 404 | No box on this handle now |
| 405 | Not a POST |
| 409 | Wrong epoch; the body carries the right one |
| 429 | Rate limited |
| 501 | A box is here but opened no exchange socket: it needs an update |
| 504 | The box did not reply within 10 s, or its exchange socket closed |

The limits, in `src/limits.ts`, keep no list: 30 requests a minute per address;
2 open and 30 a minute per room; 1 024 open in the process; a body of exactly
608 or 536 bytes in and at most 4 KiB back. During the five minutes a rotation
is spread over, `/x/` also takes the previous epoch, so a watch whose box has
not moved yet can find it.

## What it still knows

Honesty about the residue, in the spirit of `docs/architecture.md`:
Expand All @@ -182,6 +222,24 @@ Honesty about the residue, in the spirit of `docs/architecture.md`:
- The six coarse fleet labels during one daily HTTP request. It stores their
daily totals, not the request or the address that sent it.

While a watch's request is open, it also knows what
[docs/watch-exchange.md](../docs/watch-exchange.md#what-the-relay-must-never-learn)
lists:

- That someone with this hour's handle sent a request at a given time from a
given address. [architecture.md](../docs/architecture.md#what-is-not-yet-true)
names the same channel for app-to-box frames, which leave only on a person's
action.
- Whether a request opens a session, from its size. The order already told it
that.
- How long the box took. A refusal answers in milliseconds; an applied command
waits for the charger. ADR 0009 measures whether that gap shows through
network jitter, and adds a fixed floor only if it does.
- That this handle had an HTTPS client this hour.

It writes none of these down. No log line names a handle, an address, a size
or a time, and Caddy writes no line for `/x/`.

## Not here yet

- TLS. Terminate it in front; this speaks plain WebSocket.
Expand Down
8 changes: 8 additions & 0 deletions relay/deploy/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ relay.ftw.energy {
@fleet_stats path /fleet/stats /fleet/stats/
respond @fleet_stats 404

# A watch's exchange is one request per command. With the URI and the
# addresses cut, the access log would still keep one line per request
# with its time, sizes and duration: a record of each command, which is
# what the escrow had to remove from its own proxy. So these requests
# write no access log line at all.
@exchange path /x/*
log_skip @exchange

# The relay reads this to rate-limit. Behind a terminator every client is
# one address, so without it the whole fleet shares a single limit and
# ordinary reconnection load is an outage. Caddy sets it; the relay only
Expand Down
3 changes: 2 additions & 1 deletion relay/deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ The relay logs one line every fifteen seconds with aggregate counts — epoch,
rooms, sockets, frames, bytes and today's fleet reports. Deliberately no handles: a log line naming one
is the household identifier this design exists to withhold, and logs are the
least guarded thing in any deployment. The Caddy config strips the request URI
for the same reason, since the join path carries the handle.
for the same reason, since the join path carries the handle. It writes no line
at all for `/x/`, where a line per request would be a line per watch command.

Read the detailed fleet totals from the host or an SSM session:

Expand Down
9 changes: 9 additions & 0 deletions relay/deploy/caddyfile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ describe('the proxy in front of the relay', () => {
}
})

it('writes no access log line for a watch exchange', () => {
// A line per request keeps a time, sizes and a duration even with the
// URI and addresses cut. On /x/ that is a line per command.
const source = caddyfile.replace(/^\s*#.*$/gm, '')
const matcher = /@(\w+)\s+path\s+\/x\/\*\s*$/m.exec(source)
expect(matcher, 'no matcher for /x/*').not.toBeNull()
expect(source).toMatch(new RegExp(`^\\s*log_skip\\s+@${matcher![1]}\\s*$`, 'm'))
})

it('never writes a log to stdout or stderr, where Docker keeps it', () => {
expect(caddyfile).not.toContain('output stdout')
expect(caddyfile).not.toContain('output stderr')
Expand Down
20 changes: 20 additions & 0 deletions relay/src/limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,23 @@ export class AttemptCounter {
return digest.readUInt32BE(0) % this.#counts.length
}
}

/**
* The exchange's bounds, from docs/watch-exchange.md.
*
* None of them keeps a list either. The per-address count is an
* AttemptCounter of its own. The per-room allowance is a TokenBucket on the
* room, gone with the room. Open exchanges sit in a map keyed by the relay's
* own random id, and each leaves it when its answer goes or its timer fires.
*/
export const EXCHANGE = {
/** IK message 1 (608), or one transport message (536). Nothing else. */
requestBytes: [608, 536] as readonly number[],
/** The box's answer, after the id. */
maxReplyBytes: 4096,
attemptsPerMinute: 30,
openPerRoom: 2,
perRoomPerMinute: 30,
openPerProcess: 1024,
timeoutMs: 10_000,
} as const
13 changes: 12 additions & 1 deletion relay/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
* Everything the relay ever says to a peer is in this file. Close codes carry
* the reason a socket ended, and the two control words say whether the other
* side of the room is present. There is no relay message with a body, which is
* why the routing path never has to interpret anything.
* why the routing path never has to interpret anything. The one thing the
* relay adds to a message is the exchange id, on the box's exchange socket,
* and it is the relay's own random number.
*
* The carrier holds its own copy of these rather than importing them, so the
* relay can be deployed and audited without the app beside it.
Expand All @@ -30,3 +32,12 @@ export const CTRL_GONE = 'gone'

/** A rendezvous handle is 128 bits in hex. See src/lib/carrier/rendezvous.ts. */
export const HANDLE_CHARS = 32

/**
* The relay's own id in front of every message on a box's exchange socket.
*
* Random, drawn per request, and copied back by the box in front of its
* answer. It names nothing outside the relay's memory. See
* docs/watch-exchange.md.
*/
export const EXCHANGE_ID_BYTES = 16
Loading
Loading