# securechannel v3 — wire protocol specification Byte-exact specification of the handshake and transport in `securechannel/`. Threat model and residual risks: [SECURITY-ANALYSIS.md](SECURITY-ANALYSIS.md) and [../SECURITY.md](../SECURITY.md). Design influences: SIGMA (sign-the-transcript), KEMTLS (implicit KEM authentication), Noise XX (encrypted identities), TLS 1.3 (transcript hashing, key confirmation, hybrid KEM), Signal (symmetric ratchet), SSH (TOFU). ## 0. Primitives | Role | Primitive | |---|---| | Identity signature | Ed25519 (RFC 8032) | | Identity KEM (static, for PQ auth) | ML-KEM-1024 (FIPS 203) | | Ephemeral KEX | X25519 (RFC 7748) + ML-KEM-1024, fresh per connection | | Hash / KDF | SHA-512, HKDF-SHA-512 | | Transport AEAD | AES512-AEAD (DESIGN.md §3.1): 32-byte nonce, 32-byte tag | Protocol label `AES512-SC-v3`; embedded in every signature domain, KDF info and AAD. Single version, hard-fail — nothing to downgrade. ## 1. Identities, fingerprints, trust A long-term identity is an **Ed25519 signing key AND a static ML-KEM-1024 key**. Files (hex, 0600) in an identity directory: `id.ed25519.key`, `id.mlkem.key` (64-byte seed), plus public copies. The public identity is `marshal = u16(len)‖Ed25519pub ‖ u16(len)‖MLKEMek`. Fingerprint = first 8 bytes of SHA-512(marshal), grouped `xxxx-xxxx-xxxx-xxxx` — it covers **both** keys, so neither can be swapped without changing it. Clients pin the whole server identity per name (TOFU), or supply `ExpectedServerFingerprint` to skip the first-use window. Servers may gate clients via `Authorize(PublicIdentity)`. ## 2. Handshake (4 messages + key confirmation) JSON per line (`\n`-terminated, ≤16 KB, unknown fields rejected), every field base64 and length-checked after decode; then two encrypted key-confirmation frames. Bounded by `HandshakeTimeout` (default 15 s). `c*` = client→server, `s*` = server→client. Messages carrying an identity are **encrypted** under a key derived from the ephemeral secret, so identities never appear in cleartext. ``` Client Server ------ ------ c1: { ce = X25519 eph pub (32), cek = ML-KEM eph encapsulation key (1568) } → ← s1: { se = X25519 eph pub (32), cte = ML-KEM ct to cek (1568), enc = AEAD_hk{ idS, sigS } } c2: { enc = AEAD_hk{ idC, sigC, cts } } → (cts = ML-KEM ct to server static) ← s2: { enc = AEAD_hk{ ctc } } (ctc = ML-KEM ct to client static) finC (encrypted transport frame, c2s seq 0) → ← finS (encrypted transport frame, s2c seq 0) data … ⇄ data … ``` JSON envelope: every message carries `"t"` — exactly `"c1"`, `"s1"`, `"c2"`, `"s2"` — and `c1`/`s1` additionally carry `"v": 3`, which must equal 3 or the handshake aborts. There is no cryptographic reliance on that integer alone: the version string `AES512-SC-v3` is baked into every hash, signature and KDF domain below, so mixed-version transcripts cannot verify. ### 2.1 Ephemeral secret, transcript, handshake key After the ephemeral exchange both sides compute: ``` T2 = SHA-512("AES512-SC-v3|transcript2\0" ‖ lp(ce) ‖ lp(cek) ‖ lp(se) ‖ lp(cte)) ssEX = X25519(ce, se) // 32 ssEK = ML-KEM.Decap(dk_e, cte) // 32 (dk_e = client's ephemeral decaps // key for cek; the server already holds // ssEK from Encap(cek) → cte) hk = HKDF-SHA-512(ssEX ‖ ssEK, salt=T2, info="AES512-SC-v3 handshake-key", 64) ``` `lp(x) = u32be(len)‖x`; the transcript label is written raw (unprefixed). `hk` keys the AEAD that encrypts the identity blobs (`enc` fields) with AAD = T2 and fixed 32-byte nonces — all zero except byte 0: `0x01` for s1's `enc`, `0x02` for c2's, `0x03` for s2's. Fixed nonces are sound here because `hk` is fresh per connection and each nonce is used exactly once under it. Because `hk` depends only on the **ephemeral** secret, a passive observer — who has neither ephemeral private key — cannot decrypt the identities. ### 2.2 Authentication (hybrid: signatures + static KEM) Each side authenticates the other **two independent ways**: ``` sigS = Ed25519(skS, lp("AES512-SC-v3|server-auth\0") ‖ lp(T2) ‖ lp(idS)) sigC = Ed25519(skC, lp("AES512-SC-v3|client-auth\0") ‖ lp(T2) ‖ lp(idS) ‖ lp(idC) ‖ lp(cts)) ``` (Note the asymmetry with the transcript hashes: in signature inputs **every** field is length-prefixed, *including* the domain string.) - **Classical:** the peer verifies the Ed25519 signature over the transcript (role-separated domains prevent reflection; T2 binds both ephemerals so there is no unknown-key-share). - **Post-quantum (KEMTLS-style):** the client encapsulates to the server's *static* ML-KEM key (`cts`), the server to the client's (`ctc`). Both results (`ssS`, `ssC`) feed the master secret. A party that does **not** own its static ML-KEM key cannot decapsulate, derives the wrong keys, and fails key confirmation. So authentication survives if **either** the signature/curve world **or** the lattice world is broken — including by a quantum adversary. ### 2.3 Master secret, session ratchets ``` MS = ssEX ‖ ssEK ‖ ssS ‖ ssC // 128 bytes, hybrid Tf = SHA-512("AES512-SC-v3|final\0" ‖ lp(T2) ‖ lp(idS) ‖ lp(sigS) ‖ lp(cts) ‖ lp(idC) ‖ lp(sigC) ‖ lp(ctc)) ckC2S = HKDF-SHA-512(MS, salt=Tf, info="AES512-SC-v3 c2s-ratchet", 32) ckS2C = HKDF-SHA-512(MS, salt=Tf, info="AES512-SC-v3 s2c-ratchet", 32) ``` Confidentiality holds if **any** of the four MS components resists; the two ephemeral components (discarded after the handshake) provide forward secrecy — compromising the static ML-KEM keys later does not decrypt recorded sessions. X25519's all-zero (low-order) output is rejected; all intermediate secrets are zeroized after derivation. ### 2.4 Key confirmation Each side's first transport frame (sequence 0) is a `fin` frame carrying exactly `Tf`. Receiving a valid one proves the peer derived the same master secret (i.e. owns the right static KEM key and agrees on the whole transcript). Any mismatch aborts. Strict alternation (client `fin` first) also makes the flow deadlock-free on unbuffered transports. ## 3. Transport frames (ratcheted, padded) ``` frame = u32be(len(ct)) ‖ ct mk_i = HKDF-SHA-512(ck, salt=∅, info="AES512-SC-v3 message-key", 64) // i-th message ck ← HKDF-SHA-512(ck, salt=∅, info="AES512-SC-v3 chain-next", 32) // advance, one-way inner = type(1) ‖ u32be(len(msg)) ‖ msg ‖ 0… // type 0x01 = fin, 0x02 = data; // zero-padded to a multiple of // Config.PadTo (default 256) ct = AES512-AEAD(mk_i).Seal(nonce, inner, AAD="AES512-SC-v3 frame") nonce = dir(1) ‖ 0²³ ‖ u64be(seq) // dir 0x43 'C' (client→server) / 0x53 'S'; // seq counts per direction from 0 ``` - **Per-message ratchet.** Each frame uses a fresh key `mk_i`; after deriving it the chain key is advanced one-way. Compromising the current chain key cannot recover earlier `mk`s ⇒ **per-message forward secrecy**. The two sides' ratchets advance in lockstep, so a replayed, reordered, dropped or truncated frame derives the wrong key and fails authentication. - **Any authentication failure permanently kills the channel** (first error wins; ratchet keys zeroized on Close). Recovery = new handshake. No "tolerate and warn". - **Padding.** The inner plaintext (`type ‖ u32(realLen) ‖ msg`) is padded to a `PadTo` bucket before sealing, so the frame length reveals only the bucket. Handshake messages are already fixed-size (fixed-length keys). - Length bounds are enforced before allocation; sequence exhaustion (2⁶⁴) closes the channel. ## 4. Properties and limits - **Forward secrecy** (per connection and per message): identity/static-key compromise does not reveal recorded or already-sent messages. - **Post-quantum**: confidentiality *and* authentication (see §2.2). A full break needs both a quantum computer *and* a stolen static ML-KEM key. - **Identity hiding**: passive observers only (an active MITM completing its own ephemeral exchange can still learn identities — §2.1 caveat). - **No post-compromise secrecy**: the ratchet is symmetric-only; a DH/KEM ratchet is future work. Reconnect periodically. - **KCI**: reduced (impersonation needs the static ML-KEM key, not just the signing key) but not eliminated. - **Metadata**: padding coarsens lengths only; timing/volume/relationships leak. Not an anonymity system. ## 5. Reference deployment: the Ragnarok messenger The API surface is `Client`/`Server`, `Send`, `Receive`, `Close`, plus fingerprints for the trust UX. The reference application in `web/` runs this exact protocol **end-to-end between two browsers**: the full stack compiles to WebAssembly (`GOOS=js`), handshake lines and transport frames map 1:1 onto binary WebSocket messages, and the relay in the middle pairs two peers by room name and forwards ciphertext without terminating the protocol. The headless test `scripts/test-web-e2e.sh` drives two real WASM instances through a real relay — the same code path a browser executes — and is part of `make check`.