# OmniVox Protocol v2 — Normative Specification Version 2, 2026-09. Supersedes the v1 wire format (byte-compatible with the original C++ implementation); v1 traffic is rejected at the handshake and `PARITY-CONTRACT.md` is retired alongside it. ## Threat model The PSK (a scrypt hash of a shared password) authenticates peers. The network is hostile: it can observe, inject, replay, reorder, and truncate. Goals: session confidentiality and integrity, replay protection, forward secrecy for recorded traffic, and no attacker-controlled work before authentication. ## Connection lifecycle ``` TCP connect │ ├── HELLO exchange (plaintext, fixed size, timeout: handshake_timeout_ms) ├── key schedule (HKDF over ephemeral DH + PSK) ├── framed messages (AEAD, per-direction sequence numbers) └── close (any timeout / violation closes immediately) ``` ## HELLO (both directions, 69 bytes, no attacker-controlled lengths) ``` bytes 0..4 magic "OVX2" byte 4 version 2 bytes 5..37 x25519 ephemeral public key bytes 37..69 session nonce (32 random bytes) ``` The client sends first. The server validates magic, version, and key encoding **before replying**; any violation closes the connection. The server rejects an all-zero DH output (small-order contribution). ## Key schedule ``` dh = X25519(eph_local, eph_peer) prk = HKDF-SHA256-Extract(salt = nonce_client ‖ nonce_server, ikm = PSK) okm = HKDF-SHA256-Expand(prk, info = "omnivox-v2" ‖ hello_client ‖ hello_server, 64) key_c2s = okm[0..32] key_s2c = okm[32..64] ``` The client's send key is `key_c2s`, receive key `key_s2c`; reversed for the server. Direction separation means neither peer can present itself forged frames (v1's single symmetric key could). The ephemeral DH gives forward secrecy against later PSK compromise; the PSK authenticates against off-path attackers. A weak password remains guessable by an active attacker with the PSK budget to grind scrypt — use random 32-byte PSKs; a PAKE is the future upgrade if passwords must stay human. ## Framing (both directions, after key confirmation) ``` u32 LE length (0 < len ≤ max_message_size, default cap 64 KiB) AeadPacket: nonce [u8; 12] four zero bytes ‖ u64 BE sequence number tag [u8; 16] Poly1305 tag len u64 LE (cereal vector length, retained for payload lineage) ct [len] ChaCha20-Poly1305 ciphertext of the serialized payload ``` AEAD is ChaCha20-Poly1305 (RFC 8439) with the packet's own nonce as the IETF nonce and empty AAD — identical to v1's construction, now keyed per session per direction. ### Sequence discipline (the replay defense) Each sender's counter starts at 1 and increments by exactly 1 per frame; a failed write must not consume a number. The receiver's first expected value is 1. A frame whose decoded nonce is not the expected value is a protocol violation: **log and close the connection.** There is no reorder window. ### Payload encoding The plaintext is the `Cereal` binary encoding of the message type — raw little-endian fields, `u64` length prefixes for byte vectors, no archive header — unchanged from the C++ original. ## Limits and timeouts (all server- and client-side) | Knob | Default | Violation | |---|---|---| | handshake_timeout_ms | 10000 | close | | idle_timeout_ms (silence between messages) | 15000 | close | | body_stall_ms (mid-body silence) | 5000 | close | | send_timeout_ms (peer not reading) | 5000 | close that peer | | max_message_size | 65536 | close on header | | max_connections (server) | 1024 | new connections wait | Corrupt frames with the *correct* sequence number (failed AEAD or payload decode) are logged and skipped; the connection continues. Anything else — sequence violations, unreasonable headers, stalls — is a disconnect. ## Security properties | Property | Mechanism | Tested by | |---|---|---| | Confidentiality | ChaCha20-Poly1305 per-session keys | `protocol_v2::frames_from_another_session_are_undecryptable` | | Replay defense | strict sequence numbers | `protocol_v2::replayed_frame_is_dropped_and_the_connection_dies` | | Session binding | nonces enter the salt/info | same cross-session test | | Forward secrecy | ephemeral X25519 per connection | design (single-connection compromise leaks only it) | | No pre-auth work | fixed 69-byte hello, caps only after key confirmation | `wrong_version_hello_is_closed`, `v1_traffic_is_rejected_at_the_handshake` | | Liveness both roles | idle + stall timeouts client **and** server | `malicious_server_silence_times_out_the_client`, `header_stall_times_out` | | Slow-client containment | per-connection locks + send timeout | design (no shared writer mutex) | | DoS containment | connection semaphore, message caps, callback panic isolation | `panicking_callback_does_not_kill_the_connection` | ## Refusals Release builds refuse to start with the original demo key (scrypt of `SecurePassword123` / salt `01…08`) unless `allow_insecure_defaults` is set.