Backroom Relay

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:

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:

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.

FieldTypeMeaning
tstringreq, res, chunk, end, or err.
idstringCorrelates a response to its request. Unique per sender.
dstringDirection: c2m (companion to Mac) or m2c.
tsnumberUnix milliseconds at send.
mstringreq only. HTTP method.
pstringreq only. Path with query, for example /v1/agents/x/send.
hobjectOptional headers, for example {"Content-Type":"image/jpeg"}.
snumberres only. HTTP status.
estringerr 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.

Source of truth

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.