# Relay Protocol — WebSocket > Transport protocol between **any actor** (agent, client, pairing) and the **relay**, over > **a single WebSocket**. No REST. This file defines the **protobuf frame schema**, the > **authentication handshake**, the **E2E message envelope**, the **live channel**, **presence**, > and the **pairing flow**. The **encrypted content** inside the envelope is in > [payloads.md](payloads.md); the **cryptography** is in [crypto.md](crypto.md). > > MUST/SHOULD carry the RFC 2119 meaning. **Transport**: every WebSocket frame is a **binary frame** (opcode `0x2`) carrying exactly one `RelayFrame` protobuf. All binary fields (keys, signatures, nonces, namespace_id) travel as **raw bytes** — no hex, no base64. The encoding rules in [index.md §5](index.md) apply only inside the E2E JSON payloads, not to the transport layer. --- ## 1. Concepts - **Namespace**: created implicitly when an `agent` authenticates for the first time. Identified by `namespace_id = hex(SHA256(NS_DOMAIN ‖ 0x00 ‖ agent_ed25519_pub))` ([crypto.md §7](crypto.md)). Expires after **7 days** without any connection. - **Owner**: the `agent` holding the namespace private key. **Sole authority** over the authorised client list. - **Client**: a mobile device **authorised by the agent**. Before pairing it does not exist; after pairing it is `pending` until the agent authorises it. ## 2. Endpoint ``` wss:///v1/ws ``` Single endpoint for all actors. The **role** is established in the `Auth` frame. `namespace_id` is NOT in the query string: it travels inside `Auth`. Transport: **WSS mandatory** (TLS); the relay MUST reject plain WS. ## 3. RelayFrame Schema (NORMATIVE) Lives in `crates/skald-relay-common`, generated for Rust (`prost`) and iOS (`SwiftProtobuf`). Package name: `skald.relay.v2`. ```proto syntax = "proto3"; package skald.relay.v2; // One WebSocket binary frame = one RelayFrame. message RelayFrame { oneof frame { Challenge challenge = 1; Auth auth = 2; AuthOk auth_ok = 3; AuthError auth_error = 4; Authorize authorize = 5; AuthorizeOk authorize_ok = 6; PairingStart pairing_start = 7; PairingReady pairing_ready = 8; PairingStop pairing_stop = 9; PairingStopOk pairing_stop_ok = 10; ClientPaired client_paired = 11; Message message = 12; PeerOffline peer_offline = 13; PresenceRequest presence_request = 14; PresenceList presence_list = 15; PresenceEvent presence_event = 16; Error error = 17; } reserved 18, 19; // ex Ping/Pong: keepalive via native WS frames (§8), not protobuf } // --- Data plane (E2E). The relay routes, does NOT read ciphertext/nonce. --- message Message { bytes ciphertext = 1; // E2E payload: JSON+framing (framing.md). Opaque to relay. bytes nonce = 2; // 12B, AEAD nonce bytes peer = 3; // 32B: 'to' on send (sender→relay), 'from' on delivery (relay→dest) bool live = 4; // true = live channel (§6): route-or-fail, no queue/push } message PeerOffline { bytes peer = 1; } // 32B: recipient not connected (live channel only) // --- Presence (§7). Control frames, not E2E. --- message PresenceRequest {} message PresenceList { repeated bytes online = 1; } // 32B each message PresenceEvent { bytes pubkey = 1; Status status = 2; } // 32B enum Status { STATUS_UNSPECIFIED = 0; STATUS_ONLINE = 1; STATUS_OFFLINE = 2; } // --- Handshake / auth / pairing: role is implicit in the sub-message set. // No enum Role (its default 0 would mean "AGENT" — security footgun). --- message Challenge { bytes nonce = 1; } // 32B message Auth { oneof role { AuthAgent agent = 1; AuthClient client = 2; AuthPairing pairing = 3; } bytes signature = 4; // 64B, over AUTH_DOMAIN‖0x00‖nonce } message AuthAgent { bytes agent_ed25519_pub = 1; } // 32B; namespace_id = hash(pubkey) message AuthClient { bytes namespace_id = 1; // 32B bytes client_ed25519_pub = 2; // 32B string device_token = 3; // push token (opaque) Platform platform = 4; } message AuthPairing { bytes namespace_id = 1; // 32B bytes client_ed25519_pub = 2; // 32B bytes client_x25519_pub = 3; // 32B bytes pairing_token = 4; // 32B string device_token = 5; // push token (opaque) Platform platform = 6; } enum Platform { PLATFORM_UNSPECIFIED = 0; PLATFORM_IOS = 1; PLATFORM_ANDROID = 2; } message AuthOk { bytes namespace_id = 1; } message AuthError { string code = 1; string message = 2; } message Authorize { repeated bytes clients = 1; } // 32B each (replaces full list) message AuthorizeOk { uint32 authorized = 1; } message PairingStart { bytes pairing_token = 1; uint32 ttl = 2; } message PairingReady { uint32 ttl = 1; } message PairingStop {} message PairingStopOk {} message ClientPaired { bytes client_ed25519_pub = 1; bytes client_x25519_pub = 2; Platform platform = 3; } message Error { string code = 1; string message = 2; } // Keepalive: native WebSocket ping/pong frames (§8), not protobuf messages. ``` > **Validation (proto3 has no `required`).** The role split prevents cross-role confusion but > does not enforce non-empty fields: the relay MUST still validate the **presence and length** of > `bytes` fields (32B pubkeys, 64B signatures, …) and reject with `bad_request`. The > `*_UNSPECIFIED = 0` enum values make "absent enum field" distinguishable and rejectable. ## 4. Authentication Handshake **The relay speaks first.** As soon as the WS is open, it sends a `Challenge`. Until `AuthOk` arrives, the only frame accepted from the peer is `Auth`. ``` PEER (agent | pairing | client) RELAY │ ── WSS connect ───────────────────────────── ▶│ │ ◀──── Challenge { nonce: 32B } ───────────────│ relay speaks first │ ── Auth { role:..., signature: 64B } ────────▶│ │ ◀──── AuthOk { namespace_id } ────────────────│ │ OR AuthError { code, message } ────────────│ ``` - `Challenge.nonce`: 32 random bytes. Unique per connection. Expires after **30 s**: no `Auth` in time → `challenge_timeout` and close. - `Auth.signature`: Ed25519 signature of `AUTH_DOMAIN ‖ 0x00 ‖ nonce_raw` ([crypto.md §8](crypto.md)). - The relay MUST verify the signature under the role-appropriate public key **before** any other logic. ### 4.1 `role: agent` — the Skald instance The namespace may not exist yet: it is created here. ```proto Auth { agent: AuthAgent { agent_ed25519_pub: <32B> }, signature: <64B> } ``` Relay checks (in order): 1. `agent_ed25519_pub` is exactly 32 bytes. 2. `signature` valid over `AUTH_DOMAIN ‖ 0x00 ‖ nonce_raw` under `agent_ed25519_pub`. 3. Compute `namespace_id = SHA256(NS_DOMAIN ‖ 0x00 ‖ agent_ed25519_pub)`. 4. If namespace doesn't exist → create it (bind `namespace_id ↔ agent_ed25519_pub`, immutable). If it exists → pubkey MUST match (by construction it does, since the id is a hash of the key; a mismatch is a bug → `not_found`). 5. If an `agent` WS is already open for this namespace → close the old one (one agent connection per namespace at a time). Response: `AuthOk { namespace_id: <32B raw> }`. Right after, the agent SHOULD send an `Authorize` frame (§5) with the current authorised client list (possibly empty). ### 4.2 `role: pairing` — not-yet-authorised client For initial connection before authorisation. Accepted only if the namespace is in **pairing mode** (§9). ```proto Auth { pairing: AuthPairing { namespace_id: <32B>, client_ed25519_pub: <32B>, client_x25519_pub: <32B>, pairing_token: <32B>, device_token: "", platform: PLATFORM_IOS | PLATFORM_ANDROID }, signature: <64B> } ``` Relay checks: 1. `signature` valid under `client_ed25519_pub`. 2. `namespace_id` exists and is in pairing mode. 3. `pairing_token` matches **byte-for-byte** the one from `PairingStart`, **not expired**, **not yet consumed** (single-use). 4. Mark the token **consumed**. Register the client as **`pending`** (NOT yet authorised): store `client_ed25519_pub`, `client_x25519_pub` (opaque), `device_token`, `platform`. 5. Forward a `ClientPaired` frame to the agent (§9.4). Response: `AuthOk { namespace_id: <32B raw> }`. After `AuthOk` the pairing client **closes** the WS. It becomes operational by reconnecting with `role: client` **once the agent has authorised it** (the app may retry with backoff until it receives `AuthOk` instead of `unauthorized`). > `device_token` and `platform` are the **only** device data the relay knows: required for push. > Model, OS, app version do NOT pass through the relay: the app sends them **E2E** to the agent > via a `hello` message ([payloads.md](payloads.md)). ### 4.3 `role: client` — authorised device ```proto Auth { client: AuthClient { namespace_id: <32B>, client_ed25519_pub: <32B>, device_token: "", platform: PLATFORM_IOS | PLATFORM_ANDROID }, signature: <64B> } ``` Relay checks: 1. `signature` valid under `client_ed25519_pub`. 2. `namespace_id` exists. 3. `client_ed25519_pub` is in the **authorised** list (NOT `pending`). Otherwise `unauthorized`. 4. Update `device_token` (it can change: APNs/FCM rotate it). 5. If a `client` WS is already open for the same pubkey → close the old one. 6. Deliver any queued messages (store-and-forward, §6.3). Response: `AuthOk { namespace_id: <32B raw> }`. ## 5. Client Authorisation (agent only) The agent is the **sole authority**. The authorised list is declared with: ```proto Authorize { clients: [ <32B>, <32B>, … ] } ``` - **Replacement semantics**: this list **replaces** the previous one (not an append). To add a device, send the full list including it; to revoke one, send the list without it. - Relay effects, atomic: - keys present now but absent before → become `authorised` (exit `pending`); - keys absent now but present before → **revoked**: the relay MUST (a) close that client's active WS if any, (b) **purge its store-and-forward queue**, (c) forget its `device_token`. - Response: `AuthorizeOk { authorized: N }` (N = number of active authorised clients). ## 6. E2E Messages After `AuthOk`, agent and client exchange **opaque** messages routed by pubkey. ### 6.1 Sending (sender → relay) ```proto Message { ciphertext: , // E2E blob: framed JSON, encrypted AES-256-GCM nonce: <12B>, peer: <32B>, // destination ed25519 pubkey live: false // or true for live channel (§6.4) } ``` - `peer` = ed25519 pubkey of the recipient (the agent, or a client). - The relay knows the sender: it is the pubkey authenticated on **this** WS. It does NOT trust any `from` field supplied by the sender. - The relay MUST verify that `peer` belongs to the **same namespace** (namespace agent, or an authorised client). Otherwise `not_found`. - The relay NEVER reads or alters `nonce` and `ciphertext`. ### 6.2 Receiving (relay → recipient) The relay rewrites `Message.peer` from `to` (the destination sent by the sender) to `from` (the authenticated pubkey of the sender, which the relay guarantees), and adds a routing `timestamp` (advisory, ISO-8601 UTC) via delivery metadata if needed. ```proto Message { ciphertext: , nonce: <12B>, peer: <32B>, // 'from': authenticated sender pubkey live: } ``` The recipient: 1. reconstructs the AAD = `namespace_id_raw ‖ peer_pub(from) ‖ my_pub` ([crypto.md §6.2](crypto.md)); 2. selects the `aes_key` for peer `from`; 3. decrypts; verifies the **counter** in the nonce (`> last_seen`, [crypto.md §6.1](crypto.md)); 4. strips the framing header ([framing.md](framing.md)), parses payload ([payloads.md](payloads.md)). Idempotent by `request_id`. ### 6.3 Store-and-Forward (`live=false`) If the recipient is not connected when the message arrives: 1. The relay queues the message (`peer` as destination, sender pubkey, `nonce`, `ciphertext`, `created_at`). 2. If the recipient is a **client** with a `device_token`, the relay sends a **push** ([server.md §5](server.md)). 3. On the recipient's (re)connection, the relay drains the queue **in FIFO order** over the WS, then deletes delivered messages. 4. Queue TTL: **7 days**. Beyond that → silently dropped. Queue limits per recipient: see §10. ### 6.4 Live Channel (`live=true`) `live=true` selects a different delivery class: | `live` | Relay semantics | |--------|-----------------| | `false` | **Store-and-forward**: if recipient is offline, queue (max 200, TTL 7d) and push. For approvals/clarifications. | | `true` | **Route-or-fail**: forward **only** if the recipient is connected now. If offline → do NOT queue, do NOT push, reply to the sender with `PeerOffline { peer: <32B> }`. For state pulls and high-volume flows. Relay is **stateless** for this channel. | On delivery the relay rewrites `Message.peer` from `to` (destination) to `from` (authenticated sender), as in §6.2. `nonce`/`ciphertext` are never read. ### 6.5 Pull vs Notification: which traffic uses live (NORMATIVE) The value of `live` is not a free choice: it depends on the **semantic nature** of the payload. > **State pull → `live=true`. Event-driven notification that must wake the human → `live=false` > (store-and-forward + push).** A **pull** ("give me the current state") served stale is useless or harmful: route-or-fail is correct — if the peer is absent, the sender knows immediately (`PeerOffline`) and shows an offline state instead of hanging or receiving a stale snapshot hours later. A **notification** that must reach an offline phone, however, *must* be able to wait in queue and be pushed. | Payload | Direction | `live` | Why | |---------|-----------|--------|-----| | `inbox_request` (app open / reconnect) | client → agent | **`true`** | State pull: stale = useless. Agent offline → app learns immediately. | | `inbox_update` in **response** to an `inbox_request` | agent → client | **`true`** | Client just asked: it is online by construction. | | `inbox_update` for a **new event** (approval/clarification) | agent → client | **`false`** | Must reach an offline phone → queue + push. | The sender, upon receiving `PeerOffline`, **stops** sending to that peer and retries on the next `PresenceEvent { STATUS_ONLINE }` (§7) or on reconnect. > **Why `PeerOffline` is needed even with presence.** Presence declares `ONLINE` with up to > ~120 s delay on disconnect (idle-timeout). `PeerOffline` covers that blind window. > Presence = *when to start*; `PeerOffline` = *correctness backstop*. ## 7. Presence The relay exposes who is connected in the namespace. Scope is **strictly per namespace**: never propagated outside. It only reveals pubkeys already known to the relay ([index.md §4.2](index.md)). - `PresenceRequest {}` → relay replies `PresenceList { online: [<32B>, …] }` (snapshot, includes the requester). - On `AuthOk` of a connection, **and** on its close (WS close or 120 s idle-timeout), the relay sends `PresenceEvent { pubkey: <32B>, status }` to **all other** connected namespace members. Normative rules: 1. **Namespace scope**: no cross-namespace `PresenceEvent`. 2. **`OFFLINE` is best-effort and delayed** (up to ~120 s): not a guarantee of unreachability → the live channel has its own backstop `PeerOffline` (§6.4). 3. Idempotency: two consecutive `ONLINE` events for the same pubkey = no-op on the receiver. ## 8. Keepalive - The relay sends **native WS ping frames** every **30 s**; the peer responds with a **pong frame**. These are native WebSocket opcodes, not protobuf messages. - No traffic for **120 s** → the relay closes the connection. - The agent reconnects with exponential backoff **1s, 2s, 4s, 8s, …, max 60s** (+ jitter). - The client manages the WS according to its foreground/background lifecycle (see the iOS app repository documentation). ## 9. Pairing Explicit process: the agent opens a window; the relay accepts `role: pairing` only during the window; the token is **single-use**. ``` AGENT (perm. WS) RELAY CLIENT (new WS) │ ─ PairingStart ──────────▶│ │ │ {token, ttl} │ │ │ ◀─ PairingReady ──────────│ │ │ show QR ──────────────────────────────────────────── ▶│ │ │ ◀─ ws connect ─────────────│ │ │ ── Challenge ─────────────▶│ │ │ ◀─ Auth role:pairing ───────│ │ │ token, client pubkeys, │ │ │ device_token, platform │ │ │ verify: window? token ok? │ │ │ TTL? single-use? → consume│ │ │ ── AuthOk ────────────────▶│ │ ◀─ ClientPaired ──────────│ (client → close WS) │ │ client pubkeys, plat. │ │ │ ─ Authorize [.. new] ────▶│ (agent decides: authorise)│ │ ◀─ AuthorizeOk ───────────│ │ │ ─ PairingStop ───────────▶│ close window │ │ ◀─ PairingStopOk ─────────│ │ ``` ### 9.1 `PairingStart` (agent → relay) ```proto PairingStart { pairing_token: <32B>, ttl: 300 } ``` - `pairing_token`: 32 random bytes (single-use bearer, [crypto.md §9](crypto.md)). - `ttl`: seconds (default 300, max 600). The relay computes `expiry = now + ttl` and stores `{token, namespace_id, expiry, consumed: false}`. - Response: `PairingReady { ttl: 300 }`. If the agent calls `PairingStart` again while a window is open, the **new** token replaces the previous one (the old token is immediately invalidated). ### 9.2 `PairingStop` (agent → relay) ```proto PairingStop {} ``` Closes the window; the current token is invalidated. Response: `PairingStopOk {}`. ### 9.3 Implicit Stop On `ttl` expiry without `PairingStop`, the relay closes the window automatically. A consumed token stays consumed; an unused token becomes unusable. ### 9.4 `ClientPaired` (relay → agent) ```proto ClientPaired { client_ed25519_pub: <32B>, client_x25519_pub: <32B>, platform: PLATFORM_IOS | PLATFORM_ANDROID } ``` The agent: 1. computes `shared_secret = X25519(agent_x25519_priv, client_x25519_pub)` and the `aes_key` ([crypto.md §4-5](crypto.md)); 2. persists the client (pubkeys, counters at 0); 3. applies the **authorisation policy** (auto or user confirmation) and sends updated `Authorize`; 4. waits for the client's `hello` E2E message for detailed `device_info`. ## 10. Limits & Quotas (NORMATIVE, relay side) | Limit | Value | Error | |-------|-------|-------| | Max frame size (pre-auth and store-and-forward) | 64 KiB | `payload_too_large` | | Max frame size (live channel, post-auth) | 512 KiB | `payload_too_large` | | Challenge timeout | 30 s | `challenge_timeout` | | Idle timeout | 120 s | (silent close) | | Store-and-forward queue TTL | 7 days | (silent drop) | | Max queued messages per client | 200 | `queue_full` (rejects new until drained) | | Max new connections per IP | 30 / minute | `rate_limited` | | Max messages per connection | 60 / minute | `rate_limited` | | Inactive namespace TTL | 7 days | (garbage collection) | | Pairing `ttl` | default 300, max 600 s | (clamped) | The 512 KiB limit for the live channel applies **only** to `RelayFrame { message { live: true } }` and **only after `auth_ok`**. Any pre-auth frame over 64 KiB → `payload_too_large` (denies unauthenticated flood amplification). Values are reasonable defaults; the relay exposes them via config. Per-IP quotas contain unauthenticated flood on the public endpoint. ## 11. Namespace Lifecycle ``` agent auth → namespace created (if new) ├── agent disconnected → namespace "idle" ├── client connected → namespace active ├── agent reconnected → resumed └── 7 days without any connection → deleted (queues, tokens, authorised list, device_tokens) ``` Deletion is never explicit. If the agent reconnects after GC, the namespace is recreated from scratch (same `namespace_id`, because it derives from the same key) but **without** any clients: devices must re-pair. ## 12. Errors Uniform format: ```proto Error { code: "", message: "" } ``` `AuthError` uses the same shape, emitted during the handshake instead of `AuthOk`. | Code | Meaning | |------|---------| | `challenge_timeout` | No `Auth` within 30 s. | | `invalid_signature` | Challenge signature not valid. | | `unauthorized` | Client not in authorised list. | | `not_found` | Namespace or recipient not found / outside namespace. | | `pairing_closed` | Namespace not in pairing mode, or token expired/consumed/wrong. | | `rate_limited` | Per-IP or per-connection quota exceeded. | | `payload_too_large` | Frame exceeds size limit. | | `queue_full` | Recipient queue full. | | `bad_request` | Malformed protobuf, missing field, wrong byte length. | On all `auth_error` cases and after fatal errors, the relay closes the WS. ## 13. Summary: Everything on One WS | Direction | Frames | |-----------|--------| | relay → anyone | `Challenge`, `AuthOk` / `AuthError`, `Message` (with `from`), `Error`, native WS ping | | agent → relay | `Auth`(agent), `Authorize`, `PairingStart`, `PairingStop`, `Message`, native WS pong | | relay → agent | `ClientPaired`, `AuthorizeOk`, `PairingReady`, `PairingStopOk`, `Message`, `PresenceEvent` | | client → relay | `Auth`(pairing/client), `Message`, `PresenceRequest`, native WS pong | | relay → client | `Message`, `PeerOffline`, `PresenceList`, `PresenceEvent` | No REST endpoint exists. `namespace_id` is never in a query string.