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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user