# Veiled cryptography (v1, FROZEN) Goal: the host of a server cannot read messages, listen to voice, or see screen share content. It can see metadata: who is connected, from which IP, who talks to which channel and when, and the sizes and timing of frames. That limit is stated plainly in the docs and on the site rather than papered over. Library: libsodium. Client side `libsodium-wrappers-sumo` (bundled, never from a CDN), server side `argon2` + `hmac` + `sha2` crates, `libsodium-sys` in `core/` where Rust needs the same primitives for tests. | Purpose | Primitive | | --- | --- | | Message and media encryption | XChaCha20-Poly1305 IETF, 24 byte nonce, 16 byte tag | | Channel key wrapping | `crypto_box_seal` (X25519 + XSalsa20-Poly1305), anonymous sealed box | | Identity | Ed25519 signing keypair + X25519 key agreement keypair, generated client side | | Password derivation | Argon2id, m=64 MiB, t=3, p=1, out=32 | | Proof of password | HMAC-SHA256(key, nonce) | | Fingerprints | BLAKE2b-256. Short form: the first 16 bytes as 8 groups of 4 hex characters, which is what a user reads out loud. Full form: all 64 hex characters. | | Channel subkeys | BLAKE2b-256(key_material \|\| label \|\| channel_id_le \|\| epoch_le) | ## 1. Identity Each client generates, on first run, an Ed25519 keypair (signing) and an X25519 keypair (key agreement). The private keys never leave the device: on desktop they are written to `identity.json` with mode 600 next to the client config, in the browser they live in IndexedDB. A user id (uint32) is allocated by the server on first sight of the Ed25519 public key and is stable from then on. The server has its own Ed25519 keypair, generated by `--setup` and stored in `identity.json` beside the database. Its public key is `server_id`. The client pins it on first connect (TOFU) and, with an invite link, gets it out of band instead, which removes the first-connect trust assumption. An identity can be exported as an encrypted blob (Argon2id over a passphrase, then XChaCha20-Poly1305) so a user can move devices. Losing every device key means losing history keys for channels the user created; the docs say so plainly. ## 2. Channels and epochs A channel has a 32 byte symmetric key, `K_channel`, and an epoch counter starting at 1. The key is generated by the client that creates the channel and is never sent to the server in the clear. Key distribution uses sealed boxes, so the server relays them blind: ``` wrapped[user] = crypto_box_seal(K_epoch, user.x25519_pub) # 48 byte overhead ``` The server stores `(channel_id, epoch, user_id) -> wrapped blob` and hands each user the blobs addressed to them in `AUTH_OK` (op 6) or on demand via `KEY_BLOB` after a `KEY_REQ`. It cannot unwrap any of them. Derived keys per epoch: ``` K_text = BLAKE2b(K_epoch || "veiled-text-v1" || channel_id_le || epoch_le, 32) K_voice = BLAKE2b(K_epoch || "veiled-voice-v1" || channel_id_le || epoch_le, 32) K_video = BLAKE2b(K_epoch || "veiled-video-v1" || channel_id_le || epoch_le, 32) K_media = BLAKE2b(K_epoch || "veiled-mac-v1" || channel_id_le || epoch_le, 32) # relay auth ``` **Rotation.** Removing a member (kick, ban, leave-with-history, role downgrade) creates `K_epoch+1`: any member holding the current key generates a fresh 32 byte key, wraps it for the remaining members, and publishes it via `CHAN_KEY` with an Ed25519 signature over `channel_id || old_epoch || new_epoch || hash(K_new)` so that members can verify the rotation came from someone who held the old key. `KEY_PUT` is the same operation for a rotating user set. Old epochs stay readable to those who had them; new messages use the new epoch. **A user joining a public channel** gets the current epoch key from any existing member who is online, via the same `KEY_PUT` path, with the requester's X25519 key as the sealed box recipient. Members are prompted to accept key requests for channels they created; auto-accept is a server setting (`--auto-share-keys`) that defaults to on for channels with no explicit member list. ## 3. Messages ``` nonce = random 24 bytes sealed = XChaCha20-Poly1305(K_text, nonce, plaintext, aad) sig = Ed25519_sign(sender_sign_key, msg_id || sealed) ``` On the wire there is no nonce field in `MSG` or `MSG_SEND`, so `ct` is `nonce || sealed`: the first 24 bytes are the nonce and the rest is the ciphertext with its tag. The signature covers the msg id and the sealed bytes, not the nonce. `aad = channel_id_le(8) || epoch_le(4) || sender_id_le(4) || msg_id(16) || kind(1)` The plaintext is a CBOR map: `{1: body(text)}` for a text message, `{1: body, 2: reply_to}` for a reply, `{1: emoji}` for a reaction, `{1: target_msg_id, 2: new_body}` for an edit, and `{1: target_msg_id}` for a delete. `kind` in the protocol frame is the first key of the plaintext map, which is 1 for every documented shape, so it does not discriminate between them: the shape is told apart by the keys the plaintext actually carries. A reaction is byte identical in shape to a one word message, so it is only treated as a reaction when the payload is symbol only and points at a message. The server stores `ct`, `sig`, `msg_id`, `sender_id`, `channel_id` and `sent_at` and nothing else. Edits and deletes are new encrypted frames; the client applies them over its local cache. There is no server-side search, preview or notification text, because the server cannot read any of it. ## 4. Voice and video Each Opus frame is encrypted with `K_voice` (or `K_video`) using the media envelope from `protocol.md` section 4.1. Ten millisecond frames at 32 kbps voice and 48 kbps for music mode, which is 33 to 43 bytes per payload in practice once VBR and in band FEC are taken into account, so the 40 byte figure is approximate. The 28 byte header plus 16 byte tag is a real share of the packet, yet still smaller than a 20 ms frame would be end to end. Screen share video uses WebRTC between peers with `K_video` supplied as the insertable-streams key where the browser supports it, and plain DTLS-SRTP where it does not (the server is only the signalling relay in both cases, so the host never sees video). Replay protection: a per-sender sliding window of 64 sequence numbers per channel; frames outside the window are dropped. A packet that fails authentication is dropped and counted as loss, never forwarded. ## 5. Join password, admin password, invites Neither password is ever transmitted. At setup the server stores, for each: ``` join_key = Argon2id(join_password, salt_j, m=64MiB, t=3, p=1, out=32) verifier = Argon2id(join_key, salt_v, m=64MiB, t=3, p=1, out=32) # stored, plus salt_j, salt_v ``` Login is the challenge-response of `protocol.md` section 2.4: the client derives `join_key`, proves it with `HMAC(join_key, nonce)`, and the server checks the proof against the stored verifier. The admin password works the same way with its own salts. Rate limiting on failures is mandatory, not optional: 5 per connection, 20 per IP per hour, then a 15 minute cool-off. An invite link adds out-of-band key pinning and optional password-encrypted key delivery: ``` veiled://join?h=HOST:PORT#PAYLOAD desktop https://HOST/join#PAYLOAD web (fragment never leaves the browser) payload = base64url( CBOR { 1: server_id, 2: sign_pub, 3: name, 4: addr } ) encrypted = XChaCha20-Poly1305( Argon2id(invite_password, salt), nonce, payload ) # optional short = BLAKE2b-256(payload)[0..6] as 12 hex chars # human checkable fingerprint ``` An invite without an invite password only pins the server key; the join password is still typed by the user. An invite with one delivers the pinning material only to someone who knows that password. The landing page at `/join` offers the app download and a "open in browser" button and reports the server fingerprint so a user can compare it with what the host told them out of band. ## 6. Threat model **Protected:** message content, message metadata beyond sender/channel/time, voice and video content, channel keys, the two passwords, file contents (phase 2). **Not protected:** the server knows IP addresses, connection times, display names, channel names, membership, who spoke for how long, and roughly how much was said (frame sizes and counts). A user who controls the server sees all of that. If that is not acceptable, run the server on a host you control and treat the metadata as the cost of a low latency relay. **Out of scope for v1:** traffic analysis defence, cover traffic, multi-device key syncing (manual encrypted export only), deniability, and protection against a client that has been replaced by a hostile build (there is no reproducible-build verification yet). ## 7. Interop test vectors (must pass in CI) `core/tests/vectors.rs` and `client/test/vectors.test.mjs` both load `docs/vectors.json` and assert: 1. `K_text` derivation for a fixed `K_epoch`, `channel_id`, `epoch` matches the recorded bytes. 2. A fixed message plaintext, key and nonce encrypts to a recorded `ct`, and the Ed25519 signature over `msg_id || ct` matches the recorded `sig`. 3. A 28 byte media header plus a 40 byte Opus payload encrypts to a recorded frame. 4. `crypto_box_seal` of a fixed channel key to a fixed X25519 public key matches the recorded wrapper, and unsealing with the private key returns the key. 5. Argon2id of a fixed password and salt matches the recorded `join_key`. 6. Tampering with one byte of AAD, of `ct`, or of the nonce prefix fails authentication. If a vector fails after an intentional algorithm change, the vectors are regenerated in one commit that updates both implementations plus `docs/vectors.json`.