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.
93 lines
5.4 KiB
Markdown
93 lines
5.4 KiB
Markdown
> **RETIRED 2026-09.** The port it governed was completed and proven
|
|
> (all golden vectors + both cross-directions passed), then superseded by
|
|
> protocol v2 (`PROTOCOL-V2.md`) in 0.14.0: a red-team review found replay,
|
|
> frame portability, and pre-auth allocation flaws baked into the v1 wire
|
|
> format, unfixable behind parity. The v2 handshake rejects v1 traffic.
|
|
> This file remains as the historical record of the port — what was
|
|
> preserved, what was dropped, and the C++ byte formats the `Cereal` codec
|
|
> still speaks for payloads.
|
|
|
|
# OmniVox Port — Parity Contract
|
|
|
|
Source: `omnivox/` (C++17/20, asio + cereal + CryptoPP + fmt)
|
|
Target: `omnivox-rs/` (Rust, tokio + RustCrypto)
|
|
End state: **A — replacement.** Rust crate + example binaries ship; C++ deleted at cut-over.
|
|
Seam: **the TCP network boundary.** C++ and Rust must interoperate byte-for-byte at any mix.
|
|
|
|
## Unit of parity
|
|
|
|
The byte stream on the wire at the TCP boundary, and the callbacks the library
|
|
delivers to the application (connect / data / disconnect with their context).
|
|
|
|
## Wire format (recovered — this is normative)
|
|
|
|
All integers little-endian (x86 native memcpy/cereal, never htonl'd anywhere).
|
|
|
|
- Framing: `u32` payload-length prefix, then that many bytes. Read in two
|
|
async steps (header, then body) with separate timeouts.
|
|
- `AeadPacket` (cereal binary archive = raw fields, no magic/header):
|
|
`nonce[12] || tag[16] || u64 len || ciphertext[len]`
|
|
- `DatagramPayload`: `u16 id || u64 qword || u64 len || bytes[len]`
|
|
- AEAD: ChaCha20-Poly1305 per RFC 8439 (CryptoPP impl is RFC-conformant).
|
|
No AAD. Random 12-byte nonce per message (`AutoSeededRandomPool`).
|
|
- Key: scrypt per RFC 7914, `N=2^17, r=8, p=1, dkLen=32`, passphrase = raw
|
|
password bytes, salt = raw salt bytes. Both peers must set the identical key.
|
|
|
|
## Not acceptable (any difference = regression)
|
|
|
|
- Any wire byte: framing, field layout, key derivation, AEAD construction.
|
|
- Tamper behavior: `DecryptAndVerify` failure → message dropped, error logged,
|
|
**connection stays open** (exception caught in processMessage).
|
|
- Limits: `MAX_MESSAGE_SIZE = 5 MiB`; header `0` or `> 5 MiB` → log + disconnect.
|
|
- Timeouts: per-message header-wait = `timeoutMs` (server, per client arg);
|
|
body stall = 5000 ms fixed. Server per-client stall timers; **client side has
|
|
no timers** (ctx.id == serverContext.id path).
|
|
- Client IDs: server assigns from 1, monotonic (`nextClientId`), visible in
|
|
`ConnectionContext.id`.
|
|
- Callback order on accept: allow → client registered & reading before the
|
|
connect log; deny → socket closed, disconnect callback fires.
|
|
- `sendToServer` returns false unless socket open **and** status Connected;
|
|
`sendToClient` returns false if id unknown.
|
|
- Malformed/undecryptable message: logged, skipped, connection survives.
|
|
|
|
## Acceptable (do not chase)
|
|
|
|
- Log text, wording, timestamps; steady-clock `creationTime` values.
|
|
- Thread structure (pool + io_context → tokio tasks); task scheduling.
|
|
- **Message processing order**: C++ server processes on a worker pool and can
|
|
reorder concurrent messages; Rust may differ in interleaving. Per-connection
|
|
stream order must still be preserved.
|
|
- Exact race outcomes under B1/B3 below (timing-dependent in C++ itself).
|
|
- TCP_NODELAY: C++ leaves Nagle ON (`NODELAY_OPT` never defined); Rust matches
|
|
default. Never observable in payload bytes.
|
|
|
|
## Bug/quirk inventory — preserved, NOT fixed (fixes = separate commits after parity)
|
|
|
|
| # | Source | Behaviour | Port decision |
|
|
|---|--------|-----------|---------------|
|
|
| B1 | OmniVox.h:914 | Bad header size logs + `handleDisconnect` **without returning** — proceeds to read a body of that size (0-byte read succeeds and processes an empty packet → decrypt error logged) | Preserve: disconnect + best-effort body read; races allowed |
|
|
| B2 | OmniVox.h:990 | Decrypt/parse errors are swallowed (log only), connection kept | Preserve |
|
|
| B3 | OmniVox.h:630 | `sendToClient` does a **blocking** `asio::write` while holding the clients lock | Rust holds the connection write lock across the full write; log as follow-up improvement |
|
|
| B4 | OmniVox.h:960 | Processing on thread pool → concurrent reordering | Acceptable-difference above |
|
|
| B5 | OmniVox.h:869 | Client mode never arms stall timers | Preserve |
|
|
| B6 | OmniVox.h:782 | `timeoutCheckerThread`/`timeoutCheckerRunning` — dead code, never started | **Not ported** (dead) |
|
|
| B7 | OmniVox.h:99 | `inline std::optional<WorkGuard>` global — shadowed by member, dead | **Not ported** (dead) |
|
|
| B8 | client.cpp:101 | Client sleeps 15 s then stops; server sleeps `UINT32_MAX` hours | Examples mirror this |
|
|
|
|
## Deliberately not ported
|
|
|
|
B6/B7 dead code; the cereal/cryptopp/asio vendored trees; `PackedValue64` union
|
|
(only `.qword` is ever serialized → port as `u64` field on `DatagramPayload`);
|
|
`ThreadPool_` as a class (tokio replaces it; do not transliterate).
|
|
|
|
## Verification (how parity is proven)
|
|
|
|
1. **Vector tests**: golden `scrypt` + cereal-layout + AEAD vectors generated
|
|
by C++ (`cppgen` helper in `omnivox-ref/`) checked into `tests/vectors/`.
|
|
2. **Differential cross-tests over loopback**: C++ server ↔ Rust client,
|
|
Rust server ↔ C++ client — echo handshake must round-trip.
|
|
3. **Tamper test**: flip a ciphertext byte mid-stream → receiver logs and
|
|
survives.
|
|
4. Differential harness reports inputs-compared and differences-found.
|
|
|
|
`cargo test --all-features && cargo clippy --all-targets --all-features && cargo fmt --check` |