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",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bincode"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740"
dependencies = [
"bincode_derive",
"serde",
"unty",
]
[[package]]
name = "bincode_derive"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09"
dependencies = [
"virtue",
]
[[package]]
name = "bitflags"
version = "2.13.2"
@@ -33,12 +80,24 @@ dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -69,6 +128,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -80,6 +166,31 @@ dependencies = [
"zeroize",
]
[[package]]
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstyle",
"clap_lex",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -89,6 +200,75 @@ dependencies = [
"libc",
]
[[package]]
name = "criterion"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
dependencies = [
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"futures",
"is-terminal",
"itertools",
"num-traits",
"once_cell",
"oorandom",
"plotters",
"rayon",
"regex",
"serde",
"serde_derive",
"serde_json",
"tinytemplate",
"tokio",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -137,6 +317,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "errno"
version = "0.3.14"
@@ -153,6 +339,67 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "futures"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-io"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-sink",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -186,6 +433,23 @@ dependencies = [
"wasip2",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hermit-abi"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]]
name = "hkdf"
version = "0.12.4"
@@ -213,6 +477,43 @@ dependencies = [
"generic-array",
]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys",
]
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -240,6 +541,12 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mio"
version = "1.2.3"
@@ -261,14 +568,26 @@ dependencies = [
]
[[package]]
name = "omnivox"
version = "0.14.0"
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "omnivox"
version = "0.15.0"
dependencies = [
"bincode",
"chacha20poly1305",
"criterion",
"getrandom 0.3.4",
"hkdf",
"rand_core",
"scrypt",
"serde",
"sha2",
"thiserror",
"tokio",
@@ -283,6 +602,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "opaque-debug"
version = "0.3.1"
@@ -339,6 +664,34 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "poly1305"
version = "0.8.0"
@@ -383,6 +736,26 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -392,6 +765,35 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -401,6 +803,12 @@ dependencies = [
"semver",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "salsa20"
version = "0.10.2"
@@ -410,6 +818,15 @@ dependencies = [
"cipher",
]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -464,6 +881,19 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -494,6 +924,12 @@ dependencies = [
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.16.2"
@@ -567,6 +1003,16 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "tokio"
version = "1.53.1"
@@ -674,6 +1120,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "unty"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
[[package]]
name = "valuable"
version = "0.1.1"
@@ -686,6 +1138,22 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "virtue"
version = "0.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -701,6 +1169,70 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.6",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -734,6 +1266,26 @@ dependencies = [
"zeroize",
]
[[package]]
name = "zerocopy"
version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zeroize"
version = "1.9.0"
@@ -753,3 +1305,9 @@ dependencies = [
"quote",
"syn 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+19 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "omnivox"
version = "0.14.0"
version = "0.15.0"
edition = "2024"
license = "GPL-3.0-or-later"
description = "Encrypted async TCP client-server messaging: X25519 handshake, ChaCha20-Poly1305, replay-proof sequencing (OmniVox protocol v2)"
@@ -13,10 +13,28 @@ rand_core = { version = "0.6", features = ["getrandom"] }
sha2 = "0.10"
x25519-dalek = { version = "2", features = ["zeroize"] }
scrypt = "0.11"
serde = { version = "1", features = ["derive"] }
bincode = { version = "2", features = ["serde"] }
thiserror = "2"
tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "time", "sync", "macros"] }
tracing = "0.1"
[dev-dependencies]
serde = { version = "1", features = ["derive"] }
bincode = { version = "2", features = ["serde"] }
tracing-subscriber = { version = "0.3", features = ["fmt"] }
tokio = { version = "1", features = ["full"] }
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
+106 -85
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
original C++ implementation); v1 traffic is rejected at the handshake and
`PARITY-CONTRACT.md` is retired alongside it.
Wire-incompatible with the 2013 C++ OmniVox by design. Goals: perfect
forward secrecy (ephemeral X25519), per-session per-direction keys, no
IV-reuse hazard, replay defense, authenticated framing, and a stream lane
for bulk data. Everything is one-shot AEAD — no streaming ciphers.
## Threat model
## Handshake (OVX2)
The PSK (a scrypt hash of a shared password) authenticates peers. The network
is hostile: it can observe, inject, replay, reorder, and truncate. Goals:
session confidentiality and integrity, replay protection, forward secrecy
for recorded traffic, and no attacker-controlled work before authentication.
## Connection lifecycle
```
TCP connect
│
├── HELLO exchange (plaintext, fixed size, timeout: handshake_timeout_ms)
├── key schedule (HKDF over ephemeral DH + PSK)
├── framed messages (AEAD, per-direction sequence numbers)
└── close (any timeout / violation closes immediately)
```
## HELLO (both directions, 69 bytes, no attacker-controlled lengths)
TCP is connected, then both sides exchange a fixed 69-byte hello:
```
bytes 0..4 magic "OVX2"
byte 4 version 2
bytes 5..37 x25519 ephemeral public key
bytes 5..37 ephemeral X25519 public key
bytes 37..69 session nonce (32 random bytes)
```
The client sends first. The server validates magic, version, and key
encoding **before replying**; any violation closes the connection. The
server rejects an all-zero DH output (small-order contribution).
## Key schedule
A hello with wrong magic or version fails closed (connection closed, no
response). Both hellos are exchanged before any key is derived, so the two
peers are symmetric roles distinguished only by their nonces.
```
dh = X25519(eph_local, eph_peer)
prk = HKDF-SHA256-Extract(salt = nonce_client ‖ nonce_server, ikm = PSK)
okm = HKDF-SHA256-Expand(prk, info = "omnivox-v2" ‖ hello_client ‖ hello_server, 64)
key_c2s = okm[0..32] key_s2c = okm[32..64]
dh = X25519(eph_sk_peer_side, eph_pk_other_side)
salt = nonce_client ‖ nonce_server
prk = HKDF-SHA256-Extract(salt, ikm = PSK ‖ dh)
send_key(server→client) = HKDF-Expand(prk, info = "omnivox2|c2s", 32)
send_key(client→server) = HKDF-Expand(prk, info = "omnivox2|s2c", 32)
```
The client's send key is `key_c2s`, receive key `key_s2c`; reversed for the
server. Direction separation means neither peer can present itself forged
frames (v1's single symmetric key could). The ephemeral DH gives forward
secrecy against later PSK compromise; the PSK authenticates against
off-path attackers. A weak password remains guessable by an active attacker
with the PSK budget to grind scrypt — use random 32-byte PSKs; a PAKE is the
future upgrade if passwords must stay human.
(Names are from the client's view: each peer encrypts with the key whose
name is the direction it writes.) The PSK is `scrypt(password, salt,
N=2^15, r=8, p=1, 32)` — run on a blocking thread. The pre-hello exchange
is unauthenticated; the first framed message (which authenticates under
the derived keys) is the proof both sides hold the same PSK. An
attacker without the PSK sees every frame fail authentication and the
connection die — they never get an oracle, only a closed socket.
## Framing (both directions, after key confirmation)
The ephemeral DH gives forward secrecy: recorded traffic cannot be
decrypted later even if the PSK leaks. Session nonces bind keys to this
run: frames captured from one session are ciphertext to another.
## Frame format
Every post-handshake packet, in both directions:
```
u32 LE length (0 < len ≤ max_message_size, default cap 64 KiB)
AeadPacket:
nonce [u8; 12] four zero bytes ‖ u64 BE sequence number
tag [u8; 16] Poly1305 tag
len u64 LE (cereal vector length, retained for payload lineage)
ct [len] ChaCha20-Poly1305 ciphertext of the serialized payload
u32 LE len total byte count of type ‖ ciphertext ‖ tag
u8 type 0 = message (bincode payload), 1 = stream (raw bytes)
bytes ct ChaCha20-Poly1305 ciphertext (plaintext length = len − 17)
bytes tag 16-byte Poly1305 tag
```
AEAD is ChaCha20-Poly1305 (RFC 8439) with the packet's own nonce as the IETF
nonce and empty AAD — identical to v1's construction, now keyed per session
per direction.
`len ∈ [17, max_frame]`; zero or oversized is a protocol violation
(close). `max_frame` is `max_message_size` for type 0 (default 64 KiB)
and `max_stream_frame` for type 1 (default 1 MiB) — both caps are
enforced *before* allocation.
### Sequence discipline (the replay defense)
### Nonce
Each sender's counter starts at 1 and increments by exactly 1 per frame; a
failed write must not consume a number. The receiver's first expected value
is 1. A frame whose decoded nonce is not the expected value is a protocol
violation: **log and close the connection.** There is no reorder window.
The nonce is **not** on the wire. It is derived from the sender's
per-direction sequence number:
### Payload encoding
```
nonce(counter) = 00 00 00 00 ‖ u64 BE counter
```
The plaintext is the `Cereal` binary encoding of the message type — raw
little-endian fields, `u64` length prefixes for byte vectors, no archive
header — unchanged from the C++ original.
Each sender's counter starts at 1 and increments by exactly one per
frame; each receiver tracks its own expected counter, starting at 1. The
receiver decrypts only with its expected counter, so a replayed or
reordered frame authenticates against the wrong nonce and fails —
guaranteed, not heuristic. 2^64 frames per direction before the nonce
space is exhausted (close at the limit).
## Limits and timeouts (all server- and client-side)
### AAD
| Knob | Default | Violation |
The type byte is the AEAD additional authenticated data. Flipping
message↔stream is therefore an authentication failure, not a lane
switch.
### Failure policy
- **Any frame-level failure** (bad length, unknown type, AEAD failure)
logs and **closes the connection**. v2.1 has no skip-and-continue: an
authenticated bitstream that stops authenticating means someone is
tampering or the two ends disagree about state — either way the
session is over.
- **Payload decode failure** (frame authenticated, bincode decode
fails, e.g. a `Message` claiming a 2^40 byte vector): the frame is
dropped and counted, the connection **stays open**. Authenticated junk
is the sender's bug, not an attack.
- Stream frames are handed to `on_stream` as raw bytes; no decoding.
## Framing discipline
- A body-stall timer (`body_stall_ms`) fires while reading a frame body
that started but never completes.
- An idle timer (`idle_timeout_ms`) closes connections with no inbound
frames at all.
- The reader loop processes frames strictly in wire order; messages and
stream chunks share one sequence, so callbacks observe the exact
interleaving the sender produced.
## Outbound path
Each connection owns a bounded queue (depth 8) and a writer task.
`send`/`send_stream` await queue space (backpressure); the synchronous
callback helpers `reply`/`reply_stream`/`send_to` use `try_send` and
return `false` when full instead of blocking the reader lane. The writer
task seals frames with its own counter and writes them out, so slow
sockets never stall the read loop.
## Timeouts (defaults)
| Timer | Default | Fires when |
|---|---|---|
| handshake_timeout_ms | 10000 | close |
| idle_timeout_ms (silence between messages) | 15000 | close |
| body_stall_ms (mid-body silence) | 5000 | close |
| send_timeout_ms (peer not reading) | 5000 | close that peer |
| max_message_size | 65536 | close on header |
| max_connections (server) | 1024 | new connections wait |
| handshake | 10 s | hello exchange incomplete |
| body stall | 5 s | frame body started, never finished |
| idle | 15 s | no inbound frames at all |
| send queue | 5 s | outbound queue full and staying full |
Corrupt frames with the *correct* sequence number (failed AEAD or payload
decode) are logged and skipped; the connection continues. Anything else —
sequence violations, unreasonable headers, stalls — is a disconnect.
## Test coverage
## Security properties
| Property | Mechanism | Tested by |
| Property | Mechanism | Test |
|---|---|---|
| Confidentiality | ChaCha20-Poly1305 per-session keys | `protocol_v2::frames_from_another_session_are_undecryptable` |
| Replay defense | strict sequence numbers | `protocol_v2::replayed_frame_is_dropped_and_the_connection_dies` |
| Session binding | nonces enter the salt/info | same cross-session test |
| Forward secrecy | ephemeral X25519 per connection | design (single-connection compromise leaks only it) |
| No pre-auth work | fixed 69-byte hello, caps only after key confirmation | `wrong_version_hello_is_closed`, `v1_traffic_is_rejected_at_the_handshake` |
| Liveness both roles | idle + stall timeouts client **and** server | `malicious_server_silence_times_out_the_client`, `header_stall_times_out` |
| Slow-client containment | per-connection locks + send timeout | design (no shared writer mutex) |
| DoS containment | connection semaphore, message caps, callback panic isolation | `panicking_callback_does_not_kill_the_connection` |
## Refusals
Release builds refuse to start with the original demo key (scrypt of
`SecurePassword123` / salt `01…08`) unless `allow_insecure_defaults` is set.
| Confidentiality | per-session per-direction keys | `frames_from_another_session_are_rejected` |
| Replay defense | derived nonce + expected counter | `replayed_frame_kills_the_connection` |
| Session binding | nonces in salt | same cross-session test |
| Type tampering | type byte as AAD | `frame_type_tamper_is_rejected` |
| Version downgrade | magic+version gate | `wrong_version_hello_is_closed` |
| v1 traffic | fails hello | `v1_traffic_is_rejected_at_the_handshake` |
| Handler panics | catch_unwind per callback | `panicking_callbacks_never_kill_the_connection` |
| Decode bombs | skip authenticated junk | `lying_decoder_length_is_skipped_connection_survives` |
| Silent peer | idle timers both roles | `malicious_server_silence_times_out_the_client` |
| Ordering | shared sequence | `messages_and_streams_interleave_in_order` |
+67 -27
View File
@@ -1,44 +1,65 @@
# omnivox-rs
Encrypted async TCP client-server messaging — protocol **v2**. A Rust
Encrypted async TCP client-server messaging — protocol **v2.1**. A Rust
library plus example binaries, descended from the OmniVox C++ library and
hardened past it after a full red-team review of v1.
hardened past it after a full red-team review.
- X25519 handshake on every connection → forward secrecy, session-bound keys
- ChaCha20-Poly1305 with strictly increasing sequence numbers → replay-proof
- Direction-separated traffic keys, fixed-size pre-auth surface
- scrypt (RFC 7914) password-based key derivation; `Cereal` payload encoding
(byte-exact with the original's cereal binary format) for data portability
- tokio transport: connection caps, idle/stall/send timeouts, panic-isolated
callbacks; zero `unsafe`
**Not wire-compatible with the C++ original or v1** — by design; v1 peers
are refused at the handshake. See `PROTOCOL-V2.md` for the full normative
spec and `PARITY-CONTRACT.md` (retired) for the port's history.
## Usage
The API is the original C++ shape — your struct goes in, a match arms over
it in one callback — with all the boilerplate removed:
```rust
use omnivox::{Config, Cereal, CodecError, Decision, OmniVox, derive_encrypt_key};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
let endpoint = OmniVox::<MyPayload>::new();
endpoint.set_encrypt_key(derive_encrypt_key(b"password", b"salt", 17, 8, 1)?);
endpoint.start_server(8080, Config::default(), on_data, on_disconnect, on_connect).await?;
#[derive(Serialize, Deserialize, Debug)]
enum AppMsg { Hello, Note(String) }
// server
let server = omnivox::server::<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
lengths — the original's serialization). Runnable demos: `examples/`.
Any `Serialize + DeserializeOwned` type is a message — serde+bincode
replaces the original's hand-written `Cereal`. For bulk data there is a
second lane: `client.send_stream(&bytes)` pairs with an `on_stream`
callback — no encoding, 1 MiB frames, same sequence numbers, so messages
and stream chunks arrive in exactly the order they were sent.
- X25519 handshake on every connection → forward secrecy, session-bound keys
- ChaCha20-Poly1305, nonce derived from per-direction sequence numbers →
replay-proof, no nonce on the wire
- Direction-separated traffic keys; frame type authenticated as AAD
- scrypt password KDF (blocking-thread), random-PSK support, panic-isolated
callbacks, connection caps, idle/stall/send timeouts, zero `unsafe`
**Not wire-compatible with the C++ original or v1** — by design; v1 peers
are refused at the handshake. See `PROTOCOL-V2.md` for the normative spec
and `PARITY-CONTRACT.md` (retired) for the port's history.
## Security posture
| v1 weakness | v2 answer |
| v1 weakness | v2.1 answer |
|---|---|
| captured frames replay forever | strict sequence numbers; a replay kills the connection |
| captured frames replay forever | sequence-number nonces; a replay kills the connection |
| frames portable across connections | keys bound to per-session nonces |
| 5 MiB pre-auth allocation from 8 bytes | fixed 69-byte handshake before any attacker-sized read |
| one symmetric key, either peer can forge as itself | direction-separated HKDF session keys |
| static key, no forward secrecy | ephemeral X25519 per connection |
| message/stream confusion impossible to detect | type byte authenticated as AAD |
| no client-side timeouts | full idle/stall discipline both roles |
| panicking user callback killed the read loop | callbacks panic-isolated |
| demo password in every repo checkout | release builds refuse the demo key |
@@ -46,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
against human passwords (PAKE planned).
## Examples
```bash
cargo run --example omnivox-server # terminal 1 — enum echo server
cargo run --example omnivox-client # terminal 2
cargo run --example dump-agent # terminal 1 — streams a toy 16 MiB
cargo run --example dump-viewer # terminal 2 — receives, verifies, saves
```
## Benchmarks
`cargo bench --bench transport` — measured on the author's machine
(Intel Core Ultra 7 258V, loopback):
| What | Result |
|---|---|
| ChaCha20-Poly1305 seal, 1 MiB frame | ≈1.18 GiB/s single-threaded |
| 256 B messages, encrypted round trip | ≈120 000 msg/s |
| Stream lane, 1 MiB frames | ≈0.7 GiB/s (vs ≈2.0 raw loopback echo) |
## Build & test
```bash
cargo test # includes attack regressions: replay, version, panic, timeouts
cargo test # unit, attack regressions, golden vectors, doctests
cargo clippy --all-targets -- -D warnings
cargo run --example omnivox-server # terminal 1
cargo run --example omnivox-client # terminal 2
```
## License
+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(())
}
+32 -61
View File
@@ -1,12 +1,17 @@
//! Port of OmniVox-client.cpp: connects, sends "Hello", prints what the
//! server echoes, then stops after 15 seconds like the C++ example.
//! The minimal OmniVox client: connect, talk, done.
//!
//! Companion to `omnivox-server`. Sends `Hello`, prints the echo, then
//! exchanges two more notes and stops.
#[path = "common/mod.rs"]
mod common;
use omnivox::ConnectionStatus;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use common::{DatagramPayload, PASSWORD, SALT, SERVER_ADDR, SERVER_PORT};
use omnivox::{Config, ConnectionContext, ConnectionStatus, OmniVox, derive_encrypt_key};
use std::sync::Arc;
#[derive(Serialize, Deserialize, Debug)]
enum ChatMsg {
Hello,
Note(String),
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -15,63 +20,29 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.without_time()
.init();
tracing::info!("Connecting to server at {SERVER_ADDR}:{SERVER_PORT}");
let client = Arc::new(OmniVox::<DatagramPayload>::new());
let key = derive_encrypt_key(PASSWORD.as_bytes(), SALT, 17, 8, 1)?;
client.set_encrypt_key(key);
let data_cb = Arc::new(|_data: &DatagramPayload, ctx: &ConnectionContext| {
tracing::info!("Server ID: {}, sent data!", ctx.id);
});
let connector = Arc::clone(&client);
let connection_cb = Arc::new(move |ctx: &ConnectionContext| match ctx.connected {
ConnectionStatus::Connected => {
tracing::info!(
"[onConnect] Successfully connected to: {}:{}",
ctx.ip,
ctx.port
);
let client = Arc::clone(&connector);
tokio::spawn(async move {
let payload = DatagramPayload {
id: 1,
v1: 0,
payload: b"Hello".to_vec(),
};
if client.send_to_server(&payload).await {
tracing::info!("Sent initial data to server");
let client = omnivox::client::<ChatMsg>()
// Same password + salt as the server — they derive the same PSK.
.password("SecurePassword123", b"static-demo-salt")
.on_data(|msg, _ctx| match msg {
ChatMsg::Hello => tracing::info!("server greeted us unprompted?"),
ChatMsg::Note(text) => tracing::info!("server note: {text}"),
})
// Fires on Connected, ConnectionFailed, and later NotConnected.
.on_state(|ctx| {
tracing::info!("state: {:?}", ctx.status());
if ctx.status() == ConnectionStatus::ConnectionFailed {
tracing::error!("is the server running? (cargo run --example omnivox-server)");
}
});
}
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,
)
})
.connect("127.0.0.1", 8080)
.await?;
tokio::time::sleep(std::time::Duration::from_secs(15)).await;
client.send(&ChatMsg::Hello).await;
tokio::time::sleep(Duration::from_millis(300)).await;
client.send(&ChatMsg::Note("first note".into())).await;
client.send(&ChatMsg::Note("second note".into())).await;
tokio::time::sleep(Duration::from_millis(300)).await;
client.stop().await;
Ok(())
}
+41 -76
View File
@@ -1,15 +1,21 @@
//! Port of OmniVox-server.cpp: echoes each client payload back with the
//! client id, refusing no client that holds the key.
//! The minimal OmniVox server: one enum, one closure, one call.
//!
//! Run this, then run `cargo run --example omnivox-client` in another
//! terminal. The client sends `Hello`; we echo a `Note` back and finish
//! after three exchanges — the same callback shape as the original C++
//! demo, minus the boilerplate.
#[path = "common/mod.rs"]
mod common;
use omnivox::Decision;
use serde::{Deserialize, Serialize};
use common::{DatagramPayload, PASSWORD, SALT, SERVER_PORT};
use omnivox::{Config, ConnectionContext, Decision, OmniVox, derive_encrypt_key};
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
/// The one message type of our tiny protocol. Because this is an enum,
/// `on_data`'s `match` is the switch-on-id pattern from the C++ days,
/// checked by the compiler instead of a runtime default case.
#[derive(Serialize, Deserialize, Debug)]
enum ChatMsg {
Hello,
Note(String),
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -18,78 +24,37 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.without_time()
.init();
tracing::info!("Starting server on port {SERVER_PORT}");
let server = Arc::new(OmniVox::<DatagramPayload>::new());
let key = derive_encrypt_key(PASSWORD.as_bytes(), SALT, 17, 8, 1)?;
server.set_encrypt_key(key);
// The C++ example's g_clientHalt map, kept for behavioral parity.
let client_halt: Arc<Mutex<HashMap<u32, bool>>> = Arc::new(Mutex::new(HashMap::new()));
let responder = Arc::clone(&server);
let received = Arc::new(move |data: &DatagramPayload, ctx: &ConnectionContext| {
tracing::info!(
"Received data from client {}: Payload size={}",
ctx.id,
data.payload.len()
);
if !data.payload.is_empty() {
tracing::info!(
"Payload content: {}",
String::from_utf8_lossy(&data.payload)
);
let server = omnivox::server::<ChatMsg>()
// Both peers must derive the same key. scrypt runs once, at startup,
// on a blocking thread (~1 s, 128 MiB). Prefer a random 32-byte PSK
// via `.key([u8; 32])` in real deployments.
.password("SecurePassword123", b"static-demo-salt")
.on_data(|msg, ctx| match msg {
ChatMsg::Hello => {
tracing::info!("client {} says hello", ctx.id());
// Synchronous, non-blocking enqueue. A `false` return would
// mean the peer stopped reading — never a panic, never a stall.
let _ = ctx.reply(&ChatMsg::Note(format!("echo {}", ctx.id())));
}
let 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| {
tracing::info!(
"Client {} {}:{} is disconnecting from the server cleaning up objects.",
ctx.id,
ctx.ip,
ctx.port
);
*halt.lock().unwrap().entry(ctx.id).or_default() = true;
});
let halt = Arc::clone(&client_halt);
let connect = Arc::new(move |ctx: &ConnectionContext| {
*halt.lock().unwrap().entry(ctx.id).or_default() = false;
tracing::info!("New client has connected - {}:{}", ctx.ip, ctx.port);
})
// Every authenticated client is welcome; return `Decision::Deny`
// to close specific peers (per-IP policy, allowlists, ...).
.on_connect(|ctx| {
tracing::info!("accepting {}:{}", ctx.ip(), ctx.port());
Decision::Allow
});
server
.start_server(
SERVER_PORT,
Config::default(),
received,
disconnect,
connect,
)
})
.on_disconnect(|ctx| tracing::info!("client {} left", ctx.id()))
.serve(8080)
.await?;
tracing::info!("Server started successfully");
tracing::info!("listening on :8080 (Ctrl-C to stop)");
// C++ slept u32::MAX hours; we serve until killed, like the reference.
// The handle can live forever; `server.stop().await` tears everything
// down cleanly when you want it to.
let _ = &server;
std::future::pending::<()>().await;
Ok(())
}
+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");
}
}
+968 -607
View File
File diff suppressed because it is too large Load Diff
+68 -107
View File
@@ -1,9 +1,9 @@
//! Loopback behavior tests for the v2 transport: framing discipline,
//! timeouts, junk handling, and the send-path contracts.
//! Behavior tests for the v2.1 transport via the public builder API:
//! framing discipline, timeouts, the echo roundtrip, and send-path returns.
mod support;
use omnivox::{Config, ConnectionContext, Decision, OmniVox};
use omnivox::{Config, Decision, Server};
use std::{
sync::{
Arc,
@@ -11,7 +11,7 @@ use std::{
},
time::Duration,
};
use support::{Msg, TEST_PSK, client_session, frame};
use support::{Msg, TEST_PSK, client_session};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
fn test_config() -> Config {
@@ -21,33 +21,28 @@ fn test_config() -> Config {
send_timeout_ms: 1000,
handshake_timeout_ms: 5000,
max_message_size: 8192,
max_stream_frame: 8192,
max_connections: 16,
allow_insecure_defaults: true,
}
}
async fn start_server(port: u16, rx: Arc<AtomicU32>, config: Config) -> Arc<OmniVox<Msg>> {
let server = Arc::new(OmniVox::<Msg>::new());
server.set_encrypt_key(TEST_PSK);
let data = Arc::new(move |msg: &Msg, _ctx: &ConnectionContext| {
async fn counting_server(port: u16, rx: Arc<AtomicU32>) -> Server<Msg> {
omnivox::server::<Msg>()
.key(TEST_PSK)
.config(test_config())
.on_data(move |msg: &Msg, _ctx| {
if msg.0.starts_with(b"ok") {
rx.fetch_add(1, Ordering::SeqCst);
}
});
server
.start_server(
port,
config,
data,
Arc::new(|_ctx| {}),
Arc::new(|_ctx| Decision::Allow),
)
})
.on_connect(|_ctx| Decision::Allow)
.serve(port)
.await
.unwrap();
server
.unwrap()
}
async fn read_expect_eof(r: &mut tokio::net::tcp::OwnedReadHalf) {
async fn expect_eof(r: &mut tokio::net::tcp::OwnedReadHalf) {
let mut buf = [0u8; 1];
let n = tokio::time::timeout(Duration::from_secs(4), r.read(&mut buf))
.await
@@ -57,136 +52,102 @@ async fn read_expect_eof(r: &mut tokio::net::tcp::OwnedReadHalf) {
}
#[tokio::test]
async fn tampered_body_with_valid_counter_is_skipped_connection_survives() {
let port = 18201;
async fn tampered_frame_disconnects_v21_policy() {
// v2.1: authentication failure is fatal — no skip-and-continue.
let port = 18401;
let rx = Arc::new(AtomicU32::new(0));
let server = start_server(port, Arc::clone(&rx), test_config()).await;
let server = counting_server(port, Arc::clone(&rx)).await;
let (r, mut w, s) = client_session(port, &TEST_PSK).await;
// Counter 1, tampered ciphertext: auth fails, frame dropped, alive.
let mut bad = frame(&s.send_key, 1, b"ok-junk");
let last = bad.len() - 1;
bad[last] ^= 0x01;
w.write_all(&bad).await.unwrap();
// Counter 1 valid (the tampered frame consumed nothing).
w.write_all(&frame(&s.send_key, 1, b"ok1")).await.unwrap();
w.write_all(&frame(&s.send_key, 2, b"ok2")).await.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(rx.load(Ordering::SeqCst), 2);
// still alive: another frame gets through
w.write_all(&frame(&s.send_key, 3, b"ok3")).await.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(rx.load(Ordering::SeqCst), 3);
let (mut r, mut w, s) = client_session(port, &TEST_PSK).await;
let mut good = support::msg_frame(&s.send_key, 1, b"ok1");
let last = good.len() - 5; // flip a ciphertext byte
good[last] ^= 0x01;
w.write_all(&good).await.unwrap();
expect_eof(&mut r).await;
assert_eq!(rx.load(Ordering::SeqCst), 0);
server.stop().await;
drop(r);
drop(w);
}
#[tokio::test]
async fn zero_size_header_disconnects_immediately() {
// v2 retires the v1 no-return quirk: bad header closes, no follow-up.
let port = 18202;
async fn zero_size_header_disconnects() {
let port = 18402;
let rx = Arc::new(AtomicU32::new(0));
let server = start_server(port, Arc::clone(&rx), test_config()).await;
let server = counting_server(port, rx).await;
let (mut r, mut w, _s) = client_session(port, &TEST_PSK).await;
w.write_all(&0u32.to_le_bytes()).await.unwrap();
read_expect_eof(&mut r).await;
expect_eof(&mut r).await;
server.stop().await;
}
#[tokio::test]
async fn oversized_header_disconnects() {
let port = 18203;
let port = 18403;
let rx = Arc::new(AtomicU32::new(0));
let server = start_server(port, Arc::clone(&rx), test_config()).await;
let server = counting_server(port, rx).await;
let (mut r, mut w, _s) = client_session(port, &TEST_PSK).await;
w.write_all(&8193u32.to_le_bytes()).await.unwrap();
read_expect_eof(&mut r).await;
w.write_all(&9000u32.to_le_bytes()).await.unwrap();
expect_eof(&mut r).await;
server.stop().await;
}
#[tokio::test]
async fn header_stall_times_out() {
let port = 18204;
let port = 18404;
let rx = Arc::new(AtomicU32::new(0));
let server = start_server(port, Arc::clone(&rx), test_config()).await;
let server = counting_server(port, rx).await;
let (mut r, mut w, _s) = client_session(port, &TEST_PSK).await;
w.write_all(&[0x10, 0x00]).await.unwrap(); // partial header, then silence
read_expect_eof(&mut r).await;
expect_eof(&mut r).await;
server.stop().await;
}
#[tokio::test]
async fn send_to_unknown_client_is_false() {
let server = OmniVox::<Msg>::new();
server.set_encrypt_key(TEST_PSK);
assert!(!server.send_to_client(&Msg(b"x".to_vec()), 999).await);
assert!(!server.send_to_server(&Msg(b"x".to_vec())).await);
let rx = Arc::new(AtomicU32::new(0));
let server = omnivox::server::<Msg>()
.key(TEST_PSK)
.config(test_config())
.on_data(move |_: &Msg, _| {})
.serve(18405)
.await
.unwrap();
assert!(!server.send_to_client(999, &Msg(b"x".to_vec())).await);
assert_eq!(server.broadcast(&Msg(b"x".to_vec())).await, 0);
server.stop().await;
let _ = rx;
}
#[tokio::test]
async fn valid_handshake_then_api_roundtrip() {
let port = 18205;
let rx = Arc::new(AtomicU32::new(0));
let server = Arc::new(OmniVox::<Msg>::new());
server.set_encrypt_key(TEST_PSK);
let data = Arc::new({
let rx = Arc::clone(&rx);
move |msg: &Msg, ctx: &ConnectionContext| {
if msg.0.starts_with(b"ping") {
rx.fetch_add(1, Ordering::SeqCst);
let server = Arc::clone(SERVER_FOR_ECHO.get().unwrap());
let id = ctx.id;
let reply = Msg(b"pong".to_vec());
tokio::spawn(async move {
server.send_to_client(&reply, id).await;
});
}
}
});
let _ = SERVER_FOR_ECHO.set(server.clone());
server
.start_server(
port,
test_config(),
data,
Arc::new(|_c| {}),
Arc::new(|_c| Decision::Allow),
)
async fn echo_roundtrip_through_the_public_api() {
let port = 18406;
let server = omnivox::server::<Msg>()
.key(TEST_PSK)
.config(test_config())
.on_data(|msg, ctx| {
// echo via the synchronous callback lane
let _ = ctx.reply(msg);
})
.serve(port)
.await
.unwrap();
let client = OmniVox::<Msg>::new();
client.set_encrypt_key(TEST_PSK);
let got = Arc::new(AtomicU32::new(0));
let g = Arc::clone(&got);
client
.connect_to_server(
"127.0.0.1",
port,
test_config(),
Some(Arc::new(move |msg: &Msg, _c| {
if msg.0 == b"pong" {
let client = omnivox::client::<Msg>()
.key(TEST_PSK)
.config(test_config())
.on_data(move |msg, _ctx| {
if msg.0 == b"ping" {
g.fetch_add(1, Ordering::SeqCst);
}
})),
Arc::new(|_c| {}),
)
})
.connect("127.0.0.1", port)
.await
.unwrap();
assert!(client.send_to_server(&Msg(b"ping".to_vec())).await);
assert!(client.send(&Msg(b"ping".to_vec())).await);
tokio::time::sleep(Duration::from_millis(400)).await;
assert_eq!(rx.load(Ordering::SeqCst), 1);
assert_eq!(got.load(Ordering::SeqCst), 1, "encrypted echo back");
assert_eq!(got.load(Ordering::SeqCst), 1, "encrypted echo returned");
client.stop().await;
server.stop().await;
}
static SERVER_FOR_ECHO: std::sync::OnceLock<Arc<OmniVox<Msg>>> = std::sync::OnceLock::new();
+209 -95
View File
@@ -1,9 +1,9 @@
//! Attack regressions: every v1 vulnerability class from the red-team
//! review must now fail closed, provably, with zero callbacks fired.
//! Attack regressions for protocol v2.1: every red-team finding class must
//! fail closed, provably, with zero callbacks fired on the attack input.
mod support;
use omnivox::{Config, ConnectionContext, ConnectionStatus, Decision, OmniVox, handshake::perform};
use omnivox::{Config, ConnectionStatus, Decision};
use std::{
sync::{
Arc,
@@ -11,7 +11,7 @@ use std::{
},
time::Duration,
};
use support::{Msg, TEST_PSK, client_session, frame};
use support::{Msg, TEST_PSK, client_session, msg_frame, raw_frame, stream_frame};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpStream,
@@ -24,34 +24,24 @@ fn test_config() -> Config {
send_timeout_ms: 1000,
handshake_timeout_ms: 5000,
max_message_size: 8192,
max_stream_frame: 8192,
max_connections: 16,
allow_insecure_defaults: true,
}
}
async fn counting_server_with(port: u16, rx: Arc<AtomicU32>, config: Config) -> Arc<OmniVox<Msg>> {
let server = Arc::new(OmniVox::<Msg>::new());
server.set_encrypt_key(TEST_PSK);
let data = Arc::new(move |msg: &Msg, _ctx: &ConnectionContext| {
async fn counting_server(port: u16, rx: Arc<AtomicU32>) -> omnivox::Server<Msg> {
omnivox::server::<Msg>()
.key(TEST_PSK)
.config(test_config())
.on_data(move |msg: &Msg, _ctx| {
if msg.0.starts_with(b"ok") {
rx.fetch_add(1, Ordering::SeqCst);
}
});
server
.start_server(
port,
config,
data,
Arc::new(|_ctx| {}),
Arc::new(|_ctx| Decision::Allow),
)
})
.serve(port)
.await
.unwrap();
server
}
async fn counting_server(port: u16, rx: Arc<AtomicU32>) -> Arc<OmniVox<Msg>> {
counting_server_with(port, rx, test_config()).await
.unwrap()
}
async fn expect_eof(r: &mut tokio::net::tcp::OwnedReadHalf) {
@@ -64,21 +54,21 @@ async fn expect_eof(r: &mut tokio::net::tcp::OwnedReadHalf) {
}
#[tokio::test]
async fn replayed_frame_is_dropped_and_the_connection_dies() {
let port = 18301;
async fn replayed_frame_kills_the_connection() {
let port = 18501;
let rx = Arc::new(AtomicU32::new(0));
let server = counting_server(port, Arc::clone(&rx)).await;
let (mut r, mut w, s) = client_session(port, &TEST_PSK).await;
let good = frame(&s.send_key, 1, b"ok-transfer-1000");
let good = msg_frame(&s.send_key, 1, b"ok-transfer-1000");
w.write_all(&good).await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(rx.load(Ordering::SeqCst), 1, "first delivery");
// Reinject the verbatim captured frame.
// Reinject the verbatim captured frame: nonce is derived from our own
// counter (now 2), so authentication fails outright.
w.write_all(&good).await.unwrap();
expect_eof(&mut r).await; // server must drop the connection outright
tokio::time::sleep(Duration::from_millis(200)).await;
expect_eof(&mut r).await;
assert_eq!(
rx.load(Ordering::SeqCst),
1,
@@ -88,43 +78,47 @@ async fn replayed_frame_is_dropped_and_the_connection_dies() {
}
#[tokio::test]
async fn frames_from_another_session_are_undecryptable() {
// Frame portability: keys are session-bound by the handshake nonces,
// so a frame valid on connection A is junk on connection B.
let port = 18302;
async fn frames_from_another_session_are_rejected() {
let port = 18502;
let rx = Arc::new(AtomicU32::new(0));
let server = counting_server(port, Arc::clone(&rx)).await;
let (_r1, _w1, s1) = client_session(port, &TEST_PSK).await;
let (_r2, mut w2, s2) = client_session(port, &TEST_PSK).await;
let (mut r2, mut w2, _s2) = client_session(port, &TEST_PSK).await;
let portable = frame(&s1.send_key, 1, b"ok-portable");
// A perfectly valid frame from session A, injected into session B.
let portable = msg_frame(&s1.send_key, 1, b"ok-portable");
w2.write_all(&portable).await.unwrap();
// Auth failure with a matching counter would continue (junk policy), but
// the counter check passes while decryption fails → logged, skipped;
// the connection survives and the payload never fires.
tokio::time::sleep(Duration::from_millis(200)).await;
expect_eof(&mut r2).await; // v2.1: auth failure is fatal
assert_eq!(
rx.load(Ordering::SeqCst),
0,
"cross-session frame must not decrypt"
);
// connection B is still alive with its own keys:
w2.write_all(&frame(&s2.send_key, 1, b"ok-native"))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(
rx.load(Ordering::SeqCst),
1,
"native frame on B must arrive"
);
server.stop().await;
}
#[tokio::test]
async fn frame_type_tamper_is_rejected() {
// Message -> stream downgrade attempt: type byte is AAD, so this is an
// authentication failure, not a lane switch.
let port = 18503;
let rx = Arc::new(AtomicU32::new(0));
let server = counting_server(port, rx).await;
let (mut r, mut w, s) = client_session(port, &TEST_PSK).await;
let mut frame = msg_frame(&s.send_key, 1, b"ok-msg");
let type_idx = frame.len() - 17; // [len|type|ct|tag] -> type at offset 4
frame[4] = 1;
let _ = type_idx;
w.write_all(&frame).await.unwrap();
expect_eof(&mut r).await;
server.stop().await;
}
#[tokio::test]
async fn wrong_version_hello_is_closed() {
let port = 18303;
let port = 18504;
let rx = Arc::new(AtomicU32::new(0));
let server = counting_server(port, rx).await;
@@ -140,19 +134,20 @@ async fn wrong_version_hello_is_closed() {
#[tokio::test]
async fn v1_traffic_is_rejected_at_the_handshake() {
// A v1 OmniVox client speaks u32-length-prefixed AeadPackets. The v2
// server must refuse before any allocation path.
let port = 18304;
let port = 18505;
let rx = Arc::new(AtomicU32::new(0));
let server = counting_server_with(
port,
rx,
Config {
let server = omnivox::server::<Msg>()
.key(TEST_PSK)
.config(Config {
handshake_timeout_ms: 700,
..test_config()
},
)
.await;
})
.on_data(move |_: &Msg, _| {
rx.fetch_add(1, Ordering::SeqCst);
})
.serve(port)
.await
.unwrap();
let raw = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
let (mut r, mut w) = raw.into_split();
@@ -163,41 +158,43 @@ async fn v1_traffic_is_rejected_at_the_handshake() {
}
#[tokio::test]
async fn panicking_callback_does_not_kill_the_connection() {
let port = 18305;
async fn panicking_callbacks_never_kill_the_connection() {
let port = 18506;
let rx = Arc::new(AtomicU32::new(0));
let server = Arc::new(OmniVox::<Msg>::new());
server.set_encrypt_key(TEST_PSK);
let ok_rx = Arc::clone(&rx);
let data = Arc::new(move |msg: &Msg, _ctx: &ConnectionContext| {
let server = omnivox::server::<Msg>()
.key(TEST_PSK)
.config(test_config())
.on_data(move |msg: &Msg, _ctx| {
if msg.0 == b"boom" {
let _v: [u8; 8] = msg.0[..8].try_into().unwrap(); // explodes (len 4)
panic!("handler exploded on attacker input");
}
if msg.0.starts_with(b"ok") {
ok_rx.fetch_add(1, Ordering::SeqCst);
}
});
server
.start_server(
port,
test_config(),
data,
Arc::new(|_c| {}),
Arc::new(|_c| Decision::Allow),
)
})
.on_stream(|_b, _ctx| {
panic!("stream handler exploded");
})
.serve(port)
.await
.unwrap();
let (mut r, mut w, s) = client_session(port, &TEST_PSK).await;
w.write_all(&frame(&s.send_key, 1, b"boom")).await.unwrap();
w.write_all(&frame(&s.send_key, 2, b"ok-after-panic"))
w.write_all(&msg_frame(&s.send_key, 1, b"boom"))
.await
.unwrap();
w.write_all(&stream_frame(&s.send_key, 2, b"stream-boom"))
.await
.unwrap();
w.write_all(&msg_frame(&s.send_key, 3, b"ok-after-panics"))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(
rx.load(Ordering::SeqCst),
1,
"survives a panicking callback"
"connection survives two panics"
);
let mut buf = [0u8; 1];
assert!(
@@ -211,8 +208,7 @@ async fn panicking_callback_does_not_kill_the_connection() {
#[tokio::test]
async fn malicious_server_silence_times_out_the_client() {
// v2 retires B5: the client side now has the same idle/stall discipline.
let port = 18306;
let port = 18507;
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port))
.await
.unwrap();
@@ -221,30 +217,27 @@ async fn malicious_server_silence_times_out_the_client() {
let (stream, _) = listener.accept().await.unwrap();
let (mut r, mut w) = stream.into_split();
// Complete the handshake as the server, then go silent forever.
perform(&mut r, &mut w, &TEST_PSK, true).await.unwrap();
omnivox::handshake::perform(&mut r, &mut w, &TEST_PSK, true)
.await
.unwrap();
tokio::time::sleep(Duration::from_secs(60)).await;
});
let client = OmniVox::<Msg>::new();
client.set_encrypt_key(TEST_PSK);
let dropped = Arc::new(AtomicU8::new(0));
let d = Arc::clone(&dropped);
let cfg = Config {
let client = omnivox::client::<Msg>()
.key(TEST_PSK)
.config(Config {
idle_timeout_ms: 400,
..test_config()
};
client
.connect_to_server(
"127.0.0.1",
port,
cfg,
Some(Arc::new(|_m: &Msg, _c: &ConnectionContext| {})),
Arc::new(move |ctx: &ConnectionContext| {
if ctx.connected == ConnectionStatus::NotConnected {
})
.on_data(|_m: &Msg, _c| {})
.on_state(move |ctx| {
if ctx.status() == ConnectionStatus::NotConnected {
d.store(1, Ordering::SeqCst);
}
}),
)
})
.connect("127.0.0.1", port)
.await
.unwrap();
@@ -257,3 +250,124 @@ async fn malicious_server_silence_times_out_the_client() {
accepted.abort();
client.stop().await;
}
#[tokio::test]
async fn messages_and_streams_interleave_in_order() {
// One sequence across both lanes: strict per-direction ordering of the
// exact interleaving the sender produced.
let port = 18508;
let tape = Arc::new(std::sync::Mutex::new(Vec::<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
//! live server, and raw frame construction.
#![allow(dead_code)] // each test binary uses a subset
use omnivox::{Cereal, CodecError, Session, encrypt_with_nonce, frame_nonce, handshake::perform};
//! Shared helpers for the integration tests: raw v2.1 client sessions
//! (handshake completed, manual framing) for the attack regressions.
use omnivox::{
FrameType,
frame::{FrameType as Ft, seal},
handshake::{Session, perform},
};
use serde::{Deserialize, Serialize};
use tokio::net::TcpStream;
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
#[derive(Debug, Default)]
/// Test message type: serde-derives make it a `Message` by blanket impl.
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct Msg(pub Vec<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];
/// Builds one framed message with the given sequence number.
pub fn frame(key: &[u8; 32], counter: u64, msg: &[u8]) -> Vec<u8> {
let mut plain = Vec::new();
Msg(msg.to_vec()).encode(&mut plain);
let pkt = encrypt_with_nonce(key, &frame_nonce(counter), &plain);
let mut bytes = Vec::new();
pkt.encode(&mut bytes);
let mut out = Vec::new();
out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
out.extend_from_slice(&bytes);
out
pub fn bincode_cfg() -> bincode::config::Configuration<
bincode::config::LittleEndian,
bincode::config::Fixint,
bincode::config::Limit<16777216>,
> {
bincode::config::standard()
.with_fixed_int_encoding()
.with_limit::<16777216>()
}
/// Connects and completes the v2 handshake as the client role, returning
/// the raw halves plus the session keys for manual framing.
/// Serialize like the library does.
pub fn encode(msg: &Msg) -> Vec<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) {
let stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
let (mut r, mut w) = stream.into_split();