Protocol v2: X25519 handshake, sequence-number nonces, hardened transport

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.
This commit is contained in:
2026-09-30 06:56:52 -04:00
parent 5cbac8037d
commit c614d8345f
13 changed files with 1325 additions and 352 deletions
+23
View File
@@ -136,3 +136,26 @@ pub fn decrypt(key: &[u8; KEY_LEN], packet: &AeadPacket) -> Result<Vec<u8>, Cryp
.map_err(|_| CryptoError::AuthenticationFailed)?;
Ok(payload)
}
/// v2 frame nonce: four zero bytes ‖ u64 big-endian sequence number.
/// Deterministic nonces make reuse impossible and turn the AEAD into a
/// replay detector (see `PROTOCOL-V2.md`).
#[must_use]
pub fn frame_nonce(counter: u64) -> [u8; NONCE_LEN] {
let mut n = [0u8; NONCE_LEN];
n[4..].copy_from_slice(&counter.to_be_bytes());
n
}
/// Recovers the sequence number from a v2 frame nonce, or `None` if the
/// nonce is not in v2 format.
#[must_use]
pub fn nonce_counter(nonce: &[u8; NONCE_LEN]) -> Option<u64> {
if nonce[..4] == [0; 4] {
let mut c = [0u8; 8];
c.copy_from_slice(&nonce[4..]);
Some(u64::from_be_bytes(c))
} else {
None
}
}
+171
View File
@@ -0,0 +1,171 @@
//! OmniVox v2 handshake and session key schedule.
//!
//! Both peers exchange a fixed-size `HELLO` (no attacker-controlled
//! lengths before key confirmation):
//!
//! ```text
//! "OVX2" ‖ u8 version ‖ x25519 pubkey[32] ‖ nonce[32] (69 bytes)
//! ```
//!
//! The session keys are derived as
//!
//! ```text
//! dh = X25519(eph_local, eph_peer)
//! prk = HKDF-Extract(salt = nonce_client ‖ nonce_server, ikm = psk)
//! keys = HKDF-Expand(prk, "omnivox-v2" ‖ hello_client ‖ hello_server)
//! ```
//!
//! `keys` is split into a client-to-server and a server-to-client half:
//! direction-separated keys, so a peer cannot present itself forged frames,
//! unlike v1's single symmetric key. The ephemeral pair gives forward
//! secrecy for recorded sessions; the PSK (scrypt of the shared password)
//! authenticates the exchange.
use hkdf::Hkdf;
use sha2::Sha256;
use thiserror::Error;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use x25519_dalek::{EphemeralSecret, PublicKey};
/// Protocol magic at the start of every hello.
pub const MAGIC: &[u8; 4] = b"OVX2";
/// Protocol version carried in the hello.
pub const VERSION: u8 = 2;
/// Exact hello length; nothing about a hello is attacker-length.
pub const HELLO_LEN: usize = 4 + 1 + 32 + 32;
/// Errors surfaced from the handshake; any error closes the connection.
#[derive(Debug, Error)]
pub enum HandshakeError {
#[error("peer sent bad magic or unsupported version")]
BadVersion,
#[error("degenerate x25519 shared secret (rejected small-order peer key)")]
DegenerateKey,
#[error(transparent)]
Io(#[from] std::io::Error),
}
/// A session's direction-separated traffic keys.
#[derive(Clone)]
pub struct Session {
/// Key for frames this peer sends.
pub send_key: [u8; 32],
/// Key for frames this peer expects to receive.
pub recv_key: [u8; 32],
}
/// The v2 key schedule, given both hellos on the wire and the raw X25519
/// DH output. Returns 64 bytes: c2s key ‖ s2c key.
///
/// # Errors
///
/// [`HandshakeError::DegenerateKey`] if the DH output is all-zero
/// (small-order peer contribution).
pub fn key_schedule(
client_hello: &[u8; HELLO_LEN],
server_hello: &[u8; HELLO_LEN],
shared_secret: &[u8; 32],
psk: &[u8; 32],
) -> Result<[u8; 64], HandshakeError> {
if shared_secret.iter().all(|&b| b == 0) {
return Err(HandshakeError::DegenerateKey);
}
let mut salt = [0u8; 64];
salt[..32].copy_from_slice(&client_hello[37..]);
salt[32..].copy_from_slice(&server_hello[37..]);
let hk = Hkdf::<Sha256>::new(Some(&salt), psk);
let mut info = Vec::with_capacity(10 + 2 * HELLO_LEN);
info.extend_from_slice(b"omnivox-v2");
info.extend_from_slice(client_hello);
info.extend_from_slice(server_hello);
let mut keys = [0u8; 64];
hk.expand(&info, &mut keys)
.expect("64-byte output is within HKDF-SHA256 limits");
Ok(keys)
}
/// Splits key-schedule output into this peer's direction keys.
pub fn split_keys(keys: &[u8; 64], server: bool) -> Session {
let (c2s, s2c) = keys.split_at(32);
let (mut a, mut b) = ([0u8; 32], [0u8; 32]);
a.copy_from_slice(c2s);
b.copy_from_slice(s2c);
if server {
Session {
send_key: b,
recv_key: a,
}
} else {
Session {
send_key: a,
recv_key: b,
}
}
}
/// Builds the exact 69-byte hello for a public key and session nonce.
pub fn build_hello(pubkey: &PublicKey, nonce: &[u8; 32]) -> [u8; HELLO_LEN] {
let mut hello = [0u8; HELLO_LEN];
hello[..4].copy_from_slice(MAGIC);
hello[4] = VERSION;
hello[5..37].copy_from_slice(pubkey.as_bytes());
hello[37..].copy_from_slice(nonce);
hello
}
/// Parses and validates a hello's magic and version.
pub fn check_hello(hello: &[u8; HELLO_LEN]) -> Result<PublicKey, HandshakeError> {
if &hello[..4] != MAGIC || hello[4] != VERSION {
return Err(HandshakeError::BadVersion);
}
let mut bytes = [0u8; 32];
bytes.copy_from_slice(&hello[5..37]);
Ok(PublicKey::from(bytes))
}
/// Performs the client or server side of the v2 handshake over an
/// already-connected stream pair, returning the session keys.
pub async fn perform<R, W>(
reader: &mut R,
writer: &mut W,
psk: &[u8; 32],
server: bool,
) -> Result<Session, HandshakeError>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let secret = EphemeralSecret::random_from_rng(rand_core::OsRng);
let pubkey = PublicKey::from(&secret);
let mut nonce = [0u8; 32];
getrandom::fill(&mut nonce).expect("OS RNG failure");
let our_hello = build_hello(&pubkey, &nonce);
// The client speaks first; the server reads before replying, so the
// transcript order (client ‖ server) is unambiguous for both sides.
let mut peer_hello = [0u8; HELLO_LEN];
if server {
// Validate the peer's hello BEFORE replying: a wrong-version or
// non-OmniVox peer must see a closed connection, not an echo.
reader.read_exact(&mut peer_hello).await?;
check_hello(&peer_hello)?;
writer.write_all(&our_hello).await?;
writer.flush().await?;
} else {
writer.write_all(&our_hello).await?;
writer.flush().await?;
reader.read_exact(&mut peer_hello).await?;
}
let peer_pub = check_hello(&peer_hello)?;
let shared = secret.diffie_hellman(&peer_pub);
let (client_hello, server_hello) = if server {
(peer_hello, our_hello)
} else {
(our_hello, peer_hello)
};
let keys = key_schedule(&client_hello, &server_hello, shared.as_bytes(), psk)?;
Ok(split_keys(&keys, server))
}
+411 -170
View File
@@ -1,14 +1,21 @@
//! OmniVox — encrypted async TCP client/server messaging.
//! OmniVox — encrypted async TCP client/server messaging, protocol v2.
//!
//! Rust port of the OmniVox C++ library, wire-compatible byte-for-byte with
//! the original (framing, cereal field layout, scrypt + ChaCha20-Poly1305).
//! See `PARITY-CONTRACT.md` at the workspace root for the normative spec,
//! including the quirks this port deliberately preserves (B1, B2, B5).
//! Wire format and key schedule: see `PROTOCOL-V2.md`. In one paragraph:
//! a fixed-size X25519 hello handshake on every connection derives
//! direction-separated session keys via HKDF from the PSK (scrypt of the
//! shared password), then every frame is ChaCha20-Poly1305 sealed under the
//! sender's direction key with a strictly increasing sequence-number nonce.
//! Replay, frame portability across connections, and pre-auth attacker
//! allocations are structurally impossible; recorded sessions lose their
//! secrecy only together with the session's ephemeral keys.
//!
//! Payloads implement [`Cereal`], the byte-exact cereal binary field
//! encoding, unchanged from v1.
//!
//! # Example
//!
//! ```no_run
//! use omnivox::{Cereal, CodecError, ConnectionContext, Decision, OmniVox, OmniVoxError};
//! use omnivox::{Cereal, CodecError, Config, ConnectionContext, Decision, OmniVox, OmniVoxError};
//! use std::sync::Arc;
//!
//! #[derive(Default)]
@@ -25,8 +32,7 @@
//! server
//! .start_server(
//! 8080,
//! 2,
//! 15000,
//! Config::default(),
//! Arc::new(|_data: &Ping, _ctx: &ConnectionContext| {}),
//! Arc::new(|_ctx| {}),
//! Arc::new(|_ctx| Decision::Allow),
@@ -35,18 +41,21 @@
//! # Ok(()) }
//! ```
mod codec;
mod crypto;
pub mod codec;
pub mod crypto;
pub mod handshake;
pub use codec::{Cereal, CodecError, Reader, write_byte_vec};
pub use crypto::{
AeadPacket, CryptoError, KEY_LEN, NONCE_LEN, SCRYPT_LOG_N, SCRYPT_P, SCRYPT_R, TAG_LEN,
decrypt, derive_encrypt_key, encrypt, encrypt_with_nonce,
decrypt, derive_encrypt_key, encrypt, encrypt_with_nonce, frame_nonce, nonce_counter,
};
pub use handshake::{HandshakeError, Session};
use std::{
collections::HashMap,
net::SocketAddr,
panic::{AssertUnwindSafe, catch_unwind},
sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicBool, AtomicU32, Ordering},
@@ -60,16 +69,18 @@ use tokio::{
TcpListener, TcpStream,
tcp::{OwnedReadHalf, OwnedWriteHalf},
},
sync::Mutex as AsyncMutex,
sync::{Mutex as AsyncMutex, OwnedSemaphorePermit, Semaphore},
task::JoinHandle,
time::{Duration, timeout},
};
use tracing::{error, info};
use tracing::{error, info, warn};
/// 5 MiB per-message allocation cap, matching `MAX_MESSAGE_SIZE`.
pub const MAX_MESSAGE_SIZE: u32 = 1024 * 1024 * 5;
/// Fixed body-read stall cap, matching `MAX_TIMEOUT_STALL_MS`.
pub const MAX_TIMEOUT_STALL_MS: u64 = 5000;
/// scrypt of the original demo password + salt; servers and clients refuse
/// to run with it in release builds unless explicitly allowed.
const DEMO_KEY: [u8; KEY_LEN] = [
0xf6, 0x4f, 0x4c, 0x05, 0xeb, 0x28, 0x67, 0x6b, 0xe5, 0xe4, 0xdd, 0x76, 0x0c, 0x7f, 0x94, 0x09,
0x32, 0x7a, 0x47, 0xe1, 0x1a, 0xbb, 0x66, 0xad, 0xc5, 0x93, 0xb2, 0xbd, 0x03, 0x9a, 0x06, 0x50,
];
/// State of a connection, matching the C++ `ConnectionStatus`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -83,7 +94,7 @@ pub enum ConnectionStatus {
/// Per-connection metadata handed to every callback.
///
/// `total_sent` mirrors the C++ `iTotalSent`, which the original never
/// increments; it is preserved as always-zero for parity.
/// incremented; it is preserved as always-zero.
#[derive(Debug, Clone)]
pub struct ConnectionContext {
pub connected: ConnectionStatus,
@@ -118,16 +129,53 @@ pub type DataHandler<T> = Arc<dyn Fn(&T, &ConnectionContext) + Send + Sync + 'st
pub type ConnHandler = Arc<dyn Fn(&ConnectionContext) + Send + Sync + 'static>;
pub type DecisionHandler = Arc<dyn Fn(&ConnectionContext) -> Decision + Send + Sync + 'static>;
/// Library errors surfaced from setup calls. Runtime message failures are
/// logged and swallowed, matching the C++ exception swallowing (contract B2).
/// Runtime knobs. Everything has a conservative default; tune deliberately.
#[derive(Debug, Clone)]
pub struct Config {
/// Max silence between messages (or between handshake halves) before
/// the connection is dropped. Applies to server and client alike.
pub idle_timeout_ms: u64,
/// Max gap between bytes of one message body once its header arrived.
pub body_stall_ms: u64,
/// Max duration of a full frame write before the peer is dropped.
pub send_timeout_ms: u64,
/// Max duration of the v2 handshake.
pub handshake_timeout_ms: u64,
/// Hard cap on one message body; oversized headers disconnect.
pub max_message_size: u32,
/// Concurrent connections (server); excess connections wait.
pub max_connections: usize,
/// Allow the retired demo key in release builds. Not for production.
pub allow_insecure_defaults: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
idle_timeout_ms: 15_000,
body_stall_ms: 5_000,
send_timeout_ms: 5_000,
handshake_timeout_ms: 10_000,
max_message_size: 64 * 1024,
max_connections: 1024,
allow_insecure_defaults: false,
}
}
}
/// Library errors surfaced from setup and security-violation paths.
#[derive(Debug, thiserror::Error)]
pub enum OmniVoxError {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Crypto(#[from] CryptoError),
#[error("already started")]
AlreadyStarted,
#[error(transparent)]
Handshake(#[from] HandshakeError),
#[error(
"refusing to run with the retired demo key; set allow_insecure_defaults or change the password/salt"
)]
InsecureDefaults,
}
fn now_micros() -> u64 {
@@ -137,24 +185,29 @@ fn now_micros() -> u64 {
.unwrap_or(0)
}
/// One peer's write side: half of the socket plus its traffic key and the
/// sequence number the next frame will carry.
struct WriterState {
half: OwnedWriteHalf,
key: [u8; KEY_LEN],
counter: u64,
}
struct ClientHandle {
/// Held across the full frame write, mirroring the C++ clients-mutex
/// hold across the blocking `asio::write` (contract B3). `Option` so a
/// disconnect can close the socket even while the read task still holds
/// the handle, matching the C++ `handleDisconnect` socket close.
writer: AsyncMutex<Option<OwnedWriteHalf>>,
/// Held across the full frame write, so a slow reader stalls at most its
/// own connection, and at most `send_timeout_ms`.
writer: AsyncMutex<Option<WriterState>>,
context: ConnectionContext,
}
struct Inner<T> {
key: Mutex<Option<[u8; KEY_LEN]>>,
psk: Mutex<Option<[u8; KEY_LEN]>>,
config: OnceLock<Config>,
clients: Mutex<HashMap<u32, Arc<ClientHandle>>>,
next_id: AtomicU32,
stopping: AtomicBool,
/// Client-mode connection state; id 0 means "never connected", so the
/// C++ `ctx.id != serverContext.id` timer-gating reproduces exactly.
server_ctx: Mutex<Option<ConnectionContext>>,
server_writer: AsyncMutex<Option<OwnedWriteHalf>>,
server_writer: AsyncMutex<Option<WriterState>>,
received: OnceLock<DataHandler<T>>,
disconnected: OnceLock<ConnHandler>,
connect_decision: OnceLock<DecisionHandler>,
@@ -163,9 +216,6 @@ struct Inner<T> {
}
/// Encrypted TCP endpoint usable in either client or server role.
///
/// Cheap to hand out: state is shared behind an `Arc`; clone or borrow the
/// same instance from callbacks.
pub struct OmniVox<T> {
inner: Arc<Inner<T>>,
}
@@ -176,13 +226,20 @@ impl<T: Cereal + Send + Sync + 'static> Default for OmniVox<T> {
}
}
fn check_defaults(config: &Config, psk: [u8; KEY_LEN]) -> Result<(), OmniVoxError> {
if !config.allow_insecure_defaults && !cfg!(debug_assertions) && psk == DEMO_KEY {
return Err(OmniVoxError::InsecureDefaults);
}
Ok(())
}
impl<T: Cereal + Send + Sync + 'static> OmniVox<T> {
/// Creates an endpoint with no key set (traffic then uses the all-zero
/// key, reproducing the C++ default-constructed `SecByteBlock`).
/// Creates an endpoint with no key set.
pub fn new() -> Self {
Self {
inner: Arc::new(Inner {
key: Mutex::new(None),
psk: Mutex::new(None),
config: OnceLock::new(),
clients: Mutex::new(HashMap::new()),
next_id: AtomicU32::new(1),
stopping: AtomicBool::new(false),
@@ -197,41 +254,50 @@ impl<T: Cereal + Send + Sync + 'static> OmniVox<T> {
}
}
/// Sets the ChaCha20-Poly1305 key. Until set, traffic uses the all-zero
/// key, which is what the C++ default-constructed `SecByteBlock` did.
/// Sets the pre-shared key for the v2 handshake. Both peers must set
/// the identical key (typically [`derive_encrypt_key`]).
pub fn set_encrypt_key(&self, key: [u8; KEY_LEN]) {
*self.inner.key.lock().unwrap() = Some(key);
*self.inner.psk.lock().unwrap() = Some(key);
}
fn key_bytes(&self) -> [u8; KEY_LEN] {
self.inner.key.lock().unwrap().unwrap_or([0u8; KEY_LEN])
fn psk(&self) -> Option<[u8; KEY_LEN]> {
*self.inner.psk.lock().unwrap()
}
/// Starts the server on `0.0.0.0:port`.
///
/// `num_threads` is accepted for API parity with the C++ signature; the
/// tokio runtime's worker pool already provides the parallelism the C++
/// `ThreadPool_` existed to add, so it configures nothing (contract:
/// thread structure is an accepted difference).
///
/// `timeout_ms` is the per-client idle cap for waiting on a message
/// header. Errors only if already started; runtime failures are logged.
fn install_config(&self, config: Config) -> Config {
let _ = self.inner.config.set(config.clone());
self.inner.config.get().expect("just set").clone()
}
fn config(&self) -> Config {
self.inner.config.get().cloned().unwrap_or_default()
}
/// Starts the server on `0.0.0.0:port`. Connections beyond
/// `config.max_connections` wait for a slot instead of being queued
/// without bound.
pub async fn start_server(
&self,
port: u16,
num_threads: usize,
timeout_ms: u64,
config: Config,
data: DataHandler<T>,
disconnect: ConnHandler,
connect: DecisionHandler,
) -> Result<(), OmniVoxError> {
let _ = num_threads;
let config = self.install_config(config);
let psk = self
.psk()
.ok_or(OmniVoxError::Crypto(CryptoError::EmptyInput))?;
check_defaults(&config, psk)?;
let _ = self.inner.received.set(data);
let _ = self.inner.disconnected.set(disconnect);
let _ = self.inner.connect_decision.set(connect);
let listener = TcpListener::bind(("0.0.0.0", port)).await?;
let permits = Arc::new(Semaphore::new(config.max_connections));
let accept_inner = Arc::clone(&self.inner);
let accept_psk = psk;
let accept_task = tokio::spawn(async move {
loop {
let (stream, peer) = match listener.accept().await {
@@ -242,9 +308,14 @@ impl<T: Cereal + Send + Sync + 'static> OmniVox<T> {
continue;
}
};
let permit = Arc::clone(&permits)
.acquire_owned()
.await
.expect("semaphore closed");
let conn_inner = Arc::clone(&accept_inner);
let cfg = accept_inner.config.get().cloned().unwrap_or_default();
let task = tokio::spawn(async move {
Self::serve_connection(conn_inner, stream, peer, timeout_ms).await;
Self::serve_connection(conn_inner, stream, peer, accept_psk, cfg, permit).await;
});
accept_inner.tasks.lock().unwrap().push(task);
}
@@ -252,7 +323,8 @@ impl<T: Cereal + Send + Sync + 'static> OmniVox<T> {
self.inner.tasks.lock().unwrap().push(accept_task);
info!(
"[OMNIVOX] Server started on port {port} with {num_threads} threads and {timeout_ms} ms timeout"
"[OMNIVOX] Server v2 started on port {port} (idle {} ms, cap {} conns, {} ms stall)",
config.idle_timeout_ms, config.max_connections, config.body_stall_ms
);
Ok(())
}
@@ -261,9 +333,32 @@ impl<T: Cereal + Send + Sync + 'static> OmniVox<T> {
inner: Arc<Inner<T>>,
mut stream: TcpStream,
peer: SocketAddr,
timeout_ms: u64,
psk: [u8; KEY_LEN],
config: Config,
permit: OwnedSemaphorePermit,
) {
let id = inner.next_id.fetch_add(1, Ordering::Relaxed);
let session = {
let (mut r, mut w) = stream.split();
match timeout(
Duration::from_millis(config.handshake_timeout_ms),
handshake::perform(&mut r, &mut w, &psk, true),
)
.await
{
Err(_) => {
warn!("[OMNIVOX] handshake timed out from {peer}");
return;
}
Ok(Err(e)) => {
warn!("[OMNIVOX] handshake failed from {peer}: {e}");
return;
}
Ok(Ok(s)) => s,
}
};
let (mut reader, mut writer) = stream.into_split();
let id = Self::claim_id(&inner);
let mut ctx = ConnectionContext {
connected: ConnectionStatus::Connected,
id,
@@ -276,25 +371,30 @@ impl<T: Cereal + Send + Sync + 'static> OmniVox<T> {
let decision = inner
.connect_decision
.get()
.map(|f| f(&ctx))
.map(|f| fire_decision(f, &ctx))
.unwrap_or(Decision::Allow);
if decision == Decision::Deny {
let _ = stream.shutdown().await;
let _ = writer.shutdown().await;
drop(reader);
ctx.connected = ConnectionStatus::Rejected;
if let Some(f) = inner.disconnected.get() {
f(&ctx);
fire_ctx(f, &ctx);
}
info!(
"[OMNIVOX] Client connection from {}:{} rejected",
ctx.ip, ctx.port
);
drop(permit);
return;
}
let (mut reader, writer) = stream.into_split();
let handle = Arc::new(ClientHandle {
writer: AsyncMutex::new(Some(writer)),
writer: AsyncMutex::new(Some(WriterState {
half: writer,
key: session.send_key,
counter: 1,
})),
context: ctx.clone(),
});
inner
@@ -307,135 +407,175 @@ impl<T: Cereal + Send + Sync + 'static> OmniVox<T> {
ctx.ip, ctx.port
);
Self::read_loop(inner, handle, &mut reader, timeout_ms).await;
Self::read_loop(inner, handle, &mut reader, session.recv_key, &config).await;
drop(permit);
}
fn claim_id(inner: &Inner<T>) -> u32 {
let clients = inner.clients.lock().unwrap();
loop {
let id = inner.next_id.fetch_add(1, Ordering::Relaxed);
if id != 0 && !clients.contains_key(&id) {
return id;
}
}
}
#[allow(clippy::too_many_arguments)]
async fn read_loop(
inner: Arc<Inner<T>>,
handle: Arc<ClientHandle>,
reader: &mut OwnedReadHalf,
timeout_ms: u64,
recv_key: [u8; KEY_LEN],
config: &Config,
) {
let ctx = &handle.context;
let ctx = handle.context.clone();
let mut expected: u64 = 1;
loop {
// Header: u32 LE message length, bounded by the client timeout.
let mut header = [0u8; 4];
match timeout(
Duration::from_millis(timeout_ms),
Duration::from_millis(config.idle_timeout_ms),
reader.read_exact(&mut header),
)
.await
{
Err(_elapsed) => {
Err(_) => {
info!(
"[OMNIVOX] Client {} {}:{} Timed out",
ctx.id, ctx.ip, ctx.port
);
Self::handle_client_disconnect(&inner, ctx).await;
Self::handle_client_disconnect(&inner, &ctx).await;
return;
}
Ok(Err(_)) => {
Self::handle_client_disconnect(&inner, ctx).await;
Self::handle_client_disconnect(&inner, &ctx).await;
return;
}
Ok(Ok(_)) => {}
}
let size = u32::from_le_bytes(header);
// Contract B1, preserved: on an unreasonable size the C++ code
// disconnects but does NOT return — it still attempts the body
// read (a 0-byte read succeeds and its empty packet then fails
// to decrypt, which is logged, not fatal).
if size == 0 || size > MAX_MESSAGE_SIZE {
if size == 0 || size > config.max_message_size {
// v2: disconnect and RETURN (the v1 no-return quirk is retired).
info!(
"[OMNIVOX] Client {} {}:{} has an unreasonable header size!",
ctx.id, ctx.ip, ctx.port
);
Self::handle_client_disconnect(&inner, ctx).await;
Self::handle_client_disconnect(&inner, &ctx).await;
return;
}
// Body: fixed 5 s stall cap against slow-body attacks.
let mut body = vec![0u8; size as usize];
match timeout(
Duration::from_millis(MAX_TIMEOUT_STALL_MS),
Duration::from_millis(config.body_stall_ms),
reader.read_exact(&mut body),
)
.await
{
Err(_elapsed) => {
Err(_) => {
info!(
"[OMNIVOX] Client {} {}:{} had a stall during body read!",
ctx.id, ctx.ip, ctx.port
);
Self::handle_client_disconnect(&inner, ctx).await;
Self::handle_client_disconnect(&inner, &ctx).await;
return;
}
Ok(Err(_)) => {
Self::handle_client_disconnect(&inner, ctx).await;
Self::handle_client_disconnect(&inner, &ctx).await;
return;
}
Ok(Ok(_)) => {}
}
// Contract B2, preserved: bad packets are logged and skipped;
// the connection stays open.
if let Err(e) = Self::process_message(&inner, &body, ctx) {
error!("[OMNIVOX] Error processing message: {e}");
let opened = open_frame(&recv_key, expected, &body);
let (plaintext, next) = match opened {
Ok(v) => v,
Err(e) => {
// Security violation or junk: the v2 stance is to drop
// replays/reorderings outright; junk bodies are logged
// and skipped (v1's B2 survives only for corrupt frames
// with a valid sequence number).
match e {
FrameError::Replay { seen, expected } => {
warn!(
"[OMNIVOX] Client {} {}:{} replayed/out-of-order frame (seen {seen}, expected {expected})",
ctx.id, ctx.ip, ctx.port
);
Self::handle_client_disconnect(&inner, &ctx).await;
return;
}
other => {
error!("[OMNIVOX] Error processing message: {other}");
continue;
}
}
}
};
expected = next;
match T::decode(&plaintext) {
Ok(payload) => {
if let Some(f) = inner.received.get() {
fire_data(f, &payload, &ctx);
}
}
Err(e) => error!("[OMNIVOX] Error processing message: {e}"),
}
}
}
fn process_message(
inner: &Inner<T>,
body: &[u8],
ctx: &ConnectionContext,
) -> Result<(), ProcessError> {
let packet = AeadPacket::decode(body)?;
let plaintext = decrypt(&inner_key(inner), &packet)?;
let payload = T::decode(&plaintext)?;
if let Some(f) = inner.received.get() {
f(&payload, ctx);
}
Ok(())
}
async fn handle_client_disconnect(inner: &Inner<T>, ctx: &ConnectionContext) {
let removed = {
let mut clients = inner.clients.lock().unwrap();
clients.remove(&ctx.id)
};
let removed = inner.clients.lock().unwrap().remove(&ctx.id);
if let Some(handle) = removed {
if let Some(mut writer) = handle.writer.lock().await.take() {
let _ = writer.shutdown().await;
if let Some(mut state) = handle.writer.lock().await.take() {
let _ = state.half.shutdown().await;
}
if let Some(f) = inner.disconnected.get() {
f(ctx);
fire_ctx(f, ctx);
}
info!("[OMNIVOX] Client {} disconnected", ctx.id);
}
}
/// Connects to `host:port`, registering callbacks. The read loop runs
/// only when `data` is provided — the C++ gate on `receivedCallback`,
/// including contract B5 (client mode never arms stall timers).
///
/// The `connection` callback fires exactly once with the outcome; data
/// arriving later reaches `data` on a background task.
/// Connects to `host:port`, completing the v2 handshake before the
/// connection callback fires with `Connected`.
pub async fn connect_to_server(
&self,
host: &str,
port: u16,
config: Config,
data: Option<DataHandler<T>>,
connection: ConnHandler,
) -> Result<(), OmniVoxError> {
let config = self.install_config(config);
let psk = self
.psk()
.ok_or(OmniVoxError::Crypto(CryptoError::EmptyInput))?;
check_defaults(&config, psk)?;
if let Some(d) = data {
let _ = self.inner.received.set(d);
}
let _ = self.inner.server_connected.set(connection);
let ctx = match Self::dial(host, port).await {
let outcome = async {
let stream = Self::dial(host, port).await?;
let (mut reader, mut writer) = stream.into_split();
let session = timeout(
Duration::from_millis(config.handshake_timeout_ms),
handshake::perform(&mut reader, &mut writer, &psk, false),
)
.await
.map_err(|_| {
std::io::Error::new(std::io::ErrorKind::TimedOut, "handshake timed out")
})??;
Ok::<_, OmniVoxError>((reader, writer, session))
}
.await;
let ctx = match outcome {
Err(_) => ConnectionContext::unknown(host, port),
Ok(stream) => {
Ok((reader, writer, session)) => {
let ctx = ConnectionContext {
connected: ConnectionStatus::Connected,
id: self.inner.next_id.fetch_add(1, Ordering::Relaxed),
@@ -445,17 +585,22 @@ impl<T: Cereal + Send + Sync + 'static> OmniVox<T> {
port,
};
*self.inner.server_ctx.lock().unwrap() = Some(ctx.clone());
let (reader, writer) = stream.into_split();
*self.inner.server_writer.lock().await = Some(writer);
*self.inner.server_writer.lock().await = Some(WriterState {
half: writer,
key: session.send_key,
counter: 1,
});
if self.inner.received.get().is_some() {
let inner = Arc::clone(&self.inner);
let read_ctx = ctx.clone();
let cfg = config.clone();
self.inner
.tasks
.lock()
.unwrap()
.push(tokio::spawn(async move {
Self::client_read_loop(inner, reader, read_ctx).await;
Self::client_read_loop(inner, reader, session.recv_key, read_ctx, cfg)
.await;
}));
}
ctx
@@ -463,14 +608,13 @@ impl<T: Cereal + Send + Sync + 'static> OmniVox<T> {
};
if let Some(f) = self.inner.server_connected.get() {
f(&ctx);
fire_ctx(f, &ctx);
}
Ok(())
}
async fn dial(host: &str, port: u16) -> Result<TcpStream, std::io::Error> {
// asio::async_connect tries the resolved list in order and stops at
// the first success.
// asio::async_connect tried the resolved list in order; keep that.
let addrs = tokio::net::lookup_host((host, port)).await?;
let mut last = Err(std::io::Error::other("no addresses resolved"));
for addr in addrs {
@@ -485,39 +629,65 @@ impl<T: Cereal + Send + Sync + 'static> OmniVox<T> {
async fn client_read_loop(
inner: Arc<Inner<T>>,
mut reader: OwnedReadHalf,
recv_key: [u8; KEY_LEN],
ctx: ConnectionContext,
config: Config,
) {
let mut expected: u64 = 1;
loop {
let mut header = [0u8; 4];
if reader.read_exact(&mut header).await.is_err() {
break;
match timeout(
Duration::from_millis(config.idle_timeout_ms),
reader.read_exact(&mut header),
)
.await
{
Err(_) | Ok(Err(_)) => break,
Ok(Ok(_)) => {}
}
let size = u32::from_le_bytes(header);
if size == 0 || size > MAX_MESSAGE_SIZE {
info!(
"[OMNIVOX] Client {} {}:{} has an unreasonable header size!",
ctx.id, ctx.ip, ctx.port
);
}
let mut body = vec![0u8; size as usize];
if reader.read_exact(&mut body).await.is_err() {
if size == 0 || size > config.max_message_size {
break;
}
if let Err(e) = Self::process_message(&inner, &body, &ctx) {
error!("[OMNIVOX] Error processing message: {e}");
let mut body = vec![0u8; size as usize];
match timeout(
Duration::from_millis(config.body_stall_ms),
reader.read_exact(&mut body),
)
.await
{
Err(_) | Ok(Err(_)) => break,
Ok(Ok(_)) => {}
}
match open_frame(&recv_key, expected, &body) {
Ok((plaintext, next)) => {
expected = next;
match T::decode(&plaintext) {
Ok(payload) => {
if let Some(f) = inner.received.get() {
fire_data(f, &payload, &ctx);
}
}
Err(e) => error!("[OMNIVOX] Error processing message: {e}"),
}
}
Err(e) => {
warn!("[OMNIVOX] Bad frame from server: {e}");
break;
}
}
}
*inner.server_writer.lock().await = None;
if let Some(mut sc) = inner.server_ctx.lock().unwrap().take() {
sc.connected = ConnectionStatus::NotConnected;
if let Some(f) = inner.server_connected.get() {
f(&sc);
fire_ctx(f, &sc);
}
}
}
/// Sends to the connected server. `false` if not connected — the C++
/// checks `is_open() && status == Connected` and returns, not throws.
/// Sends to the connected server. `false` if not connected or the
/// write failed/timed out.
pub async fn send_to_server(&self, data: &T) -> bool {
let connected = self
.inner
@@ -529,46 +699,66 @@ impl<T: Cereal + Send + Sync + 'static> OmniVox<T> {
if !connected {
return false;
}
let frame = match Self::sealed_frame(&self.key_bytes(), data) {
Ok(f) => f,
Err(()) => return false,
};
let config = self.config();
let mut guard = self.inner.server_writer.lock().await;
match guard.as_mut() {
Some(writer) => writer.write_all(&frame).await.is_ok(),
None => false,
let Some(state) = guard.as_mut() else {
return false;
};
let frame = match seal_frame(&state.key, state.counter, data) {
Some(f) => f,
None => return false,
};
match timeout(
Duration::from_millis(config.send_timeout_ms),
state.half.write_all(&frame),
)
.await
{
Ok(Ok(())) => {
state.counter += 1;
true
}
_ => {
*guard = None;
false
}
}
}
/// Sends to client `id`. `false` if unknown — contract B3 preserved: the
/// write lock is held across the full frame write, as the C++ held the
/// clients mutex across the blocking `asio::write`.
/// Sends to client `id`. `false` if unknown, closed, or the write
/// timed out (slow-client drop). The per-connection lock is held for
/// the full write; no other client is affected.
pub async fn send_to_client(&self, data: &T, id: u32) -> bool {
let client = self.inner.clients.lock().unwrap().get(&id).cloned();
let Some(client) = client else { return false };
let Ok(frame) = Self::sealed_frame(&self.key_bytes(), data) else {
let config = self.config();
let mut guard = client.writer.lock().await;
let Some(state) = guard.as_mut() else {
return false;
};
let mut guard = client.writer.lock().await;
match guard.as_mut() {
Some(writer) => writer.write_all(&frame).await.is_ok(),
None => false,
let frame = match seal_frame(&state.key, state.counter, data) {
Some(f) => f,
None => return false,
};
match timeout(
Duration::from_millis(config.send_timeout_ms),
state.half.write_all(&frame),
)
.await
{
Ok(Ok(())) => {
state.counter += 1;
true
}
_ => {
*guard = None;
drop(guard);
Self::handle_client_disconnect(&self.inner, &client.context).await;
false
}
}
}
fn sealed_frame(key: &[u8; KEY_LEN], data: &T) -> Result<Vec<u8>, ()> {
let mut serialized = Vec::new();
data.encode(&mut serialized);
let packet = encrypt(key, &serialized);
let mut packet_bytes = Vec::new();
packet.encode(&mut packet_bytes);
let mut frame = Vec::with_capacity(4 + packet_bytes.len());
// C++ memcpy'd a size_t into a u32 slot — truncation kept.
frame.extend_from_slice(&(packet_bytes.len() as u32).to_le_bytes());
frame.extend_from_slice(&packet_bytes);
Ok(frame)
}
/// Stops everything: accept loop, read loops, all sockets. Idempotent.
pub async fn stop(&self) {
self.inner.stopping.store(true, Ordering::Relaxed);
@@ -584,9 +774,14 @@ impl<T: Cereal + Send + Sync + 'static> OmniVox<T> {
.drain()
.map(|(_, c)| c)
.collect();
drop(clients);
let server = self.inner.server_writer.lock().await.take();
drop(server);
for c in clients {
if let Some(mut state) = c.writer.lock().await.take() {
let _ = state.half.shutdown().await;
}
}
if let Some(mut s) = self.inner.server_writer.lock().await.take() {
let _ = s.half.shutdown().await;
}
info!("[OMNIVOX] OmniVox stopped");
}
@@ -605,14 +800,60 @@ impl<T: Cereal + Send + Sync + 'static> OmniVox<T> {
}
}
fn inner_key<T>(inner: &Inner<T>) -> [u8; KEY_LEN] {
inner.key.lock().unwrap().unwrap_or([0u8; KEY_LEN])
/// Seals one frame: `u32 len ‖ AeadPacket` with the sequence-number nonce.
/// `None` only if the payload encoding were to fail (it cannot today).
fn seal_frame<T: Cereal>(key: &[u8; KEY_LEN], counter: u64, data: &T) -> Option<Vec<u8>> {
let mut plain = Vec::new();
data.encode(&mut plain);
let packet = encrypt_with_nonce(key, &frame_nonce(counter), &plain);
let mut bytes = Vec::new();
packet.encode(&mut bytes);
let mut frame = Vec::with_capacity(4 + bytes.len());
frame.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
frame.extend_from_slice(&bytes);
Some(frame)
}
#[derive(Debug, thiserror::Error)]
enum ProcessError {
enum FrameError {
#[error("replayed or out-of-order frame (seen {seen}, expected {expected})")]
Replay { seen: u64, expected: u64 },
#[error(transparent)]
Codec(#[from] CodecError),
#[error(transparent)]
Crypto(#[from] CryptoError),
}
/// Opens one frame, enforcing the strictly increasing sequence.
fn open_frame(
key: &[u8; KEY_LEN],
expected: u64,
body: &[u8],
) -> Result<(Vec<u8>, u64), FrameError> {
let packet = AeadPacket::decode(body)?;
let seen = nonce_counter(&packet.nonce).ok_or(FrameError::Replay {
seen: u64::MAX,
expected,
})?;
if seen != expected {
return Err(FrameError::Replay { seen, expected });
}
let plaintext = decrypt(key, &packet)?;
Ok((plaintext, expected + 1))
}
fn fire_data<T>(cb: &DataHandler<T>, data: &T, ctx: &ConnectionContext) {
if catch_unwind(AssertUnwindSafe(|| cb(data, ctx))).is_err() {
error!("[OMNIVOX] data callback panicked; connection continues");
}
}
fn fire_ctx(cb: &ConnHandler, ctx: &ConnectionContext) {
if catch_unwind(AssertUnwindSafe(|| cb(ctx))).is_err() {
error!("[OMNIVOX] connection callback panicked");
}
}
fn fire_decision(cb: &DecisionHandler, ctx: &ConnectionContext) -> Decision {
catch_unwind(AssertUnwindSafe(|| cb(ctx))).unwrap_or(Decision::Deny)
}