0.15.0: simple builder API, v2.1 framing, stream lane, benches
- omnivox::server::<T>()/client::<T>() 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
This commit is contained in:
Generated
+560
-2
@@ -12,12 +12,59 @@ dependencies = [
|
|||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "base64ct"
|
name = "base64ct"
|
||||||
version = "1.8.3"
|
version = "1.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
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]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.2"
|
version = "2.13.2"
|
||||||
@@ -33,12 +80,24 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bumpalo"
|
||||||
|
version = "3.20.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.12.1"
|
version = "1.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cast"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -69,6 +128,33 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
version = "0.4.4"
|
version = "0.4.4"
|
||||||
@@ -80,6 +166,31 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -89,6 +200,75 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -137,6 +317,12 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
@@ -153,6 +339,67 @@ version = "0.2.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
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]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.7"
|
||||||
@@ -186,6 +433,23 @@ dependencies = [
|
|||||||
"wasip2",
|
"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]]
|
[[package]]
|
||||||
name = "hkdf"
|
name = "hkdf"
|
||||||
version = "0.12.4"
|
version = "0.12.4"
|
||||||
@@ -213,6 +477,43 @@ dependencies = [
|
|||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -240,6 +541,12 @@ version = "0.4.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.3"
|
version = "1.2.3"
|
||||||
@@ -261,14 +568,26 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "omnivox"
|
name = "num-traits"
|
||||||
version = "0.14.0"
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "omnivox"
|
||||||
|
version = "0.15.0"
|
||||||
|
dependencies = [
|
||||||
|
"bincode",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
|
"criterion",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"hkdf",
|
"hkdf",
|
||||||
"rand_core",
|
"rand_core",
|
||||||
"scrypt",
|
"scrypt",
|
||||||
|
"serde",
|
||||||
"sha2",
|
"sha2",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -283,6 +602,12 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oorandom"
|
||||||
|
version = "11.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opaque-debug"
|
name = "opaque-debug"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -339,6 +664,34 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
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]]
|
[[package]]
|
||||||
name = "poly1305"
|
name = "poly1305"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -383,6 +736,26 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"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]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -392,6 +765,35 @@ dependencies = [
|
|||||||
"bitflags",
|
"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]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -401,6 +803,12 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "salsa20"
|
name = "salsa20"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -410,6 +818,15 @@ dependencies = [
|
|||||||
"cipher",
|
"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]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -464,6 +881,19 @@ dependencies = [
|
|||||||
"syn 3.0.6",
|
"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]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -494,6 +924,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slab"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.16.2"
|
version = "1.16.2"
|
||||||
@@ -567,6 +1003,16 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.53.1"
|
version = "1.53.1"
|
||||||
@@ -674,6 +1120,12 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unty"
|
||||||
|
version = "0.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "valuable"
|
name = "valuable"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -686,6 +1138,22 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
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]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
@@ -701,6 +1169,70 @@ dependencies = [
|
|||||||
"wit-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -734,6 +1266,26 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "zeroize"
|
name = "zeroize"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
@@ -753,3 +1305,9 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
|
|||||||
+20
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "omnivox"
|
name = "omnivox"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
description = "Encrypted async TCP client-server messaging: X25519 handshake, ChaCha20-Poly1305, replay-proof sequencing (OmniVox protocol v2)"
|
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"
|
sha2 = "0.10"
|
||||||
x25519-dalek = { version = "2", features = ["zeroize"] }
|
x25519-dalek = { version = "2", features = ["zeroize"] }
|
||||||
scrypt = "0.11"
|
scrypt = "0.11"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
bincode = { version = "2", features = ["serde"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "time", "sync", "macros"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "time", "sync", "macros"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
bincode = { version = "2", features = ["serde"] }
|
||||||
tracing-subscriber = { version = "0.3", features = ["fmt"] }
|
tracing-subscriber = { version = "0.3", features = ["fmt"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
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
|
||||||
|
|||||||
+108
-87
@@ -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
|
Wire-incompatible with the 2013 C++ OmniVox by design. Goals: perfect
|
||||||
original C++ implementation); v1 traffic is rejected at the handshake and
|
forward secrecy (ephemeral X25519), per-session per-direction keys, no
|
||||||
`PARITY-CONTRACT.md` is retired alongside it.
|
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
|
TCP is connected, then both sides exchange a fixed 69-byte hello:
|
||||||
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 connect
|
bytes 0..4 magic "OVX2"
|
||||||
│
|
byte 4 version 2
|
||||||
├── HELLO exchange (plaintext, fixed size, timeout: handshake_timeout_ms)
|
bytes 5..37 ephemeral X25519 public key
|
||||||
├── 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 37..69 session nonce (32 random bytes)
|
bytes 37..69 session nonce (32 random bytes)
|
||||||
```
|
```
|
||||||
|
|
||||||
The client sends first. The server validates magic, version, and key
|
A hello with wrong magic or version fails closed (connection closed, no
|
||||||
encoding **before replying**; any violation closes the connection. The
|
response). Both hellos are exchanged before any key is derived, so the two
|
||||||
server rejects an all-zero DH output (small-order contribution).
|
peers are symmetric roles distinguished only by their nonces.
|
||||||
|
|
||||||
## Key schedule
|
|
||||||
|
|
||||||
```
|
```
|
||||||
dh = X25519(eph_local, eph_peer)
|
dh = X25519(eph_sk_peer_side, eph_pk_other_side)
|
||||||
prk = HKDF-SHA256-Extract(salt = nonce_client ‖ nonce_server, ikm = PSK)
|
salt = nonce_client ‖ nonce_server
|
||||||
okm = HKDF-SHA256-Expand(prk, info = "omnivox-v2" ‖ hello_client ‖ hello_server, 64)
|
prk = HKDF-SHA256-Extract(salt, ikm = PSK ‖ dh)
|
||||||
key_c2s = okm[0..32] key_s2c = okm[32..64]
|
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
|
(Names are from the client's view: each peer encrypts with the key whose
|
||||||
server. Direction separation means neither peer can present itself forged
|
name is the direction it writes.) The PSK is `scrypt(password, salt,
|
||||||
frames (v1's single symmetric key could). The ephemeral DH gives forward
|
N=2^15, r=8, p=1, 32)` — run on a blocking thread. The pre-hello exchange
|
||||||
secrecy against later PSK compromise; the PSK authenticates against
|
is unauthenticated; the first framed message (which authenticates under
|
||||||
off-path attackers. A weak password remains guessable by an active attacker
|
the derived keys) is the proof both sides hold the same PSK. An
|
||||||
with the PSK budget to grind scrypt — use random 32-byte PSKs; a PAKE is the
|
attacker without the PSK sees every frame fail authentication and the
|
||||||
future upgrade if passwords must stay human.
|
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)
|
u32 LE len total byte count of type ‖ ciphertext ‖ tag
|
||||||
AeadPacket:
|
u8 type 0 = message (bincode payload), 1 = stream (raw bytes)
|
||||||
nonce [u8; 12] four zero bytes ‖ u64 BE sequence number
|
bytes ct ChaCha20-Poly1305 ciphertext (plaintext length = len − 17)
|
||||||
tag [u8; 16] Poly1305 tag
|
bytes tag 16-byte Poly1305 tag
|
||||||
len u64 LE (cereal vector length, retained for payload lineage)
|
|
||||||
ct [len] ChaCha20-Poly1305 ciphertext of the serialized payload
|
|
||||||
```
|
```
|
||||||
|
|
||||||
AEAD is ChaCha20-Poly1305 (RFC 8439) with the packet's own nonce as the IETF
|
`len ∈ [17, max_frame]`; zero or oversized is a protocol violation
|
||||||
nonce and empty AAD — identical to v1's construction, now keyed per session
|
(close). `max_frame` is `max_message_size` for type 0 (default 64 KiB)
|
||||||
per direction.
|
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
|
The nonce is **not** on the wire. It is derived from the sender's
|
||||||
failed write must not consume a number. The receiver's first expected value
|
per-direction sequence number:
|
||||||
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.
|
|
||||||
|
|
||||||
### Payload encoding
|
```
|
||||||
|
nonce(counter) = 00 00 00 00 ‖ u64 BE counter
|
||||||
|
```
|
||||||
|
|
||||||
The plaintext is the `Cereal` binary encoding of the message type — raw
|
Each sender's counter starts at 1 and increments by exactly one per
|
||||||
little-endian fields, `u64` length prefixes for byte vectors, no archive
|
frame; each receiver tracks its own expected counter, starting at 1. The
|
||||||
header — unchanged from the C++ original.
|
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 |
|
| handshake | 10 s | hello exchange incomplete |
|
||||||
| idle_timeout_ms (silence between messages) | 15000 | close |
|
| body stall | 5 s | frame body started, never finished |
|
||||||
| body_stall_ms (mid-body silence) | 5000 | close |
|
| idle | 15 s | no inbound frames at all |
|
||||||
| send_timeout_ms (peer not reading) | 5000 | close that peer |
|
| send queue | 5 s | outbound queue full and staying full |
|
||||||
| max_message_size | 65536 | close on header |
|
|
||||||
| max_connections (server) | 1024 | new connections wait |
|
|
||||||
|
|
||||||
Corrupt frames with the *correct* sequence number (failed AEAD or payload
|
## Test coverage
|
||||||
decode) are logged and skipped; the connection continues. Anything else —
|
|
||||||
sequence violations, unreasonable headers, stalls — is a disconnect.
|
|
||||||
|
|
||||||
## Security properties
|
| Property | Mechanism | Test |
|
||||||
|
|
||||||
| Property | Mechanism | Tested by |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Confidentiality | ChaCha20-Poly1305 per-session keys | `protocol_v2::frames_from_another_session_are_undecryptable` |
|
| Confidentiality | per-session per-direction keys | `frames_from_another_session_are_rejected` |
|
||||||
| Replay defense | strict sequence numbers | `protocol_v2::replayed_frame_is_dropped_and_the_connection_dies` |
|
| Replay defense | derived nonce + expected counter | `replayed_frame_kills_the_connection` |
|
||||||
| Session binding | nonces enter the salt/info | same cross-session test |
|
| Session binding | nonces in salt | same cross-session test |
|
||||||
| Forward secrecy | ephemeral X25519 per connection | design (single-connection compromise leaks only it) |
|
| Type tampering | type byte as AAD | `frame_type_tamper_is_rejected` |
|
||||||
| 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` |
|
| Version downgrade | magic+version gate | `wrong_version_hello_is_closed` |
|
||||||
| Liveness both roles | idle + stall timeouts client **and** server | `malicious_server_silence_times_out_the_client`, `header_stall_times_out` |
|
| v1 traffic | fails hello | `v1_traffic_is_rejected_at_the_handshake` |
|
||||||
| Slow-client containment | per-connection locks + send timeout | design (no shared writer mutex) |
|
| Handler panics | catch_unwind per callback | `panicking_callbacks_never_kill_the_connection` |
|
||||||
| DoS containment | connection semaphore, message caps, callback panic isolation | `panicking_callback_does_not_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` |
|
||||||
## Refusals
|
| Ordering | shared sequence | `messages_and_streams_interleave_in_order` |
|
||||||
|
|
||||||
Release builds refuse to start with the original demo key (scrypt of
|
|
||||||
`SecurePassword123` / salt `01…08`) unless `allow_insecure_defaults` is set.
|
|
||||||
|
|||||||
@@ -1,44 +1,65 @@
|
|||||||
# omnivox-rs
|
# 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
|
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
|
The API is the original C++ shape — your struct goes in, a match arms over
|
||||||
- ChaCha20-Poly1305 with strictly increasing sequence numbers → replay-proof
|
it in one callback — with all the boilerplate removed:
|
||||||
- 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
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use omnivox::{Config, Cereal, CodecError, Decision, OmniVox, derive_encrypt_key};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
let endpoint = OmniVox::<MyPayload>::new();
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
endpoint.set_encrypt_key(derive_encrypt_key(b"password", b"salt", 17, 8, 1)?);
|
enum AppMsg { Hello, Note(String) }
|
||||||
endpoint.start_server(8080, Config::default(), on_data, on_disconnect, on_connect).await?;
|
|
||||||
|
// server
|
||||||
|
let server = omnivox::server::<AppMsg>()
|
||||||
|
.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::<AppMsg>()
|
||||||
|
.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
|
Any `Serialize + DeserializeOwned` type is a message — serde+bincode
|
||||||
lengths — the original's serialization). Runnable demos: `examples/`.
|
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
|
## 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 |
|
| 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 |
|
| 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 |
|
| one symmetric key, either peer can forge as itself | direction-separated HKDF session keys |
|
||||||
| static key, no forward secrecy | ephemeral X25519 per connection |
|
| 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 |
|
| no client-side timeouts | full idle/stall discipline both roles |
|
||||||
| panicking user callback killed the read loop | callbacks panic-isolated |
|
| panicking user callback killed the read loop | callbacks panic-isolated |
|
||||||
| demo password in every repo checkout | release builds refuse the demo key |
|
| 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
|
Use random 32-byte PSKs where you can; an active attacker can grind scrypt
|
||||||
against human passwords (PAKE planned).
|
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
|
## Build & test
|
||||||
|
|
||||||
```bash
|
```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 clippy --all-targets -- -D warnings
|
||||||
cargo run --example omnivox-server # terminal 1
|
|
||||||
cargo run --example omnivox-client # terminal 2
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
GPL-3.0-or-later.
|
GPL-3.0-or-later.
|
||||||
@@ -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<u8>);
|
||||||
|
|
||||||
|
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::<M>()
|
||||||
|
.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::<M>()
|
||||||
|
.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::<M>()
|
||||||
|
.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::<M>()
|
||||||
|
.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<u8> = 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();
|
||||||
|
}
|
||||||
@@ -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<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Cereal for DatagramPayload {
|
|
||||||
fn encode(&self, out: &mut Vec<u8>) {
|
|
||||||
out.extend_from_slice(&self.id.to_le_bytes());
|
|
||||||
out.extend_from_slice(&self.v1.to_le_bytes());
|
|
||||||
write_byte_vec(out, &self.payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode(bytes: &[u8]) -> Result<Self, CodecError> {
|
|
||||||
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";
|
|
||||||
@@ -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<dyn std::error::Error>> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_target(false)
|
||||||
|
.without_time()
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let agent = omnivox::client::<AgentMsg>()
|
||||||
|
.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(())
|
||||||
|
}
|
||||||
@@ -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<dyn std::error::Error>> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_target(false)
|
||||||
|
.without_time()
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// Shared across both callbacks (and the Done handler) — a plain
|
||||||
|
// Arc<Mutex<..>> 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::<AgentMsg>()
|
||||||
|
.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(())
|
||||||
|
}
|
||||||
+33
-62
@@ -1,12 +1,17 @@
|
|||||||
//! Port of OmniVox-client.cpp: connects, sends "Hello", prints what the
|
//! The minimal OmniVox client: connect, talk, done.
|
||||||
//! server echoes, then stops after 15 seconds like the C++ example.
|
//!
|
||||||
|
//! Companion to `omnivox-server`. Sends `Hello`, prints the echo, then
|
||||||
|
//! exchanges two more notes and stops.
|
||||||
|
|
||||||
#[path = "common/mod.rs"]
|
use omnivox::ConnectionStatus;
|
||||||
mod common;
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use common::{DatagramPayload, PASSWORD, SALT, SERVER_ADDR, SERVER_PORT};
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
use omnivox::{Config, ConnectionContext, ConnectionStatus, OmniVox, derive_encrypt_key};
|
enum ChatMsg {
|
||||||
use std::sync::Arc;
|
Hello,
|
||||||
|
Note(String),
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -15,63 +20,29 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.without_time()
|
.without_time()
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
tracing::info!("Connecting to server at {SERVER_ADDR}:{SERVER_PORT}");
|
let client = omnivox::client::<ChatMsg>()
|
||||||
|
// Same password + salt as the server — they derive the same PSK.
|
||||||
let client = Arc::new(OmniVox::<DatagramPayload>::new());
|
.password("SecurePassword123", b"static-demo-salt")
|
||||||
let key = derive_encrypt_key(PASSWORD.as_bytes(), SALT, 17, 8, 1)?;
|
.on_data(|msg, _ctx| match msg {
|
||||||
client.set_encrypt_key(key);
|
ChatMsg::Hello => tracing::info!("server greeted us unprompted?"),
|
||||||
|
ChatMsg::Note(text) => tracing::info!("server note: {text}"),
|
||||||
let data_cb = Arc::new(|_data: &DatagramPayload, ctx: &ConnectionContext| {
|
})
|
||||||
tracing::info!("Server ID: {}, sent data!", ctx.id);
|
// Fires on Connected, ConnectionFailed, and later NotConnected.
|
||||||
});
|
.on_state(|ctx| {
|
||||||
|
tracing::info!("state: {:?}", ctx.status());
|
||||||
let connector = Arc::clone(&client);
|
if ctx.status() == ConnectionStatus::ConnectionFailed {
|
||||||
let connection_cb = Arc::new(move |ctx: &ConnectionContext| match ctx.connected {
|
tracing::error!("is the server running? (cargo run --example omnivox-server)");
|
||||||
ConnectionStatus::Connected => {
|
}
|
||||||
tracing::info!(
|
})
|
||||||
"[onConnect] Successfully connected to: {}:{}",
|
.connect("127.0.0.1", 8080)
|
||||||
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,
|
|
||||||
)
|
|
||||||
.await?;
|
.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;
|
client.stop().await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-78
@@ -1,15 +1,21 @@
|
|||||||
//! Port of OmniVox-server.cpp: echoes each client payload back with the
|
//! The minimal OmniVox server: one enum, one closure, one call.
|
||||||
//! client id, refusing no client that holds the key.
|
//!
|
||||||
|
//! 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"]
|
use omnivox::Decision;
|
||||||
mod common;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use common::{DatagramPayload, PASSWORD, SALT, SERVER_PORT};
|
/// The one message type of our tiny protocol. Because this is an enum,
|
||||||
use omnivox::{Config, ConnectionContext, Decision, OmniVox, derive_encrypt_key};
|
/// `on_data`'s `match` is the switch-on-id pattern from the C++ days,
|
||||||
use std::{
|
/// checked by the compiler instead of a runtime default case.
|
||||||
collections::HashMap,
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
sync::{Arc, Mutex},
|
enum ChatMsg {
|
||||||
};
|
Hello,
|
||||||
|
Note(String),
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -18,78 +24,37 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.without_time()
|
.without_time()
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
tracing::info!("Starting server on port {SERVER_PORT}");
|
let server = omnivox::server::<ChatMsg>()
|
||||||
|
// Both peers must derive the same key. scrypt runs once, at startup,
|
||||||
let server = Arc::new(OmniVox::<DatagramPayload>::new());
|
// on a blocking thread (~1 s, 128 MiB). Prefer a random 32-byte PSK
|
||||||
let key = derive_encrypt_key(PASSWORD.as_bytes(), SALT, 17, 8, 1)?;
|
// via `.key([u8; 32])` in real deployments.
|
||||||
server.set_encrypt_key(key);
|
.password("SecurePassword123", b"static-demo-salt")
|
||||||
|
.on_data(|msg, ctx| match msg {
|
||||||
// The C++ example's g_clientHalt map, kept for behavioral parity.
|
ChatMsg::Hello => {
|
||||||
let client_halt: Arc<Mutex<HashMap<u32, bool>>> = Arc::new(Mutex::new(HashMap::new()));
|
tracing::info!("client {} says hello", ctx.id());
|
||||||
|
// Synchronous, non-blocking enqueue. A `false` return would
|
||||||
let responder = Arc::clone(&server);
|
// mean the peer stopped reading — never a panic, never a stall.
|
||||||
let received = Arc::new(move |data: &DatagramPayload, ctx: &ConnectionContext| {
|
let _ = ctx.reply(&ChatMsg::Note(format!("echo {}", ctx.id())));
|
||||||
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}");
|
|
||||||
}
|
}
|
||||||
});
|
ChatMsg::Note(text) => {
|
||||||
});
|
tracing::info!("client {} wrote: {text}", ctx.id());
|
||||||
|
}
|
||||||
let halt = Arc::clone(&client_halt);
|
})
|
||||||
let disconnect = Arc::new(move |ctx: &ConnectionContext| {
|
// Every authenticated client is welcome; return `Decision::Deny`
|
||||||
tracing::info!(
|
// to close specific peers (per-IP policy, allowlists, ...).
|
||||||
"Client {} {}:{} is disconnecting from the server cleaning up objects.",
|
.on_connect(|ctx| {
|
||||||
ctx.id,
|
tracing::info!("accepting {}:{}", ctx.ip(), ctx.port());
|
||||||
ctx.ip,
|
Decision::Allow
|
||||||
ctx.port
|
})
|
||||||
);
|
.on_disconnect(|ctx| tracing::info!("client {} left", ctx.id()))
|
||||||
*halt.lock().unwrap().entry(ctx.id).or_default() = true;
|
.serve(8080)
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
.await?;
|
.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;
|
std::future::pending::<()>().await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+263
@@ -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<Self, FrameError> {
|
||||||
|
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<u8>,
|
||||||
|
) {
|
||||||
|
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<u8> {
|
||||||
|
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<u8>), 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1013
-652
File diff suppressed because it is too large
Load Diff
+73
-112
@@ -1,9 +1,9 @@
|
|||||||
//! Loopback behavior tests for the v2 transport: framing discipline,
|
//! Behavior tests for the v2.1 transport via the public builder API:
|
||||||
//! timeouts, junk handling, and the send-path contracts.
|
//! framing discipline, timeouts, the echo roundtrip, and send-path returns.
|
||||||
|
|
||||||
mod support;
|
mod support;
|
||||||
|
|
||||||
use omnivox::{Config, ConnectionContext, Decision, OmniVox};
|
use omnivox::{Config, Decision, Server};
|
||||||
use std::{
|
use std::{
|
||||||
sync::{
|
sync::{
|
||||||
Arc,
|
Arc,
|
||||||
@@ -11,7 +11,7 @@ use std::{
|
|||||||
},
|
},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
use support::{Msg, TEST_PSK, client_session, frame};
|
use support::{Msg, TEST_PSK, client_session};
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
fn test_config() -> Config {
|
fn test_config() -> Config {
|
||||||
@@ -21,33 +21,28 @@ fn test_config() -> Config {
|
|||||||
send_timeout_ms: 1000,
|
send_timeout_ms: 1000,
|
||||||
handshake_timeout_ms: 5000,
|
handshake_timeout_ms: 5000,
|
||||||
max_message_size: 8192,
|
max_message_size: 8192,
|
||||||
|
max_stream_frame: 8192,
|
||||||
max_connections: 16,
|
max_connections: 16,
|
||||||
allow_insecure_defaults: true,
|
allow_insecure_defaults: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn start_server(port: u16, rx: Arc<AtomicU32>, config: Config) -> Arc<OmniVox<Msg>> {
|
async fn counting_server(port: u16, rx: Arc<AtomicU32>) -> Server<Msg> {
|
||||||
let server = Arc::new(OmniVox::<Msg>::new());
|
omnivox::server::<Msg>()
|
||||||
server.set_encrypt_key(TEST_PSK);
|
.key(TEST_PSK)
|
||||||
let data = Arc::new(move |msg: &Msg, _ctx: &ConnectionContext| {
|
.config(test_config())
|
||||||
if msg.0.starts_with(b"ok") {
|
.on_data(move |msg: &Msg, _ctx| {
|
||||||
rx.fetch_add(1, Ordering::SeqCst);
|
if msg.0.starts_with(b"ok") {
|
||||||
}
|
rx.fetch_add(1, Ordering::SeqCst);
|
||||||
});
|
}
|
||||||
server
|
})
|
||||||
.start_server(
|
.on_connect(|_ctx| Decision::Allow)
|
||||||
port,
|
.serve(port)
|
||||||
config,
|
|
||||||
data,
|
|
||||||
Arc::new(|_ctx| {}),
|
|
||||||
Arc::new(|_ctx| Decision::Allow),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap()
|
||||||
server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 mut buf = [0u8; 1];
|
||||||
let n = tokio::time::timeout(Duration::from_secs(4), r.read(&mut buf))
|
let n = tokio::time::timeout(Duration::from_secs(4), r.read(&mut buf))
|
||||||
.await
|
.await
|
||||||
@@ -57,136 +52,102 @@ async fn read_expect_eof(r: &mut tokio::net::tcp::OwnedReadHalf) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn tampered_body_with_valid_counter_is_skipped_connection_survives() {
|
async fn tampered_frame_disconnects_v21_policy() {
|
||||||
let port = 18201;
|
// v2.1: authentication failure is fatal — no skip-and-continue.
|
||||||
|
let port = 18401;
|
||||||
let rx = Arc::new(AtomicU32::new(0));
|
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;
|
let (mut r, mut w, s) = client_session(port, &TEST_PSK).await;
|
||||||
|
let mut good = support::msg_frame(&s.send_key, 1, b"ok1");
|
||||||
// Counter 1, tampered ciphertext: auth fails, frame dropped, alive.
|
let last = good.len() - 5; // flip a ciphertext byte
|
||||||
let mut bad = frame(&s.send_key, 1, b"ok-junk");
|
good[last] ^= 0x01;
|
||||||
let last = bad.len() - 1;
|
w.write_all(&good).await.unwrap();
|
||||||
bad[last] ^= 0x01;
|
expect_eof(&mut r).await;
|
||||||
w.write_all(&bad).await.unwrap();
|
assert_eq!(rx.load(Ordering::SeqCst), 0);
|
||||||
|
|
||||||
// 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);
|
|
||||||
server.stop().await;
|
server.stop().await;
|
||||||
drop(r);
|
|
||||||
drop(w);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn zero_size_header_disconnects_immediately() {
|
async fn zero_size_header_disconnects() {
|
||||||
// v2 retires the v1 no-return quirk: bad header closes, no follow-up.
|
let port = 18402;
|
||||||
let port = 18202;
|
|
||||||
let rx = Arc::new(AtomicU32::new(0));
|
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;
|
let (mut r, mut w, _s) = client_session(port, &TEST_PSK).await;
|
||||||
w.write_all(&0u32.to_le_bytes()).await.unwrap();
|
w.write_all(&0u32.to_le_bytes()).await.unwrap();
|
||||||
read_expect_eof(&mut r).await;
|
expect_eof(&mut r).await;
|
||||||
server.stop().await;
|
server.stop().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn oversized_header_disconnects() {
|
async fn oversized_header_disconnects() {
|
||||||
let port = 18203;
|
let port = 18403;
|
||||||
let rx = Arc::new(AtomicU32::new(0));
|
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;
|
let (mut r, mut w, _s) = client_session(port, &TEST_PSK).await;
|
||||||
w.write_all(&8193u32.to_le_bytes()).await.unwrap();
|
w.write_all(&9000u32.to_le_bytes()).await.unwrap();
|
||||||
read_expect_eof(&mut r).await;
|
expect_eof(&mut r).await;
|
||||||
server.stop().await;
|
server.stop().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn header_stall_times_out() {
|
async fn header_stall_times_out() {
|
||||||
let port = 18204;
|
let port = 18404;
|
||||||
let rx = Arc::new(AtomicU32::new(0));
|
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;
|
let (mut r, mut w, _s) = client_session(port, &TEST_PSK).await;
|
||||||
w.write_all(&[0x10, 0x00]).await.unwrap(); // partial header, then silence
|
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;
|
server.stop().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn send_to_unknown_client_is_false() {
|
async fn send_to_unknown_client_is_false() {
|
||||||
let server = OmniVox::<Msg>::new();
|
let rx = Arc::new(AtomicU32::new(0));
|
||||||
server.set_encrypt_key(TEST_PSK);
|
let server = omnivox::server::<Msg>()
|
||||||
assert!(!server.send_to_client(&Msg(b"x".to_vec()), 999).await);
|
.key(TEST_PSK)
|
||||||
assert!(!server.send_to_server(&Msg(b"x".to_vec())).await);
|
.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]
|
#[tokio::test]
|
||||||
async fn valid_handshake_then_api_roundtrip() {
|
async fn echo_roundtrip_through_the_public_api() {
|
||||||
let port = 18205;
|
let port = 18406;
|
||||||
let rx = Arc::new(AtomicU32::new(0));
|
let server = omnivox::server::<Msg>()
|
||||||
let server = Arc::new(OmniVox::<Msg>::new());
|
.key(TEST_PSK)
|
||||||
server.set_encrypt_key(TEST_PSK);
|
.config(test_config())
|
||||||
let data = Arc::new({
|
.on_data(|msg, ctx| {
|
||||||
let rx = Arc::clone(&rx);
|
// echo via the synchronous callback lane
|
||||||
move |msg: &Msg, ctx: &ConnectionContext| {
|
let _ = ctx.reply(msg);
|
||||||
if msg.0.starts_with(b"ping") {
|
})
|
||||||
rx.fetch_add(1, Ordering::SeqCst);
|
.serve(port)
|
||||||
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),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let client = OmniVox::<Msg>::new();
|
|
||||||
client.set_encrypt_key(TEST_PSK);
|
|
||||||
let got = Arc::new(AtomicU32::new(0));
|
let got = Arc::new(AtomicU32::new(0));
|
||||||
let g = Arc::clone(&got);
|
let g = Arc::clone(&got);
|
||||||
client
|
let client = omnivox::client::<Msg>()
|
||||||
.connect_to_server(
|
.key(TEST_PSK)
|
||||||
"127.0.0.1",
|
.config(test_config())
|
||||||
port,
|
.on_data(move |msg, _ctx| {
|
||||||
test_config(),
|
if msg.0 == b"ping" {
|
||||||
Some(Arc::new(move |msg: &Msg, _c| {
|
g.fetch_add(1, Ordering::SeqCst);
|
||||||
if msg.0 == b"pong" {
|
}
|
||||||
g.fetch_add(1, Ordering::SeqCst);
|
})
|
||||||
}
|
.connect("127.0.0.1", port)
|
||||||
})),
|
|
||||||
Arc::new(|_c| {}),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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;
|
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||||
assert_eq!(rx.load(Ordering::SeqCst), 1);
|
assert_eq!(got.load(Ordering::SeqCst), 1, "encrypted echo returned");
|
||||||
assert_eq!(got.load(Ordering::SeqCst), 1, "encrypted echo back");
|
|
||||||
client.stop().await;
|
client.stop().await;
|
||||||
server.stop().await;
|
server.stop().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
static SERVER_FOR_ECHO: std::sync::OnceLock<Arc<OmniVox<Msg>>> = std::sync::OnceLock::new();
|
|
||||||
|
|||||||
+221
-107
@@ -1,9 +1,9 @@
|
|||||||
//! Attack regressions: every v1 vulnerability class from the red-team
|
//! Attack regressions for protocol v2.1: every red-team finding class must
|
||||||
//! review must now fail closed, provably, with zero callbacks fired.
|
//! fail closed, provably, with zero callbacks fired on the attack input.
|
||||||
|
|
||||||
mod support;
|
mod support;
|
||||||
|
|
||||||
use omnivox::{Config, ConnectionContext, ConnectionStatus, Decision, OmniVox, handshake::perform};
|
use omnivox::{Config, ConnectionStatus, Decision};
|
||||||
use std::{
|
use std::{
|
||||||
sync::{
|
sync::{
|
||||||
Arc,
|
Arc,
|
||||||
@@ -11,7 +11,7 @@ use std::{
|
|||||||
},
|
},
|
||||||
time::Duration,
|
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::{
|
use tokio::{
|
||||||
io::{AsyncReadExt, AsyncWriteExt},
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
net::TcpStream,
|
net::TcpStream,
|
||||||
@@ -24,34 +24,24 @@ fn test_config() -> Config {
|
|||||||
send_timeout_ms: 1000,
|
send_timeout_ms: 1000,
|
||||||
handshake_timeout_ms: 5000,
|
handshake_timeout_ms: 5000,
|
||||||
max_message_size: 8192,
|
max_message_size: 8192,
|
||||||
|
max_stream_frame: 8192,
|
||||||
max_connections: 16,
|
max_connections: 16,
|
||||||
allow_insecure_defaults: true,
|
allow_insecure_defaults: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn counting_server_with(port: u16, rx: Arc<AtomicU32>, config: Config) -> Arc<OmniVox<Msg>> {
|
async fn counting_server(port: u16, rx: Arc<AtomicU32>) -> omnivox::Server<Msg> {
|
||||||
let server = Arc::new(OmniVox::<Msg>::new());
|
omnivox::server::<Msg>()
|
||||||
server.set_encrypt_key(TEST_PSK);
|
.key(TEST_PSK)
|
||||||
let data = Arc::new(move |msg: &Msg, _ctx: &ConnectionContext| {
|
.config(test_config())
|
||||||
if msg.0.starts_with(b"ok") {
|
.on_data(move |msg: &Msg, _ctx| {
|
||||||
rx.fetch_add(1, Ordering::SeqCst);
|
if msg.0.starts_with(b"ok") {
|
||||||
}
|
rx.fetch_add(1, Ordering::SeqCst);
|
||||||
});
|
}
|
||||||
server
|
})
|
||||||
.start_server(
|
.serve(port)
|
||||||
port,
|
|
||||||
config,
|
|
||||||
data,
|
|
||||||
Arc::new(|_ctx| {}),
|
|
||||||
Arc::new(|_ctx| Decision::Allow),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap()
|
||||||
server
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn counting_server(port: u16, rx: Arc<AtomicU32>) -> Arc<OmniVox<Msg>> {
|
|
||||||
counting_server_with(port, rx, test_config()).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn expect_eof(r: &mut tokio::net::tcp::OwnedReadHalf) {
|
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]
|
#[tokio::test]
|
||||||
async fn replayed_frame_is_dropped_and_the_connection_dies() {
|
async fn replayed_frame_kills_the_connection() {
|
||||||
let port = 18301;
|
let port = 18501;
|
||||||
let rx = Arc::new(AtomicU32::new(0));
|
let rx = Arc::new(AtomicU32::new(0));
|
||||||
let server = counting_server(port, Arc::clone(&rx)).await;
|
let server = counting_server(port, Arc::clone(&rx)).await;
|
||||||
|
|
||||||
let (mut r, mut w, s) = client_session(port, &TEST_PSK).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();
|
w.write_all(&good).await.unwrap();
|
||||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
assert_eq!(rx.load(Ordering::SeqCst), 1, "first delivery");
|
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();
|
w.write_all(&good).await.unwrap();
|
||||||
expect_eof(&mut r).await; // server must drop the connection outright
|
expect_eof(&mut r).await;
|
||||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rx.load(Ordering::SeqCst),
|
rx.load(Ordering::SeqCst),
|
||||||
1,
|
1,
|
||||||
@@ -88,43 +78,47 @@ async fn replayed_frame_is_dropped_and_the_connection_dies() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn frames_from_another_session_are_undecryptable() {
|
async fn frames_from_another_session_are_rejected() {
|
||||||
// Frame portability: keys are session-bound by the handshake nonces,
|
let port = 18502;
|
||||||
// so a frame valid on connection A is junk on connection B.
|
|
||||||
let port = 18302;
|
|
||||||
let rx = Arc::new(AtomicU32::new(0));
|
let rx = Arc::new(AtomicU32::new(0));
|
||||||
let server = counting_server(port, Arc::clone(&rx)).await;
|
let server = counting_server(port, Arc::clone(&rx)).await;
|
||||||
|
|
||||||
let (_r1, _w1, s1) = client_session(port, &TEST_PSK).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();
|
w2.write_all(&portable).await.unwrap();
|
||||||
// Auth failure with a matching counter would continue (junk policy), but
|
expect_eof(&mut r2).await; // v2.1: auth failure is fatal
|
||||||
// the counter check passes while decryption fails → logged, skipped;
|
|
||||||
// the connection survives and the payload never fires.
|
|
||||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rx.load(Ordering::SeqCst),
|
rx.load(Ordering::SeqCst),
|
||||||
0,
|
0,
|
||||||
"cross-session frame must not decrypt"
|
"cross-session frame must not decrypt"
|
||||||
);
|
);
|
||||||
// connection B is still alive with its own keys:
|
server.stop().await;
|
||||||
w2.write_all(&frame(&s2.send_key, 1, b"ok-native"))
|
}
|
||||||
.await
|
|
||||||
.unwrap();
|
#[tokio::test]
|
||||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
async fn frame_type_tamper_is_rejected() {
|
||||||
assert_eq!(
|
// Message -> stream downgrade attempt: type byte is AAD, so this is an
|
||||||
rx.load(Ordering::SeqCst),
|
// authentication failure, not a lane switch.
|
||||||
1,
|
let port = 18503;
|
||||||
"native frame on B must arrive"
|
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;
|
server.stop().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn wrong_version_hello_is_closed() {
|
async fn wrong_version_hello_is_closed() {
|
||||||
let port = 18303;
|
let port = 18504;
|
||||||
let rx = Arc::new(AtomicU32::new(0));
|
let rx = Arc::new(AtomicU32::new(0));
|
||||||
let server = counting_server(port, rx).await;
|
let server = counting_server(port, rx).await;
|
||||||
|
|
||||||
@@ -140,19 +134,20 @@ async fn wrong_version_hello_is_closed() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn v1_traffic_is_rejected_at_the_handshake() {
|
async fn v1_traffic_is_rejected_at_the_handshake() {
|
||||||
// A v1 OmniVox client speaks u32-length-prefixed AeadPackets. The v2
|
let port = 18505;
|
||||||
// server must refuse before any allocation path.
|
|
||||||
let port = 18304;
|
|
||||||
let rx = Arc::new(AtomicU32::new(0));
|
let rx = Arc::new(AtomicU32::new(0));
|
||||||
let server = counting_server_with(
|
let server = omnivox::server::<Msg>()
|
||||||
port,
|
.key(TEST_PSK)
|
||||||
rx,
|
.config(Config {
|
||||||
Config {
|
|
||||||
handshake_timeout_ms: 700,
|
handshake_timeout_ms: 700,
|
||||||
..test_config()
|
..test_config()
|
||||||
},
|
})
|
||||||
)
|
.on_data(move |_: &Msg, _| {
|
||||||
.await;
|
rx.fetch_add(1, Ordering::SeqCst);
|
||||||
|
})
|
||||||
|
.serve(port)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let raw = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
|
let raw = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
|
||||||
let (mut r, mut w) = raw.into_split();
|
let (mut r, mut w) = raw.into_split();
|
||||||
@@ -163,41 +158,43 @@ async fn v1_traffic_is_rejected_at_the_handshake() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn panicking_callback_does_not_kill_the_connection() {
|
async fn panicking_callbacks_never_kill_the_connection() {
|
||||||
let port = 18305;
|
let port = 18506;
|
||||||
let rx = Arc::new(AtomicU32::new(0));
|
let rx = Arc::new(AtomicU32::new(0));
|
||||||
let server = Arc::new(OmniVox::<Msg>::new());
|
|
||||||
server.set_encrypt_key(TEST_PSK);
|
|
||||||
let ok_rx = Arc::clone(&rx);
|
let ok_rx = Arc::clone(&rx);
|
||||||
let data = Arc::new(move |msg: &Msg, _ctx: &ConnectionContext| {
|
let server = omnivox::server::<Msg>()
|
||||||
if msg.0 == b"boom" {
|
.key(TEST_PSK)
|
||||||
let _v: [u8; 8] = msg.0[..8].try_into().unwrap(); // explodes (len 4)
|
.config(test_config())
|
||||||
}
|
.on_data(move |msg: &Msg, _ctx| {
|
||||||
if msg.0.starts_with(b"ok") {
|
if msg.0 == b"boom" {
|
||||||
ok_rx.fetch_add(1, Ordering::SeqCst);
|
panic!("handler exploded on attacker input");
|
||||||
}
|
}
|
||||||
});
|
if msg.0.starts_with(b"ok") {
|
||||||
server
|
ok_rx.fetch_add(1, Ordering::SeqCst);
|
||||||
.start_server(
|
}
|
||||||
port,
|
})
|
||||||
test_config(),
|
.on_stream(|_b, _ctx| {
|
||||||
data,
|
panic!("stream handler exploded");
|
||||||
Arc::new(|_c| {}),
|
})
|
||||||
Arc::new(|_c| Decision::Allow),
|
.serve(port)
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let (mut r, mut w, s) = client_session(port, &TEST_PSK).await;
|
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(&msg_frame(&s.send_key, 1, b"boom"))
|
||||||
w.write_all(&frame(&s.send_key, 2, b"ok-after-panic"))
|
.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
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rx.load(Ordering::SeqCst),
|
rx.load(Ordering::SeqCst),
|
||||||
1,
|
1,
|
||||||
"survives a panicking callback"
|
"connection survives two panics"
|
||||||
);
|
);
|
||||||
let mut buf = [0u8; 1];
|
let mut buf = [0u8; 1];
|
||||||
assert!(
|
assert!(
|
||||||
@@ -211,8 +208,7 @@ async fn panicking_callback_does_not_kill_the_connection() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn malicious_server_silence_times_out_the_client() {
|
async fn malicious_server_silence_times_out_the_client() {
|
||||||
// v2 retires B5: the client side now has the same idle/stall discipline.
|
let port = 18507;
|
||||||
let port = 18306;
|
|
||||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port))
|
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -221,30 +217,27 @@ async fn malicious_server_silence_times_out_the_client() {
|
|||||||
let (stream, _) = listener.accept().await.unwrap();
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
let (mut r, mut w) = stream.into_split();
|
let (mut r, mut w) = stream.into_split();
|
||||||
// Complete the handshake as the server, then go silent forever.
|
// 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;
|
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
let client = OmniVox::<Msg>::new();
|
|
||||||
client.set_encrypt_key(TEST_PSK);
|
|
||||||
let dropped = Arc::new(AtomicU8::new(0));
|
let dropped = Arc::new(AtomicU8::new(0));
|
||||||
let d = Arc::clone(&dropped);
|
let d = Arc::clone(&dropped);
|
||||||
let cfg = Config {
|
let client = omnivox::client::<Msg>()
|
||||||
idle_timeout_ms: 400,
|
.key(TEST_PSK)
|
||||||
..test_config()
|
.config(Config {
|
||||||
};
|
idle_timeout_ms: 400,
|
||||||
client
|
..test_config()
|
||||||
.connect_to_server(
|
})
|
||||||
"127.0.0.1",
|
.on_data(|_m: &Msg, _c| {})
|
||||||
port,
|
.on_state(move |ctx| {
|
||||||
cfg,
|
if ctx.status() == ConnectionStatus::NotConnected {
|
||||||
Some(Arc::new(|_m: &Msg, _c: &ConnectionContext| {})),
|
d.store(1, Ordering::SeqCst);
|
||||||
Arc::new(move |ctx: &ConnectionContext| {
|
}
|
||||||
if ctx.connected == ConnectionStatus::NotConnected {
|
})
|
||||||
d.store(1, Ordering::SeqCst);
|
.connect("127.0.0.1", port)
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -257,3 +250,124 @@ async fn malicious_server_silence_times_out_the_client() {
|
|||||||
accepted.abort();
|
accepted.abort();
|
||||||
client.stop().await;
|
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::<String>::new()));
|
||||||
|
let t = Arc::clone(&tape);
|
||||||
|
let t2 = Arc::clone(&tape);
|
||||||
|
let server = omnivox::server::<Msg>()
|
||||||
|
.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<u8>) 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::<Msg>()
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
+42
-26
@@ -1,39 +1,55 @@
|
|||||||
//! Shared helpers for the integration tests: v2 client sessions against a
|
#![allow(dead_code)] // each test binary uses a subset
|
||||||
//! live server, and raw frame construction.
|
|
||||||
|
|
||||||
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::TcpStream;
|
||||||
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
|
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<u8>);
|
pub struct Msg(pub Vec<u8>);
|
||||||
|
|
||||||
impl Cereal for Msg {
|
|
||||||
fn encode(&self, out: &mut Vec<u8>) {
|
|
||||||
omnivox::write_byte_vec(out, &self.0);
|
|
||||||
}
|
|
||||||
fn decode(bytes: &[u8]) -> Result<Self, CodecError> {
|
|
||||||
Ok(Self(omnivox::Reader::new(bytes).byte_vec()?))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const TEST_PSK: [u8; 32] = [42u8; 32];
|
pub const TEST_PSK: [u8; 32] = [42u8; 32];
|
||||||
|
|
||||||
/// Builds one framed message with the given sequence number.
|
pub fn bincode_cfg() -> bincode::config::Configuration<
|
||||||
pub fn frame(key: &[u8; 32], counter: u64, msg: &[u8]) -> Vec<u8> {
|
bincode::config::LittleEndian,
|
||||||
let mut plain = Vec::new();
|
bincode::config::Fixint,
|
||||||
Msg(msg.to_vec()).encode(&mut plain);
|
bincode::config::Limit<16777216>,
|
||||||
let pkt = encrypt_with_nonce(key, &frame_nonce(counter), &plain);
|
> {
|
||||||
let mut bytes = Vec::new();
|
bincode::config::standard()
|
||||||
pkt.encode(&mut bytes);
|
.with_fixed_int_encoding()
|
||||||
let mut out = Vec::new();
|
.with_limit::<16777216>()
|
||||||
out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
|
|
||||||
out.extend_from_slice(&bytes);
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connects and completes the v2 handshake as the client role, returning
|
/// Serialize like the library does.
|
||||||
/// the raw halves plus the session keys for manual framing.
|
pub fn encode(msg: &Msg) -> Vec<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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) {
|
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 stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
|
||||||
let (mut r, mut w) = stream.into_split();
|
let (mut r, mut w) = stream.into_split();
|
||||||
|
|||||||
Reference in New Issue
Block a user