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:
+119
-140
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user