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
+119 -140
View File
@@ -1,10 +1,9 @@
//! Loopback behavior tests mirroring the contract's runtime clauses
//! (B1 unreasonable header, B2 tampered packet survives, framing).
//! Raw keys via `set_encrypt_key` keep these fast (no scrypt).
//! Loopback behavior tests for the v2 transport: framing discipline,
//! timeouts, junk handling, and the send-path contracts.
use omnivox::{
AeadPacket, Cereal, ConnectionContext, Decision, MAX_MESSAGE_SIZE, OmniVox, encrypt,
};
mod support;
use omnivox::{Config, ConnectionContext, Decision, OmniVox};
use std::{
sync::{
Arc,
@@ -12,38 +11,24 @@ use std::{
},
time::Duration,
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpStream,
};
use support::{Msg, TEST_PSK, client_session, frame};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[derive(Debug, Default)]
struct Msg(Vec<u8>);
impl Cereal for Msg {
fn encode(&self, out: &mut Vec<u8>) {
omnivox::write_byte_vec(out, &self.0);
}
fn decode(bytes: &[u8]) -> Result<Self, omnivox::CodecError> {
Ok(Self(omnivox::Reader::new(bytes).byte_vec()?))
fn test_config() -> Config {
Config {
idle_timeout_ms: 1500,
body_stall_ms: 800,
send_timeout_ms: 1000,
handshake_timeout_ms: 5000,
max_message_size: 8192,
max_connections: 16,
allow_insecure_defaults: true,
}
}
fn frame(key: &[u8; 32], msg: &[u8]) -> Vec<u8> {
let mut plain = Vec::new();
Msg(msg.to_vec()).encode(&mut plain);
let packet = encrypt(key, &plain);
let mut bytes = Vec::new();
packet.encode(&mut bytes);
let mut out = Vec::new();
out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
out.extend_from_slice(&bytes);
out
}
async fn start_server(port: u16, rx: Arc<AtomicU32>) -> Arc<OmniVox<Msg>> {
async fn start_server(port: u16, rx: Arc<AtomicU32>, config: Config) -> Arc<OmniVox<Msg>> {
let server = Arc::new(OmniVox::<Msg>::new());
server.set_encrypt_key([42u8; 32]);
server.set_encrypt_key(TEST_PSK);
let data = Arc::new(move |msg: &Msg, _ctx: &ConnectionContext| {
if msg.0.starts_with(b"ok") {
rx.fetch_add(1, Ordering::SeqCst);
@@ -52,8 +37,7 @@ async fn start_server(port: u16, rx: Arc<AtomicU32>) -> Arc<OmniVox<Msg>> {
server
.start_server(
port,
1,
2000,
config,
data,
Arc::new(|_ctx| {}),
Arc::new(|_ctx| Decision::Allow),
@@ -63,151 +47,146 @@ async fn start_server(port: u16, rx: Arc<AtomicU32>) -> Arc<OmniVox<Msg>> {
server
}
fn free_port(hint: u16) -> u16 {
// bind-and-drop probe; tests run sequentially per port
hint
async fn read_expect_eof(r: &mut tokio::net::tcp::OwnedReadHalf) {
let mut buf = [0u8; 1];
let n = tokio::time::timeout(Duration::from_secs(4), r.read(&mut buf))
.await
.expect("connection must close, not hang")
.unwrap();
assert_eq!(n, 0, "expected EOF");
}
#[tokio::test]
async fn tampered_message_is_dropped_but_connection_survives() {
let port = free_port(18101);
async fn tampered_body_with_valid_counter_is_skipped_connection_survives() {
let port = 18201;
let rx = Arc::new(AtomicU32::new(0));
let server = start_server(port, Arc::clone(&rx)).await;
let server = start_server(port, Arc::clone(&rx), test_config()).await;
let client = OmniVox::<Msg>::new();
client.set_encrypt_key([42u8; 32]);
let got = Arc::new(AtomicU32::new(0));
let g = Arc::clone(&got);
client
.connect_to_server(
"127.0.0.1",
port,
Some(Arc::new(move |_d, _c| {
g.fetch_add(1, Ordering::SeqCst);
})),
Arc::new(|_c| {}),
)
.await
.unwrap();
let (r, mut w, s) = client_session(port, &TEST_PSK).await;
// Valid through the API.
assert!(client.send_to_server(&Msg(b"ok1".to_vec())).await);
// Tampered raw frame: flip a ciphertext byte.
let mut bad = frame(&[42u8; 32], b"ok2");
// Counter 1, tampered ciphertext: auth fails, frame dropped, alive.
let mut bad = frame(&s.send_key, 1, b"ok-junk");
let last = bad.len() - 1;
bad[last] ^= 0x01;
let mut raw = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
raw.write_all(&bad).await.unwrap();
// Valid again on the tampered connection's server slot (new conn).
assert!(client.send_to_server(&Msg(b"ok3".to_vec())).await);
w.write_all(&bad).await.unwrap();
// Counter 1 valid (the tampered frame consumed nothing).
w.write_all(&frame(&s.send_key, 1, b"ok1")).await.unwrap();
w.write_all(&frame(&s.send_key, 2, b"ok2")).await.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(
rx.load(Ordering::SeqCst),
2,
"tampered frame must not decode"
);
// Contract B2: the tampering connection is NOT dropped — server keeps
// its header wait armed (rx-timeout is 2 s, so a 500 ms read must time
// out without EOF).
let r = tokio::time::timeout(Duration::from_millis(500), raw.read(&mut [0u8; 1])).await;
assert!(r.is_err(), "tampering connection must stay open (B2)");
assert_eq!(
got.load(Ordering::SeqCst),
0,
"server does not echo in this test"
);
assert_eq!(rx.load(Ordering::SeqCst), 2);
// still alive: another frame gets through
w.write_all(&frame(&s.send_key, 3, b"ok3")).await.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(rx.load(Ordering::SeqCst), 3);
server.stop().await;
client.stop().await;
drop(r);
drop(w);
}
#[tokio::test]
async fn zero_size_header_disconnects_like_cpp_b1() {
let port = free_port(18102);
async fn zero_size_header_disconnects_immediately() {
// v2 retires the v1 no-return quirk: bad header closes, no follow-up.
let port = 18202;
let rx = Arc::new(AtomicU32::new(0));
let server = start_server(port, Arc::clone(&rx)).await;
let server = start_server(port, Arc::clone(&rx), test_config()).await;
let mut raw = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
raw.write_all(&0u32.to_le_bytes()).await.unwrap();
// C++ B1: server logs, disconnects, then the 0-byte read "succeeds" and
// the empty packet fails to decode (logged). The socket must close.
let mut buf = [0u8; 1];
let n = tokio::time::timeout(Duration::from_secs(2), raw.read(&mut buf))
.await
.expect("server must close, not hang")
.unwrap();
assert_eq!(n, 0, "EOF after unreasonable header");
let (mut r, mut w, _s) = client_session(port, &TEST_PSK).await;
w.write_all(&0u32.to_le_bytes()).await.unwrap();
read_expect_eof(&mut r).await;
server.stop().await;
}
#[tokio::test]
async fn oversized_header_disconnects() {
let port = free_port(18103);
let port = 18203;
let rx = Arc::new(AtomicU32::new(0));
let server = start_server(port, Arc::clone(&rx)).await;
let server = start_server(port, Arc::clone(&rx), test_config()).await;
let mut raw = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
raw.write_all(&(MAX_MESSAGE_SIZE + 1).to_le_bytes())
.await
.unwrap();
let mut buf = [0u8; 1];
let n = tokio::time::timeout(Duration::from_secs(2), raw.read(&mut buf))
.await
.expect("server must close, not hang")
.unwrap();
assert_eq!(n, 0);
let (mut r, mut w, _s) = client_session(port, &TEST_PSK).await;
w.write_all(&8193u32.to_le_bytes()).await.unwrap();
read_expect_eof(&mut r).await;
server.stop().await;
}
#[tokio::test]
async fn header_stall_times_out_server_side() {
let port = free_port(18104);
async fn header_stall_times_out() {
let port = 18204;
let rx = Arc::new(AtomicU32::new(0));
let server = start_server(port, Arc::clone(&rx), test_config()).await;
let (mut r, mut w, _s) = client_session(port, &TEST_PSK).await;
w.write_all(&[0x10, 0x00]).await.unwrap(); // partial header, then silence
read_expect_eof(&mut r).await;
server.stop().await;
}
#[tokio::test]
async fn send_to_unknown_client_is_false() {
let server = OmniVox::<Msg>::new();
server.set_encrypt_key(TEST_PSK);
assert!(!server.send_to_client(&Msg(b"x".to_vec()), 999).await);
assert!(!server.send_to_server(&Msg(b"x".to_vec())).await);
}
#[tokio::test]
async fn valid_handshake_then_api_roundtrip() {
let port = 18205;
let rx = Arc::new(AtomicU32::new(0));
// start_server timeout_ms = 300 for this one
let server = Arc::new(OmniVox::<Msg>::new());
server.set_encrypt_key([42u8; 32]);
server.set_encrypt_key(TEST_PSK);
let data = Arc::new({
let rx = Arc::clone(&rx);
move |msg: &Msg, ctx: &ConnectionContext| {
if msg.0.starts_with(b"ping") {
rx.fetch_add(1, Ordering::SeqCst);
let server = Arc::clone(SERVER_FOR_ECHO.get().unwrap());
let id = ctx.id;
let reply = Msg(b"pong".to_vec());
tokio::spawn(async move {
server.send_to_client(&reply, id).await;
});
}
}
});
let _ = SERVER_FOR_ECHO.set(server.clone());
server
.start_server(
port,
1,
300,
Arc::new(move |_: &Msg, _: &ConnectionContext| {
rx.fetch_add(1, Ordering::SeqCst);
}),
test_config(),
data,
Arc::new(|_c| {}),
Arc::new(|_c| Decision::Allow),
)
.await
.unwrap();
let mut raw = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
raw.write_all(&[0x10, 0x00]).await.unwrap(); // partial header, then silence
let mut buf = [0u8; 1];
let n = tokio::time::timeout(Duration::from_secs(3), raw.read(&mut buf))
let client = OmniVox::<Msg>::new();
client.set_encrypt_key(TEST_PSK);
let got = Arc::new(AtomicU32::new(0));
let g = Arc::clone(&got);
client
.connect_to_server(
"127.0.0.1",
port,
test_config(),
Some(Arc::new(move |msg: &Msg, _c| {
if msg.0 == b"pong" {
g.fetch_add(1, Ordering::SeqCst);
}
})),
Arc::new(|_c| {}),
)
.await
.expect("stall timeout must fire")
.unwrap();
assert_eq!(n, 0);
assert!(client.send_to_server(&Msg(b"ping".to_vec())).await);
tokio::time::sleep(Duration::from_millis(400)).await;
assert_eq!(rx.load(Ordering::SeqCst), 1);
assert_eq!(got.load(Ordering::SeqCst), 1, "encrypted echo back");
client.stop().await;
server.stop().await;
}
#[tokio::test]
async fn send_to_unknown_client_is_false_like_cpp() {
let server = OmniVox::<Msg>::new();
server.set_encrypt_key([0u8; 32]);
assert!(!server.send_to_client(&Msg(b"x".to_vec()), 999).await);
assert!(!server.send_to_server(&Msg(b"x".to_vec())).await);
}
#[test]
fn aead_packet_roundtrip_matches_wire_shape() {
let pkt = AeadPacket {
nonce: [9; 12],
tag: [8; 16],
payload: vec![7; 3],
};
let mut b = Vec::new();
pkt.encode(&mut b);
assert_eq!(b.len(), 12 + 16 + 8 + 3);
assert_eq!(AeadPacket::decode(&b).unwrap(), pkt);
}
static SERVER_FOR_ECHO: std::sync::OnceLock<Arc<OmniVox<Msg>>> = std::sync::OnceLock::new();
+259
View File
@@ -0,0 +1,259 @@
//! Attack regressions: every v1 vulnerability class from the red-team
//! review must now fail closed, provably, with zero callbacks fired.
mod support;
use omnivox::{Config, ConnectionContext, ConnectionStatus, Decision, OmniVox, handshake::perform};
use std::{
sync::{
Arc,
atomic::{AtomicU8, AtomicU32, Ordering},
},
time::Duration,
};
use support::{Msg, TEST_PSK, client_session, frame};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpStream,
};
fn test_config() -> Config {
Config {
idle_timeout_ms: 1500,
body_stall_ms: 800,
send_timeout_ms: 1000,
handshake_timeout_ms: 5000,
max_message_size: 8192,
max_connections: 16,
allow_insecure_defaults: true,
}
}
async fn counting_server_with(port: u16, rx: Arc<AtomicU32>, config: Config) -> Arc<OmniVox<Msg>> {
let server = Arc::new(OmniVox::<Msg>::new());
server.set_encrypt_key(TEST_PSK);
let data = Arc::new(move |msg: &Msg, _ctx: &ConnectionContext| {
if msg.0.starts_with(b"ok") {
rx.fetch_add(1, Ordering::SeqCst);
}
});
server
.start_server(
port,
config,
data,
Arc::new(|_ctx| {}),
Arc::new(|_ctx| Decision::Allow),
)
.await
.unwrap();
server
}
async fn counting_server(port: u16, rx: Arc<AtomicU32>) -> Arc<OmniVox<Msg>> {
counting_server_with(port, rx, test_config()).await
}
async fn expect_eof(r: &mut tokio::net::tcp::OwnedReadHalf) {
let mut buf = [0u8; 1];
let n = tokio::time::timeout(Duration::from_secs(4), r.read(&mut buf))
.await
.expect("connection must close")
.unwrap();
assert_eq!(n, 0);
}
#[tokio::test]
async fn replayed_frame_is_dropped_and_the_connection_dies() {
let port = 18301;
let rx = Arc::new(AtomicU32::new(0));
let server = counting_server(port, Arc::clone(&rx)).await;
let (mut r, mut w, s) = client_session(port, &TEST_PSK).await;
let good = frame(&s.send_key, 1, b"ok-transfer-1000");
w.write_all(&good).await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(rx.load(Ordering::SeqCst), 1, "first delivery");
// Reinject the verbatim captured frame.
w.write_all(&good).await.unwrap();
expect_eof(&mut r).await; // server must drop the connection outright
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(
rx.load(Ordering::SeqCst),
1,
"replay must never reach the app"
);
server.stop().await;
}
#[tokio::test]
async fn frames_from_another_session_are_undecryptable() {
// Frame portability: keys are session-bound by the handshake nonces,
// so a frame valid on connection A is junk on connection B.
let port = 18302;
let rx = Arc::new(AtomicU32::new(0));
let server = counting_server(port, Arc::clone(&rx)).await;
let (_r1, _w1, s1) = client_session(port, &TEST_PSK).await;
let (_r2, mut w2, s2) = client_session(port, &TEST_PSK).await;
let portable = frame(&s1.send_key, 1, b"ok-portable");
w2.write_all(&portable).await.unwrap();
// Auth failure with a matching counter would continue (junk policy), but
// the counter check passes while decryption fails → logged, skipped;
// the connection survives and the payload never fires.
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(
rx.load(Ordering::SeqCst),
0,
"cross-session frame must not decrypt"
);
// connection B is still alive with its own keys:
w2.write_all(&frame(&s2.send_key, 1, b"ok-native"))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(
rx.load(Ordering::SeqCst),
1,
"native frame on B must arrive"
);
server.stop().await;
}
#[tokio::test]
async fn wrong_version_hello_is_closed() {
let port = 18303;
let rx = Arc::new(AtomicU32::new(0));
let server = counting_server(port, rx).await;
let raw = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
let (mut r, mut w) = raw.into_split();
let mut hello = [0u8; 69];
hello[..4].copy_from_slice(b"XXXX");
hello[4] = 99;
w.write_all(&hello).await.unwrap();
expect_eof(&mut r).await;
server.stop().await;
}
#[tokio::test]
async fn v1_traffic_is_rejected_at_the_handshake() {
// A v1 OmniVox client speaks u32-length-prefixed AeadPackets. The v2
// server must refuse before any allocation path.
let port = 18304;
let rx = Arc::new(AtomicU32::new(0));
let server = counting_server_with(
port,
rx,
Config {
handshake_timeout_ms: 700,
..test_config()
},
)
.await;
let raw = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
let (mut r, mut w) = raw.into_split();
w.write_all(&36u32.to_le_bytes()).await.unwrap(); // v1 header
w.write_all(&[0u8; 36]).await.unwrap();
expect_eof(&mut r).await;
server.stop().await;
}
#[tokio::test]
async fn panicking_callback_does_not_kill_the_connection() {
let port = 18305;
let rx = Arc::new(AtomicU32::new(0));
let server = Arc::new(OmniVox::<Msg>::new());
server.set_encrypt_key(TEST_PSK);
let ok_rx = Arc::clone(&rx);
let data = Arc::new(move |msg: &Msg, _ctx: &ConnectionContext| {
if msg.0 == b"boom" {
let _v: [u8; 8] = msg.0[..8].try_into().unwrap(); // explodes (len 4)
}
if msg.0.starts_with(b"ok") {
ok_rx.fetch_add(1, Ordering::SeqCst);
}
});
server
.start_server(
port,
test_config(),
data,
Arc::new(|_c| {}),
Arc::new(|_c| Decision::Allow),
)
.await
.unwrap();
let (mut r, mut w, s) = client_session(port, &TEST_PSK).await;
w.write_all(&frame(&s.send_key, 1, b"boom")).await.unwrap();
w.write_all(&frame(&s.send_key, 2, b"ok-after-panic"))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(
rx.load(Ordering::SeqCst),
1,
"survives a panicking callback"
);
let mut buf = [0u8; 1];
assert!(
tokio::time::timeout(Duration::from_millis(300), r.read(&mut buf))
.await
.is_err(),
"connection must still be open"
);
server.stop().await;
}
#[tokio::test]
async fn malicious_server_silence_times_out_the_client() {
// v2 retires B5: the client side now has the same idle/stall discipline.
let port = 18306;
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port))
.await
.unwrap();
let accepted = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let (mut r, mut w) = stream.into_split();
// Complete the handshake as the server, then go silent forever.
perform(&mut r, &mut w, &TEST_PSK, true).await.unwrap();
tokio::time::sleep(Duration::from_secs(60)).await;
});
let client = OmniVox::<Msg>::new();
client.set_encrypt_key(TEST_PSK);
let dropped = Arc::new(AtomicU8::new(0));
let d = Arc::clone(&dropped);
let cfg = Config {
idle_timeout_ms: 400,
..test_config()
};
client
.connect_to_server(
"127.0.0.1",
port,
cfg,
Some(Arc::new(|_m: &Msg, _c: &ConnectionContext| {})),
Arc::new(move |ctx: &ConnectionContext| {
if ctx.connected == ConnectionStatus::NotConnected {
d.store(1, Ordering::SeqCst);
}
}),
)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(1500)).await;
assert_eq!(
dropped.load(Ordering::SeqCst),
1,
"client must time out a silent server and report NotConnected"
);
accepted.abort();
client.stop().await;
}
+42
View File
@@ -0,0 +1,42 @@
//! Shared helpers for the integration tests: v2 client sessions against a
//! live server, and raw frame construction.
use omnivox::{Cereal, CodecError, Session, encrypt_with_nonce, frame_nonce, handshake::perform};
use tokio::net::TcpStream;
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
#[derive(Debug, Default)]
pub struct Msg(pub Vec<u8>);
impl Cereal for Msg {
fn encode(&self, out: &mut Vec<u8>) {
omnivox::write_byte_vec(out, &self.0);
}
fn decode(bytes: &[u8]) -> Result<Self, CodecError> {
Ok(Self(omnivox::Reader::new(bytes).byte_vec()?))
}
}
pub const TEST_PSK: [u8; 32] = [42u8; 32];
/// Builds one framed message with the given sequence number.
pub fn frame(key: &[u8; 32], counter: u64, msg: &[u8]) -> Vec<u8> {
let mut plain = Vec::new();
Msg(msg.to_vec()).encode(&mut plain);
let pkt = encrypt_with_nonce(key, &frame_nonce(counter), &plain);
let mut bytes = Vec::new();
pkt.encode(&mut bytes);
let mut out = Vec::new();
out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
out.extend_from_slice(&bytes);
out
}
/// Connects and completes the v2 handshake as the client role, returning
/// the raw halves plus the session keys for manual framing.
pub async fn client_session(port: u16, psk: &[u8; 32]) -> (OwnedReadHalf, OwnedWriteHalf, Session) {
let stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
let (mut r, mut w) = stream.into_split();
let session = perform(&mut r, &mut w, psk, false).await.unwrap();
(r, w, session)
}