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
agentauthenticates for the first time. Identified bynamespace_id = hex(SHA256(NS_DOMAIN ‖ 0x00 ‖ agent_ed25519_pub))(crypto.md §7). Expires after 7 days without any connection. - Owner: the
agentholding 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
pendinguntil 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 ofbytesfields (32B pubkeys, 64B signatures, …) and reject withbad_request. The*_UNSPECIFIED = 0enum 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: noAuthin time →challenge_timeoutand close.Auth.signature: Ed25519 signature ofAUTH_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):
agent_ed25519_pubis exactly 32 bytes.signaturevalid overAUTH_DOMAIN ‖ 0x00 ‖ nonce_rawunderagent_ed25519_pub.- Compute
namespace_id = SHA256(NS_DOMAIN ‖ 0x00 ‖ agent_ed25519_pub). - 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). - If an
agentWS 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:
signaturevalid underclient_ed25519_pub.namespace_idexists and is in pairing mode.pairing_tokenmatches byte-for-byte the one fromPairingStart, not expired, not yet consumed (single-use).- Mark the token consumed. Register the client as
pending(NOT yet authorised): storeclient_ed25519_pub,client_x25519_pub(opaque),device_token,platform. - Forward a
ClientPairedframe 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_tokenandplatformare 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 ahellomessage (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:
signaturevalid underclient_ed25519_pub.namespace_idexists.client_ed25519_pubis in the authorised list (NOTpending). Otherwiseunauthorized.- Update
device_token(it can change: APNs/FCM rotate it). - If a
clientWS is already open for the same pubkey → close the old one. - 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(exitpending); - 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.
- keys present now but absent before → become
- 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
fromfield supplied by the sender. - The relay MUST verify that
peerbelongs to the same namespace (namespace agent, or an authorised client). Otherwisenot_found. - The relay NEVER reads or alters
nonceandciphertext.
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:
- reconstructs the AAD =
namespace_id_raw ‖ peer_pub(from) ‖ my_pub(crypto.md §6.2); - selects the
aes_keyfor peerfrom; - decrypts; verifies the counter in the nonce (
> last_seen, crypto.md §6.1); - 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:
- The relay queues the message (
peeras destination, sender pubkey,nonce,ciphertext,created_at). - If the recipient is a client with a
device_token, the relay sends a push (server.md §5). - On the recipient's (re)connection, the relay drains the queue in FIFO order over the WS, then deletes delivered messages.
- 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
PeerOfflineis needed even with presence. Presence declaresONLINEwith up to ~120 s delay on disconnect (idle-timeout).PeerOfflinecovers 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 repliesPresenceList { online: [<32B>, …] }(snapshot, includes the requester).- On
AuthOkof a connection, and on its close (WS close or 120 s idle-timeout), the relay sendsPresenceEvent { pubkey: <32B>, status }to all other connected namespace members.
Normative rules:
- Namespace scope: no cross-namespace
PresenceEvent. OFFLINEis best-effort and delayed (up to ~120 s): not a guarantee of unreachability → the live channel has its own backstopPeerOffline(§6.4).- Idempotency: two consecutive
ONLINEevents 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 computesexpiry = now + ttland 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:
- computes
shared_secret = X25519(agent_x25519_priv, client_x25519_pub)and theaes_key(crypto.md §4-5); - persists the client (pubkeys, counters at 0);
- applies the authorisation policy (auto or user confirmation) and sends updated
Authorize; - waits for the client's
helloE2E message for detaileddevice_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.