Files
michael fea8ea3ab1 0.15.0: simple builder API, v2.1 framing, stream lane, benches
- omnivox::server::<T>()/client::<T>() builders; plain closures, enum in,
  match in the callback — the original C++ UX with the ceremony removed
- serde + bincode replace the hand-rolled Cereal codec (vectors.rs still
  governs the crypto leaves via the C++ golden oracles)
- v2.1 frames: u32 len | type | ct | tag; nonce derived from the sequence
  counter (no wire nonce), type byte authenticated as AAD; any frame-level
  failure is fatal, authenticated-but-undecodable payloads are skipped
- stream lane: send_stream/on_stream, 1 MiB frames, shared sequence with
  messages (exact sender interleaving preserved); see dump-agent/viewer
- per-connection bounded queues + writer tasks; reply/reply_stream are
  non-blocking try_send lanes for callbacks
- benches: seal/open criterion group + transport harness (spray msg/s,
  stream GiB/s, raw loopback baseline)
- profile.dev.package scrypt/pbkdf2 opt-level 3: golden-vector scrypt at
  N=2^17 was unusable in debug builds
- PROTOCOL-V2.md and README rewritten for v2.1
2026-09-30 08:07:38 -04:00

5.5 KiB
Raw Permalink Blame History

OmniVox protocol v2.1

Wire-incompatible with the 2013 C++ OmniVox by design. Goals: perfect forward secrecy (ephemeral X25519), per-session per-direction keys, no IV-reuse hazard, replay defense, authenticated framing, and a stream lane for bulk data. Everything is one-shot AEAD — no streaming ciphers.

Handshake (OVX2)

TCP is connected, then both sides exchange a fixed 69-byte hello:

bytes 0..4     magic "OVX2"
byte  4        version 2
bytes 5..37    ephemeral X25519 public key
bytes 37..69   session nonce (32 random bytes)

A hello with wrong magic or version fails closed (connection closed, no response). Both hellos are exchanged before any key is derived, so the two peers are symmetric roles distinguished only by their nonces.

dh    = X25519(eph_sk_peer_side, eph_pk_other_side)
salt  = nonce_client ‖ nonce_server
prk   = HKDF-SHA256-Extract(salt, ikm = PSK ‖ dh)
send_key(server→client) = HKDF-Expand(prk, info = "omnivox2|c2s", 32)
send_key(client→server) = HKDF-Expand(prk, info = "omnivox2|s2c", 32)

(Names are from the client's view: each peer encrypts with the key whose name is the direction it writes.) The PSK is scrypt(password, salt, N=2^15, r=8, p=1, 32) — run on a blocking thread. The pre-hello exchange is unauthenticated; the first framed message (which authenticates under the derived keys) is the proof both sides hold the same PSK. An attacker without the PSK sees every frame fail authentication and the connection die — they never get an oracle, only a closed socket.

The ephemeral DH gives forward secrecy: recorded traffic cannot be decrypted later even if the PSK leaks. Session nonces bind keys to this run: frames captured from one session are ciphertext to another.

Frame format

Every post-handshake packet, in both directions:

u32 LE   len   total byte count of type ‖ ciphertext ‖ tag
u8       type  0 = message (bincode payload), 1 = stream (raw bytes)
bytes    ct    ChaCha20-Poly1305 ciphertext (plaintext length = len − 17)
bytes    tag   16-byte Poly1305 tag

len ∈ [17, max_frame]; zero or oversized is a protocol violation (close). max_frame is max_message_size for type 0 (default 64 KiB) and max_stream_frame for type 1 (default 1 MiB) — both caps are enforced before allocation.

Nonce

The nonce is not on the wire. It is derived from the sender's per-direction sequence number:

nonce(counter) = 00 00 00 00 ‖ u64 BE counter

Each sender's counter starts at 1 and increments by exactly one per frame; each receiver tracks its own expected counter, starting at 1. The receiver decrypts only with its expected counter, so a replayed or reordered frame authenticates against the wrong nonce and fails — guaranteed, not heuristic. 2^64 frames per direction before the nonce space is exhausted (close at the limit).

AAD

The type byte is the AEAD additional authenticated data. Flipping message↔stream is therefore an authentication failure, not a lane switch.

Failure policy

  • Any frame-level failure (bad length, unknown type, AEAD failure) logs and closes the connection. v2.1 has no skip-and-continue: an authenticated bitstream that stops authenticating means someone is tampering or the two ends disagree about state — either way the session is over.
  • Payload decode failure (frame authenticated, bincode decode fails, e.g. a Message claiming a 2^40 byte vector): the frame is dropped and counted, the connection stays open. Authenticated junk is the sender's bug, not an attack.
  • Stream frames are handed to on_stream as raw bytes; no decoding.

Framing discipline

  • A body-stall timer (body_stall_ms) fires while reading a frame body that started but never completes.
  • An idle timer (idle_timeout_ms) closes connections with no inbound frames at all.
  • The reader loop processes frames strictly in wire order; messages and stream chunks share one sequence, so callbacks observe the exact interleaving the sender produced.

Outbound path

Each connection owns a bounded queue (depth 8) and a writer task. send/send_stream await queue space (backpressure); the synchronous callback helpers reply/reply_stream/send_to use try_send and return false when full instead of blocking the reader lane. The writer task seals frames with its own counter and writes them out, so slow sockets never stall the read loop.

Timeouts (defaults)

Timer Default Fires when
handshake 10 s hello exchange incomplete
body stall 5 s frame body started, never finished
idle 15 s no inbound frames at all
send queue 5 s outbound queue full and staying full

Test coverage

Property Mechanism Test
Confidentiality per-session per-direction keys frames_from_another_session_are_rejected
Replay defense derived nonce + expected counter replayed_frame_kills_the_connection
Session binding nonces in salt same cross-session test
Type tampering type byte as AAD frame_type_tamper_is_rejected
Version downgrade magic+version gate wrong_version_hello_is_closed
v1 traffic fails hello v1_traffic_is_rejected_at_the_handshake
Handler panics catch_unwind per callback panicking_callbacks_never_kill_the_connection
Decode bombs skip authenticated junk lying_decoder_length_is_skipped_connection_survives
Silent peer idle timers both roles malicious_server_silence_times_out_the_client
Ordering shared sequence messages_and_streams_interleave_in_order