From fea8ea3ab177d27f5d90d253189b92187f288e0b Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 30 Sep 2026 08:07:38 -0400 Subject: [PATCH] 0.15.0: simple builder API, v2.1 framing, stream lane, benches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - omnivox::server::()/client::() builders; plain closures, enum in, match in the callback — the original C++ UX with the ceremony removed - serde + bincode replace the hand-rolled Cereal codec (vectors.rs still governs the crypto leaves via the C++ golden oracles) - v2.1 frames: u32 len | type | ct | tag; nonce derived from the sequence counter (no wire nonce), type byte authenticated as AAD; any frame-level failure is fatal, authenticated-but-undecodable payloads are skipped - stream lane: send_stream/on_stream, 1 MiB frames, shared sequence with messages (exact sender interleaving preserved); see dump-agent/viewer - per-connection bounded queues + writer tasks; reply/reply_stream are non-blocking try_send lanes for callbacks - benches: seal/open criterion group + transport harness (spray msg/s, stream GiB/s, raw loopback baseline) - profile.dev.package scrypt/pbkdf2 opt-level 3: golden-vector scrypt at N=2^17 was unusable in debug builds - PROTOCOL-V2.md and README rewritten for v2.1 --- Cargo.lock | 562 +++++++++++- Cargo.toml | 22 +- PROTOCOL-V2.md | 195 +++-- README.md | 96 ++- benches/transport.rs | 226 +++++ examples/common/mod.rs | 37 - examples/dump-agent.rs | 73 ++ examples/dump-viewer.rs | 84 ++ examples/omnivox-client.rs | 95 +- examples/omnivox-server.rs | 121 +-- src/frame.rs | 263 ++++++ src/lib.rs | 1665 ++++++++++++++++++++++-------------- tests/loopback.rs | 185 ++-- tests/protocol_v2.rs | 328 ++++--- tests/support/mod.rs | 68 +- 15 files changed, 2827 insertions(+), 1193 deletions(-) create mode 100644 benches/transport.rs delete mode 100644 examples/common/mod.rs create mode 100644 examples/dump-agent.rs create mode 100644 examples/dump-viewer.rs create mode 100644 src/frame.rs diff --git a/Cargo.lock b/Cargo.lock index 32d278f..6a10dae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,12 +12,59 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64ct" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bitflags" version = "2.13.2" @@ -33,12 +80,24 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cfg-if" version = "1.0.5" @@ -69,6 +128,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -80,6 +166,31 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -89,6 +200,75 @@ dependencies = [ "libc", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "futures", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "tokio", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -137,6 +317,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "errno" version = "0.3.14" @@ -153,6 +339,67 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -186,6 +433,23 @@ dependencies = [ "wasip2", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hkdf" version = "0.12.4" @@ -213,6 +477,43 @@ dependencies = [ "generic-array", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -240,6 +541,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "mio" version = "1.2.3" @@ -261,14 +568,26 @@ dependencies = [ ] [[package]] -name = "omnivox" -version = "0.14.0" +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ + "autocfg", +] + +[[package]] +name = "omnivox" +version = "0.15.0" +dependencies = [ + "bincode", "chacha20poly1305", + "criterion", "getrandom 0.3.4", "hkdf", "rand_core", "scrypt", + "serde", "sha2", "thiserror", "tokio", @@ -283,6 +602,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -339,6 +664,34 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -383,6 +736,26 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -392,6 +765,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rustc_version" version = "0.4.1" @@ -401,6 +803,12 @@ dependencies = [ "semver", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "salsa20" version = "0.10.2" @@ -410,6 +818,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -464,6 +881,19 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "sha2" version = "0.10.9" @@ -494,6 +924,12 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.16.2" @@ -567,6 +1003,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tokio" version = "1.53.1" @@ -674,6 +1120,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "valuable" version = "0.1.1" @@ -686,6 +1138,22 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -701,6 +1169,70 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -734,6 +1266,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zeroize" version = "1.9.0" @@ -753,3 +1305,9 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 45c2d9c..697410d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "omnivox" -version = "0.14.0" +version = "0.15.0" edition = "2024" license = "GPL-3.0-or-later" description = "Encrypted async TCP client-server messaging: X25519 handshake, ChaCha20-Poly1305, replay-proof sequencing (OmniVox protocol v2)" @@ -13,10 +13,28 @@ rand_core = { version = "0.6", features = ["getrandom"] } sha2 = "0.10" x25519-dalek = { version = "2", features = ["zeroize"] } scrypt = "0.11" +serde = { version = "1", features = ["derive"] } +bincode = { version = "2", features = ["serde"] } thiserror = "2" tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "time", "sync", "macros"] } tracing = "0.1" [dev-dependencies] +serde = { version = "1", features = ["derive"] } +bincode = { version = "2", features = ["serde"] } tracing-subscriber = { version = "0.3", features = ["fmt"] } -tokio = { version = "1", features = ["full"] } \ No newline at end of file +tokio = { version = "1", features = ["full"] } +criterion = { version = "0.5", features = ["async_tokio"] } + +[[bench]] +name = "transport" +harness = false +# scrypt at N=2^17 (golden vectors) is unusable in unoptimized builds. +[profile.dev.package.scrypt] +opt-level = 3 +[profile.dev.package.pbkdf2] +opt-level = 3 +[profile.dev.package.salsa20] +opt-level = 3 +[profile.dev.package.sha2] +opt-level = 3 diff --git a/PROTOCOL-V2.md b/PROTOCOL-V2.md index 3a32f99..20fbc96 100644 --- a/PROTOCOL-V2.md +++ b/PROTOCOL-V2.md @@ -1,114 +1,135 @@ -# OmniVox Protocol v2 — Normative Specification +# OmniVox protocol v2.1 -Version 2, 2026-09. Supersedes the v1 wire format (byte-compatible with the -original C++ implementation); v1 traffic is rejected at the handshake and -`PARITY-CONTRACT.md` is retired alongside it. +Wire-incompatible with the 2013 C++ OmniVox by design. Goals: perfect +forward secrecy (ephemeral X25519), per-session per-direction keys, no +IV-reuse hazard, replay defense, authenticated framing, and a stream lane +for bulk data. Everything is one-shot AEAD — no streaming ciphers. -## Threat model +## Handshake (OVX2) -The PSK (a scrypt hash of a shared password) authenticates peers. The network -is hostile: it can observe, inject, replay, reorder, and truncate. Goals: -session confidentiality and integrity, replay protection, forward secrecy -for recorded traffic, and no attacker-controlled work before authentication. - -## Connection lifecycle +TCP is connected, then both sides exchange a fixed 69-byte hello: ``` -TCP connect - │ - ├── HELLO exchange (plaintext, fixed size, timeout: handshake_timeout_ms) - ├── key schedule (HKDF over ephemeral DH + PSK) - ├── framed messages (AEAD, per-direction sequence numbers) - └── close (any timeout / violation closes immediately) -``` - -## HELLO (both directions, 69 bytes, no attacker-controlled lengths) - -``` -bytes 0..4 magic "OVX2" -byte 4 version 2 -bytes 5..37 x25519 ephemeral public key +bytes 0..4 magic "OVX2" +byte 4 version 2 +bytes 5..37 ephemeral X25519 public key bytes 37..69 session nonce (32 random bytes) ``` -The client sends first. The server validates magic, version, and key -encoding **before replying**; any violation closes the connection. The -server rejects an all-zero DH output (small-order contribution). - -## Key schedule +A hello with wrong magic or version fails closed (connection closed, no +response). Both hellos are exchanged before any key is derived, so the two +peers are symmetric roles distinguished only by their nonces. ``` -dh = X25519(eph_local, eph_peer) -prk = HKDF-SHA256-Extract(salt = nonce_client ‖ nonce_server, ikm = PSK) -okm = HKDF-SHA256-Expand(prk, info = "omnivox-v2" ‖ hello_client ‖ hello_server, 64) -key_c2s = okm[0..32] key_s2c = okm[32..64] +dh = X25519(eph_sk_peer_side, eph_pk_other_side) +salt = nonce_client ‖ nonce_server +prk = HKDF-SHA256-Extract(salt, ikm = PSK ‖ dh) +send_key(server→client) = HKDF-Expand(prk, info = "omnivox2|c2s", 32) +send_key(client→server) = HKDF-Expand(prk, info = "omnivox2|s2c", 32) ``` -The client's send key is `key_c2s`, receive key `key_s2c`; reversed for the -server. Direction separation means neither peer can present itself forged -frames (v1's single symmetric key could). The ephemeral DH gives forward -secrecy against later PSK compromise; the PSK authenticates against -off-path attackers. A weak password remains guessable by an active attacker -with the PSK budget to grind scrypt — use random 32-byte PSKs; a PAKE is the -future upgrade if passwords must stay human. +(Names are from the client's view: each peer encrypts with the key whose +name is the direction it writes.) The PSK is `scrypt(password, salt, +N=2^15, r=8, p=1, 32)` — run on a blocking thread. The pre-hello exchange +is unauthenticated; the first framed message (which authenticates under +the derived keys) is the proof both sides hold the same PSK. An +attacker without the PSK sees every frame fail authentication and the +connection die — they never get an oracle, only a closed socket. -## Framing (both directions, after key confirmation) +The ephemeral DH gives forward secrecy: recorded traffic cannot be +decrypted later even if the PSK leaks. Session nonces bind keys to this +run: frames captured from one session are ciphertext to another. + +## Frame format + +Every post-handshake packet, in both directions: ``` -u32 LE length (0 < len ≤ max_message_size, default cap 64 KiB) -AeadPacket: - nonce [u8; 12] four zero bytes ‖ u64 BE sequence number - tag [u8; 16] Poly1305 tag - len u64 LE (cereal vector length, retained for payload lineage) - ct [len] ChaCha20-Poly1305 ciphertext of the serialized payload +u32 LE len total byte count of type ‖ ciphertext ‖ tag +u8 type 0 = message (bincode payload), 1 = stream (raw bytes) +bytes ct ChaCha20-Poly1305 ciphertext (plaintext length = len − 17) +bytes tag 16-byte Poly1305 tag ``` -AEAD is ChaCha20-Poly1305 (RFC 8439) with the packet's own nonce as the IETF -nonce and empty AAD — identical to v1's construction, now keyed per session -per direction. +`len ∈ [17, max_frame]`; zero or oversized is a protocol violation +(close). `max_frame` is `max_message_size` for type 0 (default 64 KiB) +and `max_stream_frame` for type 1 (default 1 MiB) — both caps are +enforced *before* allocation. -### Sequence discipline (the replay defense) +### Nonce -Each sender's counter starts at 1 and increments by exactly 1 per frame; a -failed write must not consume a number. The receiver's first expected value -is 1. A frame whose decoded nonce is not the expected value is a protocol -violation: **log and close the connection.** There is no reorder window. +The nonce is **not** on the wire. It is derived from the sender's +per-direction sequence number: -### Payload encoding +``` +nonce(counter) = 00 00 00 00 ‖ u64 BE counter +``` -The plaintext is the `Cereal` binary encoding of the message type — raw -little-endian fields, `u64` length prefixes for byte vectors, no archive -header — unchanged from the C++ original. +Each sender's counter starts at 1 and increments by exactly one per +frame; each receiver tracks its own expected counter, starting at 1. The +receiver decrypts only with its expected counter, so a replayed or +reordered frame authenticates against the wrong nonce and fails — +guaranteed, not heuristic. 2^64 frames per direction before the nonce +space is exhausted (close at the limit). -## Limits and timeouts (all server- and client-side) +### AAD -| Knob | Default | Violation | +The type byte is the AEAD additional authenticated data. Flipping +message↔stream is therefore an authentication failure, not a lane +switch. + +### Failure policy + +- **Any frame-level failure** (bad length, unknown type, AEAD failure) + logs and **closes the connection**. v2.1 has no skip-and-continue: an + authenticated bitstream that stops authenticating means someone is + tampering or the two ends disagree about state — either way the + session is over. +- **Payload decode failure** (frame authenticated, bincode decode + fails, e.g. a `Message` claiming a 2^40 byte vector): the frame is + dropped and counted, the connection **stays open**. Authenticated junk + is the sender's bug, not an attack. +- Stream frames are handed to `on_stream` as raw bytes; no decoding. + +## Framing discipline + +- A body-stall timer (`body_stall_ms`) fires while reading a frame body + that started but never completes. +- An idle timer (`idle_timeout_ms`) closes connections with no inbound + frames at all. +- The reader loop processes frames strictly in wire order; messages and + stream chunks share one sequence, so callbacks observe the exact + interleaving the sender produced. + +## Outbound path + +Each connection owns a bounded queue (depth 8) and a writer task. +`send`/`send_stream` await queue space (backpressure); the synchronous +callback helpers `reply`/`reply_stream`/`send_to` use `try_send` and +return `false` when full instead of blocking the reader lane. The writer +task seals frames with its own counter and writes them out, so slow +sockets never stall the read loop. + +## Timeouts (defaults) + +| Timer | Default | Fires when | |---|---|---| -| handshake_timeout_ms | 10000 | close | -| idle_timeout_ms (silence between messages) | 15000 | close | -| body_stall_ms (mid-body silence) | 5000 | close | -| send_timeout_ms (peer not reading) | 5000 | close that peer | -| max_message_size | 65536 | close on header | -| max_connections (server) | 1024 | new connections wait | +| handshake | 10 s | hello exchange incomplete | +| body stall | 5 s | frame body started, never finished | +| idle | 15 s | no inbound frames at all | +| send queue | 5 s | outbound queue full and staying full | -Corrupt frames with the *correct* sequence number (failed AEAD or payload -decode) are logged and skipped; the connection continues. Anything else — -sequence violations, unreasonable headers, stalls — is a disconnect. +## Test coverage -## Security properties - -| Property | Mechanism | Tested by | +| Property | Mechanism | Test | |---|---|---| -| Confidentiality | ChaCha20-Poly1305 per-session keys | `protocol_v2::frames_from_another_session_are_undecryptable` | -| Replay defense | strict sequence numbers | `protocol_v2::replayed_frame_is_dropped_and_the_connection_dies` | -| Session binding | nonces enter the salt/info | same cross-session test | -| Forward secrecy | ephemeral X25519 per connection | design (single-connection compromise leaks only it) | -| No pre-auth work | fixed 69-byte hello, caps only after key confirmation | `wrong_version_hello_is_closed`, `v1_traffic_is_rejected_at_the_handshake` | -| Liveness both roles | idle + stall timeouts client **and** server | `malicious_server_silence_times_out_the_client`, `header_stall_times_out` | -| Slow-client containment | per-connection locks + send timeout | design (no shared writer mutex) | -| DoS containment | connection semaphore, message caps, callback panic isolation | `panicking_callback_does_not_kill_the_connection` | - -## Refusals - -Release builds refuse to start with the original demo key (scrypt of -`SecurePassword123` / salt `01…08`) unless `allow_insecure_defaults` is set. +| Confidentiality | per-session per-direction keys | `frames_from_another_session_are_rejected` | +| Replay defense | derived nonce + expected counter | `replayed_frame_kills_the_connection` | +| Session binding | nonces in salt | same cross-session test | +| Type tampering | type byte as AAD | `frame_type_tamper_is_rejected` | +| Version downgrade | magic+version gate | `wrong_version_hello_is_closed` | +| v1 traffic | fails hello | `v1_traffic_is_rejected_at_the_handshake` | +| Handler panics | catch_unwind per callback | `panicking_callbacks_never_kill_the_connection` | +| Decode bombs | skip authenticated junk | `lying_decoder_length_is_skipped_connection_survives` | +| Silent peer | idle timers both roles | `malicious_server_silence_times_out_the_client` | +| Ordering | shared sequence | `messages_and_streams_interleave_in_order` | diff --git a/README.md b/README.md index 63025ac..6a5fbb7 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,65 @@ # omnivox-rs -Encrypted async TCP client-server messaging — protocol **v2**. A Rust +Encrypted async TCP client-server messaging — protocol **v2.1**. A Rust library plus example binaries, descended from the OmniVox C++ library and -hardened past it after a full red-team review of v1. +hardened past it after a full red-team review. -- X25519 handshake on every connection → forward secrecy, session-bound keys -- ChaCha20-Poly1305 with strictly increasing sequence numbers → replay-proof -- Direction-separated traffic keys, fixed-size pre-auth surface -- scrypt (RFC 7914) password-based key derivation; `Cereal` payload encoding - (byte-exact with the original's cereal binary format) for data portability -- tokio transport: connection caps, idle/stall/send timeouts, panic-isolated - callbacks; zero `unsafe` - -**Not wire-compatible with the C++ original or v1** — by design; v1 peers -are refused at the handshake. See `PROTOCOL-V2.md` for the full normative -spec and `PARITY-CONTRACT.md` (retired) for the port's history. - -## Usage +The API is the original C++ shape — your struct goes in, a match arms over +it in one callback — with all the boilerplate removed: ```rust -use omnivox::{Config, Cereal, CodecError, Decision, OmniVox, derive_encrypt_key}; -use std::sync::Arc; +use serde::{Deserialize, Serialize}; -let endpoint = OmniVox::::new(); -endpoint.set_encrypt_key(derive_encrypt_key(b"password", b"salt", 17, 8, 1)?); -endpoint.start_server(8080, Config::default(), on_data, on_disconnect, on_connect).await?; +#[derive(Serialize, Deserialize, Debug)] +enum AppMsg { Hello, Note(String) } + +// server +let server = omnivox::server::() + .password("hunter2", b"static-salt") // or .key([u8; 32]) + .on_data(|msg, ctx| match msg { + AppMsg::Hello => println!("hello from {}", ctx.ip()), + AppMsg::Note(n) => { ctx.reply(&AppMsg::Note(n.to_uppercase())); } + }) + .serve(8080) + .await?; + +// client +let client = omnivox::client::() + .password("hunter2", b"static-salt") + .on_data(|msg, _ctx| println!("server said {msg:?}")) + .connect("127.0.0.1", 8080) + .await?; + +client.send(&AppMsg::Hello).await; ``` -Payload types implement `Cereal` (raw little-endian fields, `u64` vector -lengths — the original's serialization). Runnable demos: `examples/`. +Any `Serialize + DeserializeOwned` type is a message — serde+bincode +replaces the original's hand-written `Cereal`. For bulk data there is a +second lane: `client.send_stream(&bytes)` pairs with an `on_stream` +callback — no encoding, 1 MiB frames, same sequence numbers, so messages +and stream chunks arrive in exactly the order they were sent. + +- X25519 handshake on every connection → forward secrecy, session-bound keys +- ChaCha20-Poly1305, nonce derived from per-direction sequence numbers → + replay-proof, no nonce on the wire +- Direction-separated traffic keys; frame type authenticated as AAD +- scrypt password KDF (blocking-thread), random-PSK support, panic-isolated + callbacks, connection caps, idle/stall/send timeouts, zero `unsafe` + +**Not wire-compatible with the C++ original or v1** — by design; v1 peers +are refused at the handshake. See `PROTOCOL-V2.md` for the normative spec +and `PARITY-CONTRACT.md` (retired) for the port's history. ## Security posture -| v1 weakness | v2 answer | +| v1 weakness | v2.1 answer | |---|---| -| captured frames replay forever | strict sequence numbers; a replay kills the connection | +| captured frames replay forever | sequence-number nonces; a replay kills the connection | | frames portable across connections | keys bound to per-session nonces | | 5 MiB pre-auth allocation from 8 bytes | fixed 69-byte handshake before any attacker-sized read | | one symmetric key, either peer can forge as itself | direction-separated HKDF session keys | | static key, no forward secrecy | ephemeral X25519 per connection | +| message/stream confusion impossible to detect | type byte authenticated as AAD | | no client-side timeouts | full idle/stall discipline both roles | | panicking user callback killed the read loop | callbacks panic-isolated | | demo password in every repo checkout | release builds refuse the demo key | @@ -46,15 +67,34 @@ lengths — the original's serialization). Runnable demos: `examples/`. Use random 32-byte PSKs where you can; an active attacker can grind scrypt against human passwords (PAKE planned). +## Examples + +```bash +cargo run --example omnivox-server # terminal 1 — enum echo server +cargo run --example omnivox-client # terminal 2 + +cargo run --example dump-agent # terminal 1 — streams a toy 16 MiB +cargo run --example dump-viewer # terminal 2 — receives, verifies, saves +``` + +## Benchmarks + +`cargo bench --bench transport` — measured on the author's machine +(Intel Core Ultra 7 258V, loopback): + +| What | Result | +|---|---| +| ChaCha20-Poly1305 seal, 1 MiB frame | ≈1.18 GiB/s single-threaded | +| 256 B messages, encrypted round trip | ≈120 000 msg/s | +| Stream lane, 1 MiB frames | ≈0.7 GiB/s (vs ≈2.0 raw loopback echo) | + ## Build & test ```bash -cargo test # includes attack regressions: replay, version, panic, timeouts +cargo test # unit, attack regressions, golden vectors, doctests cargo clippy --all-targets -- -D warnings -cargo run --example omnivox-server # terminal 1 -cargo run --example omnivox-client # terminal 2 ``` ## License -GPL-3.0-or-later. +GPL-3.0-or-later. \ No newline at end of file diff --git a/benches/transport.rs b/benches/transport.rs new file mode 100644 index 0000000..da392f0 --- /dev/null +++ b/benches/transport.rs @@ -0,0 +1,226 @@ +//! Throughput benchmarks for OmniVox v2.1, answering: what does the +//! protocol cost versus raw loopback TCP on this machine? +//! +//! - `crypto`: criterion stats for frame seal/open at three sizes. +//! - `transport`: hand-rolled async harness — spray (small messages/s), +//! dump (GiB/s at 1 MiB stream frames), and a raw-TCP echo baseline. +//! +//! Run: `cargo bench --bench transport` + +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group}; +use omnivox::{Config, FrameType, frame::seal_into}; +use serde::{Deserialize, Serialize}; +use std::{ + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, Instant}, +}; + +#[derive(Serialize, Deserialize)] +struct M(#[allow(dead_code)] Vec); + +fn crypto_benches(c: &mut Criterion) { + let mut g = c.benchmark_group("crypto"); + let key = [1u8; 32]; + for size in [256usize, 4096, 1 << 20] { + let payload = vec![7u8; size]; + g.throughput(Throughput::Bytes(size as u64)); + g.bench_function(BenchmarkId::new("seal", size), |b| { + let mut buf = Vec::with_capacity(size + 64); + b.iter(|| { + seal_into( + &key, + 1, + FrameType::Message, + std::hint::black_box(&payload), + &mut buf, + ) + }); + }); + // open measured as copy + decrypt-in-place of a sealed frame + let sealed = omnivox::frame::seal(&key, 1, FrameType::Message, &payload); + g.bench_function(BenchmarkId::new("open", size), |b| { + b.iter(|| { + let mut body = std::hint::black_box(&sealed[4..]).to_vec(); + let (kind, _pt) = omnivox::frame::open_in_place(&key, 1, &mut body).unwrap(); + std::hint::black_box(kind) + }); + }); + } + g.finish(); +} + +fn test_config(stream_frame: u32) -> Config { + Config { + idle_timeout_ms: 60_000, + body_stall_ms: 60_000, + send_timeout_ms: 60_000, + max_message_size: 2 * 1024 * 1024, + max_stream_frame: stream_frame, + allow_insecure_defaults: true, + ..Config::default() + } +} + +fn transport_harness() { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + // ---- spray: small messages per second ---- + const SPRAY_PORT: u16 = 18701; + let rx = Arc::new(AtomicU64::new(0)); + let r = Arc::clone(&rx); + let server = omnivox::server::() + .key([7u8; 32]) + .config(test_config(1 << 20)) + .on_data(move |_: &M, _| { + r.fetch_add(1, Ordering::Relaxed); + }) + .serve(SPRAY_PORT) + .await + .unwrap(); + let client = omnivox::client::() + .key([7u8; 32]) + .config(test_config(1 << 20)) + .on_data(|_: &M, _| {}) + .connect("127.0.0.1", SPRAY_PORT) + .await + .unwrap(); + + let msg = M(vec![9u8; 256]); + let n: u64 = 200_000; + let start = Instant::now(); + let mut sent = 0; + while sent < n { + if client.send(&msg).await { + sent += 1; + } else { + break; + } + } + // drain + while rx.load(Ordering::Relaxed) < sent { + tokio::time::sleep(Duration::from_millis(20)).await; + } + let secs = start.elapsed().as_secs_f64(); + println!( + "spray: {:>10.0} msg/s ({} x 256 B, encrypted round trip loopback)", + sent as f64 / secs, + sent + ); + client.stop().await; + server.stop().await; + + // ---- dump: stream GiB/s ---- + const DUMP_PORT: u16 = 18702; + let rx = Arc::new(AtomicU64::new(0)); + let r = Arc::clone(&rx); + let server = omnivox::server::() + .key([7u8; 32]) + .config(test_config(1 << 20)) + .on_stream(move |b, _| { + r.fetch_add(b.len() as u64, Ordering::Relaxed); + }) + .serve(DUMP_PORT) + .await + .unwrap(); + let client = omnivox::client::() + .key([7u8; 32]) + .config(test_config(1 << 20)) + .on_data(|_: &M, _| {}) + .connect("127.0.0.1", DUMP_PORT) + .await + .unwrap(); + + let page = vec![3u8; 1 << 20]; // 1 MiB + let pages: u64 = 256; // 256 MiB + let start = Instant::now(); + for _ in 0..pages { + client.send_stream(&page).await; + } + let want = pages << 20; + while rx.load(Ordering::Relaxed) < want { + tokio::time::sleep(Duration::from_millis(20)).await; + } + let secs = start.elapsed().as_secs_f64(); + println!( + "dump: {:>10.2} GiB/s ({} MiB over encrypted stream lane, loopback)", + want as f64 / secs / (1u64 << 30) as f64, + want >> 20 + ); + client.stop().await; + server.stop().await; + + // ---- raw loopback baseline (no protocol) ---- + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 18703)) + .await + .unwrap(); + let echo = tokio::spawn(async move { + let (s, _) = listener.accept().await.unwrap(); + let (mut r, mut w) = s.into_split(); + let mut buf = vec![0u8; 1 << 20]; + loop { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + if r.read_exact(&mut buf).await.is_err() { + break; + } + if w.write_all(&buf).await.is_err() { + break; + } + } + }); + let raw = tokio::net::TcpStream::connect(("127.0.0.1", 18703)) + .await + .unwrap(); + raw.set_nodelay(true).unwrap(); + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let (mut rr, mut rw) = raw.into_split(); + let chunk: Vec = vec![5u8; 1 << 20]; + let total: u64 = 64 << 20; + let reader = tokio::spawn(async move { + let mut got = 0u64; + let mut sink = vec![0u8; 1 << 20]; + while got < total { + let n = rr.read(&mut sink).await.unwrap(); + if n == 0 { + break; + } + got += n as u64; + } + got + }); + let start = Instant::now(); + let mut written = 0u64; + while written < total { + rw.write_all(&chunk).await.unwrap(); + written += chunk.len() as u64; + } + let want = total; + while !reader.is_finished() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + let got = reader.await.unwrap(); + assert!(got >= want, "raw echo short: {got}"); + let secs = start.elapsed().as_secs_f64(); + println!( + "raw baseline: {:>10.2} GiB/s (unencrypted loopback write+echo, same box)", + want as f64 / secs / (1u64 << 30) as f64 + ); + echo.abort(); + }); +} + +criterion_group! { + name = crypto; + config = Criterion::default().sample_size(20).measurement_time(Duration::from_secs(2)); + targets = crypto_benches +} + +fn main() { + crypto(); + transport_harness(); +} diff --git a/examples/common/mod.rs b/examples/common/mod.rs deleted file mode 100644 index 9e657c3..0000000 --- a/examples/common/mod.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Port of the C++ `common.h`: shared payload type and demo constants. - -use omnivox::{Cereal, CodecError, Reader, write_byte_vec}; - -/// Wire-identical to the C++ `DatagramPayload` (cereal field order: -/// `id`, `v1.qword`, `payload`; the `PackedValue64` union only ever -/// serialized its `qword`, so it ports as a plain `u64`). -#[derive(Debug, Default)] -pub struct DatagramPayload { - pub id: u16, - pub v1: u64, - pub payload: Vec, -} - -impl Cereal for DatagramPayload { - fn encode(&self, out: &mut Vec) { - out.extend_from_slice(&self.id.to_le_bytes()); - out.extend_from_slice(&self.v1.to_le_bytes()); - write_byte_vec(out, &self.payload); - } - - fn decode(bytes: &[u8]) -> Result { - let mut r = Reader::new(bytes); - Ok(Self { - id: r.u16()?, - v1: r.u64()?, - payload: r.byte_vec()?, - }) - } -} - -pub const PASSWORD: &str = "SecurePassword123"; -pub const SALT: &[u8] = &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]; -pub const SERVER_PORT: u16 = 8080; -/// Only used by the client example. -#[allow(dead_code)] -pub const SERVER_ADDR: &str = "127.0.0.1"; diff --git a/examples/dump-agent.rs b/examples/dump-agent.rs new file mode 100644 index 0000000..fec1a3e --- /dev/null +++ b/examples/dump-agent.rs @@ -0,0 +1,73 @@ +//! The original OmniVox use case: a dump **agent** streams physical +//! memory to a viewer while spraying small control messages over the same +//! encrypted session. +//! +//! This example fabricates a toy 16 MiB "memory" image (no root needed :), +//! streams it in 1 MiB encrypted chunks through the stream lane, and +//! interleaves `Progress` messages through the message lane. Run +//! `dump-viewer` first, then this. + +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// Control-plane messages. Bulk bytes deliberately do NOT travel as +/// messages — the stream lane skips serde entirely. +#[derive(Serialize, Deserialize, Debug)] +enum AgentMsg { + /// "About to stream page {page} of {pages}" + Progress { page: u32, pages: u32 }, + /// "Entire image streamed; {bytes} bytes sent." + Done { bytes: u64 }, +} + +const PAGE: usize = 1024 * 1024; // 1 MiB stream frames +const PAGES: u32 = 16; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_target(false) + .without_time() + .init(); + + let agent = omnivox::client::() + .password("SecurePassword123", b"static-demo-salt") + .connect("127.0.0.1", 8080) + .await?; + + let mut image = Vec::with_capacity(PAGE * PAGES as usize); + for page in 0..(PAGE * PAGES as usize) { + // A deterministic fake "memory image": anything recognizable works. + image.push((page % 251) as u8); + } + + for (n, page) in image.chunks(PAGE).enumerate() { + // Control plane: announce the page (tiny, latency-sensitive). + agent + .send(&AgentMsg::Progress { + page: n as u32, + pages: PAGES, + }) + .await; + + // Data plane: stream the page. `send_stream` splits anything larger + // than Config::max_stream_frame and awaits each chunk, so even a + // 4 GiB dump here would flow with proper backpressure — a slow + // viewer throttles the agent instead of exploding memory. + if !agent.send_stream(page).await { + tracing::error!("viewer vanished mid-dump"); + break; + } + // Pace the toy dump so the interleaving is visible in the logs. + tokio::time::sleep(Duration::from_millis(50)).await; + } + + agent + .send(&AgentMsg::Done { + bytes: image.len() as u64, + }) + .await; + tokio::time::sleep(Duration::from_millis(200)).await; + agent.stop().await; + Ok(()) +} diff --git a/examples/dump-viewer.rs b/examples/dump-viewer.rs new file mode 100644 index 0000000..5079ea7 --- /dev/null +++ b/examples/dump-viewer.rs @@ -0,0 +1,84 @@ +//! The dump **viewer** side: receives an encrypted memory stream while +//! control messages arrive on the same session, in order, interleaved. +//! +//! Run this first, then `cargo run --example dump-agent`. Pages are +//! appended to `/tmp/omnivox-dump.bin`; the `FNV-1a` hash printed at the +//! end proves byte-exact arrival (run the agent twice — same hash). + +use omnivox::Config; +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::sync::{Arc, Mutex}; + +#[derive(Serialize, Deserialize, Debug)] +enum AgentMsg { + Progress { page: u32, pages: u32 }, + Done { bytes: u64 }, +} + +/// Running FNV-1a over everything the stream lane delivers. +#[derive(Default)] +struct Digest(u64); + +impl Digest { + fn add(&mut self, bytes: &[u8]) { + for &b in bytes { + self.0 ^= b as u64; + self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3); + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_target(false) + .without_time() + .init(); + + // Shared across both callbacks (and the Done handler) — a plain + // Arc> like any app state. + let digest = Arc::new(Mutex::new(Digest::default())); + let file = Arc::new(Mutex::new(std::fs::File::create("/tmp/omnivox-dump.bin")?)); + + let _server = omnivox::server::() + .password("SecurePassword123", b"static-demo-salt") + // Larger stream frames than the default, matching the agent. + .config(Config { + max_stream_frame: 1024 * 1024, + ..Config::default() + }) + .on_data({ + let file = Arc::clone(&file); + let digest = Arc::clone(&digest); + move |msg, _ctx| match msg { + AgentMsg::Progress { page, pages } => { + tracing::info!("page {page}/{pages} inbound"); + file.lock().unwrap().write_all(b"\n").ok(); // page boundary marker + } + AgentMsg::Done { bytes } => { + tracing::info!( + "dump complete: {bytes} bytes, fnv1a={:016x}, saved to /tmp/omnivox-dump.bin", + digest.lock().unwrap().0 + ); + std::process::exit(0); + } + } + }) + // The bulk lane: raw decrypted bytes, zero serde, zero copies beyond + // the queue hop. This is where multi-GB dumps land. + .on_stream({ + let file = Arc::clone(&file); + let digest = Arc::clone(&digest); + move |chunk: &[u8], _ctx| { + digest.lock().unwrap().add(chunk); + file.lock().unwrap().write_all(chunk).ok(); + } + }) + .serve(8080) + .await?; + + tracing::info!("viewer listening on :8080, waiting for agent..."); + std::future::pending::<()>().await; + Ok(()) +} diff --git a/examples/omnivox-client.rs b/examples/omnivox-client.rs index fa33a4f..acaa477 100644 --- a/examples/omnivox-client.rs +++ b/examples/omnivox-client.rs @@ -1,12 +1,17 @@ -//! Port of OmniVox-client.cpp: connects, sends "Hello", prints what the -//! server echoes, then stops after 15 seconds like the C++ example. +//! The minimal OmniVox client: connect, talk, done. +//! +//! Companion to `omnivox-server`. Sends `Hello`, prints the echo, then +//! exchanges two more notes and stops. -#[path = "common/mod.rs"] -mod common; +use omnivox::ConnectionStatus; +use serde::{Deserialize, Serialize}; +use std::time::Duration; -use common::{DatagramPayload, PASSWORD, SALT, SERVER_ADDR, SERVER_PORT}; -use omnivox::{Config, ConnectionContext, ConnectionStatus, OmniVox, derive_encrypt_key}; -use std::sync::Arc; +#[derive(Serialize, Deserialize, Debug)] +enum ChatMsg { + Hello, + Note(String), +} #[tokio::main] async fn main() -> Result<(), Box> { @@ -15,63 +20,29 @@ async fn main() -> Result<(), Box> { .without_time() .init(); - tracing::info!("Connecting to server at {SERVER_ADDR}:{SERVER_PORT}"); - - let client = Arc::new(OmniVox::::new()); - let key = derive_encrypt_key(PASSWORD.as_bytes(), SALT, 17, 8, 1)?; - client.set_encrypt_key(key); - - let data_cb = Arc::new(|_data: &DatagramPayload, ctx: &ConnectionContext| { - tracing::info!("Server ID: {}, sent data!", ctx.id); - }); - - let connector = Arc::clone(&client); - let connection_cb = Arc::new(move |ctx: &ConnectionContext| match ctx.connected { - ConnectionStatus::Connected => { - tracing::info!( - "[onConnect] Successfully connected to: {}:{}", - ctx.ip, - ctx.port - ); - let client = Arc::clone(&connector); - tokio::spawn(async move { - let payload = DatagramPayload { - id: 1, - v1: 0, - payload: b"Hello".to_vec(), - }; - if client.send_to_server(&payload).await { - tracing::info!("Sent initial data to server"); - } - }); - } - ConnectionStatus::ConnectionFailed => { - tracing::info!( - "[onConnect] Connection failed or timed out to: {}:{}", - ctx.ip, - ctx.port - ); - } - _ => { - tracing::info!( - "[onConnect] Unexpected connection status to: {}:{}", - ctx.ip, - ctx.port - ); - } - }); - - client - .connect_to_server( - SERVER_ADDR, - SERVER_PORT, - Config::default(), - Some(data_cb), - connection_cb, - ) + let client = omnivox::client::() + // Same password + salt as the server — they derive the same PSK. + .password("SecurePassword123", b"static-demo-salt") + .on_data(|msg, _ctx| match msg { + ChatMsg::Hello => tracing::info!("server greeted us unprompted?"), + ChatMsg::Note(text) => tracing::info!("server note: {text}"), + }) + // Fires on Connected, ConnectionFailed, and later NotConnected. + .on_state(|ctx| { + tracing::info!("state: {:?}", ctx.status()); + if ctx.status() == ConnectionStatus::ConnectionFailed { + tracing::error!("is the server running? (cargo run --example omnivox-server)"); + } + }) + .connect("127.0.0.1", 8080) .await?; - tokio::time::sleep(std::time::Duration::from_secs(15)).await; + client.send(&ChatMsg::Hello).await; + tokio::time::sleep(Duration::from_millis(300)).await; + client.send(&ChatMsg::Note("first note".into())).await; + client.send(&ChatMsg::Note("second note".into())).await; + tokio::time::sleep(Duration::from_millis(300)).await; + client.stop().await; Ok(()) } diff --git a/examples/omnivox-server.rs b/examples/omnivox-server.rs index 313ee0f..7fdc328 100644 --- a/examples/omnivox-server.rs +++ b/examples/omnivox-server.rs @@ -1,15 +1,21 @@ -//! Port of OmniVox-server.cpp: echoes each client payload back with the -//! client id, refusing no client that holds the key. +//! The minimal OmniVox server: one enum, one closure, one call. +//! +//! Run this, then run `cargo run --example omnivox-client` in another +//! terminal. The client sends `Hello`; we echo a `Note` back and finish +//! after three exchanges — the same callback shape as the original C++ +//! demo, minus the boilerplate. -#[path = "common/mod.rs"] -mod common; +use omnivox::Decision; +use serde::{Deserialize, Serialize}; -use common::{DatagramPayload, PASSWORD, SALT, SERVER_PORT}; -use omnivox::{Config, ConnectionContext, Decision, OmniVox, derive_encrypt_key}; -use std::{ - collections::HashMap, - sync::{Arc, Mutex}, -}; +/// The one message type of our tiny protocol. Because this is an enum, +/// `on_data`'s `match` is the switch-on-id pattern from the C++ days, +/// checked by the compiler instead of a runtime default case. +#[derive(Serialize, Deserialize, Debug)] +enum ChatMsg { + Hello, + Note(String), +} #[tokio::main] async fn main() -> Result<(), Box> { @@ -18,78 +24,37 @@ async fn main() -> Result<(), Box> { .without_time() .init(); - tracing::info!("Starting server on port {SERVER_PORT}"); - - let server = Arc::new(OmniVox::::new()); - let key = derive_encrypt_key(PASSWORD.as_bytes(), SALT, 17, 8, 1)?; - server.set_encrypt_key(key); - - // The C++ example's g_clientHalt map, kept for behavioral parity. - let client_halt: Arc>> = Arc::new(Mutex::new(HashMap::new())); - - let responder = Arc::clone(&server); - let received = Arc::new(move |data: &DatagramPayload, ctx: &ConnectionContext| { - tracing::info!( - "Received data from client {}: Payload size={}", - ctx.id, - data.payload.len() - ); - if !data.payload.is_empty() { - tracing::info!( - "Payload content: {}", - String::from_utf8_lossy(&data.payload) - ); - } - let response = DatagramPayload { - id: ctx.id as u16, - v1: 0, - payload: data.payload.clone(), - }; - let server = Arc::clone(&responder); - let id = ctx.id; - // C++ echoed synchronously inside the callback; the Rust callback is - // sync, so the echo rides a task — per-connection socket order is - // still serialized by the client's write lock (contract B3). - tokio::spawn(async move { - if server.send_to_client(&response, id).await { - tracing::info!("Sent response to client {id}"); - } else { - tracing::info!("Error sending data to client {id}"); + let server = omnivox::server::() + // Both peers must derive the same key. scrypt runs once, at startup, + // on a blocking thread (~1 s, 128 MiB). Prefer a random 32-byte PSK + // via `.key([u8; 32])` in real deployments. + .password("SecurePassword123", b"static-demo-salt") + .on_data(|msg, ctx| match msg { + ChatMsg::Hello => { + tracing::info!("client {} says hello", ctx.id()); + // Synchronous, non-blocking enqueue. A `false` return would + // mean the peer stopped reading — never a panic, never a stall. + let _ = ctx.reply(&ChatMsg::Note(format!("echo {}", ctx.id()))); } - }); - }); - - let halt = Arc::clone(&client_halt); - let disconnect = Arc::new(move |ctx: &ConnectionContext| { - tracing::info!( - "Client {} {}:{} is disconnecting from the server cleaning up objects.", - ctx.id, - ctx.ip, - ctx.port - ); - *halt.lock().unwrap().entry(ctx.id).or_default() = true; - }); - - let halt = Arc::clone(&client_halt); - let connect = Arc::new(move |ctx: &ConnectionContext| { - *halt.lock().unwrap().entry(ctx.id).or_default() = false; - tracing::info!("New client has connected - {}:{}", ctx.ip, ctx.port); - Decision::Allow - }); - - server - .start_server( - SERVER_PORT, - Config::default(), - received, - disconnect, - connect, - ) + ChatMsg::Note(text) => { + tracing::info!("client {} wrote: {text}", ctx.id()); + } + }) + // Every authenticated client is welcome; return `Decision::Deny` + // to close specific peers (per-IP policy, allowlists, ...). + .on_connect(|ctx| { + tracing::info!("accepting {}:{}", ctx.ip(), ctx.port()); + Decision::Allow + }) + .on_disconnect(|ctx| tracing::info!("client {} left", ctx.id())) + .serve(8080) .await?; - tracing::info!("Server started successfully"); + tracing::info!("listening on :8080 (Ctrl-C to stop)"); - // C++ slept u32::MAX hours; we serve until killed, like the reference. + // The handle can live forever; `server.stop().await` tears everything + // down cleanly when you want it to. + let _ = &server; std::future::pending::<()>().await; Ok(()) } diff --git a/src/frame.rs b/src/frame.rs new file mode 100644 index 0000000..31db321 --- /dev/null +++ b/src/frame.rs @@ -0,0 +1,263 @@ +//! OmniVox v2.1 frame codec — the entire post-handshake wire format. +//! +//! A frame is +//! +//! ```text +//! u32 LE len length of type + ct + tag that follows +//! u8 type 0 = message, 1 = stream chunk; authenticated as AAD +//! ct [len - 17] ChaCha20-Poly1305 ciphertext +//! tag [16] Poly1305 tag, at the END of the frame +//! ct [len - 17] ChaCha20-Poly1305 ciphertext +//! tag [16] Poly1305 tag, at the END of the frame +//! ``` +//! +//! The type byte is authenticated data — flipping a message frame into a +//! stream frame (or vice versa) is an authentication failure, not a +//! downgrade. +//! +//! There is no nonce on the wire: the v2 nonce is `0000 ‖ u64 BE sequence` +//! and both sides can compute it from their own counter, so transmitting it +//! was pure redundancy. The receiver decrypts with the *expected* +//! sequence-derived nonce — a replayed, reordered, or cross-session frame +//! fails authentication, and the policy is uniform: **any authentication +//! failure closes the connection** (see `PROTOCOL-V2.md`). +//! +//! [`seal_into`] and [`open_in_place`] work with reusable buffers so hot +//! paths (the library's own read/write loops, and benchmarks) allocate +//! nothing per frame. + +use chacha20poly1305::{AeadInPlace, ChaCha20Poly1305, KeyInit, aead::generic_array::GenericArray}; +use thiserror::Error; + +use crate::crypto::{KEY_LEN, NONCE_LEN, TAG_LEN, frame_nonce}; + +/// Fixed overhead of the encrypted region: type byte + tag. +pub const FRAME_OVERHEAD: usize = 1 + TAG_LEN; + +/// What a frame carries. Serialized messages and raw stream chunks share +/// one session, one key schedule, and one sequence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum FrameType { + /// A serde-encoded message (see [`crate::Message`]). + Message = 0, + /// A raw byte chunk of a bulk stream (memory pages, files, ...). + Stream = 1, +} + +impl FrameType { + /// The wire byte for this kind. + #[must_use] + pub fn to_u8(self) -> u8 { + self as u8 + } + + /// Parses a wire byte; unknown values are protocol violations. + /// + /// # Errors + /// + /// [`FrameError::BadType`] for any byte other than 0 or 1. + pub fn from_u8(b: u8) -> Result { + match b { + 0 => Ok(Self::Message), + 1 => Ok(Self::Stream), + other => Err(FrameError::BadType(other)), + } + } +} + +/// Errors from opening a frame. Every one of them is a disconnect under +/// the v2.1 policy; callers should log and close. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum FrameError { + /// The frame is shorter than the fixed overhead (framing desync or + /// a lying length prefix). + #[error("frame too short: {0} bytes")] + TooShort(usize), + /// The type byte is not a known frame kind. + #[error("unknown frame type {0}")] + BadType(u8), + /// Authentication failed: wrong key, wrong sequence (replay/reorder), + /// or tampering. Indistinguishable by design; all are fatal. + #[error("authentication failed (tampered, replayed, or wrong session)")] + Auth, +} + +/// Seals `payload` as a frame with the given sequence number, appending +/// `u32 len ‖ type ‖ ct ‖ tag` to `out` (which is cleared first). +/// +/// Reusable-buffer hot path: `out` keeps its capacity across calls. +pub fn seal_into( + key: &[u8; KEY_LEN], + counter: u64, + kind: FrameType, + payload: &[u8], + out: &mut Vec, +) { + let cipher = ChaCha20Poly1305::new(key.into()); + out.clear(); + // Reserve len(4) + type(1); ct goes here, tag appended after. + out.resize(1 + 4, 0); + out.extend_from_slice(payload); + let ct = &mut out[5..]; + let tag = cipher + .encrypt_in_place_detached( + GenericArray::from_slice(&frame_nonce(counter)), + &[kind.to_u8()], + ct, + ) + .expect("ChaCha20Poly1305 detached encryption is infallible"); + out[4] = kind.to_u8(); + out.extend_from_slice(&tag); + let len = (out.len() - 4) as u32; + out[..4].copy_from_slice(&len.to_le_bytes()); +} + +/// Opens a frame body (`type ‖ ct ‖ tag`, without the u32 length prefix) +/// in place using the expected sequence number. +/// +/// On success returns the frame kind and the plaintext, which lives inside +/// `frame` (at `frame[1..]`) — zero allocation on the hot path. +/// +/// # Errors +/// +/// Any [`FrameError`] is fatal for the connection under the v2.1 policy. +pub fn open_in_place<'a>( + key: &[u8; KEY_LEN], + counter: u64, + frame: &'a mut [u8], +) -> Result<(FrameType, &'a mut [u8]), FrameError> { + if frame.len() < FRAME_OVERHEAD { + return Err(FrameError::TooShort(frame.len())); + } + let kind = FrameType::from_u8(frame[0])?; + let pt_len = frame.len() - FRAME_OVERHEAD; + let (body, tag) = frame[1..].split_at_mut(pt_len); + let cipher = ChaCha20Poly1305::new(key.into()); + cipher + .decrypt_in_place_detached( + GenericArray::from_slice(&frame_nonce(counter)), + &[kind.to_u8()], + body, + GenericArray::from_slice(tag), + ) + .map_err(|_| FrameError::Auth)?; + Ok((kind, body)) +} + +/// Convenience wrapper: seals into a fresh `Vec` (see [`seal_into`]). +#[must_use] +pub fn seal(key: &[u8; KEY_LEN], counter: u64, kind: FrameType, payload: &[u8]) -> Vec { + let mut out = Vec::with_capacity(4 + FRAME_OVERHEAD + payload.len()); + seal_into(key, counter, kind, payload, &mut out); + out +} + +/// Convenience wrapper: opens a copy (see [`open_in_place`]). +/// +/// # Errors +/// +/// Propagates [`FrameError`] from in-place opening. +pub fn open( + key: &[u8; KEY_LEN], + counter: u64, + frame: &[u8], +) -> Result<(FrameType, Vec), FrameError> { + let mut buf = frame.to_vec(); + let (kind, pt) = open_in_place(key, counter, &mut buf)?; + Ok((kind, pt.to_vec())) +} + +/// The nonce length (for downstream tooling; the nonce is never on the wire). +pub const WIRE_NONCE_LEN: usize = NONCE_LEN; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seal_open_roundtrip_both_kinds() { + let key = [7u8; KEY_LEN]; + for kind in [FrameType::Message, FrameType::Stream] { + let f = seal(&key, 1, kind, b"payload bytes"); + let (k, pt) = open(&key, 1, &f[4..]).unwrap(); + assert_eq!(k, kind); + assert_eq!(pt, b"payload bytes"); + } + } + + #[test] + fn wire_layout_is_len_type_ct_tag() { + let f = seal(&[0u8; KEY_LEN], 42, FrameType::Message, b"AB"); + assert_eq!(&f[..4], &19u32.to_le_bytes()); // 1 + 2 + 16 + assert_eq!(f[4], 0); + assert_eq!(f.len(), 4 + 1 + 2 + 16); + } + + #[test] + fn wrong_sequence_fails_auth() { + let key = [9u8; KEY_LEN]; + let f = seal(&key, 5, FrameType::Message, b"x"); + assert_eq!(open(&key, 6, &f[4..]).unwrap_err(), FrameError::Auth); + assert_eq!(open(&key, 4, &f[4..]).unwrap_err(), FrameError::Auth); + } + + #[test] + fn wrong_key_fails_auth() { + let f = seal(&[1u8; KEY_LEN], 1, FrameType::Message, b"x"); + assert_eq!( + open(&[2u8; KEY_LEN], 1, &f[4..]).unwrap_err(), + FrameError::Auth + ); + } + + #[test] + fn tamper_fails_auth() { + let key = [3u8; KEY_LEN]; + let mut f = seal(&key, 1, FrameType::Message, b"hello"); + let last_ct = f.len() - 17; + f[last_ct] ^= 0x01; + assert_eq!(open(&key, 1, &f[4..]).unwrap_err(), FrameError::Auth); + } + + #[test] + fn frame_type_is_authenticated() { + // Flipping the type byte must be an auth failure, not a kind + // downgrade (message -> stream or vice versa). + let key = [3u8; KEY_LEN]; + let mut f = seal(&key, 1, FrameType::Message, b"hello"); + f[4] = FrameType::Stream.to_u8(); + assert_eq!(open(&key, 1, &f[4..]).unwrap_err(), FrameError::Auth); + let mut g = seal(&key, 1, FrameType::Stream, b"hello"); + g[4] = 9; + // unknown kind byte: rejected before decryption either way + assert_eq!(open(&key, 1, &g[4..]).unwrap_err(), FrameError::BadType(9)); + } + + #[test] + fn truncated_frame_reported() { + assert_eq!( + open(&[0u8; KEY_LEN], 1, &[0, 1, 2]).unwrap_err(), + FrameError::TooShort(3) + ); + } + + #[test] + fn seal_into_reuses_capacity() { + let key = [5u8; KEY_LEN]; + let mut buf = Vec::new(); + seal_into( + &key, + 1, + FrameType::Stream, + b"aaaaaaaaaaaaaaaaaaaa", + &mut buf, + ); + let cap_after_big = buf.capacity(); + seal_into(&key, 2, FrameType::Stream, b"b", &mut buf); + assert_eq!(buf.capacity(), cap_after_big, "buffer must be reused"); + let (k, pt) = open_in_place(&key, 2, &mut buf[4..]).unwrap(); + assert_eq!(k, FrameType::Stream); + assert_eq!(pt, b"b"); + } +} diff --git a/src/lib.rs b/src/lib.rs index a4f4c5e..abfb08d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,48 +1,64 @@ -//! OmniVox — encrypted async TCP client/server messaging, protocol v2. +//! OmniVox — encrypted async TCP messaging, protocol v2.1. //! -//! 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 +//! The whole user experience is three verbs: build an endpoint, hand it +//! closures, move typed values. //! //! ```no_run -//! use omnivox::{Cereal, CodecError, Config, ConnectionContext, Decision, OmniVox, OmniVoxError}; -//! use std::sync::Arc; +//! use serde::{Serialize, Deserialize}; //! -//! #[derive(Default)] -//! struct Ping; +//! #[derive(Serialize, Deserialize, Debug)] +//! enum AppMsg { Hello, Note(String) } //! -//! impl Cereal for Ping { -//! fn encode(&self, out: &mut Vec) { out.push(1); } -//! fn decode(_: &[u8]) -> Result { Ok(Ping) } -//! } -//! -//! # async fn run() -> Result<(), OmniVoxError> { -//! let server = Arc::new(OmniVox::::new()); -//! server.set_encrypt_key([7u8; 32]); -//! server -//! .start_server( -//! 8080, -//! Config::default(), -//! Arc::new(|_data: &Ping, _ctx: &ConnectionContext| {}), -//! Arc::new(|_ctx| {}), -//! Arc::new(|_ctx| Decision::Allow), -//! ) +//! # async fn demo() -> Result<(), Box> { +//! // server: struct in, match, done — the original C++ shape, minus the boilerplate +//! let server = omnivox::server::() +//! .password("hunter2", b"static-salt") +//! .on_data(|msg, ctx| match msg { +//! AppMsg::Hello => println!("hello from client {}", ctx.id()), +//! AppMsg::Note(n) => { ctx.reply(&AppMsg::Note(n.to_uppercase())); } +//! }) +//! .serve(8080) //! .await?; +//! +//! // client +//! let client = omnivox::client::() +//! .password("hunter2", b"static-salt") +//! .on_data(|msg, _ctx| println!("server said {msg:?}")) +//! .connect("127.0.0.1", 8080) +//! .await?; +//! +//! client.send(&AppMsg::Hello).await; //! # Ok(()) } //! ``` +//! +//! # Protocol summary +//! +//! Every connection runs the v2 X25519 handshake ([`handshake`], full spec +//! in `PROTOCOL-V2.md`): ephemeral DH + the pre-shared key become +//! direction-separated session keys via HKDF, and each direction then +//! carries [`frame`]s sealed with strictly increasing sequence numbers — +//! serde messages ([`Message`]) and raw stream chunks share the session. +//! Replay, reorder, cross-session frames, and tampering all fail +//! authentication and close the connection. Unauthenticated peers can never +//! make the server allocate anything beyond a fixed 69-byte hello. +//! +//! # Design notes +//! +//! - **No `Arc` ceremony**: builders take plain closures; boxing is +//! internal. +//! - **Sending from callbacks**: [`ConnCtx`] carries synchronous, bounded +//! `reply`/`send_to`/`send_stream` that enqueue onto each connection's +//! writer task (queue depth 8). `false` means backpressure (slow peer) or +//! a dead connection — never a panic, never a stall of your callback. +//! - **Bulk data**: [`Client::send_stream`] (and `Ctx::send_stream`) take +//! arbitrarily large buffers and split them at +//! [`Config::max_stream_frame`]; pair with [`on_stream`](ServerBuilder::on_stream). +//! - **Panic-isolated**: a panicking user callback is logged; the connection +//! survives (a panic inside `on_connect` denies the client). pub mod codec; pub mod crypto; +pub mod frame; pub mod handshake; pub use codec::{Cereal, CodecError, Reader, write_byte_vec}; @@ -50,8 +66,10 @@ 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, frame_nonce, nonce_counter, }; +pub use frame::{FrameError, FrameType}; pub use handshake::{HandshakeError, Session}; +use serde::{Serialize, de::DeserializeOwned}; use std::{ collections::HashMap, net::SocketAddr, @@ -62,88 +80,107 @@ use std::{ }, time::{SystemTime, UNIX_EPOCH}, }; - use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::{ TcpListener, TcpStream, tcp::{OwnedReadHalf, OwnedWriteHalf}, }, - sync::{Mutex as AsyncMutex, OwnedSemaphorePermit, Semaphore}, + sync::{Mutex as AsyncMutex, OwnedSemaphorePermit, Semaphore, mpsc}, task::JoinHandle, time::{Duration, timeout}, }; use tracing::{error, info, warn}; -/// scrypt of the original demo password + salt; servers and clients refuse -/// to run with it in release builds unless explicitly allowed. +/// Depth of each connection's outbound queue. Sends from user callbacks are +/// non-blocking up to this depth; beyond it a peer that isn't reading is +/// dropped (the slow-client defense), so this bounds memory per connection +/// at roughly `8 × max_stream_frame`. +pub const QUEUE_DEPTH: usize = 8; + +/// Hard ceiling for bincode's own deserializer limits; the per-message +/// [`Config::max_message_size`] applies well below this in practice. +const BINCODE_LIMIT: usize = 16 * 1024 * 1024; + +/// scrypt of the original demo password + salt; endpoints refuse to run +/// with it in release builds unless [`Config::allow_insecure_defaults`]. 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`. +/// State of a connection. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConnectionStatus { + /// Disconnected (or, in a state callback after the fact: peer closed). NotConnected, + /// Handshake completed; traffic may flow. Connected, + /// Dial or handshake failed. ConnectionFailed, + /// The server's `on_connect` decision rejected this client. Rejected, } -/// Per-connection metadata handed to every callback. -/// -/// `total_sent` mirrors the C++ `iTotalSent`, which the original never -/// incremented; it is preserved as always-zero. -#[derive(Debug, Clone)] -pub struct ConnectionContext { - pub connected: ConnectionStatus, - pub id: u32, - pub total_sent: usize, - pub creation_time: u64, - pub ip: String, - pub port: u16, -} - -impl ConnectionContext { - fn unknown(host: &str, port: u16) -> Self { - Self { - connected: ConnectionStatus::ConnectionFailed, - id: 0, - total_sent: 0, - creation_time: 0, - ip: host.to_owned(), - port, - } - } -} - -/// Server-side accept decision, matching `ConnectionDecision`. +/// The accept-or-reject answer from [`ServerBuilder::on_connect`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Decision { + /// Register the client and start reading. Allow, + /// Close immediately (disconnect callback still fires, status `Rejected`). Deny, } -pub type DataHandler = Arc; -pub type ConnHandler = Arc; -pub type DecisionHandler = Arc Decision + Send + Sync + 'static>; +/// Everything a message type must be: serde-serializable, thread-safe. +/// +/// Implement it by deriving [`serde::Serialize`] + [`serde::Deserialize`]; +/// a blanket impl does the rest. The wire encoding is bincode with fixed +/// little-endian integers and `u64` lengths. +pub trait Message: Serialize + DeserializeOwned + Send + Sync + 'static {} +impl Message for T {} -/// Runtime knobs. Everything has a conservative default; tune deliberately. +fn encode_msg(msg: &T) -> Option> { + bincode::serde::encode_to_vec(msg, bincode_cfg()).ok() +} + +fn decode_msg(bytes: &[u8]) -> Result { + bincode::serde::decode_from_slice(bytes, bincode_cfg()).map(|(v, _)| v) +} + +fn bincode_cfg() -> bincode::config::Configuration< + bincode::config::LittleEndian, + bincode::config::Fixint, + bincode::config::Limit, +> { + bincode::config::standard() + .with_fixed_int_encoding() + .with_limit::() +} + +/// Runtime knobs. Every one has a conservative default; tune deliberately. +/// +/// ``` +/// use omnivox::Config; +/// let cfg = Config { +/// max_stream_frame: 4 * 1024 * 1024, // fatter dump frames +/// ..Config::default() +/// }; +/// ``` #[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. + /// Max silence between frames before the connection is dropped, both roles. pub idle_timeout_ms: u64, - /// Max gap between bytes of one message body once its header arrived. + /// Max gap between bytes of one frame body once its length prefix 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. + /// Max duration of the handshake. pub handshake_timeout_ms: u64, - /// Hard cap on one message body; oversized headers disconnect. + /// Hard cap on one message frame's payload. pub max_message_size: u32, - /// Concurrent connections (server); excess connections wait. + /// Hard cap on one stream chunk; larger sends are split client-side. + pub max_stream_frame: u32, + /// Concurrent connections (server); excess connections wait for a slot. pub max_connections: usize, /// Allow the retired demo key in release builds. Not for production. pub allow_insecure_defaults: bool, @@ -157,13 +194,15 @@ impl Default for Config { send_timeout_ms: 5_000, handshake_timeout_ms: 10_000, max_message_size: 64 * 1024, + max_stream_frame: 1024 * 1024, max_connections: 1024, allow_insecure_defaults: false, } } } -/// Library errors surfaced from setup and security-violation paths. +/// Errors surfaced from endpoint construction. Runtime message failures +/// are logged and never panic; `false` returns mark the soft ones. #[derive(Debug, thiserror::Error)] pub enum OmniVoxError { #[error(transparent)] @@ -172,8 +211,10 @@ pub enum OmniVoxError { Crypto(#[from] CryptoError), #[error(transparent)] Handshake(#[from] HandshakeError), + #[error("neither .key() nor .password() was set on the builder")] + NoKey, #[error( - "refusing to run with the retired demo key; set allow_insecure_defaults or change the password/salt" + "refusing to run with the retired demo key; change the password/salt or set allow_insecure_defaults" )] InsecureDefaults, } @@ -185,436 +226,494 @@ 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, +/// One frame waiting on a connection's outbound queue. +enum OutFrame { + Message(Vec), + Stream(Vec), } -struct ClientHandle { - /// Held across the full frame write, so a slow reader stalls at most its - /// own connection, and at most `send_timeout_ms`. - writer: AsyncMutex>, - context: ConnectionContext, +/// Per-connection outbound plumbing shared by the map and the writer task. +struct ConnState { + tx: mpsc::Sender, + meta: Meta, } -struct Inner { - psk: Mutex>, - config: OnceLock, - clients: Mutex>>, - next_id: AtomicU32, - stopping: AtomicBool, - server_ctx: Mutex>, - server_writer: AsyncMutex>, - received: OnceLock>, - disconnected: OnceLock, - connect_decision: OnceLock, - server_connected: OnceLock, - tasks: Mutex>>, +#[derive(Clone)] +struct Meta { + id: u32, + connected: ConnectionStatus, + creation_time: u64, + ip: String, + port: u16, } -/// Encrypted TCP endpoint usable in either client or server role. -pub struct OmniVox { +/// Per-connection handle passed to every callback: metadata plus the +/// connection's send lanes. Cheap to clone; all sends are non-blocking up +/// to [`QUEUE_DEPTH`] queued frames. +/// +/// On the server the methods target *this* client (or, for `send_to`, any +/// client); on the client they target the server. +pub struct ConnCtx { + meta: Meta, + /// `Some` on the server side (route to `meta.id`); `None` targets the + /// client's single server lane. + server_side: bool, inner: Arc>, } -impl Default for OmniVox { - fn default() -> Self { - Self::new() - } -} - -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 OmniVox { - /// Creates an endpoint with no key set. - pub fn new() -> Self { +impl Clone for ConnCtx { + fn clone(&self) -> Self { Self { - inner: Arc::new(Inner { - psk: Mutex::new(None), - config: OnceLock::new(), - clients: Mutex::new(HashMap::new()), - next_id: AtomicU32::new(1), - stopping: AtomicBool::new(false), - server_ctx: Mutex::new(None), - server_writer: AsyncMutex::const_new(None), - received: OnceLock::new(), - disconnected: OnceLock::new(), - connect_decision: OnceLock::new(), - server_connected: OnceLock::new(), - tasks: Mutex::new(Vec::new()), - }), + meta: self.meta.clone(), + server_side: self.server_side, + inner: Arc::clone(&self.inner), } } +} - /// 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.psk.lock().unwrap() = Some(key); +impl ConnCtx { + /// Unique id of this connection (server: client id; client: local id). + #[must_use] + pub fn id(&self) -> u32 { + self.meta.id + } + /// Peer IP as seen by this endpoint. + #[must_use] + pub fn ip(&self) -> &str { + &self.meta.ip + } + /// Peer port. + #[must_use] + pub fn port(&self) -> u16 { + self.meta.port + } + /// Current connection status. + #[must_use] + pub fn status(&self) -> ConnectionStatus { + self.meta.connected + } + /// Monotonic-epoch microseconds at connection creation. + #[must_use] + pub fn creation_time(&self) -> u64 { + self.meta.creation_time } - fn psk(&self) -> Option<[u8; KEY_LEN]> { - *self.inner.psk.lock().unwrap() + /// Queue a message back over this connection (server → this client, + /// client → server). + /// + /// Returns `false` (never blocks, never panics) when the peer is + /// backpressured or gone; the frame is then simply not sent. + #[must_use] + pub fn reply(&self, msg: &T) -> bool { + let Some(bytes) = encode_msg(msg) else { + return false; + }; + self.queue(OutFrame::Message(bytes), self.meta.id) } - fn install_config(&self, config: Config) -> Config { - let _ = self.inner.config.set(config.clone()); - self.inner.config.get().expect("just set").clone() + /// Queue raw bytes onto this connection's stream lane (memory pages, + /// file slices, ...). Must be ≤ [`Config::max_stream_frame`]; use + /// [`Client::send_stream`](crate::Client::send_stream) for chunking. + #[must_use] + pub fn reply_stream(&self, bytes: &[u8]) -> bool { + self.queue(OutFrame::Stream(bytes.to_vec()), self.meta.id) } - fn config(&self) -> Config { - self.inner.config.get().cloned().unwrap_or_default() + /// Server only: queue a message to any connected client by id. + #[must_use] + pub fn send_to(&self, client_id: u32, msg: &T) -> bool { + if !self.server_side { + return false; + } + let Some(bytes) = encode_msg(msg) else { + return false; + }; + self.queue(OutFrame::Message(bytes), client_id) } - /// 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, - config: Config, - data: DataHandler, - disconnect: ConnHandler, - connect: DecisionHandler, - ) -> Result<(), OmniVoxError> { - let config = self.install_config(config); - let psk = self - .psk() - .ok_or(OmniVoxError::Crypto(CryptoError::EmptyInput))?; - check_defaults(&config, psk)?; + fn queue(&self, frame: OutFrame, client_id: u32) -> bool { + if self.server_side { + let tx = { + let clients = self.inner.clients.lock().unwrap(); + clients.get(&client_id).map(|c| c.tx.clone()) + }; + match tx { + Some(tx) => tx.try_send(frame).is_ok(), + None => false, + } + } else { + let tx = self.inner.server_tx.try_lock(); + match tx { + Ok(guard) => match guard.as_ref() { + Some(tx) => tx.try_send(frame).is_ok(), + None => false, + }, + Err(_) => false, + } + } + } +} +struct Inner { + psk: Mutex<[u8; KEY_LEN]>, + config: Config, + clients: Mutex>>, + next_id: AtomicU32, + stopping: AtomicBool, + server_meta: Mutex>, + server_tx: AsyncMutex>>, + received: OnceLock>, + stream: OnceLock>, + disconnected: OnceLock>, + connect_decision: OnceLock>, + server_state: OnceLock>, + tasks: Mutex>>, + semaphore: OnceLock>, +} - let _ = self.inner.received.set(data); - let _ = self.inner.disconnected.set(disconnect); - let _ = self.inner.connect_decision.set(connect); +type DataHandler = Arc) + Send + Sync + 'static>; +type StreamHandler = Arc) + Send + Sync + 'static>; +type DecisionHandler = Arc) -> Decision + Send + Sync + 'static>; +type ConnHandler = Arc) + Send + Sync + 'static>; + +/// Shared setup for both endpoint roles; see the builders. +struct Setup { + key: Option<[u8; KEY_LEN]>, + creds: Option<(Vec, Vec)>, + config: Config, + on_data: Option>, + on_stream: Option>, + on_connect: Option>, + on_disconnect: Option>, + on_state: Option>, +} + +impl Default for Setup { + fn default() -> Self { + Self { + key: None, + creds: None, + config: Config::default(), + on_data: None, + on_stream: None, + on_connect: None, + on_disconnect: None, + on_state: None, + } + } +} + +/// Shared builder methods for both roles (macro-free duplication keeps +/// each builder's docs and method lists explicit). +macro_rules! shared_builder { + () => { + /// Set the pre-shared key directly (e.g. a random 32-byte PSK, or + /// one computed once with [`derive_encrypt_key`]). + #[must_use = "builder does nothing until .serve()/.connect()"] + pub fn key(mut self, key: [u8; KEY_LEN]) -> Self { + self.s.key = Some(key); + self + } + + /// Derive the PSK from a password and salt with scrypt (N = 2^17, + /// r = 8, p = 1) at startup. Derivation runs on a blocking thread; + /// expect ~1 s and 128 MiB. + #[must_use = "builder does nothing until .serve()/.connect()"] + pub fn password(mut self, password: impl Into>, salt: impl Into>) -> Self { + self.s.creds = Some((password.into(), salt.into())); + self + } + + /// Replace the runtime [`Config`]. + #[must_use = "builder does nothing until .serve()/.connect()"] + pub fn config(mut self, config: Config) -> Self { + self.s.config = config; + self + } + + /// Called for every message frame, after decryption and decode. + /// Panics inside are caught and logged; the connection survives. + #[must_use = "builder does nothing until .serve()/.connect()"] + pub fn on_data(mut self, f: F) -> Self + where + F: Fn(&T, &ConnCtx) + Send + Sync + 'static, + { + self.s.on_data = Some(Arc::new(f)); + self + } + + /// Called for every stream chunk, after decryption. Bulk data that + /// skips serde entirely. + #[must_use = "builder does nothing until .serve()/.connect()"] + pub fn on_stream(mut self, f: F) -> Self + where + F: Fn(&[u8], &ConnCtx) + Send + Sync + 'static, + { + self.s.on_stream = Some(Arc::new(f)); + self + } + }; +} + +/// Start building a server endpoint. +#[must_use = "a server does nothing until .serve(port) is awaited"] +pub fn server() -> ServerBuilder { + ServerBuilder { + s: Setup::default(), + } +} + +/// Start building a client endpoint. +#[must_use = "a client does nothing until .connect(host, port) is awaited"] +pub fn client() -> ClientBuilder { + ClientBuilder { + s: Setup::default(), + } +} + +/// Fluent builder for the server role; finish with [`serve`](Self::serve). +pub struct ServerBuilder { + s: Setup, +} + +impl ServerBuilder { + shared_builder!(); + + /// Decide whether each connecting (authenticated) client may stay. + /// A panic here denies the client. + #[must_use = "builder does nothing until .serve() is awaited"] + pub fn on_connect(mut self, f: F) -> Self + where + F: Fn(&ConnCtx) -> Decision + Send + Sync + 'static, + { + self.s.on_connect = Some(Arc::new(f)); + self + } + + /// Called when a registered client disconnects or is dropped. + #[must_use = "builder does nothing until .serve() is awaited"] + pub fn on_disconnect(mut self, f: F) -> Self + where + F: Fn(&ConnCtx) + Send + Sync + 'static, + { + self.s.on_disconnect = Some(Arc::new(f)); + self + } + + /// Bind `0.0.0.0:port` and start serving. Returns a [`Server`] handle; + /// dropping the handle does **not** stop the server — call + /// [`Server::stop`]. + /// + /// # Errors + /// + /// [`OmniVoxError::NoKey`] if neither key nor password was set, + /// [`OmniVoxError::InsecureDefaults`] for the demo key in release, or + /// bind errors from the OS. + pub async fn serve(self, port: u16) -> Result, OmniVoxError> { + let psk = resolve_psk(&self.s).await?; + check_defaults(&self.s.config, psk)?; + let inner = Arc::new(Inner::new(self.s, psk)); + inner + .semaphore + .set(Arc::new(Semaphore::new(inner.config.max_connections))) + .ok(); 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 { + let a = Arc::clone(&inner); + let accept = tokio::spawn(async move { loop { let (stream, peer) = match listener.accept().await { Ok(v) => v, - Err(_) if accept_inner.stopping.load(Ordering::Relaxed) => break, + Err(_) if a.stopping.load(Ordering::Relaxed) => break, Err(e) => { error!("[OMNIVOX] accept error: {e}"); 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, accept_psk, cfg, permit).await; - }); - accept_inner.tasks.lock().unwrap().push(task); + let permit = match Arc::clone( + a.semaphore + .get_or_init(|| Arc::new(Semaphore::new(a.config.max_connections))), + ) + .acquire_owned() + .await + { + Ok(p) => p, + Err(_) => break, + }; + let c = Arc::clone(&a); + a.tasks.lock().unwrap().push(tokio::spawn(async move { + serve_connection(c, stream, peer, permit).await; + })); } }); - self.inner.tasks.lock().unwrap().push(accept_task); + inner.tasks.lock().unwrap().push(accept); + info!("[OMNIVOX] server v2.1 listening on port {port}"); + Ok(Server { inner }) + } +} - info!( - "[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(()) +/// Running server handle. Cheap to clone. +#[derive(Clone)] +pub struct Server { + inner: Arc>, +} + +impl Server { + /// Queue a message to a connected client, waiting if its queue is full + /// (flow control; fails if the client dies first). + pub async fn send_to_client(&self, client_id: u32, msg: &T) -> bool { + let tx = { + let clients = self.inner.clients.lock().unwrap(); + clients.get(&client_id).map(|c| c.tx.clone()) + }; + match (tx, encode_msg(msg)) { + (Some(tx), Some(bytes)) => tx.send(OutFrame::Message(bytes)).await.is_ok(), + _ => false, + } } - async fn serve_connection( - inner: Arc>, - mut stream: TcpStream, - peer: SocketAddr, - psk: [u8; KEY_LEN], - config: Config, - permit: OwnedSemaphorePermit, - ) { - 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 + /// Send a message to every connected client; returns the delivered count. + pub async fn broadcast(&self, msg: &T) -> usize { + let txs: Vec<_> = { + let clients = self.inner.clients.lock().unwrap(); + clients.values().map(|c| c.tx.clone()).collect() + }; + let mut n = 0; + for tx in txs { + if let Some(bytes) = encode_msg(msg) + && tx.send(OutFrame::Message(bytes)).await.is_ok() { - Err(_) => { - warn!("[OMNIVOX] handshake timed out from {peer}"); - return; - } - Ok(Err(e)) => { - warn!("[OMNIVOX] handshake failed from {peer}: {e}"); - return; - } - Ok(Ok(s)) => s, + n += 1; } - }; - let (mut reader, mut writer) = stream.into_split(); - - let id = Self::claim_id(&inner); - let mut ctx = ConnectionContext { - connected: ConnectionStatus::Connected, - id, - total_sent: 0, - creation_time: now_micros(), - ip: peer.ip().to_string(), - port: peer.port(), - }; - - let decision = inner - .connect_decision - .get() - .map(|f| fire_decision(f, &ctx)) - .unwrap_or(Decision::Allow); - - if decision == Decision::Deny { - let _ = writer.shutdown().await; - drop(reader); - ctx.connected = ConnectionStatus::Rejected; - if let Some(f) = inner.disconnected.get() { - fire_ctx(f, &ctx); - } - info!( - "[OMNIVOX] Client connection from {}:{} rejected", - ctx.ip, ctx.port - ); - drop(permit); - return; } + n + } - let handle = Arc::new(ClientHandle { - writer: AsyncMutex::new(Some(WriterState { - half: writer, - key: session.send_key, - counter: 1, - })), - context: ctx.clone(), - }); - inner + /// Snapshot of connected clients. + #[must_use] + pub fn clients(&self) -> Vec> { + self.inner .clients .lock() .unwrap() - .insert(id, Arc::clone(&handle)); - info!( - "[OMNIVOX] Client {id} connected from {}:{}", - ctx.ip, ctx.port - ); - - Self::read_loop(inner, handle, &mut reader, session.recv_key, &config).await; - drop(permit); + .values() + .map(|c| ConnCtx { + meta: c.meta.clone(), + server_side: true, + inner: Arc::clone(&self.inner), + }) + .collect() } - fn claim_id(inner: &Inner) -> 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; - } - } + /// Stop accepting, tear down every connection, join tasks. Idempotent. + pub async fn stop(&self) { + self.inner.shutdown().await; + } +} + +/// Fluent builder for the client role; finish with [`connect`](Self::connect). +pub struct ClientBuilder { + s: Setup, +} + +impl ClientBuilder { + shared_builder!(); + + /// Called on connection state changes: `Connected`, `ConnectionFailed`, + /// then `NotConnected` when the server closes. + #[must_use = "builder does nothing until .connect() is awaited"] + pub fn on_state(mut self, f: F) -> Self + where + F: Fn(&ConnCtx) + Send + Sync + 'static, + { + self.s.on_state = Some(Arc::new(f)); + self } - #[allow(clippy::too_many_arguments)] - async fn read_loop( - inner: Arc>, - handle: Arc, - reader: &mut OwnedReadHalf, - recv_key: [u8; KEY_LEN], - config: &Config, - ) { - let ctx = handle.context.clone(); - let mut expected: u64 = 1; - loop { - let mut header = [0u8; 4]; - match timeout( - Duration::from_millis(config.idle_timeout_ms), - reader.read_exact(&mut header), - ) - .await - { - Err(_) => { - info!( - "[OMNIVOX] Client {} {}:{} Timed out", - ctx.id, ctx.ip, ctx.port - ); - Self::handle_client_disconnect(&inner, &ctx).await; - return; - } - Ok(Err(_)) => { - Self::handle_client_disconnect(&inner, &ctx).await; - return; - } - Ok(Ok(_)) => {} - } + /// Dial `host:port` and complete the handshake before returning. + /// + /// A failure to connect or handshake is returned as `Ok(Client)` with + /// the state callback fired `ConnectionFailed` — mirroring v1's + /// callback-driven error style; check [`Client::state`] if you prefer + /// to inspect. + /// + /// # Errors + /// + /// Only [`OmniVoxError::NoKey`] / [`OmniVoxError::InsecureDefaults`] / + /// configuration errors; transport failures are reported via state. + pub async fn connect(self, host: &str, port: u16) -> Result, OmniVoxError> { + let psk = resolve_psk(&self.s).await?; + check_defaults(&self.s.config, psk)?; + let inner = Arc::new(Inner::new(self.s, psk)); - let size = u32::from_le_bytes(header); - 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; - return; - } - - let mut body = vec![0u8; size as usize]; - match timeout( - Duration::from_millis(config.body_stall_ms), - reader.read_exact(&mut body), - ) - .await - { - Err(_) => { - info!( - "[OMNIVOX] Client {} {}:{} had a stall during body read!", - ctx.id, ctx.ip, ctx.port - ); - Self::handle_client_disconnect(&inner, &ctx).await; - return; - } - Ok(Err(_)) => { - Self::handle_client_disconnect(&inner, &ctx).await; - return; - } - Ok(Ok(_)) => {} - } - - 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}"), - } - } - } - - async fn handle_client_disconnect(inner: &Inner, ctx: &ConnectionContext) { - let removed = inner.clients.lock().unwrap().remove(&ctx.id); - if let Some(handle) = removed { - if let Some(mut state) = handle.writer.lock().await.take() { - let _ = state.half.shutdown().await; - } - if let Some(f) = inner.disconnected.get() { - fire_ctx(f, ctx); - } - info!("[OMNIVOX] Client {} disconnected", ctx.id); - } - } - - /// 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>, - 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 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((reader, writer, session)) => { - let ctx = ConnectionContext { - connected: ConnectionStatus::Connected, - id: self.inner.next_id.fetch_add(1, Ordering::Relaxed), - total_sent: 0, - creation_time: now_micros(), + let dialed = Self::dial(host, port).await; + let ctx_meta = match dialed { + Err(_) => Some(( + Meta { + id: 0, + connected: ConnectionStatus::ConnectionFailed, + creation_time: 0, ip: host.to_owned(), port, - }; - *self.inner.server_ctx.lock().unwrap() = Some(ctx.clone()); - *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, session.recv_key, read_ctx, cfg) - .await; - })); + }, + None, + )), + Ok(stream) => { + let _ = stream.set_nodelay(true); + let (mut r, mut w) = stream.into_split(); + match timeout( + Duration::from_millis(inner.config.handshake_timeout_ms), + handshake::perform(&mut r, &mut w, &inner.psk_bytes(), false), + ) + .await + { + Err(_) | Ok(Err(_)) => Some(( + Meta { + id: 0, + connected: ConnectionStatus::ConnectionFailed, + creation_time: 0, + ip: host.to_owned(), + port, + }, + None, + )), + Ok(Ok(session)) => { + let meta = Meta { + id: inner.next_id.fetch_add(1, Ordering::Relaxed), + connected: ConnectionStatus::Connected, + creation_time: now_micros(), + ip: host.to_owned(), + port, + }; + Some((meta, Some((r, w, session)))) + } } - ctx } }; - if let Some(f) = self.inner.server_connected.get() { + let (meta, transport) = ctx_meta.unwrap(); + if let Some((reader, writer, session)) = transport { + let (tx, rx) = mpsc::channel::(QUEUE_DEPTH); + *inner.server_tx.lock().await = Some(tx); + *inner.server_meta.lock().unwrap() = Some(meta.clone()); + inner.spawn_writer(writer, session.send_key, rx, None); + let c = Arc::clone(&inner); + let m = meta.clone(); + inner.tasks.lock().unwrap().push(tokio::spawn(async move { + client_read_loop(c, reader, session.recv_key, m).await; + })); + } + let ctx = ConnCtx { + meta, + server_side: false, + inner: Arc::clone(&inner), + }; + if let Some(f) = inner.server_state.get() { fire_ctx(f, &ctx); } - Ok(()) + Ok(Client { inner }) } async fn dial(host: &str, port: u16) -> Result { - // 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 { @@ -625,235 +724,497 @@ impl OmniVox { } last } +} - async fn client_read_loop( - inner: Arc>, - mut reader: OwnedReadHalf, - recv_key: [u8; KEY_LEN], - ctx: ConnectionContext, - config: Config, +/// Running client handle: one server connection. Cheap to clone. +#[derive(Clone)] +pub struct Client { + inner: Arc>, +} + +impl Client { + /// Send a message, waiting if the outbound queue is full. + /// `false` means disconnected (or the peer died mid-send). + pub async fn send(&self, msg: &T) -> bool { + let tx = self.inner.server_tx.lock().await.clone(); + match (tx, encode_msg(msg)) { + (Some(tx), Some(bytes)) => tx.send(OutFrame::Message(bytes)).await.is_ok(), + _ => false, + } + } + + /// Send bulk data. Buffers larger than [`Config::max_stream_frame`] + /// are split into chunks and awaited piecewise, giving natural flow + /// control for multi-gigabyte memory dumps. + pub async fn send_stream(&self, bytes: &[u8]) -> bool { + let chunk = self.inner.config.max_stream_frame as usize; + let tx = self.inner.server_tx.lock().await.clone(); + let Some(tx) = tx else { return false }; + for piece in bytes.chunks(chunk) { + if tx.send(OutFrame::Stream(piece.to_vec())).await.is_err() { + return false; + } + } + true + } + + /// Current connection state (`None` before connect finished, or after stop). + #[must_use] + pub fn state(&self) -> Option> { + self.inner + .server_meta + .lock() + .unwrap() + .clone() + .map(|meta| ConnCtx { + meta, + server_side: false, + inner: Arc::clone(&self.inner), + }) + } + + /// Close the connection and join tasks. Idempotent. + pub async fn stop(&self) { + self.inner.shutdown().await; + } +} + +// --------------------------------------------------------------------------- +// internals +// --------------------------------------------------------------------------- + +impl Inner { + fn new(setup: Setup, psk: [u8; KEY_LEN]) -> Self { + let inner = Self { + psk: Mutex::new(psk), + config: setup.config, + clients: Mutex::new(HashMap::new()), + next_id: AtomicU32::new(1), + stopping: AtomicBool::new(false), + server_meta: Mutex::new(None), + server_tx: AsyncMutex::new(None), + received: OnceLock::new(), + stream: OnceLock::new(), + disconnected: OnceLock::new(), + connect_decision: OnceLock::new(), + server_state: OnceLock::new(), + tasks: Mutex::new(Vec::new()), + semaphore: OnceLock::new(), + }; + if let Some(f) = setup.on_data { + let _ = inner.received.set(f); + } + if let Some(f) = setup.on_stream { + let _ = inner.stream.set(f); + } + if let Some(f) = setup.on_disconnect { + let _ = inner.disconnected.set(f); + } + if let Some(f) = setup.on_connect { + let _ = inner.connect_decision.set(f); + } + if let Some(f) = setup.on_state { + let _ = inner.server_state.set(f); + } + inner + } + + fn psk_bytes(&self) -> [u8; KEY_LEN] { + *self.psk.lock().unwrap() + } + + /// One writer task per connection: owns the socket half, the direction + /// key, and the sequence counter; seals into a reusable buffer. + fn spawn_writer( + self: &Arc, + half: OwnedWriteHalf, + key: [u8; KEY_LEN], + mut rx: mpsc::Receiver, + client_id: Option, ) { - let mut expected: u64 = 1; - loop { - let mut header = [0u8; 4]; - 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 > config.max_message_size { - break; - } - 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}"); + let cap = 5 + frame::FRAME_OVERHEAD + self.config.max_stream_frame as usize; + let cfg = self.config.clone(); + let inner = Arc::clone(self); + self.tasks.lock().unwrap().push(tokio::spawn(async move { + let mut half = half; + let mut buf = Vec::with_capacity(cap); + let mut counter: u64 = 1; + let mut dead = false; + while let Some(frame) = rx.recv().await { + let (kind, payload) = match &frame { + OutFrame::Message(b) => (FrameType::Message, b.as_slice()), + OutFrame::Stream(b) => (FrameType::Stream, b.as_slice()), + }; + frame::seal_into(&key, counter, kind, payload, &mut buf); + counter += 1; + if timeout( + Duration::from_millis(cfg.send_timeout_ms), + half.write_all(&buf), + ) + .await + .map(|r| r.is_err()) + .unwrap_or(true) + { + dead = true; 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() { - fire_ctx(f, &sc); + let _ = half.shutdown().await; + drop(rx); + if dead { + // Slow/dead writer: make the reader observe EOF promptly. + if let Some(id) = client_id + && let Some(ctx) = inner.client_ctx(id) + { + inner.drop_connection(&ctx).await; + } } + })); + } + + fn client_ctx(self: &Arc, id: u32) -> Option> { + let clients = self.clients.lock().unwrap(); + clients.get(&id).map(|c| ConnCtx { + meta: c.meta.clone(), + server_side: true, + inner: Arc::clone(self), + }) + } + + /// Remove, close, notify. Idempotent (first caller wins). + async fn drop_connection(self: &Arc, ctx: &ConnCtx) { + let removed = self.clients.lock().unwrap().remove(&ctx.meta.id); + if let Some(state) = removed { + drop(state); // writer task drains, then exits + if let Some(f) = self.disconnected.get() { + fire_ctx(f, ctx); + } + info!("[OMNIVOX] client {} disconnected", ctx.meta.id); } } - /// 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 - .server_ctx - .lock() - .unwrap() - .as_ref() - .is_some_and(|c| c.connected == ConnectionStatus::Connected); - if !connected { - return false; - } - let config = self.config(); - let mut guard = self.inner.server_writer.lock().await; - 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, 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 config = self.config(); - let mut guard = client.writer.lock().await; - 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; - drop(guard); - Self::handle_client_disconnect(&self.inner, &client.context).await; - false - } - } - } - - /// Stops everything: accept loop, read loops, all sockets. Idempotent. - pub async fn stop(&self) { - self.inner.stopping.store(true, Ordering::Relaxed); - let tasks: Vec<_> = self.inner.tasks.lock().unwrap().drain(..).collect(); + async fn shutdown(&self) { + self.stopping.store(true, Ordering::Relaxed); + let tasks: Vec<_> = self.tasks.lock().unwrap().drain(..).collect(); for t in tasks { t.abort(); } - let clients: Vec<_> = self - .inner - .clients - .lock() - .unwrap() - .drain() - .map(|(_, c)| c) - .collect(); - for c in clients { - if let Some(mut state) = c.writer.lock().await.take() { - let _ = state.half.shutdown().await; + let drained: Vec<_> = self.clients.lock().unwrap().drain().collect(); + for (_, state) in drained { + drop(state); + } + let tx = self.server_tx.lock().await.take(); + drop(tx); + if let Some(mut m) = self.server_meta.lock().unwrap().take() { + m.connected = ConnectionStatus::NotConnected; + } + info!("[OMNIVOX] omnivox stopped"); + } +} + +/// Resolve the PSK from key-or-password, running scrypt off the async +/// pool when a password was given. +async fn resolve_psk(s: &Setup) -> Result<[u8; KEY_LEN], OmniVoxError> { + if let Some(k) = s.key { + return Ok(k); + } + let (pw, salt) = s.creds.clone().ok_or(OmniVoxError::NoKey)?; + let key = tokio::task::spawn_blocking(move || { + derive_encrypt_key(&pw, &salt, SCRYPT_LOG_N, SCRYPT_R, SCRYPT_P) + }) + .await + .map_err(|_| OmniVoxError::Crypto(CryptoError::EmptyInput))??; + Ok(key) +} + +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(()) +} + +/// Per-connection server engine: handshake, decision, writer + reader. +async fn serve_connection( + inner: Arc>, + mut stream: TcpStream, + peer: SocketAddr, + permit: OwnedSemaphorePermit, +) { + let _ = stream.set_nodelay(true); + let psk = inner.psk_bytes(); + let session = { + let (mut r, mut w) = stream.split(); + match timeout( + Duration::from_millis(inner.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 (reader, mut writer) = stream.into_split(); + + let id = claim_id(&inner); + let meta = Meta { + id, + connected: ConnectionStatus::Connected, + creation_time: now_micros(), + ip: peer.ip().to_string(), + port: peer.port(), + }; + let ctx = ConnCtx { + meta, + server_side: true, + inner: Arc::clone(&inner), + }; + + let decision = inner + .connect_decision + .get() + .map(|f| fire_decision(f, &ctx)) + .unwrap_or(Decision::Allow); + if decision == Decision::Deny { + let mut meta = ctx.meta.clone(); + meta.connected = ConnectionStatus::Rejected; + let denied = ConnCtx { + meta, + server_side: true, + inner: Arc::clone(&inner), + }; + let _ = writer.shutdown().await; + drop(reader); + if let Some(f) = inner.disconnected.get() { + fire_ctx(f, &denied); + } + info!( + "[OMNIVOX] client connection from {}:{} rejected", + denied.ip(), + denied.port() + ); + return; + } + + let (tx, rx) = mpsc::channel::(QUEUE_DEPTH); + inner.clients.lock().unwrap().insert( + id, + Arc::new(ConnState { + tx, + meta: ctx.meta.clone(), + }), + ); + inner.spawn_writer(writer, session.send_key, rx, Some(id)); + info!( + "[OMNIVOX] client {id} connected from {}:{}", + ctx.ip(), + ctx.port() + ); + + server_read_loop(inner.clone(), reader, session.recv_key, &ctx).await; + inner.drop_connection(&ctx).await; + drop(permit); +} + +fn claim_id(inner: &Arc>) -> 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; + } + } +} + +/// Shared frame pump: read length, read body, authenticate (failure = +/// disconnect), dispatch, next sequence number. Reusable buffers; the +/// plaintext is decrypted in place and passed out as a borrow of the loop +/// buffer — zero allocation per frame beyond queue copies on send paths. +async fn pump_frames( + inner: &Arc>, + reader: &mut R, + recv_key: &[u8; KEY_LEN], + ctx: &ConnCtx, + cfg: &Config, + buf: &mut Vec, +) -> bool +/* clean EOF vs violation */ +where + R: AsyncReadExt + Unpin, +{ + let mut counter: u64 = 1; + let mut header = [0u8; 4]; + loop { + match timeout( + Duration::from_millis(cfg.idle_timeout_ms), + reader.read_exact(&mut header), + ) + .await + { + Err(_) => { + info!( + "[OMNIVOX] {who} {id} {ip}:{port} timed out idle", + who = role_tag(ctx), + id = ctx.id(), + ip = ctx.ip(), + port = ctx.port() + ); + return false; + } + Ok(Err(_)) => return true, // peer closed between frames: clean + Ok(Ok(_)) => {} + } + let len = u32::from_le_bytes(header) as usize; + let hard_cap = + cfg.max_message_size.max(cfg.max_stream_frame) as usize + frame::FRAME_OVERHEAD; + if len < frame::FRAME_OVERHEAD || len > hard_cap { + info!( + "[OMNIVOX] {who} {id} {ip}:{port} unreasonable frame size {len}", + who = role_tag(ctx), + id = ctx.id(), + ip = ctx.ip(), + port = ctx.port() + ); + return false; + } + buf.resize(len, 0); + if timeout( + Duration::from_millis(cfg.body_stall_ms), + reader.read_exact(buf), + ) + .await + .map(|r| r.is_err()) + .unwrap_or(true) + { + info!( + "[OMNIVOX] {who} {id} {ip}:{port} stalled during body read", + who = role_tag(ctx), + id = ctx.id(), + ip = ctx.ip(), + port = ctx.port() + ); + return false; + } + + let (kind, plaintext) = match frame::open_in_place(recv_key, counter, buf) { + Ok(v) => v, + Err(e) => { + // v2.1 policy: authentication failures are fatal — a + // replay, reorder, cross-session frame, or tamper. + warn!( + "[OMNIVOX] {who} {id} {ip}:{port} frame rejected: {e}", + who = role_tag(ctx), + id = ctx.id(), + ip = ctx.ip(), + port = ctx.port() + ); + return false; + } + }; + counter += 1; + + let msg_cap = cfg.max_message_size as usize; + let stream_cap = cfg.max_stream_frame as usize; + match kind { + FrameType::Message => { + if plaintext.len() + frame::FRAME_OVERHEAD > msg_cap + frame::FRAME_OVERHEAD { + return false; + } + match decode_msg::(plaintext) { + Ok(msg) => { + if let Some(f) = inner.received.get() { + fire_data(f, &msg, ctx); + } + } + Err(e) => error!("[OMNIVOX] message decode failed: {e}"), + } + } + FrameType::Stream => { + if plaintext.len() > stream_cap { + return false; + } + if let Some(f) = inner.stream.get() { + fire_stream(f, plaintext, ctx); + } } } - if let Some(mut s) = self.inner.server_writer.lock().await.take() { - let _ = s.half.shutdown().await; + } +} + +fn role_tag(ctx: &ConnCtx) -> &'static str { + if ctx.server_side { "client" } else { "server" } +} + +async fn server_read_loop( + inner: Arc>, + mut reader: OwnedReadHalf, + recv_key: [u8; KEY_LEN], + ctx: &ConnCtx, +) { + let cfg = inner.config.clone(); + let cap = cfg.max_message_size.max(cfg.max_stream_frame) as usize + frame::FRAME_OVERHEAD; + let mut buf = Vec::with_capacity(cap); + pump_frames(&inner, &mut reader, &recv_key, ctx, &cfg, &mut buf).await; +} + +async fn client_read_loop( + inner: Arc>, + mut reader: OwnedReadHalf, + recv_key: [u8; KEY_LEN], + meta: Meta, +) { + let cfg = inner.config.clone(); + let ctx = ConnCtx { + meta, + server_side: false, + inner: Arc::clone(&inner), + }; + let cap = cfg.max_message_size.max(cfg.max_stream_frame) as usize + frame::FRAME_OVERHEAD; + let mut buf = Vec::with_capacity(cap); + pump_frames(&inner, &mut reader, &recv_key, &ctx, &cfg, &mut buf).await; + *inner.server_tx.lock().await = None; + if let Some(mut m) = inner.server_meta.lock().unwrap().take() { + m.connected = ConnectionStatus::NotConnected; + let down = ConnCtx { + meta: m, + server_side: false, + inner: Arc::clone(&inner), + }; + if let Some(f) = inner.server_state.get() { + fire_ctx(f, &down); } - info!("[OMNIVOX] OmniVox stopped"); - } - - /// Server-side view of a client, for callbacks that want metadata. - pub fn client_context(&self, id: u32) -> Option { - self.inner - .clients - .lock() - .unwrap() - .get(&id) - .map(|c| c.context.clone()) - } - - pub fn server_context(&self) -> Option { - self.inner.server_ctx.lock().unwrap().clone() } } -/// 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(key: &[u8; KEY_LEN], counter: u64, data: &T) -> Option> { - 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) -} +// Panic-isolated callback dispatch (contract: a user panic never takes +// the connection — or the accept loop — down). -#[derive(Debug, thiserror::Error)] -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, 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(cb: &DataHandler, data: &T, ctx: &ConnectionContext) { - if catch_unwind(AssertUnwindSafe(|| cb(data, ctx))).is_err() { - error!("[OMNIVOX] data callback panicked; connection continues"); +fn fire_data(cb: &DataHandler, msg: &T, ctx: &ConnCtx) { + if catch_unwind(AssertUnwindSafe(|| cb(msg, ctx))).is_err() { + error!("[OMNIVOX] on_data callback panicked; connection continues"); } } -fn fire_ctx(cb: &ConnHandler, ctx: &ConnectionContext) { +fn fire_stream(cb: &StreamHandler, bytes: &[u8], ctx: &ConnCtx) { + if catch_unwind(AssertUnwindSafe(|| cb(bytes, ctx))).is_err() { + error!("[OMNIVOX] on_stream callback panicked; connection continues"); + } +} + +fn fire_ctx(cb: &ConnHandler, ctx: &ConnCtx) { if catch_unwind(AssertUnwindSafe(|| cb(ctx))).is_err() { error!("[OMNIVOX] connection callback panicked"); } } -fn fire_decision(cb: &DecisionHandler, ctx: &ConnectionContext) -> Decision { +fn fire_decision(cb: &DecisionHandler, ctx: &ConnCtx) -> Decision { catch_unwind(AssertUnwindSafe(|| cb(ctx))).unwrap_or(Decision::Deny) } diff --git a/tests/loopback.rs b/tests/loopback.rs index 78e6098..43fcdbb 100644 --- a/tests/loopback.rs +++ b/tests/loopback.rs @@ -1,9 +1,9 @@ -//! Loopback behavior tests for the v2 transport: framing discipline, -//! timeouts, junk handling, and the send-path contracts. +//! Behavior tests for the v2.1 transport via the public builder API: +//! framing discipline, timeouts, the echo roundtrip, and send-path returns. mod support; -use omnivox::{Config, ConnectionContext, Decision, OmniVox}; +use omnivox::{Config, Decision, Server}; use std::{ sync::{ Arc, @@ -11,7 +11,7 @@ use std::{ }, time::Duration, }; -use support::{Msg, TEST_PSK, client_session, frame}; +use support::{Msg, TEST_PSK, client_session}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; fn test_config() -> Config { @@ -21,33 +21,28 @@ fn test_config() -> Config { send_timeout_ms: 1000, handshake_timeout_ms: 5000, max_message_size: 8192, + max_stream_frame: 8192, max_connections: 16, allow_insecure_defaults: true, } } -async fn start_server(port: u16, rx: Arc, config: Config) -> Arc> { - let server = Arc::new(OmniVox::::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), - ) +async fn counting_server(port: u16, rx: Arc) -> Server { + omnivox::server::() + .key(TEST_PSK) + .config(test_config()) + .on_data(move |msg: &Msg, _ctx| { + if msg.0.starts_with(b"ok") { + rx.fetch_add(1, Ordering::SeqCst); + } + }) + .on_connect(|_ctx| Decision::Allow) + .serve(port) .await - .unwrap(); - server + .unwrap() } -async fn read_expect_eof(r: &mut tokio::net::tcp::OwnedReadHalf) { +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 @@ -57,136 +52,102 @@ async fn read_expect_eof(r: &mut tokio::net::tcp::OwnedReadHalf) { } #[tokio::test] -async fn tampered_body_with_valid_counter_is_skipped_connection_survives() { - let port = 18201; +async fn tampered_frame_disconnects_v21_policy() { + // v2.1: authentication failure is fatal — no skip-and-continue. + let port = 18401; let rx = Arc::new(AtomicU32::new(0)); - let server = start_server(port, Arc::clone(&rx), test_config()).await; + let server = counting_server(port, Arc::clone(&rx)).await; - let (r, mut w, s) = client_session(port, &TEST_PSK).await; - - // 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; - 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); - // 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); + let (mut r, mut w, s) = client_session(port, &TEST_PSK).await; + let mut good = support::msg_frame(&s.send_key, 1, b"ok1"); + let last = good.len() - 5; // flip a ciphertext byte + good[last] ^= 0x01; + w.write_all(&good).await.unwrap(); + expect_eof(&mut r).await; + assert_eq!(rx.load(Ordering::SeqCst), 0); server.stop().await; - drop(r); - drop(w); } #[tokio::test] -async fn zero_size_header_disconnects_immediately() { - // v2 retires the v1 no-return quirk: bad header closes, no follow-up. - let port = 18202; +async fn zero_size_header_disconnects() { + let port = 18402; let rx = Arc::new(AtomicU32::new(0)); - let server = start_server(port, Arc::clone(&rx), test_config()).await; - + let server = counting_server(port, rx).await; 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; + expect_eof(&mut r).await; server.stop().await; } #[tokio::test] async fn oversized_header_disconnects() { - let port = 18203; + let port = 18403; let rx = Arc::new(AtomicU32::new(0)); - let server = start_server(port, Arc::clone(&rx), test_config()).await; - + let server = counting_server(port, rx).await; 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; + w.write_all(&9000u32.to_le_bytes()).await.unwrap(); + expect_eof(&mut r).await; server.stop().await; } #[tokio::test] async fn header_stall_times_out() { - let port = 18204; + let port = 18404; let rx = Arc::new(AtomicU32::new(0)); - let server = start_server(port, Arc::clone(&rx), test_config()).await; - + let server = counting_server(port, rx).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; + expect_eof(&mut r).await; server.stop().await; } #[tokio::test] async fn send_to_unknown_client_is_false() { - let server = OmniVox::::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); + let rx = Arc::new(AtomicU32::new(0)); + let server = omnivox::server::() + .key(TEST_PSK) + .config(test_config()) + .on_data(move |_: &Msg, _| {}) + .serve(18405) + .await + .unwrap(); + assert!(!server.send_to_client(999, &Msg(b"x".to_vec())).await); + assert_eq!(server.broadcast(&Msg(b"x".to_vec())).await, 0); + server.stop().await; + let _ = rx; } #[tokio::test] -async fn valid_handshake_then_api_roundtrip() { - let port = 18205; - let rx = Arc::new(AtomicU32::new(0)); - let server = Arc::new(OmniVox::::new()); - 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, - test_config(), - data, - Arc::new(|_c| {}), - Arc::new(|_c| Decision::Allow), - ) +async fn echo_roundtrip_through_the_public_api() { + let port = 18406; + let server = omnivox::server::() + .key(TEST_PSK) + .config(test_config()) + .on_data(|msg, ctx| { + // echo via the synchronous callback lane + let _ = ctx.reply(msg); + }) + .serve(port) .await .unwrap(); - let client = OmniVox::::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| {}), - ) + let client = omnivox::client::() + .key(TEST_PSK) + .config(test_config()) + .on_data(move |msg, _ctx| { + if msg.0 == b"ping" { + g.fetch_add(1, Ordering::SeqCst); + } + }) + .connect("127.0.0.1", port) .await .unwrap(); - assert!(client.send_to_server(&Msg(b"ping".to_vec())).await); + assert!(client.send(&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"); + assert_eq!(got.load(Ordering::SeqCst), 1, "encrypted echo returned"); client.stop().await; server.stop().await; } - -static SERVER_FOR_ECHO: std::sync::OnceLock>> = std::sync::OnceLock::new(); diff --git a/tests/protocol_v2.rs b/tests/protocol_v2.rs index d9952cb..e4e08b9 100644 --- a/tests/protocol_v2.rs +++ b/tests/protocol_v2.rs @@ -1,9 +1,9 @@ -//! Attack regressions: every v1 vulnerability class from the red-team -//! review must now fail closed, provably, with zero callbacks fired. +//! Attack regressions for protocol v2.1: every red-team finding class must +//! fail closed, provably, with zero callbacks fired on the attack input. mod support; -use omnivox::{Config, ConnectionContext, ConnectionStatus, Decision, OmniVox, handshake::perform}; +use omnivox::{Config, ConnectionStatus, Decision}; use std::{ sync::{ Arc, @@ -11,7 +11,7 @@ use std::{ }, time::Duration, }; -use support::{Msg, TEST_PSK, client_session, frame}; +use support::{Msg, TEST_PSK, client_session, msg_frame, raw_frame, stream_frame}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::TcpStream, @@ -24,34 +24,24 @@ fn test_config() -> Config { send_timeout_ms: 1000, handshake_timeout_ms: 5000, max_message_size: 8192, + max_stream_frame: 8192, max_connections: 16, allow_insecure_defaults: true, } } -async fn counting_server_with(port: u16, rx: Arc, config: Config) -> Arc> { - let server = Arc::new(OmniVox::::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), - ) +async fn counting_server(port: u16, rx: Arc) -> omnivox::Server { + omnivox::server::() + .key(TEST_PSK) + .config(test_config()) + .on_data(move |msg: &Msg, _ctx| { + if msg.0.starts_with(b"ok") { + rx.fetch_add(1, Ordering::SeqCst); + } + }) + .serve(port) .await - .unwrap(); - server -} - -async fn counting_server(port: u16, rx: Arc) -> Arc> { - counting_server_with(port, rx, test_config()).await + .unwrap() } async fn expect_eof(r: &mut tokio::net::tcp::OwnedReadHalf) { @@ -64,21 +54,21 @@ async fn expect_eof(r: &mut tokio::net::tcp::OwnedReadHalf) { } #[tokio::test] -async fn replayed_frame_is_dropped_and_the_connection_dies() { - let port = 18301; +async fn replayed_frame_kills_the_connection() { + let port = 18501; 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"); + let good = msg_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. + // Reinject the verbatim captured frame: nonce is derived from our own + // counter (now 2), so authentication fails outright. 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; + expect_eof(&mut r).await; assert_eq!( rx.load(Ordering::SeqCst), 1, @@ -88,43 +78,47 @@ async fn replayed_frame_is_dropped_and_the_connection_dies() { } #[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; +async fn frames_from_another_session_are_rejected() { + let port = 18502; 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 (mut r2, mut w2, _s2) = client_session(port, &TEST_PSK).await; - let portable = frame(&s1.send_key, 1, b"ok-portable"); + // A perfectly valid frame from session A, injected into session B. + let portable = msg_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; + expect_eof(&mut r2).await; // v2.1: auth failure is fatal 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 frame_type_tamper_is_rejected() { + // Message -> stream downgrade attempt: type byte is AAD, so this is an + // authentication failure, not a lane switch. + let port = 18503; + let rx = Arc::new(AtomicU32::new(0)); + let server = counting_server(port, rx).await; + + let (mut r, mut w, s) = client_session(port, &TEST_PSK).await; + let mut frame = msg_frame(&s.send_key, 1, b"ok-msg"); + let type_idx = frame.len() - 17; // [len|type|ct|tag] -> type at offset 4 + frame[4] = 1; + let _ = type_idx; + w.write_all(&frame).await.unwrap(); + expect_eof(&mut r).await; server.stop().await; } #[tokio::test] async fn wrong_version_hello_is_closed() { - let port = 18303; + let port = 18504; let rx = Arc::new(AtomicU32::new(0)); let server = counting_server(port, rx).await; @@ -140,19 +134,20 @@ async fn wrong_version_hello_is_closed() { #[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 port = 18505; let rx = Arc::new(AtomicU32::new(0)); - let server = counting_server_with( - port, - rx, - Config { + let server = omnivox::server::() + .key(TEST_PSK) + .config(Config { handshake_timeout_ms: 700, ..test_config() - }, - ) - .await; + }) + .on_data(move |_: &Msg, _| { + rx.fetch_add(1, Ordering::SeqCst); + }) + .serve(port) + .await + .unwrap(); let raw = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); let (mut r, mut w) = raw.into_split(); @@ -163,41 +158,43 @@ async fn v1_traffic_is_rejected_at_the_handshake() { } #[tokio::test] -async fn panicking_callback_does_not_kill_the_connection() { - let port = 18305; +async fn panicking_callbacks_never_kill_the_connection() { + let port = 18506; let rx = Arc::new(AtomicU32::new(0)); - let server = Arc::new(OmniVox::::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), - ) + let server = omnivox::server::() + .key(TEST_PSK) + .config(test_config()) + .on_data(move |msg: &Msg, _ctx| { + if msg.0 == b"boom" { + panic!("handler exploded on attacker input"); + } + if msg.0.starts_with(b"ok") { + ok_rx.fetch_add(1, Ordering::SeqCst); + } + }) + .on_stream(|_b, _ctx| { + panic!("stream handler exploded"); + }) + .serve(port) .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")) + w.write_all(&msg_frame(&s.send_key, 1, b"boom")) + .await + .unwrap(); + w.write_all(&stream_frame(&s.send_key, 2, b"stream-boom")) + .await + .unwrap(); + w.write_all(&msg_frame(&s.send_key, 3, b"ok-after-panics")) .await .unwrap(); tokio::time::sleep(Duration::from_millis(300)).await; assert_eq!( rx.load(Ordering::SeqCst), 1, - "survives a panicking callback" + "connection survives two panics" ); let mut buf = [0u8; 1]; assert!( @@ -211,8 +208,7 @@ async fn panicking_callback_does_not_kill_the_connection() { #[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 port = 18507; let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)) .await .unwrap(); @@ -221,30 +217,27 @@ async fn malicious_server_silence_times_out_the_client() { 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(); + omnivox::handshake::perform(&mut r, &mut w, &TEST_PSK, true) + .await + .unwrap(); tokio::time::sleep(Duration::from_secs(60)).await; }); - let client = OmniVox::::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); - } - }), - ) + let client = omnivox::client::() + .key(TEST_PSK) + .config(Config { + idle_timeout_ms: 400, + ..test_config() + }) + .on_data(|_m: &Msg, _c| {}) + .on_state(move |ctx| { + if ctx.status() == ConnectionStatus::NotConnected { + d.store(1, Ordering::SeqCst); + } + }) + .connect("127.0.0.1", port) .await .unwrap(); @@ -257,3 +250,124 @@ async fn malicious_server_silence_times_out_the_client() { accepted.abort(); client.stop().await; } + +#[tokio::test] +async fn messages_and_streams_interleave_in_order() { + // One sequence across both lanes: strict per-direction ordering of the + // exact interleaving the sender produced. + let port = 18508; + let tape = Arc::new(std::sync::Mutex::new(Vec::::new())); + let t = Arc::clone(&tape); + let t2 = Arc::clone(&tape); + let server = omnivox::server::() + .key(TEST_PSK) + .config(test_config()) + .on_data(move |msg: &Msg, _ctx| { + t.lock() + .unwrap() + .push(format!("msg:{}", String::from_utf8_lossy(&msg.0))); + }) + .on_stream(move |bytes, _ctx| { + t2.lock() + .unwrap() + .push(format!("stream:{}", String::from_utf8_lossy(bytes))); + }) + .serve(port) + .await + .unwrap(); + + let (_r, mut w, s) = client_session(port, &TEST_PSK).await; + w.write_all(&msg_frame(&s.send_key, 1, b"alpha")) + .await + .unwrap(); + w.write_all(&stream_frame(&s.send_key, 2, b"S1")) + .await + .unwrap(); + w.write_all(&msg_frame(&s.send_key, 3, b"beta")) + .await + .unwrap(); + w.write_all(&stream_frame(&s.send_key, 4, b"S2")) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(400)).await; + assert_eq!( + *tape.lock().unwrap(), + vec![ + "msg:alpha".to_string(), + "stream:S1".to_string(), + "msg:beta".to_string(), + "stream:S2".to_string(), + ] + ); + server.stop().await; +} + +#[tokio::test] +async fn lying_decoder_length_is_skipped_connection_survives() { + // A frame that authenticates but whose payload claims a huge bincode + // length: decode fails, frame is skipped (authenticated junk policy), + // connection continues. + let port = 18509; + 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 lying = { + // Msg(Vec) bincode: u64 LE length then bytes. Claim 2^40. + let mut plain = Vec::new(); + plain.extend_from_slice(&(1u64 << 40).to_le_bytes()); + plain.extend_from_slice(b"ha"); + raw_frame(&s.send_key, 1, omnivox::FrameType::Message, &plain) + }; + w.write_all(&lying).await.unwrap(); + w.write_all(&msg_frame(&s.send_key, 2, b"ok-real")) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(300)).await; + assert_eq!( + rx.load(Ordering::SeqCst), + 1, + "junk skipped, real frame arrives" + ); + 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 decision_deny_rejects_after_handshake() { + // A fully authenticated client can still be refused; it must see EOF + // and no data path may open. + let port = 18510; + let seen = Arc::new(AtomicU8::new(0)); + let s = Arc::clone(&seen); + let server = omnivox::server::() + .key(TEST_PSK) + .config(test_config()) + .on_data(move |_: &Msg, _| { + s.store(9, Ordering::SeqCst); // must never run + }) + .on_connect(move |_ctx| Decision::Deny) + .serve(port) + .await + .unwrap(); + + let (mut r, mut w, sess) = client_session(port, &TEST_PSK).await; + // handshake completed (client_session); the Deny must have closed us. + expect_eof(&mut r).await; + let _ = w + .write_all(&msg_frame(&sess.send_key, 1, b"ok-after-deny")) + .await; + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!( + seen.load(Ordering::SeqCst), + 0, + "denied client never reaches data path" + ); + server.stop().await; +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 806e7ad..c500129 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -1,39 +1,55 @@ -//! Shared helpers for the integration tests: v2 client sessions against a -//! live server, and raw frame construction. +#![allow(dead_code)] // each test binary uses a subset -use omnivox::{Cereal, CodecError, Session, encrypt_with_nonce, frame_nonce, handshake::perform}; +//! Shared helpers for the integration tests: raw v2.1 client sessions +//! (handshake completed, manual framing) for the attack regressions. + +use omnivox::{ + FrameType, + frame::{FrameType as Ft, seal}, + handshake::{Session, perform}, +}; +use serde::{Deserialize, Serialize}; use tokio::net::TcpStream; use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; -#[derive(Debug, Default)] +/// Test message type: serde-derives make it a `Message` by blanket impl. +#[derive(Serialize, Deserialize, Debug, PartialEq)] pub struct Msg(pub Vec); -impl Cereal for Msg { - fn encode(&self, out: &mut Vec) { - omnivox::write_byte_vec(out, &self.0); - } - fn decode(bytes: &[u8]) -> Result { - 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 { - 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 +pub fn bincode_cfg() -> bincode::config::Configuration< + bincode::config::LittleEndian, + bincode::config::Fixint, + bincode::config::Limit<16777216>, +> { + bincode::config::standard() + .with_fixed_int_encoding() + .with_limit::<16777216>() } -/// Connects and completes the v2 handshake as the client role, returning -/// the raw halves plus the session keys for manual framing. +/// Serialize like the library does. +pub fn encode(msg: &Msg) -> Vec { + bincode::serde::encode_to_vec(msg, bincode_cfg()).unwrap() +} + +/// One complete framed message (with u32 length prefix) ready to write. +pub fn msg_frame(key: &[u8; 32], counter: u64, payload: &[u8]) -> Vec { + seal(key, counter, Ft::Message, &encode(&Msg(payload.to_vec()))) +} + +/// One complete framed stream chunk. +pub fn stream_frame(key: &[u8; 32], counter: u64, payload: &[u8]) -> Vec { + seal(key, counter, Ft::Stream, payload) +} + +/// Arbitrary-kind frame for protocol-violation tests. +pub fn raw_frame(key: &[u8; 32], counter: u64, kind: FrameType, plaintext: &[u8]) -> Vec { + seal(key, counter, kind, plaintext) +} + +/// Connect and complete the v2 handshake as the client role, returning +/// raw halves and 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();