Rust port of OmniVox, wire-compatible with the C++ original
Byte-exact cereal binary layout, RFC 8439 ChaCha20-Poly1305, RFC 7914 scrypt defaults; tokio transport preserving the original's observable behavior (contract B1-B3, B5 quirks included). Parity evidence: golden C++ vectors in tests/vectors.rs and loopback behavior tests; both cross-directions verified against the unmodified C++ binaries. Source: https://git.0xfeedc0de.com/michael/OmniVox
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
//! 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).
|
||||
|
||||
use omnivox::{
|
||||
AeadPacket, Cereal, ConnectionContext, Decision, MAX_MESSAGE_SIZE, OmniVox, encrypt,
|
||||
};
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU32, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpStream,
|
||||
};
|
||||
|
||||
#[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 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>> {
|
||||
let server = Arc::new(OmniVox::<Msg>::new());
|
||||
server.set_encrypt_key([42u8; 32]);
|
||||
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,
|
||||
1,
|
||||
2000,
|
||||
data,
|
||||
Arc::new(|_ctx| {}),
|
||||
Arc::new(|_ctx| Decision::Allow),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
server
|
||||
}
|
||||
|
||||
fn free_port(hint: u16) -> u16 {
|
||||
// bind-and-drop probe; tests run sequentially per port
|
||||
hint
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tampered_message_is_dropped_but_connection_survives() {
|
||||
let port = free_port(18101);
|
||||
let rx = Arc::new(AtomicU32::new(0));
|
||||
let server = start_server(port, Arc::clone(&rx)).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();
|
||||
|
||||
// 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");
|
||||
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);
|
||||
|
||||
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"
|
||||
);
|
||||
server.stop().await;
|
||||
client.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_size_header_disconnects_like_cpp_b1() {
|
||||
let port = free_port(18102);
|
||||
let rx = Arc::new(AtomicU32::new(0));
|
||||
let server = start_server(port, Arc::clone(&rx)).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");
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_header_disconnects() {
|
||||
let port = free_port(18103);
|
||||
let rx = Arc::new(AtomicU32::new(0));
|
||||
let server = start_server(port, Arc::clone(&rx)).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);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn header_stall_times_out_server_side() {
|
||||
let port = free_port(18104);
|
||||
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
|
||||
.start_server(
|
||||
port,
|
||||
1,
|
||||
300,
|
||||
Arc::new(move |_: &Msg, _: &ConnectionContext| {
|
||||
rx.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
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))
|
||||
.await
|
||||
.expect("stall timeout must fire")
|
||||
.unwrap();
|
||||
assert_eq!(n, 0);
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Golden vectors produced by the C++ implementation (CryptoPP + cereal)
|
||||
//! in `omnivox-ref/cppgen.cpp`. The Rust leaves must reproduce these bytes.
|
||||
|
||||
use omnivox::{AeadPacket, Cereal, decrypt, derive_encrypt_key, encrypt_with_nonce};
|
||||
|
||||
fn vectors() -> Vec<(String, String)> {
|
||||
include_str!("vectors.txt")
|
||||
.lines()
|
||||
.filter_map(|l| l.split_once('='))
|
||||
.map(|(k, v)| (k.to_owned(), v.to_owned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get<'a>(vs: &'a [(String, String)], key: &str) -> &'a str {
|
||||
vs.iter()
|
||||
.find(|(k, _)| k == key)
|
||||
.unwrap_or_else(|| panic!("missing vector {key}"))
|
||||
.1
|
||||
.as_str()
|
||||
}
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrypt_key_matches_cpp_default_params() {
|
||||
let vs = vectors();
|
||||
let pw = b"SecurePassword123";
|
||||
let salt: Vec<u8> = (1u8..=8).collect();
|
||||
let key = derive_encrypt_key(pw, &salt, 17, 8, 1).expect("scrypt");
|
||||
assert_eq!(hex(key), get(&vs, "scrypt_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrypt_key_matches_cpp_short_inputs() {
|
||||
let vs = vectors();
|
||||
let key = derive_encrypt_key(b"pass", b"salt", 17, 8, 1).expect("scrypt");
|
||||
assert_eq!(hex(key), get(&vs, "scrypt_key2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_inputs_rejected_like_cpp_invalid_argument() {
|
||||
assert!(derive_encrypt_key(b"", b"salt", 17, 8, 1).is_err());
|
||||
assert!(derive_encrypt_key(b"pw", b"", 17, 8, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datagram_payload_layout_matches_cereal() {
|
||||
struct DatagramPayload {
|
||||
id: u16,
|
||||
v1: u64,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
impl Cereal for DatagramPayload {
|
||||
fn encode(&self, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&self.id.to_le_bytes());
|
||||
out.extend_from_slice(&self.v1.to_le_bytes());
|
||||
omnivox::write_byte_vec(out, &self.payload);
|
||||
}
|
||||
fn decode(bytes: &[u8]) -> Result<Self, omnivox::CodecError> {
|
||||
let mut r = omnivox::Reader::new(bytes);
|
||||
Ok(Self {
|
||||
id: r.u16()?,
|
||||
v1: r.u64()?,
|
||||
payload: r.byte_vec()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let vs = vectors();
|
||||
let dp = DatagramPayload {
|
||||
id: 0x1234,
|
||||
v1: 0x1122334455667788,
|
||||
payload: b"Hello".to_vec(),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
dp.encode(&mut out);
|
||||
assert_eq!(hex(&out), get(&vs, "cereal_datagram"));
|
||||
let back = DatagramPayload::decode(&out).unwrap();
|
||||
assert_eq!(
|
||||
(back.id, back.v1, back.payload),
|
||||
(0x1234, 0x1122334455667788, b"Hello".to_vec())
|
||||
);
|
||||
|
||||
let mut zeroed = Vec::new();
|
||||
DatagramPayload {
|
||||
id: 0,
|
||||
v1: 0,
|
||||
payload: vec![],
|
||||
}
|
||||
.encode(&mut zeroed);
|
||||
assert_eq!(hex(zeroed), get(&vs, "cereal_datagram_default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aead_packet_layout_matches_cereal() {
|
||||
let vs = vectors();
|
||||
let pkt = AeadPacket {
|
||||
nonce: std::array::from_fn(|i| i as u8),
|
||||
tag: std::array::from_fn(|i| 0xA0 + i as u8),
|
||||
payload: vec![0xDE, 0xAD, 0xBE, 0xEF],
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
pkt.encode(&mut out);
|
||||
assert_eq!(hex(&out), get(&vs, "cereal_aead"));
|
||||
assert_eq!(AeadPacket::decode(&out).unwrap(), pkt);
|
||||
|
||||
let empty = AeadPacket {
|
||||
nonce: [0; 12],
|
||||
tag: [0; 16],
|
||||
payload: vec![],
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
empty.encode(&mut out);
|
||||
assert_eq!(hex(out), get(&vs, "cereal_aead_empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aead_matches_cryptopp_output() {
|
||||
let vs = vectors();
|
||||
let key: [u8; 32] = unhex(get(&vs, "scrypt_key")).try_into().unwrap();
|
||||
let nonce: [u8; 12] = std::array::from_fn(|i| i as u8);
|
||||
|
||||
let pkt = encrypt_with_nonce(&key, &nonce, b"Hello");
|
||||
assert_eq!(hex(&pkt.payload), get(&vs, "aead_ct"));
|
||||
assert_eq!(hex(pkt.tag), get(&vs, "aead_tag"));
|
||||
assert_eq!(decrypt(&key, &pkt).unwrap(), b"Hello");
|
||||
|
||||
let empty = encrypt_with_nonce(&key, &nonce, b"");
|
||||
assert_eq!(hex(empty.tag), get(&vs, "aead_empty_tag"));
|
||||
assert_eq!(decrypt(&key, &empty).unwrap(), b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_packet_fails_authentication() {
|
||||
let key = [3u8; 32];
|
||||
let mut pkt = omnivox::encrypt(&key, b"important");
|
||||
pkt.payload[0] ^= 0x01;
|
||||
let err = decrypt(&key, &pkt).unwrap_err();
|
||||
assert!(err.to_string().contains("tampered"));
|
||||
}
|
||||
|
||||
fn hex(bytes: impl AsRef<[u8]>) -> String {
|
||||
bytes.as_ref().iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
scrypt_key=f64f4c05eb28676be5e4dd760c7f9409327a47e11abb66adc593b2bd039a0650
|
||||
scrypt_key2=f21278eee02abf1ad982428b5ab5d1ffccb62f6f1163dc616bfe30686e143ea0
|
||||
cereal_datagram=34128877665544332211050000000000000048656c6c6f
|
||||
cereal_datagram_default=000000000000000000000000000000000000
|
||||
cereal_aead=000102030405060708090a0ba0a1a2a3a4a5a6a7a8a9aaabacadaeaf0400000000000000deadbeef
|
||||
cereal_aead_empty=000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
aead_random_payloadlen=5
|
||||
aead_ct=3e3c330d3b
|
||||
aead_tag=89c6b5d2dbc9d3f7c82c12825f992c49
|
||||
aead_empty_tag=791eac80a87ddffcf45d6bad2769ea04
|
||||
Reference in New Issue
Block a user