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:
2026-09-30 08:07:38 -04:00
parent c614d8345f
commit fea8ea3ab1
15 changed files with 2827 additions and 1193 deletions
Generated
+560 -2
View File
@@ -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"
+19 -1
View File
@@ -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
View File
@@ -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.
+67 -27
View File
@@ -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,13 +67,32 @@ 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
+226
View File
@@ -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();
}
-37
View File
@@ -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";
+73
View File
@@ -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(())
}
+84
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+73 -112
View File
@@ -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
View File
@@ -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
View File
@@ -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();