# Veiled wire protocol (v1, FROZEN) Any change to this file is a breaking change and must bump `PROTO_VERSION`. Rust implementation lives in `core/src/`, the TypeScript implementation in `client/src/proto.ts` and `client/src/media.ts`. Both sides are generated by hand from this document; the opcode tables are the contract. ## 1. Transports | Transport | Default | Purpose | | --- | --- | --- | | TCP + TLS | `8443` | WebSocket control channel at `/ws`, static client, `/health`, `/api/info` | | UDP | `8444` | Media frames (voice, video), opaque relay, desktop clients only | | WebSocket binary | same socket as control | Media frames for browser clients (no UDP) | TLS is required for the control channel. If no certificate is configured the server generates a self-signed one on first run and prints its SHA-256 fingerprint; clients pin on first connect (TOFU) and show the fingerprint. Plain TCP without TLS is supported only via `--insecure` for local testing. HTTP routes served by the same listener as the WebSocket: - `GET /` static client from `--client-dir` (SPA fallback to `index.html`) - `GET /join#` invite landing page; the fragment never reaches the server - `GET /health` -> `{"status":"ok","server_id":"","proto":1,"uptime_s":N,"channels":N,"clients":N}` - `GET /api/info` -> `{"name","server_id","fingerprint","proto","voice":true,"join_password":bool}` ## 2. Control framing Every WebSocket binary message is one CBOR map (`ciborium` / `cbor-x`). Map keys are integers, as documented per message. **Map key 0 is the opcode.** The numbered fields in the tables below are map keys 1 and up, so no numbered field collides with it. This was implicit in the first revision and is stated here because both independent implementations had to settle it. Optional keys are omitted, never null. Field values are CBOR native types: `uint`, `nint`, `bytes`, `text`, `bool`, `array`, `map`. Unspecified opcodes are ignored by both sides, so new fields may be added to an existing opcode without a protocol bump. Unknown opcodes MUST be ignored, not closed on. `PROTO_VERSION = 1`. ### 2.1 Client to server | Op | Name | Fields | | --- | --- | --- | | 1 | `HELLO` | 1 proto(uint), 2 client_name(text), 3 client_version(text) | | 2 | `JOIN_AUTH` | 1 proof(bytes32) | | 3 | `ADMIN_AUTH` | 1 proof(bytes32) | | 16 | `MSG_SEND` | 1 channel(uint), 2 msg_id(bytes16), 3 ct(bytes), 4 sig(bytes64), 5 sent_at(uint ms), 6 kind(uint), 7 reply_to(bytes16)? | | 17 | `HISTORY_REQ` | 1 channel(uint), 2 before(msg index/uint), 3 limit(uint, max 200) | | 18 | `IDENT_SET` | 1 display_name(text), 2 pubkey(bytes32, X25519), 3 sign_pub(bytes32, Ed25519) | | 19 | `TYPING` | 1 channel(uint), 2 user(uint, added by the server when relaying) | | 20 | `VOICE_STATE` | 1 channel(uint, 0 = leave), 2 mute(bool), 3 deaf(bool), 4 speaking(bool) | | 21 | `KEY_REQ` | 1 channel(uint), 2 epoch(uint) | | 22 | `KEY_PUT` | 1 channel(uint), 2 epoch(uint), 3 wrapped(map user_id -> bytes), 4 new_channel_key_proof(bytes64) | | 23 | `CHAN_CREATE` | 1 kind(uint: 0 text, 1 voice), 2 name(text), 3 parent(uint)?, 4 position(uint)?, 5 wrapped(map user_id -> bytes) | | 24 | `CHAN_UPDATE` | 1 channel(uint), 2 name(text)?, 3 parent(uint)?, 4 position(uint)? | | 25 | `CHAN_DELETE` | 1 channel(uint) | | 26 | `CHAN_KEY` | 1 channel(uint), 2 epoch(uint), 3 key(bytes32), 4 wrapped(map user_id -> bytes) | | 27 | `USER_MOVE` | 1 user(uint), 2 channel(uint) | | 28 | `USER_KICK` | 1 user(uint), 2 reason(text)? | | 29 | `USER_BAN` | 1 user(uint), 2 reason(text)?, 3 duration_s(uint)? | | 30 | `ROLE_SET` | 1 user(uint), 2 role(uint: 1 member, 2 moderator, 3 admin, 4 owner) | | 31 | `RTC_SIGNAL` | 1 to(uint), 2 kind(uint: 1 offer, 2 answer, 3 ice, 4 bye), 3 payload(text) | | 32 | `MEDIA_BIND` | 1 token(bytes32) - sent over UDP, see 4.2 | | 33 | `PING` | 1 nonce(uint64) | | 34 | `ROSTER_REQ` | (no fields) | ### 2.2 Server to client | Op | Name | Fields | | --- | --- | --- | | 128 | `WELCOME` | 1 server_id(bytes32), 2 sign_pub(bytes32), 3 proto(uint), 4 salt(bytes16), 5 nonce(bytes32), 6 needs_join(bool), 7 name(text), 8 motd(text)?, 9 voice_port(uint) | | 129 | `AUTH_OK` | 1 you(uint), 2 role(uint), 3 session_media_token(bytes32), 4 channels(array of CHANNEL), 5 members(array of MEMBER), 6 keys(array of KEYBLOB), 7 server_time(uint ms) | | 130 | `AUTH_FAIL` | 1 code(uint), 2 detail(text)? | | 131 | `MSG` | 1 channel(uint), 2 msg_id(bytes16), 3 sender(uint), 4 ct(bytes), 5 sig(bytes64), 6 sent_at(uint), 7 kind(uint), 8 reply_to(bytes16)? | | 132 | `CHAN_EVENT` | 1 op(uint: 1 created, 2 updated, 3 deleted), 2 channel(CHANNEL)?, 3 channel_id(uint)? | | 133 | `USER_EVENT` | 1 op(uint: 1 joined, 2 left, 3 moved, 4 kicked, 5 banned, 6 role, 7 name, 8 voice), 2 user(MEMBER)?, 3 user_id(uint)?, 4 channel(uint)?, 5 reason(text)? | | 134 | `KEY_BLOB` | 1 channel(uint), 2 epoch(uint), 3 wrapped(bytes) | | 135 | `PONG` | 1 nonce(uint64), 2 server_time(uint64 ms) | | 136 | `RTC_SIGNAL` | 1 from(uint), 2 kind(uint), 3 payload(text) | | 137 | `ERROR` | 1 code(uint), 2 detail(text)? | | 138 | `HISTORY` | 1 channel(uint), 2 messages(array of MSG), 3 more(bool) | `CHANNEL` = { 1 id(uint), 2 kind(uint), 3 name(text), 4 parent(uint), 5 position(uint), 6 epoch(uint), 7 has_key(bool), 8 members(uint count) } `MEMBER` = { 1 id(uint), 2 display_name(text), 3 role(uint), 4 channel(uint, voice channel or 0), 5 mute(bool), 6 deaf(bool), 7 speaking(bool), 8 x25519(bytes32), 9 ed25519(bytes32) } `KEYBLOB` = { 1 channel(uint), 2 epoch(uint), 3 wrapped(bytes) } ### 2.3 Error codes | Code | Meaning | | --- | --- | | 1 | bad_frame (unparseable CBOR or missing required field) | | 2 | proto_mismatch | | 3 | auth_required | | 4 | auth_failed | | 5 | forbidden (role too low for the action) | | 6 | not_found (channel, user or message) | | 7 | rate_limited | | 8 | invalid (semantic error, e.g. empty name, duplicate channel name) | | 9 | server_full | | 10 | no_key (key blob for that epoch is not on this server) | ### 2.4 Auth handshake 1. Client opens `wss://host:8443/ws`, sends `HELLO`. 2. Server replies `WELCOME` with `salt`, a 32-byte `nonce`, `server_id`, `sign_pub`, and `needs_join` (true when the server has a join password set). 3. If `needs_join`, the client computes `join_key = Argon2id(password, salt, m=64MiB, t=3, p=1, out=32)`. The server stores only `verifier = Argon2id(join_key, salt_v)` and `salt_v = BLAKE2b-16("veiled-verify-v1" || salt)`, which is public material derived from the salt already sent in `WELCOME`, so the client can compute both sides. The client then sends `JOIN_AUTH { proof: HMAC-SHA256(verifier, nonce) }`. A server holding only a one-way verifier cannot check an HMAC keyed with `join_key`, which is why the proof is keyed with the verifier. The password itself still never crosses the wire, and guessing it still costs one Argon2id derivation per attempt. The password itself is never transmitted; the server holds only `Argon2id(join_key, salt2)` style verifier material (see `crypto.md`). 4. The client then sends `IDENT_SET`, binding its display name and public keys to the session. 5. `AUTH_OK` carries the roster snapshot and any channel keys already wrapped for this user. `session_media_token` is a 32-byte per-session secret used to bind the UDP socket. 6. `ADMIN_AUTH` follows the same challenge-response against the admin password, with its own salt `admin_salt = BLAKE2b-16("veiled-admin-salt-v1" || salt)` because `WELCOME` carries only one salt. It is refused with code 4 after 5 failures per connection, 20 per IP per hour. An open server (`needs_join = false`) skips step 3. A brand new user id is allocated on first `IDENT_SET`, keyed on the Ed25519 public key, so identity survives reconnects and IP changes. ## 3. Presence and channel semantics - `MSG` is relayed to every connected member of the channel and persisted as an opaque blob. The server never inspects `ct`. - `USER_MOVE` requires moderator or above, and only moves a user into a voice channel. A move bumps that user's `USER_EVENT` with op 3. - `CHAN_DELETE` requires admin or above and also deletes the channel's wrapped keys, ciphertext and voice membership. Clients keep local cached history. - Channel positions are server-ordered integers; gaps are allowed and clients sort by `(parent, position, id)`. - `HISTORY_REQ` with `before = 0` means "the newest `limit` messages". Pagination walks backwards by message index. ## 4. Media plane Media is never parsed by the server beyond the header. It routes on `channel` and forwards. ### 4.1 UDP envelope (28 byte header, little endian) | Offset | Size | Field | | --- | --- | --- | | 0 | 1 | version = 1 | | 1 | 1 | kind: 1 voice, 2 video, 3 ping, 4 pong, 5 bind | | 2 | 2 | channel id (0 for bind/ping) | | 4 | 4 | sender user id (0 in bind) | | 8 | 2 | key epoch | | 10 | 4 | sequence number (per sender, per channel, wraps) | | 14 | 2 | payload length | | 16 | 12 | nonce prefix (first 12 bytes of the XChaCha20 nonce) | | 28 | N | ciphertext = AEAD(opcode payload) with the 16 byte tag appended | The nonce is `nonce_prefix(12) || sender_id(4) || seq(4)`, which is 20 bytes, followed by four zero bytes to reach the 24 bytes XChaCha20-Poly1305 requires. The prefix exists so a client that restarts cannot reuse a keystream. AAD is the first 16 header bytes, which is version through the payload length field, not through the nonce prefix. `payload_length` counts the ciphertext including its 16 byte tag, so a 40 byte Opus frame gives 56. Bind, ping and pong frames carry no ciphertext: kind 5 is 32 bytes of `HMAC-SHA256(session_media_token, "veiled-media-bind")`, kinds 3 and 4 carry an 8 byte nonce and a `uint64` timestamp. ### 4.2 Socket binding A desktop client sends `MEDIA_BIND` over UDP to the media port. The server matches the HMAC against the session that issued `session_media_token`, records `src_addr` against that user id, and answers with a kind 4 frame. Media from a user with no bound socket is dropped. Bindings expire after 30 seconds of silence and rebind on the next media frame received over the control socket, so a client behind a NAT rebinding its port recovers automatically. ### 4.3 Relay rules For every accepted media frame the server forwards the frame (verbatim, header included) to every other bound member of the same channel who has the same or a newer key epoch. Frames older than the current epoch by more than one rotation are dropped. The server applies a per-user token bucket (default 1000 packets/s, 250 KB/s) and drops the excess silently; voice is loss-tolerant by design. ### 4.4 Web clients A browser cannot open a UDP socket. A web client sends and receives the identical envelope inside WebSocket binary messages prefixed with one byte, `0x01`, on the same control socket. The server relays between the two transports transparently: a UDP voice frame from a desktop client is delivered to web members as `0x01 || envelope` and vice versa. Browser clients use WebCodecs `AudioEncoder`/`AudioDecoder` (`opus`, 48 kHz) where available, and fall back to WebRTC peer connections when `AudioEncoder` is missing. A `0x01` message is never confused with a control frame because CBOR maps in this protocol always start with a major-type byte >= 0xA0. ## 5. Rate limiting and limits | Limit | Default | | --- | --- | | Control messages | 30/s burst 60 per connection | | Control bytes | 256 KB/s per connection | | Message body | 8 KB ciphertext | | Channel name | 64 bytes | | Display name | 48 bytes | | Channels per server | 200 | | Users per server | 500 | | History per channel | 5000 messages (configurable, 0 = no persistence) | ## 7. Implementers' notes (added after the first two independent implementations) - **Unsigned integers must go out as CBOR unsigned, never as float.** `cbor-x` encodes a JavaScript number above 2^32 as a float64, which a Rust `u64` field cannot decode. Every 64 bit field in this protocol must be written as a BigInt on the JavaScript side. - **Frame ordering after the handshake.** Clients pipeline `JOIN_AUTH` and `IDENT_SET`. A server that handles frames concurrently can answer `IDENT_SET` before the password proof has been checked, so authentication must be completed in order on a per connection basis. - **Additive fields are allowed** on any opcode without a protocol bump, which is how the server adds the user id to a relayed `TYPING` and how the owner changes the admin password. An unknown opcode is ignored, never fatal. ## 6. Versioning `PROTO_VERSION` bumps on any change to an existing field's meaning, opcode number, or the media header. Adding fields is not a bump. Clients refuse to talk to a server whose `proto` differs by more than 0 and surface an upgrade prompt rather than failing silently.