Files
omnivox-rs/PROTOCOL-V2.md
T
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

136 lines
5.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` |