backroom / relay / docs / protocol
Protocol
Two layers. The relay contract is what any client must speak to use the pipe. The tunnel protocol is what Backroom's two apps speak through it, published so the implementations agree; the relay never parses a byte of it.
Layer one: the relay contract
This is the whole of what the relay requires, and it is deliberately small:
- Connect with
GET /ws?room=<id>, where the id is a URL-safe token of 16 to 256 characters carrying real entropy. - Send binary frames only. A text frame from a client closes the connection.
- Any text frame you receive is a relay control signal (
peer_joined,peer_left,keepalive); handle it out of band. Ignore unknown types. - Keep a read in flight at all times, or missed pings will reap you in about 40 seconds.
- Expect frames sent while alone in a room to be dropped, not buffered.
Everything below this line is endpoint business. Another pair of applications could speak an entirely different sealed protocol through the same relay without the relay changing at all.
Layer two: keys, and why the QR does not change
The pairing QR already carries a 32+ character secret S.
Everything the tunnel needs is derived from it, so an already-paired phone
keeps working and nothing new is ever exchanged:
roomID = base64url( HKDF-SHA256(ikm: S, salt: "", info: "backroom-relay/room/v1", len: 18) ) // 24 chars
key = HKDF-SHA256(ikm: S, salt: "", info: "backroom-relay/key/v1", len: 32) // AES-256-GCM
Both sides derive both values independently. Three properties matter:
- The relay sees
roomIDand neverS, and the room id is a one-way function of the secret, so the relay cannot derive the key from what it routes. - The room id is 144 bits of derived material: comfortably past the relay's entropy floor, and unguessable. It is the capability to reach a pair, which is why it is never logged in full.
- Rotating the pairing secret rotates the room and the key together. A stale phone lands in a different room and simply finds nobody, rather than reaching the Mac and being refused.
S itself is never sent through the tunnel, and there is no
bearer token in a tunnel request. Possession of the key is
the authentication: a request that does not decrypt cannot be acted on.
Frame format
One WebSocket binary frame carries one sealed envelope:
frame = nonce(12) || AES-GCM-Seal(key, nonce, plaintext) || tag(16)
plaintext = version(1) || headerLen(2, big-endian) || header(JSON, UTF-8) || body(raw)
The body is raw bytes after the header, not base64 inside it. That is deliberate: base64 would inflate every photo upload by a third against a 1 MiB frame ceiling, for no benefit once the whole envelope is sealed anyway.
version is 0x01. A receiver that does not
recognise the version drops the frame and logs a count, never the contents.
Header fields
| Field | Type | Meaning |
|---|---|---|
t | string | req, res, chunk, end, or err. |
id | string | Correlates a response to its request. Unique per sender. |
d | string | Direction: c2m (companion to Mac) or m2c. |
ts | number | Unix milliseconds at send. |
m | string | req only. HTTP method. |
p | string | req only. Path with query, for example /v1/agents/x/send. |
h | object | Optional headers, for example {"Content-Type":"image/jpeg"}. |
s | number | res only. HTTP status. |
e | string | err only. A short reason, safe to show a user. |
A response is one res (status and headers), then zero or more
chunk frames, then exactly one end. That models a
streamed NDJSON reply directly: each line becomes one chunk, and
end replaces "the connection closed" as the terminator.
Freshness and direction
d must match the receiver's expectation (the Mac accepts only
c2m), so a frame can never be reflected back at its sender.
ts must be within 120 seconds of the
receiver's clock, and each side keeps a seen-id window for
that period.
Together those bound replay: a captured frame is useless after two minutes, and useless immediately against the peer that sent it. This is not a substitute for the encryption; it is what stops an attacker who can capture and re-send ciphertext from replaying "start a turn" a thousand times.
Sizing and chunking
The relay caps a frame at 1 MiB by default
(RELAY_MAX_MESSAGE_BYTES) and closes the connection on a
violation, so the sender must chunk. Keep plaintext bodies at or under
768 KiB per frame, which leaves room for the header, the
nonce, and the tag. A photo upload is therefore a req followed
by chunk frames: the same shape as a response, in the other
direction.
This page mirrors PROTOCOL.md in the relay repository, which
is the canonical specification the two Swift implementations
(RelayHost on the Mac, RelayClient on the
phone) are written against. A protocol change lands there and here in the
same commit.