# BYOC wire protocol, v1 (draft)

Status: draft, implemented by `server/` (byoc-server 0.1) and `sdk/` (byoc-js 0.1).

This document specifies how BYOC clients (SDKs) talk to BYOC operators, and how
a host and a guest talk to each other *through* an operator. An operator is
untrusted: it can refuse service or drop messages, but it cannot read signaling
or relay traffic, and it cannot impersonate a host.

Notation: `‖` is byte concatenation. `utf8(s)` is the UTF-8 encoding of `s`.
`b64u` is unpadded base64url (RFC 4648 §5). `b32` is unpadded lowercase
base32 (RFC 4648 §6 alphabet, lowercased: `a–z2–7`).

---

## 1. Identities and addresses

### 1.1 Host key and hostId

A host has a long-lived signing key pair. Two algorithms are defined:

| `alg` | Public key encoding (`pubkey`) | Signature encoding (`sig`) |
|---|---|---|
| `Ed25519` | 32-byte raw key | 64-byte raw signature |
| `ES256` | 65-byte uncompressed SEC1 point (P-256) | 64-byte `r ‖ s` (IEEE P1363), SHA-256 |

Both are carried as `b64u` strings.

```
hostId = b32(SHA-256(pubkeyRaw))[0..26]      // 26 chars, 130 bits
```

Operators and guests MUST recompute `hostId` from `pubkey` and reject a
mismatch.

### 1.2 Join secret and join key

A room has a join secret: 16 random bytes, encoded `b64u` (22 chars), or the
empty string for a public room. The secret never goes to the operator.

```
joinKey = HKDF-SHA256(
  ikm  = utf8("byoc-v1|" + hostId + "|" + secret),
  salt = utf8("byoc-v1"),
  info = utf8("join"),
  L    = 32) → AES-256-GCM key
```

### 1.3 Links and short codes

```
<page URL>#byoc=<hostId>.<secret>[.<operatorHint>]
```

- The address lives in the URL fragment so it never reaches a web server.
- `secret` may be empty (`#byoc=<hostId>.`), meaning a public room.
- `operatorHint`, when present, is a decimal index into the SDK's operator
  list, or a `b64u(utf8(url))` of an operator URL.

A **short code** is an operator-issued alias for an online host, 7 characters
from the alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` written `XXX-XXXX`.
Clients SHOULD normalize input by upper-casing and removing `-` and spaces.
A short code locates a room but carries no secret: a private room also needs
its secret (out of band).

---

## 2. Transport

Clients connect with a WebSocket to `wss://<operator>/v1`. Every message is a
JSON text frame, an object with a string field `t` (type). Binary frames are
not used in v1. The operator closes the socket on a malformed message.

Limits (defaults, operators MAY change them):

| Limit | Default |
|---|---|
| Max message size | 64 KiB |
| Concurrent sockets per IP | 40 |
| `claim` per IP | 10 / minute |
| `dial` per IP | 60 / minute |
| Open dials per guest socket | 8 |
| Open dials per host | 256 |
| Relay frames per dial, each direction | 200 / second (burst 400), advertised as `relayRate` |
| Relay frames across all dials | 40,000 / second (operator-wide; §3.5) |
| Bytes queued to one socket | 4 MiB, then the socket is closed as a slow consumer |

The operator pings every 20 s and drops sockets idle for 60 s (WebSocket
ping/pong counts as activity).

A client over the per-IP socket limit is still accepted as a WebSocket, then
sent `{t:"error", code:"rate-limited", message}` and closed, so it can tell a
refusal from a network failure (a bare HTTP 429 looks like the latter to
browsers).

---

## 3. Messages

### 3.1 Hello (both roles)

```
C→S {t:"hello", v:1, sdk:"byoc-js/0.1", role:"host"|"guest"}
S→C {t:"welcome", v:1, operator:"<name>", challenge:"<b64u 32 bytes>"}   // role host
S→C {t:"welcome", v:1, operator:"<name>"}                                // role guest
```

`v` other than `1` → `error` with `version-mismatch`, then close.

### 3.2 Host claim

```
C→S {t:"claim", hostId, pubkey, alg, sig, code?}
S→C {t:"claimed", hostId, code:"KX7-4PQ", ice:[RTCIceServer…], relay:true}
```

- `sig` = sign(`utf8("byoc-claim|") ‖ challengeRaw`), where `challengeRaw` is
  the decoded 32-byte challenge.
- `code` is an optional short code the host held before (e.g. before a
  reload). The operator grants it when it is free or reserved for this
  `hostId`, otherwise it issues a new one.
- A claim for a `hostId` that is already online **replaces** the old session:
  the old socket gets `{t:"replaced"}` and is closed. Dials in flight on the
  old session are closed (§3.6).
- When a host session ends, its short code stays reserved for that `hostId`
  for 10 minutes.
- `ice` is a list of `RTCIceServer` dictionaries (`urls`, and for TURN
  `username`/`credential`), ready to pass to `RTCPeerConnection`.

A host MAY set public metadata (≤ 1 KiB JSON) shown to dialers:

```
C→S {t:"meta", meta:{…}}
```

### 3.3 Guest dial

```
C→S {t:"dial", dialId, hostId? , code?, wait?}
S→C {t:"dialing", dialId, hostId, meta, ice:[…], relayRate:{perSecond, burst}}
S→H {t:"incoming", dialId, ice:[…], relayRate:{perSecond, burst}}
```

- `dialId` is chosen by the guest: 16 random bytes, `b64u`. A duplicate
  `dialId` → `error` `bad-request`.
- Exactly one of `hostId` or `code` is given.
- If the host is offline and `wait` (milliseconds, ≤ 60000) is given, the
  operator holds the dial until the host claims or `wait` elapses. Otherwise
  (or on expiry) the reply is `{t:"error", id:dialId, code:"host-offline"}`.
  An unknown short code gives `unknown-code`.
- `ice` in `incoming` is fresh for the host (TURN credentials expire).

### 3.4 Signaling

```
G→S {t:"signal", dialId, box}      S→H {t:"signal", dialId, box}
H→S {t:"signal", dialId, box}      S→G {t:"signal", dialId, box}
```

`box = b64u(iv(12) ‖ AES-256-GCM(joinKey, iv, plaintext, aad))` with a random
`iv` and `aad = utf8("byoc-signal|" + hostId + "|" + dialId)`. The plaintext
is a JSON object with a `kind` (§4). The operator forwards `box` verbatim.

### 3.5 Relay mode

```
G→S {t:"relay-open", dialId}        S→H {t:"relay-open", dialId}
G↔S↔H {t:"frame", dialId, box}
```

Frames are forwarded in order. Their `box` is encrypted with the session key
(§4.4). A host MUST NOT accept frames for a dial it has not welcomed. A host
may receive frames before it has processed `relay-open` for that dial (the
guest sends right away); it MUST hold them until then rather than drop them.

**Rate.** Each side of a dial may send relay frames at `relayRate`, a token
bucket refilled at `perSecond` and holding at most `burst` frames, counted
separately for each direction. Clients MUST pace their sends to stay under it
and queue the rest (the reference SDK uses 90% of the rate and half the
burst, leaving headroom for network jitter). The operator never drops a
single frame, because the receiver would see a sequence gap and reject the
relay anyway. A side that exceeds the budget ends the dial instead: both
sides get `{t:"hangup", dialId, reason:"rate-limited"}`.

**Capacity.** An operator MAY also cap relay frames across all dials. When a
frame doesn't fit, the operator ends that frame's dial with
`reason:"rate-limited"` (so overload makes some relays fail visibly rather
than delaying every one), and for a few seconds afterwards answers
`relay-open` with `{t:"error", id:dialId, code:"relay-unavailable",
message}`. Clients can't tell this apart from a per-dial overrun by the
hangup alone; they SHOULD back off before redialing.

### 3.6 Closing

```
C→S {t:"hangup", dialId}
S→peer {t:"hangup", dialId, reason:"peer"|"gone"|"replaced"|"rate-limited"}
```

When either side's socket closes, the operator sends `hangup` with
`reason:"gone"` for every open dial to the other side. A guest SHOULD keep its
dial open while its connection is alive (it is needed for relay mode and ICE).

### 3.7 Rejects and errors

```
H→S {t:"reject", dialId, code}    S→G {t:"error", id:dialId, code, message}
S→C {t:"error", id?, code, message}
```

A host rejects with `bad-proof`, `full` or `rejected`.

| `code` | Meaning |
|---|---|
| `bad-request` | Malformed or out-of-order message |
| `version-mismatch` | Unsupported protocol version |
| `bad-claim` | hostId/pubkey/signature check failed |
| `host-offline` | No host session for that hostId |
| `unknown-code` | No online host holds that short code |
| `bad-proof` | The guest's link secret is wrong |
| `rejected` | The host (app) refused the guest |
| `full` | The room is at capacity |
| `rate-limited` | Too many requests; retry later |
| `too-large` | Message over the size limit |
| `relay-unavailable` | Operator does not offer relay mode, or its relay is at capacity |
| `quota-exceeded` | Account quota exhausted |

---

## 4. Host–guest handshake (inside `signal` boxes)

Default offerer: the guest. Messages are sent in this order; the guest MAY
pipeline `offer` right after `hello` without waiting.

```
G: {kind:"hello", nonce, epk, resume?, rtc:true|false, info?}
H: {kind:"welcome", alg, pubkey, epk, sig}
G: {kind:"offer", sdp}                          // only if rtc
H: {kind:"answer", sdp, sig}
G/H: {kind:"ice", candidate}                    // trickle; RTCIceCandidateInit
```

`info` is optional app JSON from the guest (e.g. a display name), shown to
the host's app before it accepts the guest.

### 4.1 Proof of the join secret

A `hello` that decrypts under `joinKey` proves the guest holds the link
secret. A host that cannot decrypt a box, or whose first box from a dial is
not `hello`, replies `reject` with `bad-proof`.

### 4.2 Welcome (authenticates the host)

- `nonce`: 16 random bytes from the guest, `b64u`.
- `epk` (both sides): an ephemeral ECDH P-256 public key, 65-byte raw, `b64u`.
- `sig` = sign(`utf8("byoc-welcome|" + hostId + "|" + dialId + "|") ‖ nonce ‖ gEpk ‖ hEpk`)
  over the raw bytes.

The guest MUST check `hostId == b32(SHA-256(pubkey))[0..26]` and the
signature. On failure it aborts with `host-auth-failed` and does not use the
dial further.

### 4.3 Answer (pins the DTLS certificate)

`sig` = sign(`utf8("byoc-answer|" + hostId + "|" + dialId + "|" + fp + "|") ‖ nonce`),
where `fp` is the answer SDP's first `a=fingerprint:` value, lowercased (e.g.
`sha-256 ab:cd:…`). The guest verifies it with the key from `welcome` and
checks the SDP's fingerprint equals `fp`. The data channel is then
authenticated to the host key named by the link.

### 4.4 Session key and relay frames

```
shared     = ECDH(P-256, own ephemeral private key, peer epk)     // 32 bytes
sessionKey = HKDF-SHA256(ikm=shared, salt=nonce,
                         info=utf8("byoc-relay|" + hostId + "|" + dialId), L=32)
```

Relay `box = b64u(AES-256-GCM(sessionKey, iv, plaintext))` where
`iv = dir(1) ‖ 0x00 0x00 0x00 ‖ seq(8, big-endian)`, `dir` is `0x01` for
guest→host and `0x02` for host→guest, and `seq` starts at 0 and increments
per frame. Receivers MUST reject frames whose `seq` is not the next expected
value (the operator cannot reorder, replay or drop frames undetected; a gap
closes the relay).

The frame plaintext is a transport frame (§5) prefixed by the channel name:
`nameLen(1) ‖ utf8(name) ‖ frame`. The main channel is named `""`.

---

## 5. Peer transport frames

The same frame format is used on WebRTC data channels and relay frames. Each
data-channel message (or relay plaintext after the channel prefix) is:

```
kind(1) ‖ flags(1) ‖ payload
kind:  0 = app text (UTF-8), 1 = app binary, 2 = control (UTF-8 JSON)
flags: bit 0 = more fragments follow
```

Messages larger than 16 KiB are split into fragments of at most 16 KiB of
payload, all with the same `kind`, and reassembled by the receiver. The default
maximum reassembled message size is 1 MiB; larger messages close the
connection with code 1009.

WebRTC data channels: the main channel is a reliable, ordered channel labelled
`byoc`. Other channels are labelled `byoc:<name>`; `reliable:false` channels
use `ordered:false, maxRetransmits:0`. Relay mode delivers every channel
reliably and in order.

### 5.1 Control messages

| Message | Direction | Meaning |
|---|---|---|
| `{t:"hi", peerId, resume}` | H→G | First message on a connection. `resume` is the token for §5.2. |
| `{t:"bye", code, reason}` | both | Clean close; the receiver closes without resuming. |
| `{t:"start", after}` | both | First main-channel frame on a data channel: the sender had sent `after` main-channel frames on the relay before switching (§6.2). |
| `{t:"hidden", hidden}` | H→G | Host tab visibility changed. |
| `{t:"away"}` | both | The sender's page is unloading (closed or reloading; it can't tell which). The receiver treats the transport as dead now and waits a shorter time for a resume (§6.3). |
| `{t:"ping", ts}` / `{t:"pong", ts}` | both | RTT measurement. |

### 5.2 Resume tokens

`resume = peerId + "." + b64u(HMAC-SHA256(hostResumeKey, utf8("byoc-resume|" + peerId)))`,
where `hostResumeKey` is a secret the host keeps with its identity. On a
redial, a guest sends it in `hello.resume`; a valid token restores the same
`peerId`. Tokens are only ever sent inside encrypted channels.

---

## 6. Sequences

### 6.1 Direct connection

```
Host                         Operator                        Guest
 |--hello(host)------------->|                                 |
 |<-welcome(challenge)-------|                                 |
 |--claim(sig)-------------->|                                 |
 |<-claimed(code, ice)-------|                                 |
 |                           |<----------hello(guest)----------|
 |                           |-----------welcome-------------->|
 |                           |<----------dial(hostId)----------|
 |<-incoming(dialId)---------|-----------dialing(ice)--------->|
 |<-signal[hello]------------|<----------signal[hello]---------|
 |<-signal[offer]------------|<----------signal[offer]---------|
 |--signal[welcome]--------->|-----------signal[welcome]------>|
 |--signal[answer]---------->|-----------signal[answer]------->|
 |<========= ice trickle both ways via signal =================>|
 |<================ DTLS data channel "byoc" ==================>|
 |--control {t:"start",after:0}, then {t:"hi", peerId, resume}->|
```

### 6.2 Relay fallback

If the data channel is not open after 8 s (or ICE fails, or `rtc:false`),
the guest sends `relay-open` and both sides use relay frames.

If the data channel opens later, each side independently moves its sending to
it. A sender only ever moves from the relay to a data channel, never back.
Its first main-channel frame on the data channel is `{t:"start", after:n}`,
where `n` counts the main-channel frames (including fragments and control
frames) it sent on the relay. The receiver buffers the data channel until it
has read `n` main-channel frames from the relay, then reads the data channel.
So the main channel stays ordered across the upgrade even when the data
channel is faster than the relay. Named channels are not ordered across the
upgrade. Frames already queued on the relay when a side stops using it are
still sent.

If the transport in use dies, the connection starts a new epoch: the guest
redials with its resume token and counters restart at zero. Messages in
flight when the transport died may be lost; messages sent while reconnecting
are queued and delivered after the resume.

### 6.3 Host reload

The host re-claims with the same key (and short code). Guests notice the
transport close, redial with `wait` and their resume token, and get the same
`peerId` back.

A page that unloads sends `{t:"away"}` first (from `pagehide`), so the other
side doesn't wait to notice the silence or hold on for the full resume grace.
SDK defaults: guests wait 10 s for an away host to come back, and a host holds
an away guest's seat for 5 s, extended to the full grace once that guest's
resume `hello` arrives. `away` is best effort: if it never arrives (a crash, a
killed tab, or relay frames still being encrypted as the page dies), the
normal silence detection and grace apply.
