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:
+108
-87
@@ -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` |
|
||||
|
||||
Reference in New Issue
Block a user