Files
2026-07-10 15:02:09 +01:00

22 KiB

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; the cryptography is in 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 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). 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://<relay-host>/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.

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).
  • 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.

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).

Auth {
  pairing: AuthPairing {
    namespace_id: <32B>,
    client_ed25519_pub: <32B>,
    client_x25519_pub: <32B>,
    pairing_token: <32B>,
    device_token: "<push 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).

4.3 role: client — authorised device

Auth {
  client: AuthClient {
    namespace_id: <32B>,
    client_ed25519_pub: <32B>,
    device_token: "<push 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:

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)

Message {
  ciphertext: <bytes>,   // 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.

Message {
  ciphertext: <bytes>,
  nonce:      <12B>,
  peer:       <32B>,     // 'from': authenticated sender pubkey
  live:       <bool>
}

The recipient:

  1. reconstructs the AAD = namespace_id_raw ‖ peer_pub(from) ‖ my_pub (crypto.md §6.2);
  2. selects the aes_key for peer from;
  3. decrypts; verifies the counter in the nonce (> last_seen, crypto.md §6.1);
  4. strips the framing header (framing.md), parses payload (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).
  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).

  • 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)

PairingStart { pairing_token: <32B>, ttl: 300 }
  • pairing_token: 32 random bytes (single-use bearer, crypto.md §9).
  • 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)

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)

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);
  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:

Error { code: "<code>", message: "<description>" }

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.