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
This commit is contained in:
2026-09-30 08:07:38 -04:00
parent c614d8345f
commit fea8ea3ab1
15 changed files with 2827 additions and 1193 deletions
+108 -87
View File
@@ -1,114 +1,135 @@
# OmniVox Protocol v2 — Normative Specification
# OmniVox protocol v2.1
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.
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.
## Threat model
## Handshake (OVX2)
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 is connected, then both sides exchange a fixed 69-byte hello:
```
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 0..4 magic "OVX2"
byte 4 version 2
bytes 5..37 ephemeral X25519 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
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_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]
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)
```
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.
(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.
## Framing (both directions, after key confirmation)
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 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
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
```
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.
`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.
### Sequence discipline (the replay defense)
### Nonce
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.
The nonce is **not** on the wire. It is derived from the sender's
per-direction sequence number:
### Payload encoding
```
nonce(counter) = 00 00 00 00 ‖ u64 BE counter
```
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.
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).
## Limits and timeouts (all server- and client-side)
### AAD
| Knob | Default | Violation |
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_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 |
| 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 |
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.
## Test coverage
## Security properties
| Property | Mechanism | Tested by |
| Property | Mechanism | Test |
|---|---|---|
| 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.
| 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` |