Files
omnivox-rs/PROTOCOL-V2.md
T
michael c614d8345f Protocol v2: X25519 handshake, sequence-number nonces, hardened transport
Red-team review of the (byte-exact) v1 port found replay, cross-session
frame portability, pre-auth allocation amplification, and a symmetric
key that let either peer forge as itself — flaws inherent to the v1
wire format. v2 closes them:

- fixed 69-byte OVX2 hello; server validates before replying; v1 and
  wrong-version peers see an immediate close
- HKDF(ephemeral X25519 DH, PSK, transcript) -> direction-separated
  session keys; forward secrecy per connection
- strictly increasing u64 sequence nonces: any replay or reorder drops
  the connection (regression tests in tests/protocol_v2.rs)
- Config: message-size cap (64 KiB default), connection semaphore,
  idle/body-stall/handshake/send timeouts on both roles (client-side
  timers retire the old no-client-timeouts quirk)
- message processing inline per connection; user callbacks panic-
  isolated; poison-tolerant mutexes; demo key refused in release builds
- PROTOCOL-V2.md supersedes PARITY-CONTRACT.md (retired, kept as port
  history); Cereal payload encoding is unchanged

Payload serialization remains byte-exact with the original C++ (the
golden scrypt/cereal vectors still govern). Zero unsafe. Verified live:
server<->client handshake, encrypted echo, clean teardown.

Cuts over from the C++ implementation, which is deleted (phase 5 of the
port contract); it remains in the original gitea repository.
2026-09-30 06:56:52 -04:00

4.9 KiB

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.