General Upgrade, NEW: WebServers, Better Docs
Some checks failed
CI / checks (push) Failing after 4m20s

This commit is contained in:
Alex Emmet 2026-07-18 03:08:03 +02:00
commit 02be09ef26
122 changed files with 10309 additions and 5206 deletions

View file

@ -35,10 +35,13 @@ jobs:
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
pnpm run dup pnpm run dup
RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack test --node wasm
RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack build wasm --target web RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack build wasm --target web
RUSTFLAGS="--cfg web_sys_unstable_apis" pnpm run build RUSTFLAGS="--cfg web_sys_unstable_apis" pnpm run build
pnpm --filter mtp-web-client run build pnpm --filter mtp-web-client run build
node test/e2ee.mjs
( (
cd example cd example
export MTP_TYPE_MAPS="$PWD/type-maps.yaml" export MTP_TYPE_MAPS="$PWD/type-maps.yaml"

441
Cargo.lock generated
View file

@ -95,9 +95,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "aws-lc-rs" name = "aws-lc-rs"
version = "1.17.1" version = "1.17.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1"
dependencies = [ dependencies = [
"aws-lc-sys", "aws-lc-sys",
"untrusted 0.7.1", "untrusted 0.7.1",
@ -106,9 +106,9 @@ dependencies = [
[[package]] [[package]]
name = "aws-lc-sys" name = "aws-lc-sys"
version = "0.42.0" version = "0.43.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c"
dependencies = [ dependencies = [
"cc", "cc",
"cmake", "cmake",
@ -140,9 +140,9 @@ dependencies = [
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.0" version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
@ -206,9 +206,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "cfg_aliases" name = "cfg_aliases"
version = "0.2.1" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]] [[package]]
name = "chacha20" name = "chacha20"
@ -271,6 +271,16 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]] [[package]]
name = "console_error_panic_hook" name = "console_error_panic_hook"
version = "0.1.7" version = "0.1.7"
@ -520,6 +530,24 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "fastbloom"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8"
dependencies = [
"foldhash",
"libm",
"portable-atomic",
"siphasher",
]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]] [[package]]
name = "fiat-crypto" name = "fiat-crypto"
version = "0.2.9" version = "0.2.9"
@ -532,6 +560,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@ -548,34 +582,89 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]] [[package]]
name = "futures-channel" name = "futures"
version = "0.3.32" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.32" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
[[package]] [[package]]
name = "futures-task" name = "futures-executor"
version = "0.3.32" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-task", "futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
[[package]]
name = "futures-macro"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
[[package]]
name = "futures-task"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]]
name = "futures-util"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite", "pin-project-lite",
"slab", "slab",
] ]
@ -627,6 +716,62 @@ dependencies = [
"polyval", "polyval",
] ]
[[package]]
name = "h3"
version = "0.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be"
dependencies = [
"bytes",
"fastrand",
"futures-util",
"http",
"pin-project-lite",
"tokio",
]
[[package]]
name = "h3-datagram"
version = "0.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d2c9f77921668673721ae40f17c729fc48b9e38a663858097cea547484fdf0f"
dependencies = [
"bytes",
"h3",
"pin-project-lite",
]
[[package]]
name = "h3-quinn"
version = "0.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4"
dependencies = [
"bytes",
"futures",
"h3",
"h3-datagram",
"quinn",
"tokio",
"tokio-util",
]
[[package]]
name = "h3-webtransport"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d91a50fd582a5d67b1f756fba3cd9c66367ff4f23e1017c882f664d63b350a7"
dependencies = [
"bytes",
"futures-util",
"h3",
"h3-datagram",
"http",
"pin-project-lite",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
@ -663,6 +808,16 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877"
[[package]]
name = "http"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
]
[[package]] [[package]]
name = "hybrid-array" name = "hybrid-array"
version = "0.4.13" version = "0.4.13"
@ -801,6 +956,55 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys",
"log",
"simd_cesu8",
"thiserror 2.0.18",
"walkdir",
"windows-link",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn",
]
[[package]] [[package]]
name = "jobserver" name = "jobserver"
version = "0.1.35" version = "0.1.35"
@ -974,7 +1178,7 @@ dependencies = [
[[package]] [[package]]
name = "mtp" name = "mtp"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"mtp-client", "mtp-client",
"mtp-codec", "mtp-codec",
@ -984,41 +1188,41 @@ dependencies = [
"mtp-host", "mtp-host",
"mtp-transport", "mtp-transport",
"mtp-type-map", "mtp-type-map",
"rand 0.8.7", "mtp-webserver",
"rand 0.10.2",
"rcgen", "rcgen",
"tokio", "tokio",
] ]
[[package]] [[package]]
name = "mtp-client" name = "mtp-client"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"mtp-codec", "mtp-codec",
"mtp-common", "mtp-common",
"mtp-crypto", "mtp-crypto",
"mtp-host", "mtp-host",
"mtp-transport", "mtp-transport",
"rand 0.8.7", "rand 0.10.2",
"rcgen", "rcgen",
"tokio", "tokio",
"tracing",
] ]
[[package]] [[package]]
name = "mtp-codec" name = "mtp-codec"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"base64", "base64",
"byteorder", "byteorder",
"mtp-common", "mtp-common",
"mtp-crypto", "mtp-crypto",
"mtp-type-map", "mtp-type-map",
"rand 0.8.7", "rand 0.10.2",
] ]
[[package]] [[package]]
name = "mtp-common" name = "mtp-common"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"quinn", "quinn",
"rustls", "rustls",
@ -1028,7 +1232,7 @@ dependencies = [
[[package]] [[package]]
name = "mtp-crypto" name = "mtp-crypto"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"base64", "base64",
@ -1038,24 +1242,29 @@ dependencies = [
"hkdf", "hkdf",
"ml-dsa", "ml-dsa",
"mlkem-tls", "mlkem-tls",
"rand_core 0.6.4", "rand 0.10.2",
"rand_core 0.10.1",
"rcgen",
"serde", "serde",
"sha2 0.11.0", "sha2 0.11.0",
"thiserror 1.0.69", "thiserror 1.0.69",
"time",
"zeroize", "zeroize",
] ]
[[package]] [[package]]
name = "mtp-files" name = "mtp-files"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"mtp-crypto", "mtp-crypto",
"rand 0.10.2",
"thiserror 1.0.69", "thiserror 1.0.69",
"zeroize",
] ]
[[package]] [[package]]
name = "mtp-host" name = "mtp-host"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"mtp-codec", "mtp-codec",
"mtp-common", "mtp-common",
@ -1063,18 +1272,20 @@ dependencies = [
"mtp-transport", "mtp-transport",
"rand 0.8.7", "rand 0.8.7",
"tokio", "tokio",
"tracing",
] ]
[[package]] [[package]]
name = "mtp-transport" name = "mtp-transport"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"log", "async-trait",
"mtp-codec", "mtp-codec",
"mtp-common", "mtp-common",
"rcgen", "rcgen",
"rustls", "rustls",
"rustls-native-certs", "rustls-native-certs",
"sha2 0.11.0",
"tokio", "tokio",
"tracing", "tracing",
"wtransport", "wtransport",
@ -1082,7 +1293,7 @@ dependencies = [
[[package]] [[package]]
name = "mtp-type-map" name = "mtp-type-map"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"serde", "serde",
"serde_yaml", "serde_yaml",
@ -1090,10 +1301,11 @@ dependencies = [
[[package]] [[package]]
name = "mtp-wasm" name = "mtp-wasm"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"futures-channel", "futures-channel",
"futures-util",
"getrandom 0.2.17", "getrandom 0.2.17",
"getrandom 0.4.3", "getrandom 0.4.3",
"hex", "hex",
@ -1102,10 +1314,34 @@ dependencies = [
"mtp-common", "mtp-common",
"mtp-crypto", "mtp-crypto",
"mtp-type-map", "mtp-type-map",
"tracing",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-bindgen-test", "wasm-bindgen-test",
"web-sys", "wasm-tracing",
"zeroize",
]
[[package]]
name = "mtp-webserver"
version = "0.2.0"
dependencies = [
"async-trait",
"bytes",
"h3",
"h3-quinn",
"h3-webtransport",
"http",
"mtp-codec",
"mtp-common",
"mtp-host",
"mtp-transport",
"quinn",
"rcgen",
"rustls",
"thiserror 2.0.18",
"tokio",
"tracing",
] ]
[[package]] [[package]]
@ -1304,6 +1540,12 @@ dependencies = [
"universal-hash", "universal-hash",
] ]
[[package]]
name = "portable-atomic"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@ -1345,6 +1587,7 @@ checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [ dependencies = [
"bytes", "bytes",
"cfg_aliases", "cfg_aliases",
"futures-io",
"pin-project-lite", "pin-project-lite",
"quinn-proto", "quinn-proto",
"quinn-udp", "quinn-udp",
@ -1365,6 +1608,7 @@ checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"bytes", "bytes",
"fastbloom",
"getrandom 0.4.3", "getrandom 0.4.3",
"lru-slab", "lru-slab",
"rand 0.10.2", "rand 0.10.2",
@ -1373,6 +1617,7 @@ dependencies = [
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"rustls-platform-verifier",
"slab", "slab",
"thiserror 2.0.18", "thiserror 2.0.18",
"tinyvec", "tinyvec",
@ -1565,6 +1810,33 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.13" version = "0.103.13"
@ -1741,6 +2013,15 @@ dependencies = [
"sponge-cursor", "sponge-cursor",
] ]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
@ -1776,6 +2057,28 @@ dependencies = [
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@ -1898,6 +2201,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "thread_local"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.53" version = "0.3.53"
@ -1955,9 +2267,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.52.3" version = "1.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@ -1972,21 +2284,35 @@ dependencies = [
[[package]] [[package]]
name = "tokio-macros" name = "tokio-macros"
version = "2.7.0" version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn",
] ]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"log",
"pin-project-lite", "pin-project-lite",
"tracing-attributes", "tracing-attributes",
"tracing-core", "tracing-core",
@ -2012,6 +2338,17 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"sharded-slab",
"thread_local",
"tracing-core",
]
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
@ -2187,12 +2524,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31d56021e873866c968588ed85ccdf56db5c426e44afdb4618c39895104b920" checksum = "c31d56021e873866c968588ed85ccdf56db5c426e44afdb4618c39895104b920"
[[package]] [[package]]
name = "web-sys" name = "wasm-tracing"
version = "0.3.103" version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" checksum = "11ab253baf6d3772bbdb37a0966b67d37ab80657ccd1a084b4d7b3de3232375d"
dependencies = [ dependencies = [
"js-sys", "tracing",
"tracing-subscriber",
"wasm-bindgen", "wasm-bindgen",
] ]
@ -2206,6 +2544,15 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"

View file

@ -9,6 +9,7 @@ members = [
"client", "client",
"wasm", "wasm",
"files", "files",
"mtp-webserver",
] ]
# `wasm` is a wasm32-only crate: it relies on web-sys unstable APIs # `wasm` is a wasm32-only crate: it relies on web-sys unstable APIs
# (`--cfg=web_sys_unstable_apis`, set in wasm/.cargo/config.toml) and the # (`--cfg=web_sys_unstable_apis`, set in wasm/.cargo/config.toml) and the
@ -28,6 +29,7 @@ default-members = [
"host", "host",
"client", "client",
"files", "files",
"mtp-webserver",
] ]
resolver = "3" resolver = "3"
@ -43,23 +45,24 @@ resolver = "3"
# ============================================================================= # =============================================================================
[package] [package]
name = "mtp" name = "mtp"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
# --- always-on core --- # --- always-on core ---
mtp-common = { version = "0.1.0", path = "common" } mtp-common = { version = "0.2.0", path = "common" }
mtp-type-map = { version = "0.1.0", path = "type-map" } mtp-type-map = { version = "0.2.0", path = "type-map" }
mtp-codec = { version = "0.1.0", path = "codec" } mtp-codec = { version = "0.2.0", path = "codec" }
mtp-transport = { version = "0.1.0", path = "transport", optional = true }
# --- optional, behind features --- # --- optional, behind features ---
mtp-crypto = { version = "0.1.0", path = "crypto", optional = true, features = [ mtp-crypto = { version = "0.2.0", path = "crypto", optional = true, features = [
"serde", "serde",
"mlkem-tls", "mlkem-tls",
] } ] }
mtp-host = { version = "0.1.0", path = "host", optional = true } mtp-host = { version = "0.2.0", path = "host", optional = true }
mtp-client = { version = "0.1.0", path = "client", optional = true } mtp-client = { version = "0.2.0", path = "client", optional = true }
mtp-files = { version = "0.1.0", path = "files", optional = true } mtp-files = { version = "0.2.0", path = "files", optional = true }
mtp-webserver = { version = "0.2.0", path = "mtp-webserver", optional = true }
mtp-transport = { version = "0.2.0", path = "transport", optional = true }
[features] [features]
# Serialization # Serialization
@ -72,6 +75,7 @@ crypto = [
"mtp-codec/crypto", "mtp-codec/crypto",
"mtp-host?/crypto", "mtp-host?/crypto",
"mtp-client?/crypto", "mtp-client?/crypto",
"mtp-webserver?/crypto",
] ]
# MTP server host - version negotiation, Registry, incoming QUIC connections. # MTP server host - version negotiation, Registry, incoming QUIC connections.
@ -86,13 +90,29 @@ transport = ["dep:mtp-transport"]
# Direct access to the pipes. Pipes can be used to send raw binary # Direct access to the pipes. Pipes can be used to send raw binary
# without after creation overhead. # without after creation overhead.
pipes = ["mtp-common/pipes", "mtp-codec/pipes", "mtp-transport?/pipes", "mtp-host?/pipes", "mtp-client?/pipes"] pipes = ["mtp-common/pipes", "mtp-codec/pipes", "mtp-transport?/pipes", "mtp-host?/pipes", "mtp-client?/pipes", "mtp-webserver?/pipes"]
# On-disk storage for keyrings (`.mk`) and public key bundles (`.mpkb`). # On-disk storage for keyrings (`.mk`) and public key bundles (`.mpkb`).
# Pulls in `crypto` so the `Keyring` / `PublicKeyBundle` types are in scope. # Pulls in `crypto` so the `Keyring` / `PublicKeyBundle` types are in scope.
files = ["dep:mtp-files", "crypto"] files = ["dep:mtp-files", "crypto"]
# HTTP/3 routing and WebTransport-based MTP hosting on one QUIC endpoint.
web-server = ["dep:mtp-webserver", "dep:mtp-host", "mtp-codec/registry", "transport"]
# Complete native server surface.
full-server = ["host", "web-server", "crypto", "pipes"]
# Self-signed certificate generation helper (requires crypto).
tls = ["crypto", "mtp-crypto?/tls"]
# Insecure TLS certificate verification (development only).
# Requires MTP_INSECURE_TLS=1 at runtime.
insecure-tls = ["dep:mtp-transport", "mtp-transport?/insecure-tls"]
[dev-dependencies] [dev-dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
rcgen = "0.14" rcgen = "0.14"
rand = "0.8" rand = "0.10.1"
[package.metadata.cargo-machete]
ignored = ["mtp-transport"]

171
README.md
View file

@ -1,167 +1,96 @@
# Methanium Transport Protocol # Methanium Transport Protocol
MTP is a modular transport protocol built on QUIC. It provides version-negotiable type maps, a binary codec, cryptographic primitives (classical and post-quantum), and host/client connection management with mutual authentication. MTP is a modular transport protocol built on QUIC. It provides versioned type maps, a binary codec, native and browser clients, host and WebTransport server implementations, and optional cryptographic authentication and end-to-end encryption.
See the area-specific docs for [Native Client](./docs/NATIVE-CLIENT.md), [WASM Client](./docs/WASM-CLIENT.md), [Host](./docs/NATIVE-HOST.md), and [Type Maps](./docs/TYPE-MAP.md). Security assumptions, certificate handling, authentication, cryptographic primitives, key storage, and known limitations: [Security](./docs/SECURITY.md).
Area-specific guides:
- [Architecture](./docs/ARCHITECTURE.md)
- [Connection lifecycle](./docs/CONNECTIONS.md)
- [Native client](./docs/NATIVE-CLIENT.md)
- [WASM client](./docs/WASM-CLIENT.md)
- [Native host](./docs/NATIVE-HOST.md)
- [Web server](./docs/NATIVE-HOST-WEB-SERVER.md)
- [Connector and version negotiation](./docs/CONNECTOR.md)
- [Protocol reference](./docs/PROTOCOL-REFERENCE.md)
- [Type maps](./docs/TYPE-MAP.md)
- [Error reference](./docs/ERRORS.md)
- [Troubleshooting](./docs/TROUBLESHOOTING.md)
- [Operations](./docs/OPERATIONS.md)
`MTPWebServer` owns its UDP endpoint and must not bind to the same address and port as `MTPHost`.
## Browser SDK ## Browser SDK
The JavaScript package is `mtp`: The JavaScript package is `mtp`.
```typescript - `mtp` exports the SDK-first `MTPClient` API and codec helpers.
import { MTPClient } from "mtp"; - `mtp/raw` exports generated WASM bindings for lower-level integrations.
import { mtp } from "mtp/vite"; - `mtp/vite` exports the Vite integration that builds app-specific bindings.
``` - `mtp/type-map` exports generated TypeScript type-name unions.
Use `mtp` for the SDK-first API, `mtp/raw` for generated WASM bindings, and `mtp/vite` for the Vite integration. Use the [WASM client guide](./docs/WASM-CLIENT.md) for installation, type-map configuration, credentials, certificate pins, requests, subscriptions, pipes, and protocol pings. Use the SDK methods before raw bindings because the raw API does not provide the wrapper's validation, persistence, timeout, logging, or lifecycle handling.
```typescript The SDK exposes crypto helpers and frame codec helpers from the main `mtp` export. The security guide describes the available algorithms and their limitations.
// vite.config.ts
import { defineConfig } from "vite";
import { mtp } from "mtp/vite";
export default defineConfig({
plugins: [mtp({ typeMaps: "./type-maps.yaml" })],
});
```
```typescript
import { MTPClient, type MTPCredentials } from "mtp";
const credentialsStorageKey = "mtpCredentialsForMyApp";
function loadCredentials(): MTPCredentials {
const saved = localStorage.getItem(credentialsStorageKey);
if (saved) {
return JSON.parse(saved) as MTPCredentials;
}
return {
clientId: null,
keyring: MTPClient.crypto.generateKeyring(),
};
}
await MTPClient.init();
// Example-looking host public key bundle bytes. Replace this with the public
// key bundle published by your MTP host before connecting to a real service.
const hostPublicKey = Uint8Array.from({ length: 128 }, (_, index) => (
[0xb6, 0x4f, 0x7d, 0x9a, 0x3c, 0x21, 0xe8, 0x05][index % 8] ^ index
));
const client = await MTPClient.create({
url: "https://localhost:4433",
hostPublicKey,
credentials: loadCredentials(),
storage: window.localStorage, // Same API as localStorage for convenience
credentialsStorageKey,
pings: true,
logger: (event) => console.log("[MTP]: " + event),
});
client.subscribe("SomeType", (message) => console.log(message));
const clientId = await client.auth();
await client.send("SomeType", { value: "hello" });
console.log("Connected MTP client", clientId, client.state);
```
`client.raw` exposes the lower-level WASM client and generated binding module for advanced integrations. Prefer the SDK methods unless you specifically need an API the wrapper does not expose; raw calls bypass SDK validation, credential persistence, logging, timeout handling, frame helpers, and lifecycle safeguards.
Use `MTPClient.crypto` for SDK-level crypto helpers such as `generateKeyring()`, `generateEd25519()`, `keyringFromEd25519()`, `verifyEd25519()`, `sha256()`, `sha256Double()`, `hkdfExpand()`, and `deriveEncryptionKey()`.
Use `codec` to encode and decode MTP frames from the main SDK export:
```typescript
import { codec } from "mtp";
const frame = codec.encode("SomeType", { value: "hello" });
const parsed = codec.decode(frame);
```
## Getting Started ## Getting Started
Add the `mtp` crate with your desired features: Add the umbrella crate with the features required by the application:
```toml ```toml
[dependencies] [dependencies]
mtp = { path = "..", features = ["client", "crypto"] } mtp = { path = "..", features = ["client", "crypto"] }
``` ```
The `mtp` umbrella crate re-exports all sub-crates behind feature flags: Feature summary:
| Feature | Pulls in | Enables | | Feature | Pulls in | Enables |
| -------- | --------------------------- | ----------------------------------------- | | --- | --- | --- |
| `crypto` | `mtp::crypto` | AEAD, signatures, KEM, KDF, hashing | | `crypto` | `mtp::crypto` | AEAD, signatures, KEM, KDF, hashing |
| `host` | `mtp::host`, `mtp::codec::registry` | QUIC server, version negotiation | | `host` | `mtp::host`, codec registry | QUIC host and version negotiation |
| `client` | `mtp::client` | QUIC client connections | | `client` | `mtp::client` | QUIC client connections |
Core crates (`codec`, `transport`, `common`, `type_map`) are always available. The core crates are always available: `codec`, `transport`, `common`, and `type_map`. See the [native client](./docs/NATIVE-CLIENT.md) and [native host](./docs/NATIVE-HOST.md)
guides for configuration and usage. See [Security](./docs/SECURITY.md) for security boundaries.
```rust
use mtp::codec::{CommunicationValue, DataValue};
use mtp::type_map::{CommunicationType, DataType, TypeMap};
use mtp::transport::{Sender, Receiver};
#[cfg(feature = "crypto")]
use mtp::crypto::ChaCha20Poly1305;
```
## Sub-crates ## Sub-crates
All sub-crates are re-exported through the `mtp` facade and can be referenced as `mtp::codec`, `mtp::transport`, `mtp::common`, `mtp::type_map`, `mtp::crypto`, `mtp::host`, `mtp::client`. The `mtp` facade re-exports the following modules:
`mtp::codec`, `mtp::transport`, `mtp::common`, `mtp::type_map`, `mtp::crypto`, `mtp::host`, and `mtp::client`.
### Codec ### Codec
The codec crate handles binary encoding and decoding of MTP packets using Communication Types and Data Types resolved through the type-map registry. The codec encodes and decodes MTP frames using Communication Types and Data Types resolved through a version-specific type map. It supports containers, integers, booleans, floats, strings, arrays, bytes, null values, and optional signed or encrypted containers. See [Type Map](./docs/TYPE-MAP.md) for mapping configuration and [Connector](./docs/CONNECTOR.md) for negotiated codecs.
**Data Value types:**
- Container (key-value map of typed entries)
- Encrypted Container (requires `crypto`)
- Signed Container (requires `crypto`)
- SignedEncrypted Container (requires `crypto`)
- Signed Integer (i128)
- Unsigned Integer (u128)
- Boolean
- Float (exponent + mantissa)
- String
- Array
- Bytes
- Null
Encoding and decoding use a `TypeMap` to resolve type names to wire IDs. The `CommunicationValue` struct provides the frame format (type, flags, optional id/sender/receiver, data payload, optional signature).
### Transport ### Transport
The transport crate wraps QUIC using `wtransport`. It provides `Sender`/`Receiver` for bidirectional message passing over uni-directional QUIC streams. Supports two send modes: persistent stream and single-stream-per-message. The transport crate wraps QUIC with `wtransport`. `Sender` and `Receiver` exchange framed messages over unidirectional streams. The transport supports a persistent stream and a single-stream-per-message mode, with configurable message limits, timeouts, queues, and stream concurrency.
Certificate verification is controlled by client configuration. Use system roots or explicit certificate and SPKI pinning for production. Development self-signed and insecure modes: [Security](./docs/SECURITY.md).
### Host ### Host
The host crate provides `MTPHost` with built-in version negotiation and optional authenticated login/registration (requires `crypto`). Accepts connections, negotiates protocol version, and returns `MTPConnection` handles. The host crate provides `MTPHost`, registry-backed version negotiation, optional authentication, and `MTPConnection` handles. Authentication policies and the challenge-response protocol: [Native Host](./docs/NATIVE-HOST.md) and [Security](./docs/SECURITY.md).
### Client ### Client
The client crate provides `MTPClient` that connects to an MTP host. Supports `connect` (unauthenticated), `auth_connect` (login), and `auth_register` (registration) when built with `crypto`. The native client provides unauthenticated connections, authenticated login, and registration when the `crypto` feature is enabled. See [Native Client](./docs/NATIVE-CLIENT.md).
The browser client uses the `mtp` SDK over WebTransport. See [WASM Client](./docs/WASM-CLIENT.md).
### Common ### Common
Common defines shared error types (`CodecError`, `CommunicationError`) used across all crates. Common defines shared errors such as `CodecError` and `CommunicationError`, as well as protocol-level types used by the other crates.
### Type Map ### Type Map
The type-map build script reads a YAML configuration to generate `CommunicationType` and `DataType` enums at compile time. The runtime crate provides `TypeMap`, `Version`, `CommunicationTypeId`, `DataTypeId`, and the multi-version `Registry` (requires `registry` feature). The type-map build script reads YAML and generates `CommunicationType` and `DataType` enums at compile time. The runtime crate provides `TypeMap`, `Version`, ID types, and the multi-version `Registry` when the registry feature is enabled. See [Type Map](./docs/TYPE-MAP.md).
### Crypto Stack ### Crypto
| Crate | Audited? | Notes | `mtp-crypto` provides AEAD encryption, Ed25519 and ML-DSA-65 signatures, X25519 plus ML-KEM-768 hybrid KEM support, HKDF, SHA-256, keyrings, encrypted containers, and certificate generation for development. Feature flags and security boundaries: [Security](./docs/SECURITY.md).
| ---------------- | -------- | ------------------------------------------------ |
| ml-dsa | No | NIST vectors pass; regression bug fixed Jan 2026 | ## Examples
| ed25519-dalek | Yes | Used by Signal, Diem |
| chacha20poly1305 | Yes | NCC Group audit, Dec 2019 | The [`example/`](./example/) workspace contains native client, native server, key-generation, WebTransport server, and browser client examples. The example server stores its generated development certificate and host keys locally; use the certificate pin it prints when connecting the example client.
| aes-gcm | Yes | NCC Group audit, Dec 2019 |
| hkdf | No | Simple construction; well-reviewed |
| sha2 | No | Standard construction; widely reviewed |
| zeroize | No | Simple; widely used |
| mlkem-tls | No | mlkem-rs backend unaudited |

2
client/Cargo.lock generated
View file

@ -4,4 +4,4 @@ version = 4
[[package]] [[package]]
name = "client" name = "client"
version = "0.1.0" version = "0.2.0"

View file

@ -1,20 +1,19 @@
[package] [package]
name = "mtp-client" name = "mtp-client"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
mtp-common = { version = "0.1.0", path = "../common" } mtp-common = { version = "0.2.0", path = "../common" }
mtp-codec = { version = "0.1.0", path = "../codec" } mtp-codec = { version = "0.2.0", path = "../codec" }
mtp-transport = { version = "0.1.0", path = "../transport" } mtp-transport = { version = "0.2.0", path = "../transport" }
mtp-crypto = { version = "0.1.0", path = "../crypto", optional = true } mtp-crypto = { version = "0.2.0", path = "../crypto", optional = true }
rand = "0.8" rand = "0.10.1"
tokio = { version = "1", features = ["rt", "sync", "time"] } tokio = { version = "1", features = ["rt", "sync", "time"] }
tracing = "0.1"
[dev-dependencies] [dev-dependencies]
mtp-host = { version = "0.1.0", path = "../host" } mtp-host = { version = "0.2.0", path = "../host" }
mtp-transport = { version = "0.1.0", path = "../transport", features = ["host"] } mtp-transport = { version = "0.2.0", path = "../transport", features = ["host"] }
rcgen = "0.14" rcgen = "0.14"
[features] [features]

115
client/src/config.rs Normal file
View file

@ -0,0 +1,115 @@
use tokio::time::Duration;
pub use mtp_transport::Policy;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientTlsConfig {
SystemRoots,
PinnedPem(Vec<u8>),
}
pub struct ClientConfig {
pub url: String,
pub tls: ClientTlsConfig,
pub client_id: u64,
pub description: Option<String>,
pub policy: Policy,
pub ping_interval: Duration,
pub ping_jitter: Option<Duration>,
pub max_missed_pings: usize,
pub ping_timestamp: bool,
pub request_timeout: Duration,
#[cfg(feature = "crypto")]
pub auth_timeout: Duration,
#[cfg(feature = "crypto")]
pub require_pq: bool,
}
impl ClientConfig {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
tls: ClientTlsConfig::SystemRoots,
client_id: 0,
description: None,
policy: Policy::default(),
ping_interval: Duration::ZERO,
ping_jitter: None,
max_missed_pings: 3,
ping_timestamp: true,
request_timeout: Duration::from_secs(30),
#[cfg(feature = "crypto")]
auth_timeout: Duration::from_secs(30),
#[cfg(feature = "crypto")]
require_pq: true,
}
}
pub fn with_tls(mut self, tls: ClientTlsConfig) -> Self {
self.tls = tls;
self
}
pub fn with_pinned_pem(self, cert_pem: Vec<u8>) -> Self {
self.with_tls(ClientTlsConfig::PinnedPem(cert_pem))
}
pub fn with_client_id(mut self, client_id: u64) -> Self {
self.client_id = client_id;
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_policy(mut self, policy: Policy) -> Self {
self.policy = policy;
self
}
pub fn with_ping_interval(mut self, interval: Duration) -> Self {
self.ping_interval = interval;
self
}
pub fn with_ping_jitter(mut self, jitter: Option<Duration>) -> Self {
self.ping_jitter = jitter;
self
}
pub fn with_max_missed_pings(mut self, max_missed_pings: usize) -> Self {
self.max_missed_pings = max_missed_pings;
self
}
pub fn with_ping_timestamp(mut self, ping_timestamp: bool) -> Self {
self.ping_timestamp = ping_timestamp;
self
}
pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = timeout;
self
}
#[cfg(feature = "crypto")]
pub fn with_auth_timeout(mut self, timeout: Duration) -> Self {
self.auth_timeout = timeout;
self
}
#[cfg(feature = "crypto")]
pub fn with_require_pq(mut self, require_pq: bool) -> Self {
self.require_pq = require_pq;
self
}
pub(crate) fn server_cert(&self) -> Option<Vec<u8>> {
match &self.tls {
ClientTlsConfig::SystemRoots => None,
ClientTlsConfig::PinnedPem(cert) => Some(cert.clone()),
}
}
}

238
client/src/connection.rs Normal file
View file

@ -0,0 +1,238 @@
use mtp_codec::{CommunicationValue, Version};
#[cfg(feature = "pipes")]
use mtp_codec::{DataType, DataValue};
use mtp_common::CommunicationError;
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
use tokio::time::Duration;
use crate::config::ClientConfig;
#[cfg(feature = "crypto")]
use crate::error::AuthState;
use crate::ping::{PingSession, start_ping_session};
#[cfg(feature = "pipes")]
use crate::pipe::PipeRequest;
use crate::pipe::{PendingRequest, PipeDispatcher, run_dispatcher};
pub struct MTPConnection {
pub version: Version,
pub sender: mtp_transport::Sender,
pub receiver: mtp_transport::Receiver,
pub description: Option<String>,
pub(crate) ping: Option<PingSession>,
pub(crate) app_rx: Mutex<mpsc::Receiver<Result<CommunicationValue, CommunicationError>>>,
#[cfg(feature = "pipes")]
pub(crate) pipe_req_rx: Mutex<mpsc::Receiver<PipeRequest>>,
pub(crate) pipe_dispatcher: Arc<PipeDispatcher>,
pub(crate) request_timeout: Duration,
pub(crate) _dispatcher_task: tokio::task::JoinHandle<()>,
#[cfg(feature = "crypto")]
pub auth_state: AuthState,
#[cfg(feature = "crypto")]
pub client_id: u64,
}
impl MTPConnection {
pub fn get_ping(&self) -> Option<Duration> {
self.ping.as_ref().and_then(PingSession::get_ping)
}
pub async fn request(
&self,
request: &CommunicationValue,
expected_response: Option<mtp_codec::CommunicationType>,
) -> Result<CommunicationValue, CommunicationError> {
let request_id = request.get_id();
if request_id == 0 {
return Err(CommunicationError::Other(
"request frame must have a non-zero id".into(),
));
}
let (sender, receiver) = tokio::sync::oneshot::channel();
let token = Arc::new(());
{
let mut pending = self.pipe_dispatcher.pending_requests.lock().await;
if pending.contains_key(&request_id) {
return Err(CommunicationError::Other(format!(
"request id {request_id} is already pending"
)));
}
pending.insert(
request_id,
PendingRequest {
token: token.clone(),
sender,
},
);
}
let response = match tokio::time::timeout(self.request_timeout, async {
self.sender.send(request).await?;
receiver
.await
.map_err(|_| CommunicationError::StreamClosed)?
})
.await
{
Ok(result) => {
if result.is_err() {
crate::pipe::remove_pending_request(&self.pipe_dispatcher, request_id, &token)
.await;
}
result?
}
Err(_) => {
crate::pipe::remove_pending_request(&self.pipe_dispatcher, request_id, &token)
.await;
return Err(CommunicationError::Other(format!(
"request {request_id} timed out after {:?}",
self.request_timeout
)));
}
};
if let Some(expected) = expected_response {
let expected_type = expected.try_to_id(&mtp_codec::TypeMap::latest());
if Some(response.get_type()) != expected_type {
return Err(CommunicationError::Other(format!(
"unexpected response type: expected {:?}, got {:?}; parsed {}",
expected_type,
response.get_type(),
response
)));
}
}
Ok(response)
}
pub async fn receive(&self) -> Result<CommunicationValue, CommunicationError> {
let mut rx = self.app_rx.lock().await;
match rx.recv().await {
Some(result) => result,
None => Err(CommunicationError::StreamClosed),
}
}
}
#[cfg(feature = "pipes")]
impl MTPConnection {
pub async fn create_pipe(
&self,
description: &str,
) -> Result<crate::pipe::PipeHandle, mtp_common::PipeError> {
let pipe_id = rand::random::<u32>();
let (tx, rx) = tokio::sync::oneshot::channel();
{
let mut pending = self.pipe_dispatcher.pending_creations.lock().await;
pending.insert(pipe_id, tx);
}
let request = CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest)
.with_id(pipe_id)
.add_typed_default(DataType::Description, DataValue::Str(description.into()));
self.sender
.send(&request)
.await
.map_err(mtp_common::PipeError::from)?;
Ok(crate::pipe::PipeHandle {
pipe_id,
description: description.to_string(),
sender: self.sender.clone(),
response_rx: rx,
})
}
pub async fn receive_pipe(&self) -> Result<PipeRequest, CommunicationError> {
let mut rx = self.pipe_req_rx.lock().await;
match rx.recv().await {
Some(req) => Ok(req),
None => Err(CommunicationError::StreamClosed),
}
}
}
pub(crate) fn connection_from_parts(
config: ClientConfig,
sender: mtp_transport::Sender,
receiver: mtp_transport::Receiver,
version: Version,
#[cfg(feature = "crypto")] auth_state: AuthState,
#[cfg(feature = "crypto")] client_id: u64,
) -> MTPConnection {
let ping = start_ping_session(&config, sender.clone(), &receiver);
#[cfg(feature = "pipes")]
{
let (app_tx, app_rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(
config.policy.receiver_queue_capacity,
);
let (pipe_req_tx, pipe_req_rx) =
mpsc::channel::<PipeRequest>(config.policy.receiver_queue_capacity);
let dispatcher = Arc::new(PipeDispatcher {
pending_requests: Mutex::new(std::collections::HashMap::new()),
pending_creations: Mutex::new(std::collections::HashMap::new()),
pending_pipes: Mutex::new(std::collections::HashMap::new()),
policy: Arc::new(config.policy),
});
let dispatcher_clone = dispatcher.clone();
let sender_clone = sender.clone();
let dispatcher_task = tokio::spawn(run_dispatcher(
receiver.clone(),
sender_clone,
app_tx,
pipe_req_tx,
dispatcher_clone,
));
MTPConnection {
version,
sender,
receiver,
app_rx: Mutex::new(app_rx),
pipe_req_rx: Mutex::new(pipe_req_rx),
pipe_dispatcher: dispatcher,
request_timeout: config.request_timeout,
description: config.description,
ping,
_dispatcher_task: dispatcher_task,
#[cfg(feature = "crypto")]
auth_state,
#[cfg(feature = "crypto")]
client_id,
}
}
#[cfg(not(feature = "pipes"))]
{
let (app_tx, app_rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(
config.policy.receiver_queue_capacity,
);
let dispatcher = Arc::new(PipeDispatcher {
pending_requests: Mutex::new(std::collections::HashMap::new()),
});
let task = tokio::spawn(run_dispatcher(receiver.clone(), app_tx, dispatcher.clone()));
MTPConnection {
version,
sender,
receiver,
app_rx: Mutex::new(app_rx),
pipe_dispatcher: dispatcher,
request_timeout: config.request_timeout,
description: config.description,
ping,
_dispatcher_task: task,
#[cfg(feature = "crypto")]
auth_state,
#[cfg(feature = "crypto")]
client_id,
}
}
}

218
client/src/crypto.rs Normal file
View file

@ -0,0 +1,218 @@
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, Version};
use mtp_common::CommunicationError;
pub(crate) fn unexpected_response_type_error(
context: &str,
expected_type: mtp_codec::CommunicationTypeId,
response: &CommunicationValue,
) -> CommunicationError {
CommunicationError::AuthenticationFailed(format!(
"unexpected response type during {context}: expected {:?}, got {:?}; parsed {}",
expected_type,
response.get_type(),
response
))
}
pub(crate) fn verify_host_challenge(
challenge: &CommunicationValue,
host_pk: &mtp_crypto::PublicKeyBundle,
id: u64,
server_challenge: u128,
require_pq: bool,
) -> Result<(), CommunicationError> {
use mtp_crypto::{auth, verify_ed25519, verify_ml_dsa};
let sig = match challenge.get_data(DataType::Signature) {
DataValue::Bytes(b) => b.clone(),
_ => {
return Err(CommunicationError::AuthenticationFailed(
"Missing host challenge signature".into(),
));
}
};
let pq_sig = match challenge.get_data(DataType::PqSignature) {
DataValue::Bytes(b) => b.clone(),
_ => vec![],
};
let host_requires_pq = challenge.get_data(DataType::RequirePq) == &DataValue::BoolTrue;
if host_requires_pq && host_pk.sig_pq_public_key.as_bytes().is_empty() {
return Err(CommunicationError::AuthenticationFailed(
"Host requires post-quantum authentication but its PQ public key is absent".into(),
));
}
if require_pq && pq_sig.is_empty() {
return Err(CommunicationError::AuthenticationFailed(
"Host challenge is missing the required PQ signature".into(),
));
}
let payload = auth::challenge_payload(id, server_challenge);
verify_ed25519(&host_pk.sig_cl_public_key, &payload, &sig).map_err(|_| {
CommunicationError::AuthenticationFailed("Host challenge signature invalid".into())
})?;
if !pq_sig.is_empty() && verify_ml_dsa(&host_pk.sig_pq_public_key, &payload, &pq_sig).is_err() {
return Err(CommunicationError::AuthenticationFailed(
"Host challenge PQ signature invalid".into(),
));
}
Ok(())
}
pub(crate) fn verify_host_final(
response: &CommunicationValue,
host_pk: &mtp_crypto::PublicKeyBundle,
id: u64,
client_nonce: u128,
server_challenge: u128,
require_pq: bool,
) -> Result<(), CommunicationError> {
use mtp_crypto::{auth, verify_ed25519, verify_ml_dsa};
match response.get_data(DataType::ClientNonce) {
DataValue::UnsignedNumber(n) if *n == client_nonce => {}
_ => {
return Err(CommunicationError::AuthenticationFailed(
"Nonce mismatch".into(),
));
}
}
let sig = match response.get_data(DataType::Signature) {
DataValue::Bytes(b) => b.clone(),
_ => {
return Err(CommunicationError::AuthenticationFailed(
"Missing signature".into(),
));
}
};
let pq_sig = match response.get_data(DataType::PqSignature) {
DataValue::Bytes(b) => b.clone(),
_ => vec![],
};
if require_pq && pq_sig.is_empty() {
return Err(CommunicationError::AuthenticationFailed(
"Host confirmation is missing the required PQ signature".into(),
));
}
let payload = auth::host_final_payload(id, client_nonce, server_challenge);
verify_ed25519(&host_pk.sig_cl_public_key, &payload, &sig)
.map_err(|_| CommunicationError::AuthenticationFailed("Host signature invalid".into()))?;
if !pq_sig.is_empty() && verify_ml_dsa(&host_pk.sig_pq_public_key, &payload, &pq_sig).is_err() {
return Err(CommunicationError::AuthenticationFailed(
"Host PQ signature invalid".into(),
));
}
Ok(())
}
pub(crate) fn check_connected(
response: &CommunicationValue,
reject_msg: &str,
) -> Result<(), CommunicationError> {
match response.get_data(DataType::Connected) {
DataValue::BoolTrue => Ok(()),
DataValue::BoolFalse => Err(CommunicationError::AuthenticationFailed(
response
.get_str(DataType::ErrorMessage)
.unwrap_or(reject_msg)
.into(),
)),
_ => Err(CommunicationError::AuthenticationFailed(
"Invalid response".into(),
)),
}
}
pub(crate) fn negotiated_version(
response: &CommunicationValue,
) -> Result<Version, CommunicationError> {
match response.get_data(DataType::Version) {
DataValue::Str(version) => Version::parse(version).ok_or_else(|| {
CommunicationError::AuthenticationFailed(
"Host returned an invalid negotiated protocol version".into(),
)
}),
_ => Err(CommunicationError::AuthenticationFailed(
"Host omitted the negotiated protocol version".into(),
)),
}
}
pub(crate) fn signed_challenge_response(
keys: &mtp_crypto::Keyring,
proof_payload: &[u8],
client_nonce: u128,
) -> Result<CommunicationValue, CommunicationError> {
use mtp_crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme};
let signer = Ed25519Signer::new(&keys.sig_cl_secret_key)
.map_err(|e| CommunicationError::Other(e.to_string()))?;
let signature = signer
.sign(proof_payload)
.map_err(|e| CommunicationError::Other(e.to_string()))?;
let mut proof = CommunicationValue::new(CommunicationType::ChallengeResponse)
.add_typed_default(
DataType::ClientNonce,
DataValue::UnsignedNumber(client_nonce),
)
.add_typed_default(DataType::Signature, DataValue::Bytes(signature));
if !keys.sig_pq_secret_key.as_bytes().is_empty() {
let pq_signer = MlDsaSigner::new(&keys.sig_pq_secret_key, &keys.sig_pq_public_key)
.map_err(|e| CommunicationError::Other(e.to_string()))?;
let pq_signature = pq_signer
.sign(proof_payload)
.map_err(|e| CommunicationError::Other(e.to_string()))?;
proof = proof.add_typed_default(DataType::PqSignature, DataValue::Bytes(pq_signature));
}
Ok(proof)
}
pub(crate) async fn receive_verified_challenge(
receiver: &mtp_transport::Receiver,
tm: &mtp_codec::TypeMap,
host_public_key_bundle: &mtp_crypto::PublicKeyBundle,
bound_id: u64,
context: &str,
require_pq: bool,
client_has_pq_key: bool,
) -> Result<u128, CommunicationError> {
let challenge = receiver.receive().await?;
let expected = CommunicationType::Challenge
.try_to_id(tm)
.ok_or_else(|| CommunicationError::Other("Challenge is absent from the type map".into()))?;
if challenge.get_type() != expected {
return Err(unexpected_response_type_error(
context, expected, &challenge,
));
}
let server_challenge = match challenge.get_data(DataType::ServerNonce) {
DataValue::UnsignedNumber(n) => *n,
_ => {
return Err(CommunicationError::AuthenticationFailed(
"Missing server challenge".into(),
));
}
};
if challenge.get_data(DataType::RequirePq) == &DataValue::BoolTrue && !client_has_pq_key {
return Err(CommunicationError::AuthenticationFailed(
"Host requires post-quantum authentication but the client PQ key is absent".into(),
));
}
verify_host_challenge(
&challenge,
host_public_key_bundle,
bound_id,
server_challenge,
require_pq,
)?;
Ok(server_challenge)
}

File diff suppressed because it is too large Load diff

102
client/src/ping.rs Normal file
View file

@ -0,0 +1,102 @@
use rand::RngExt;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
use tokio::time::{Duration, Instant};
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp_transport::{Receiver, Sender};
pub(crate) struct PingSession {
pub(crate) last_ping: Arc<Mutex<Option<Duration>>>,
pub(crate) task: tokio::task::JoinHandle<()>,
}
impl PingSession {
pub(crate) fn get_ping(&self) -> Option<Duration> {
self.last_ping.try_lock().ok().and_then(|ping| *ping)
}
}
impl Drop for PingSession {
fn drop(&mut self) {
self.task.abort();
}
}
pub(crate) fn start_ping_session(
config: &crate::config::ClientConfig,
sender: Sender,
receiver: &Receiver,
) -> Option<PingSession> {
if config.ping_interval.is_zero() {
return None;
}
let (pong_tx, mut pong_rx) = mpsc::unbounded_channel();
receiver.observe_pongs(pong_tx);
let last_ping = Arc::new(Mutex::new(None));
let ping_state = last_ping.clone();
let interval = config.ping_interval;
let ping_jitter = config.ping_jitter;
let max_missed_pings = config.max_missed_pings;
let ping_timestamp = config.ping_timestamp;
let mut close_rx = receiver.handle().subscribe_close();
let task = tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
ticker.tick().await;
let mut pending = HashMap::new();
loop {
tokio::select! {
_ = close_rx.changed() => {
if close_rx.borrow().is_some() {
break;
}
}
_ = ticker.tick() => {
if max_missed_pings > 0 && !pending.is_empty() && pending.len() >= max_missed_pings {
sender.close();
break;
}
if let Some(jitter) = ping_jitter && !jitter.is_zero() {
let max_ms = jitter.as_millis() as u64;
let extra = rand::rng().random_range(0..=max_ms);
tokio::time::sleep(Duration::from_millis(extra)).await;
}
let mut ping = CommunicationValue::new(CommunicationType::Ping);
if ping_timestamp {
let sent_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
ping = ping.add_typed_default(
DataType::Timestamp,
DataValue::UnsignedNumber(sent_at),
);
}
let id = ping.get_id();
if sender.send(&ping).await.is_err() {
sender.close();
break;
}
pending.insert(id, Instant::now());
}
pong = pong_rx.recv() => match pong {
Some(pong) => {
if let Some(sent_at) = pending.remove(&pong.get_id()) {
let mut last_ping = ping_state.lock().await;
*last_ping = Some(sent_at.elapsed());
}
}
None => break,
},
}
}
});
Some(PingSession { last_ping, task })
}

229
client/src/pipe.rs Normal file
View file

@ -0,0 +1,229 @@
use mtp_codec::CommunicationValue;
use mtp_common::CommunicationError;
use mtp_transport::Receiver;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
#[cfg(feature = "pipes")]
use mtp_codec::{CommunicationType, DataType, DataValue};
#[cfg(feature = "pipes")]
use mtp_common::PipeError;
#[cfg(feature = "pipes")]
use mtp_transport::{Policy, Sender};
#[cfg(feature = "pipes")]
pub struct PipeHandle {
pub(crate) pipe_id: u32,
pub(crate) description: String,
pub(crate) sender: Sender,
pub(crate) response_rx: tokio::sync::oneshot::Receiver<Result<bool, PipeError>>,
}
#[cfg(feature = "pipes")]
impl PipeHandle {
pub fn pipe_id(&self) -> u32 {
self.pipe_id
}
pub fn description(&self) -> &str {
&self.description
}
pub async fn wait(self) -> Result<Option<mtp_transport::PipeWriter>, PipeError> {
match self.response_rx.await {
Ok(Ok(true)) => {
let writer = self
.sender
.open_pipe(self.pipe_id, &self.description)
.await
.map_err(PipeError::from)?;
Ok(Some(writer))
}
Ok(Ok(false)) => Ok(None),
Ok(Err(e)) => Err(e),
Err(_) => Err(PipeError::StreamClosed),
}
}
}
#[cfg(feature = "pipes")]
pub struct PipeRequest {
pub(crate) pipe_id: u32,
pub(crate) description: String,
pub(crate) sender: Sender,
pub(crate) dispatcher: Arc<PipeDispatcher>,
}
#[cfg(feature = "pipes")]
impl PipeRequest {
pub fn id(&self) -> u32 {
self.pipe_id
}
pub fn description(&self) -> &str {
&self.description
}
pub async fn accept(self) -> Result<mtp_transport::PipeReader, PipeError> {
let (pipe_tx, pipe_rx) = tokio::sync::oneshot::channel();
{
let mut pending = self.dispatcher.pending_pipes.lock().await;
pending.insert(self.pipe_id, pipe_tx);
}
let resp = CommunicationValue::new(CommunicationType::PipeResponse)
.with_id(self.pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolTrue);
self.sender.send(&resp).await.map_err(PipeError::from)?;
let timeout = self.dispatcher.policy.read_timeout;
tokio::time::timeout(timeout, pipe_rx)
.await
.map_err(|_| PipeError::HandshakeTimeout)?
.map_err(|_| PipeError::StreamClosed)
}
pub async fn deny(self) -> Result<(), PipeError> {
let resp = CommunicationValue::new(CommunicationType::PipeResponse)
.with_id(self.pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
self.sender.send(&resp).await.map_err(PipeError::from)?;
Ok(())
}
}
pub(crate) struct PendingRequest {
pub(crate) token: Arc<()>,
pub(crate) sender: tokio::sync::oneshot::Sender<Result<CommunicationValue, CommunicationError>>,
}
pub(crate) struct PipeDispatcher {
pub(crate) pending_requests: Mutex<HashMap<u32, PendingRequest>>,
#[cfg(feature = "pipes")]
pub(crate) pending_creations:
Mutex<HashMap<u32, tokio::sync::oneshot::Sender<Result<bool, PipeError>>>>,
#[cfg(feature = "pipes")]
pub(crate) pending_pipes:
Mutex<HashMap<u32, tokio::sync::oneshot::Sender<mtp_transport::PipeReader>>>,
#[cfg(feature = "pipes")]
pub(crate) policy: Arc<Policy>,
}
pub(crate) async fn route_message(
msg: CommunicationValue,
app_tx: &mpsc::Sender<Result<CommunicationValue, CommunicationError>>,
dispatcher: &PipeDispatcher,
) -> bool {
let pending = dispatcher
.pending_requests
.lock()
.await
.remove(&msg.get_id());
if let Some(tx) = pending {
let _ = tx.sender.send(Ok(msg));
return true;
}
app_tx.send(Ok(msg)).await.is_ok()
}
pub(crate) async fn fail_pending_requests(dispatcher: &PipeDispatcher, error: CommunicationError) {
let pending = std::mem::take(&mut *dispatcher.pending_requests.lock().await);
for (_, pending) in pending {
let _ = pending.sender.send(Err(error.clone()));
}
}
pub(crate) async fn remove_pending_request(
dispatcher: &PipeDispatcher,
request_id: u32,
token: &Arc<()>,
) {
let mut pending = dispatcher.pending_requests.lock().await;
if pending
.get(&request_id)
.is_some_and(|entry| Arc::ptr_eq(&entry.token, token))
{
pending.remove(&request_id);
}
}
#[cfg(feature = "pipes")]
pub(crate) async fn run_dispatcher(
receiver: Receiver,
sender: Sender,
app_tx: mpsc::Sender<Result<CommunicationValue, CommunicationError>>,
pipe_req_tx: mpsc::Sender<PipeRequest>,
dispatcher: Arc<PipeDispatcher>,
) {
let pipe_req_type = CommunicationType::PipeRequest.try_to_id(&mtp_codec::TypeMap::latest());
let pipe_resp_type = CommunicationType::PipeResponse.try_to_id(&mtp_codec::TypeMap::latest());
loop {
match receiver.receive_event().await {
Ok(mtp_transport::TransportEvent::Message(msg)) => {
if Some(msg.get_type()) == pipe_req_type {
let pipe_id = msg.get_id();
let description = msg.get_str(DataType::Description).unwrap_or("").to_string();
let req = PipeRequest {
pipe_id,
description,
sender: sender.clone(),
dispatcher: dispatcher.clone(),
};
let _ = pipe_req_tx.send(req).await;
continue;
}
if Some(msg.get_type()) == pipe_resp_type {
let pipe_id = msg.get_id();
let accepted = msg.get_bool(DataType::Accepted).unwrap_or(false);
let mut pending = dispatcher.pending_creations.lock().await;
if let Some(tx) = pending.remove(&pipe_id) {
let _ = tx.send(Ok(accepted));
}
continue;
}
if !route_message(msg, &app_tx, &dispatcher).await {
break;
}
}
Ok(mtp_transport::TransportEvent::Pipe(reader)) => {
let pipe_id = reader.pipe_id();
let mut pending = dispatcher.pending_pipes.lock().await;
if let Some(tx) = pending.remove(&pipe_id) {
let _ = tx.send(reader);
}
}
Err(e) => {
fail_pending_requests(&dispatcher, e.clone()).await;
let _ = app_tx.send(Err(e)).await;
break;
}
}
}
}
#[cfg(not(feature = "pipes"))]
pub(crate) async fn run_dispatcher(
receiver: Receiver,
app_tx: mpsc::Sender<Result<CommunicationValue, CommunicationError>>,
dispatcher: Arc<PipeDispatcher>,
) {
loop {
match receiver.receive().await {
Ok(msg) => {
if !route_message(msg, &app_tx, &dispatcher).await {
break;
}
}
Err(e) => {
fail_pending_requests(&dispatcher, e.clone()).await;
let _ = app_tx.send(Err(e)).await;
break;
}
}
}
}

View file

@ -32,15 +32,15 @@ async fn test_ping_rtt_and_missed_ping_teardown() -> Result<(), Box<dyn std::err
let (mut host, cert_pem) = start_host(true).await?; let (mut host, cert_pem) = start_host(true).await?;
let url = format!("https://127.0.0.1:{}", host.local_addr().port()); let url = format!("https://127.0.0.1:{}", host.local_addr().port());
let client = MTPClient::connect( let client_connect = MTPClient::connect(
ClientConfig::new(url) ClientConfig::new(url)
.with_pinned_pem(cert_pem) .with_pinned_pem(cert_pem)
.with_ping_interval(std::time::Duration::from_millis(25)) .with_ping_interval(std::time::Duration::from_millis(25))
.with_max_missed_pings(3), .with_max_missed_pings(3),
) );
.await?; let (client, accepted) = tokio::join!(client_connect, host.accept());
let client = client?;
let _accepted = host.accept().await?; let _accepted = accepted?;
let ping = tokio::time::timeout(std::time::Duration::from_secs(5), async { let ping = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop { loop {
@ -56,15 +56,15 @@ async fn test_ping_rtt_and_missed_ping_teardown() -> Result<(), Box<dyn std::err
let (mut silent_host, silent_cert_pem) = start_host(false).await?; let (mut silent_host, silent_cert_pem) = start_host(false).await?;
let silent_url = format!("https://127.0.0.1:{}", silent_host.local_addr().port()); let silent_url = format!("https://127.0.0.1:{}", silent_host.local_addr().port());
let silent_client = MTPClient::connect( let silent_connect = MTPClient::connect(
ClientConfig::new(silent_url) ClientConfig::new(silent_url)
.with_pinned_pem(silent_cert_pem) .with_pinned_pem(silent_cert_pem)
.with_ping_interval(std::time::Duration::from_millis(25)) .with_ping_interval(std::time::Duration::from_millis(25))
.with_max_missed_pings(2), .with_max_missed_pings(2),
) );
.await?; let (silent_client, accepted) = tokio::join!(silent_connect, silent_host.accept());
let silent_client = silent_client?;
let _accepted = silent_host.accept().await?; let _accepted = accepted?;
let closed = tokio::time::timeout(std::time::Duration::from_secs(5), async { let closed = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop { loop {

2
codec/Cargo.lock generated
View file

@ -60,7 +60,7 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]] [[package]]
name = "mtp-codec" name = "mtp-codec"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"base64", "base64",
"byteorder", "byteorder",

View file

@ -1,15 +1,15 @@
[package] [package]
name = "mtp-codec" name = "mtp-codec"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
mtp-type-map = { version = "0.1.0", path = "../type-map" } mtp-type-map = { version = "0.2.0", path = "../type-map" }
mtp-common = { version = "0.1.0", path = "../common" } mtp-common = { version = "0.2.0", path = "../common" }
mtp-crypto = { version = "0.1.0", path = "../crypto", optional = true } mtp-crypto = { version = "0.2.0", path = "../crypto", optional = true }
base64 = "0.22" base64 = "0.22"
byteorder = "1.5" byteorder = "1.5"
rand = { version = "0.8", features = ["std", "std_rng"] } rand = { version = "0.10.1", features = ["std", "std_rng"] }
[features] [features]
registry = ["mtp-type-map/registry"] registry = ["mtp-type-map/registry"]

View file

@ -6,11 +6,15 @@ use std::io::{Cursor, Read};
use crate::data_value::{DataKind, DataValue}; use crate::data_value::{DataKind, DataValue};
use crate::rand_u32; use crate::rand_u32;
use mtp_common::CodecError; use mtp_common::CodecError;
#[cfg(all(test, feature = "registry"))]
use mtp_type_map::Version;
use mtp_type_map::{ use mtp_type_map::{
CommunicationType, CommunicationTypeId, DataType, DataTypeId, PROTOCOL_VERSION, TypeMap, CommunicationType, CommunicationTypeId, DataType, DataTypeId, PROTOCOL_VERSION, TypeMap,
communication_type_name, data_type_name,
}; };
/// Largest sender or receiver identifier representable by the six-byte wire fields.
pub const MAX_WIRE_ID: u64 = (1 << 48) - 1;
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
use mtp_crypto::{PublicKeyBundle, SigAlgorithm, SignatureScheme}; use mtp_crypto::{PublicKeyBundle, SigAlgorithm, SignatureScheme};
@ -19,6 +23,28 @@ const FLAG_HAS_RECEIVER: u8 = 0b0000_0010;
const FLAG_HAS_ID: u8 = 0b0000_0100; const FLAG_HAS_ID: u8 = 0b0000_0100;
const FLAG_ENCRYPTED: u8 = 0b0000_1000; const FLAG_ENCRYPTED: u8 = 0b0000_1000;
const FLAG_SIGNED: u8 = 0b0001_0000; const FLAG_SIGNED: u8 = 0b0001_0000;
const FLAG_SIGNED_ENCRYPTED: u8 = 0b0010_0000;
/// An opaque, frame-level encrypted payload.
///
/// This is separate from [`DataValue`] because encrypted frame bytes are not a
/// typed data map until they have been decrypted.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg(feature = "crypto")]
pub enum EncryptedPayload {
Plain(Vec<u8>),
Signed(Vec<u8>),
}
#[cfg(feature = "crypto")]
impl EncryptedPayload {
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
match self {
Self::Plain(bytes) | Self::Signed(bytes) => bytes,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommunicationValue { pub struct CommunicationValue {
@ -27,7 +53,10 @@ pub struct CommunicationValue {
sender: u64, sender: u64,
receiver: u64, receiver: u64,
data: BTreeMap<DataTypeId, DataValue>, data: BTreeMap<DataTypeId, DataValue>,
#[cfg(feature = "crypto")]
encrypted_payload: Option<EncryptedPayload>,
type_map: Option<TypeMap>, type_map: Option<TypeMap>,
mapping_error: Option<CodecError>,
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
frame_signature: Option<(u8, Vec<u8>)>, frame_signature: Option<(u8, Vec<u8>)>,
} }
@ -36,14 +65,19 @@ impl CommunicationValue {
#[must_use] #[must_use]
pub fn new(comm_type: CommunicationType) -> Self { pub fn new(comm_type: CommunicationType) -> Self {
let tm = TypeMap::new(PROTOCOL_VERSION); let tm = TypeMap::new(PROTOCOL_VERSION);
let id = comm_type.to_id(&tm); let id = comm_type.try_to_id(&tm);
Self { Self {
id: rand_u32(), id: rand_u32(),
comm_type: id, comm_type: id.unwrap_or(CommunicationTypeId(0)),
sender: 0, sender: 0,
receiver: 0, receiver: 0,
data: BTreeMap::new(), data: BTreeMap::new(),
#[cfg(feature = "crypto")]
encrypted_payload: None,
type_map: Some(tm), type_map: Some(tm),
mapping_error: id
.is_none()
.then(|| CodecError::UnknownCommunicationType(comm_type.name().to_string())),
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
frame_signature: None, frame_signature: None,
} }
@ -52,14 +86,19 @@ impl CommunicationValue {
#[cfg(feature = "registry")] #[cfg(feature = "registry")]
#[must_use] #[must_use]
pub fn from_comm(comm_type: CommunicationType, tm: &TypeMap) -> Self { pub fn from_comm(comm_type: CommunicationType, tm: &TypeMap) -> Self {
let id = comm_type.to_id(tm); let id = comm_type.try_to_id(tm);
Self { Self {
id: rand_u32(), id: rand_u32(),
comm_type: id, comm_type: id.unwrap_or(CommunicationTypeId(0)),
sender: 0, sender: 0,
receiver: 0, receiver: 0,
data: BTreeMap::new(), data: BTreeMap::new(),
#[cfg(feature = "crypto")]
encrypted_payload: None,
type_map: Some(tm.clone()), type_map: Some(tm.clone()),
mapping_error: id
.is_none()
.then(|| CodecError::UnknownCommunicationType(comm_type.name().to_string())),
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
frame_signature: None, frame_signature: None,
} }
@ -99,8 +138,22 @@ impl CommunicationValue {
self.comm_type self.comm_type
} }
/// Returns the protocol type map attached to this frame.
pub fn type_map(&self) -> Option<&TypeMap> {
self.type_map.as_ref()
}
/// Binds the frame's numeric type identifiers to a protocol version.
pub fn set_type_map(&mut self, tm: &TypeMap) {
self.type_map = Some(tm.clone());
}
#[must_use] #[must_use]
pub fn add_data(mut self, data: DataTypeId, value: DataValue) -> Self { pub fn add_data(mut self, data: DataTypeId, value: DataValue) -> Self {
#[cfg(feature = "crypto")]
{
self.encrypted_payload = None;
}
self.data.insert(data, value); self.data.insert(data, value);
self self
} }
@ -108,14 +161,30 @@ impl CommunicationValue {
#[cfg(feature = "registry")] #[cfg(feature = "registry")]
#[must_use] #[must_use]
pub fn add_typed(mut self, data: DataType, tm: &TypeMap, value: DataValue) -> Self { pub fn add_typed(mut self, data: DataType, tm: &TypeMap, value: DataValue) -> Self {
self.data.insert(data.to_id(tm), value); #[cfg(feature = "crypto")]
{
self.encrypted_payload = None;
}
if let Some(id) = data.try_to_id(tm) {
self.data.insert(id, value);
} else if self.mapping_error.is_none() {
self.mapping_error = Some(CodecError::UnknownDataType(data.name().to_string()));
}
self self
} }
#[must_use] #[must_use]
pub fn add_typed_default(mut self, data: DataType, value: DataValue) -> Self { pub fn add_typed_default(mut self, data: DataType, value: DataValue) -> Self {
#[cfg(feature = "crypto")]
{
self.encrypted_payload = None;
}
let tm = self.type_map.clone().unwrap_or_else(TypeMap::latest); let tm = self.type_map.clone().unwrap_or_else(TypeMap::latest);
self.data.insert(data.to_id(&tm), value); if let Some(id) = data.try_to_id(&tm) {
self.data.insert(id, value);
} else if self.mapping_error.is_none() {
self.mapping_error = Some(CodecError::UnknownDataType(data.name().to_string()));
}
self self
} }
@ -170,10 +239,54 @@ impl CommunicationValue {
&self.data &self.data
} }
/// Returns the number of cleartext data entries.
///
/// An encrypted frame has no cleartext entries until
/// [`Self::set_decrypted_container`] is called.
pub fn data_len(&self) -> usize { pub fn data_len(&self) -> usize {
self.data.len() self.data.len()
} }
#[cfg(feature = "crypto")]
#[must_use]
pub fn with_encrypted_payload(mut self, payload: EncryptedPayload) -> Self {
self.data.clear();
self.encrypted_payload = Some(payload);
self
}
#[cfg(feature = "crypto")]
#[must_use]
pub fn encrypted_payload(&self) -> Option<&EncryptedPayload> {
self.encrypted_payload.as_ref()
}
#[cfg(feature = "crypto")]
#[must_use]
pub fn is_encrypted(&self) -> bool {
self.encrypted_payload.is_some()
}
/// Replaces an opaque encrypted payload with its decrypted typed entries.
#[cfg(feature = "crypto")]
pub fn set_decrypted_container(
&mut self,
entries: impl IntoIterator<Item = (DataTypeId, DataValue)>,
) {
self.data = entries.into_iter().collect();
self.encrypted_payload = None;
}
/// Returns the number of logical payload items available in the frame.
#[must_use]
pub fn payload_len(&self) -> usize {
#[cfg(feature = "crypto")]
if self.encrypted_payload.is_some() {
return 1;
}
self.data.len()
}
// ── type checks ────────────────────────────────────────────────────────── // ── type checks ──────────────────────────────────────────────────────────
pub fn is_type(&self, comm_type: CommunicationType) -> bool { pub fn is_type(&self, comm_type: CommunicationType) -> bool {
@ -181,15 +294,23 @@ impl CommunicationValue {
} }
pub fn get_type_name(&self) -> Option<&'static str> { pub fn get_type_name(&self) -> Option<&'static str> {
communication_type_name(self.comm_type.0) self.type_map
.as_ref()
.and_then(|tm| tm.communication_type_name(self.comm_type.0))
} }
// ── mutation ───────────────────────────────────────────────────────────── // ── mutation ─────────────────────────────────────────────────────────────
pub fn set_data(&mut self, data_type: DataType, value: DataValue) { pub fn set_data(&mut self, data_type: DataType, value: DataValue) {
let tm = self.type_map.clone().unwrap_or_else(TypeMap::latest); let tm = self.type_map.clone().unwrap_or_else(TypeMap::latest);
if let Some(raw_id) = tm.data_id_enum(data_type) { if let Some(id) = data_type.try_to_id(&tm) {
self.data.insert(DataTypeId(raw_id), value); #[cfg(feature = "crypto")]
{
self.encrypted_payload = None;
}
self.data.insert(id, value);
} else if self.mapping_error.is_none() {
self.mapping_error = Some(CodecError::UnknownDataType(data_type.name().to_string()));
} }
} }
@ -220,6 +341,13 @@ impl CommunicationValue {
} }
pub fn merge(&mut self, other: &CommunicationValue) { pub fn merge(&mut self, other: &CommunicationValue) {
if self.mapping_error.is_none() {
self.mapping_error.clone_from(&other.mapping_error);
}
#[cfg(feature = "crypto")]
if !other.data.is_empty() {
self.encrypted_payload = None;
}
for (id, value) in &other.data { for (id, value) in &other.data {
self.data.insert(*id, value.clone()); self.data.insert(*id, value.clone());
} }
@ -252,7 +380,7 @@ impl CommunicationValue {
self.get_data_opt(data_type)?.as_signed_number() self.get_data_opt(data_type)?.as_signed_number()
} }
pub fn get_float(&self, data_type: DataType) -> Option<(u8, u32)> { pub fn get_float(&self, data_type: DataType) -> Option<f64> {
self.get_data_opt(data_type)?.as_float() self.get_data_opt(data_type)?.as_float()
} }
@ -284,6 +412,7 @@ impl CommunicationValue {
* bit2 => has id * bit2 => has id
* bit3 => is data encrypted If so data bytes will be an encrypted container * bit3 => is data encrypted If so data bytes will be an encrypted container
* bit4 => is communication value signed * bit4 => is communication value signed
* bit5 => encrypted payload contains a signed container
*/ */
/* /*
* Build the canonical metadata header and data payload shared by both * Build the canonical metadata header and data payload shared by both
@ -302,21 +431,25 @@ impl CommunicationValue {
&self, &self,
force_signed: bool, force_signed: bool,
) -> Result<(Vec<u8>, Vec<u8>), CodecError> { ) -> Result<(Vec<u8>, Vec<u8>), CodecError> {
if let Some(error) = &self.mapping_error {
return Err(error.clone());
}
if self.sender > MAX_WIRE_ID || self.receiver > MAX_WIRE_ID {
return Err(CodecError::InvalidEncoding);
}
let has_sender = self.sender != 0; let has_sender = self.sender != 0;
let has_receiver = self.receiver != 0; let has_receiver = self.receiver != 0;
let has_id = self.id != 0; let has_id = self.id != 0;
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
let is_encrypted = self.data.len() == 1 let is_encrypted = self.encrypted_payload.is_some();
&& self.data.values().any(|v| {
matches!(
v,
DataValue::EncryptedContainer(_) | DataValue::SignedEncryptedContainer(_)
)
});
#[cfg(not(feature = "crypto"))] #[cfg(not(feature = "crypto"))]
let is_encrypted = false; let is_encrypted = false;
#[cfg(feature = "crypto")]
let is_signed_encrypted =
matches!(self.encrypted_payload, Some(EncryptedPayload::Signed(_)));
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
let has_frame_sig = self.frame_signature.is_some(); let has_frame_sig = self.frame_signature.is_some();
#[cfg(not(feature = "crypto"))] #[cfg(not(feature = "crypto"))]
@ -335,6 +468,10 @@ impl CommunicationValue {
if is_encrypted { if is_encrypted {
flags |= FLAG_ENCRYPTED; flags |= FLAG_ENCRYPTED;
} }
#[cfg(feature = "crypto")]
if is_signed_encrypted {
flags |= FLAG_SIGNED_ENCRYPTED;
}
if has_frame_sig || force_signed { if has_frame_sig || force_signed {
flags |= FLAG_SIGNED; flags |= FLAG_SIGNED;
} }
@ -358,17 +495,9 @@ impl CommunicationValue {
} }
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
let data_bytes = if is_encrypted { let data_bytes = match &self.encrypted_payload {
self.data Some(payload) => payload.as_bytes().to_vec(),
.values() None => DataValue::container_from_map(&self.data).to_bytes()?,
.find_map(|v| match v {
DataValue::EncryptedContainer(ct) => Some(ct.clone()),
DataValue::SignedEncryptedContainer(ct) => Some(ct.clone()),
_ => None,
})
.unwrap_or_default()
} else {
DataValue::container_from_map(&self.data).to_bytes()?
}; };
#[cfg(not(feature = "crypto"))] #[cfg(not(feature = "crypto"))]
@ -409,12 +538,13 @@ impl CommunicationValue {
let total_len = cursor let total_len = cursor
.read_u32::<BigEndian>() .read_u32::<BigEndian>()
.map_err(|_| CodecError::InvalidEncoding)? as usize; .map_err(|_| CodecError::InvalidEncoding)? as usize;
if bytes.len() < 4 + total_len { let frame_end = 4usize
.checked_add(total_len)
.ok_or(CodecError::InvalidEncoding)?;
if bytes.len() != frame_end {
return Err(CodecError::InvalidEncoding); return Err(CodecError::InvalidEncoding);
} }
let frame_end = 4 + total_len;
let comm_type_num = cursor let comm_type_num = cursor
.read_u16::<BigEndian>() .read_u16::<BigEndian>()
.map_err(|_| CodecError::InvalidEncoding)?; .map_err(|_| CodecError::InvalidEncoding)?;
@ -426,9 +556,14 @@ impl CommunicationValue {
let has_id = (flags & FLAG_HAS_ID) != 0; let has_id = (flags & FLAG_HAS_ID) != 0;
let is_encrypted = (flags & FLAG_ENCRYPTED) != 0; let is_encrypted = (flags & FLAG_ENCRYPTED) != 0;
let is_signed = (flags & FLAG_SIGNED) != 0; let is_signed = (flags & FLAG_SIGNED) != 0;
let is_signed_encrypted = (flags & FLAG_SIGNED_ENCRYPTED) != 0;
if is_signed_encrypted && !is_encrypted {
return Err(CodecError::InvalidEncoding);
}
#[cfg(not(feature = "crypto"))] #[cfg(not(feature = "crypto"))]
if is_signed || is_encrypted { if is_signed || is_encrypted || is_signed_encrypted {
return Err(CodecError::InvalidEncoding); return Err(CodecError::InvalidEncoding);
} }
@ -481,17 +616,20 @@ impl CommunicationValue {
let data_bytes = &bytes[pos..frame_end]; let data_bytes = &bytes[pos..frame_end];
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
let data = if is_encrypted { let (encrypted_payload, data) = if is_encrypted {
let mut map = BTreeMap::new(); let payload = if is_signed_encrypted {
map.insert( EncryptedPayload::Signed(data_bytes.to_vec())
DataType::Version.to_id(&TypeMap::latest()), } else {
DataValue::EncryptedContainer(data_bytes.to_vec()), EncryptedPayload::Plain(data_bytes.to_vec())
); };
map (Some(payload), BTreeMap::new())
} else { } else {
let data_value = let data_value =
DataValue::from_bytes(data_bytes).ok_or(CodecError::InvalidEncoding)?; DataValue::from_bytes(data_bytes).ok_or(CodecError::InvalidEncoding)?;
data_value.as_map().ok_or(CodecError::InvalidEncoding)? (
None,
data_value.as_map().ok_or(CodecError::InvalidEncoding)?,
)
}; };
#[cfg(not(feature = "crypto"))] #[cfg(not(feature = "crypto"))]
@ -507,7 +645,10 @@ impl CommunicationValue {
sender, sender,
receiver, receiver,
data, data,
type_map: None, #[cfg(feature = "crypto")]
encrypted_payload,
type_map: Some(TypeMap::new(PROTOCOL_VERSION)),
mapping_error: None,
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
frame_signature, frame_signature,
}) })
@ -515,7 +656,7 @@ impl CommunicationValue {
pub fn from_bytes_with(bytes: &[u8], tm: &TypeMap) -> Result<Self, CodecError> { pub fn from_bytes_with(bytes: &[u8], tm: &TypeMap) -> Result<Self, CodecError> {
let mut val = Self::from_bytes(bytes)?; let mut val = Self::from_bytes(bytes)?;
val.type_map = Some(tm.clone()); val.set_type_map(tm);
Ok(val) Ok(val)
} }
@ -635,8 +776,18 @@ impl CommunicationValue {
} }
#[cfg(feature = "registry")] #[cfg(feature = "registry")]
/// Migrates this frame to `target_tm`.
///
/// Migration changes the signed wire representation, so any existing frame
/// signature is discarded. Call [`Self::sign_frame`] after migration when
/// the migrated frame needs to be authenticated.
pub fn migrate(&self, target_tm: &TypeMap) -> Result<Self, CodecError> { pub fn migrate(&self, target_tm: &TypeMap) -> Result<Self, CodecError> {
let comm_name = communication_type_name(self.comm_type.0) if let Some(error) = &self.mapping_error {
return Err(error.clone());
}
let source_tm = self.type_map.as_ref().ok_or(CodecError::InvalidEncoding)?;
let comm_name = source_tm
.communication_type_name(self.comm_type.0)
.ok_or_else(|| CodecError::UnknownCommunicationType(self.comm_type.0.to_string()))?; .ok_or_else(|| CodecError::UnknownCommunicationType(self.comm_type.0.to_string()))?;
let comm_variant = CommunicationType::from_name(comm_name) let comm_variant = CommunicationType::from_name(comm_name)
.ok_or_else(|| CodecError::UnknownCommunicationType(comm_name.to_string()))?; .ok_or_else(|| CodecError::UnknownCommunicationType(comm_name.to_string()))?;
@ -648,7 +799,8 @@ impl CommunicationValue {
let mut new_data = BTreeMap::new(); let mut new_data = BTreeMap::new();
for (&old_id, value) in &self.data { for (&old_id, value) in &self.data {
let name = data_type_name(old_id.0) let name = source_tm
.data_type_name(old_id.0)
.ok_or_else(|| CodecError::UnknownDataType(old_id.0.to_string()))?; .ok_or_else(|| CodecError::UnknownDataType(old_id.0.to_string()))?;
let variant = DataType::from_name(name) let variant = DataType::from_name(name)
.ok_or_else(|| CodecError::UnknownDataType(name.to_string()))?; .ok_or_else(|| CodecError::UnknownDataType(name.to_string()))?;
@ -666,14 +818,17 @@ impl CommunicationValue {
sender: self.sender, sender: self.sender,
receiver: self.receiver, receiver: self.receiver,
data: new_data, data: new_data,
type_map: Some(target_tm.clone()),
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
frame_signature: self.frame_signature.clone(), encrypted_payload: self.encrypted_payload.clone(),
type_map: Some(target_tm.clone()),
mapping_error: None,
#[cfg(feature = "crypto")]
frame_signature: None,
}) })
} }
} }
fn fmt_data_value(val: &DataValue, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt_data_value(val: &DataValue, tm: &TypeMap, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match val { match val {
DataValue::Container(entries) => { DataValue::Container(entries) => {
write!(f, "{{")?; write!(f, "{{")?;
@ -681,9 +836,9 @@ fn fmt_data_value(val: &DataValue, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if i > 0 { if i > 0 {
write!(f, ", ")?; write!(f, ", ")?;
} }
let name = data_type_name(key.0).unwrap_or("?"); let name = tm.data_type_name(key.0).unwrap_or("?");
write!(f, "{}: ", name)?; write!(f, "{}: ", name)?;
fmt_data_value(value, f)?; fmt_data_value(value, tm, f)?;
} }
write!(f, "}}") write!(f, "}}")
} }
@ -693,7 +848,7 @@ fn fmt_data_value(val: &DataValue, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if i > 0 { if i > 0 {
write!(f, ", ")?; write!(f, ", ")?;
} }
fmt_data_value(value, f)?; fmt_data_value(value, tm, f)?;
} }
write!(f, "]") write!(f, "]")
} }
@ -745,25 +900,27 @@ impl fmt::Display for CommunicationValue {
write!(f, ", R:{}{:X}{}", ORANGE, self.receiver, RESET)?; write!(f, ", R:{}{:X}{}", ORANGE, self.receiver, RESET)?;
} }
let name = self let name = self.get_comm_type_enum().map(|t| t.name()).unwrap_or("?");
.get_comm_type_enum()
.map(|t| t.name())
.unwrap_or_else(|| communication_type_name(self.comm_type.0).unwrap_or("?"));
write!(f, ", {}: ", name)?; write!(f, ", {}: ", name)?;
let tm = self.type_map.clone().unwrap_or_else(TypeMap::latest); let tm = self.type_map.clone().unwrap_or_else(TypeMap::latest);
write!(f, "{{")?; write!(f, "{{")?;
#[cfg(feature = "crypto")]
if let Some(payload) = &self.encrypted_payload {
match payload {
EncryptedPayload::Plain(bytes) => write!(f, "(Encrypted, {} bytes)", bytes.len())?,
EncryptedPayload::Signed(bytes) => {
write!(f, "(SignedEncrypted, {} bytes)", bytes.len())?
}
}
}
for (i, (raw_id, value)) in self.data.iter().enumerate() { for (i, (raw_id, value)) in self.data.iter().enumerate() {
if i > 0 { if i > 0 {
write!(f, ", ")?; write!(f, ", ")?;
} }
let dname = tm let dname = tm.data_enum_id(raw_id.0).map(|t| t.name()).unwrap_or("?");
.data_enum_id(raw_id.0)
.map(|t| t.name())
.or_else(|| data_type_name(raw_id.0))
.unwrap_or("?");
write!(f, "{}: ", dname)?; write!(f, "{}: ", dname)?;
fmt_data_value(value, f)?; fmt_data_value(value, &tm, f)?;
} }
write!(f, "}}") write!(f, "}}")
} }
@ -854,7 +1011,12 @@ mod tests {
assert_eq!(decoded.get_id(), 1234); assert_eq!(decoded.get_id(), 1234);
assert_eq!(decoded.get_sender(), 111); assert_eq!(decoded.get_sender(), 111);
assert_eq!(decoded.get_receiver(), 222); assert_eq!(decoded.get_receiver(), 222);
assert_eq!(decoded.get_type(), CommunicationType::Disconnect.to_id(&tm)); assert_eq!(
decoded.get_type(),
CommunicationType::Disconnect
.try_to_id(&tm)
.expect("built-in type must be mapped")
);
assert_eq!( assert_eq!(
decoded.get_data(DataType::Id), decoded.get_data(DataType::Id),
&DataValue::Str("alice".to_string()) &DataValue::Str("alice".to_string())
@ -866,6 +1028,190 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn endpoint_ids_are_limited_to_wire_width() -> Result<(), Box<dyn std::error::Error>> {
let max = CommunicationValue::new(CommunicationType::Ping)
.with_sender(MAX_WIRE_ID)
.with_receiver(MAX_WIRE_ID);
let decoded = roundtrip(max)?;
assert_eq!(decoded.get_sender(), MAX_WIRE_ID);
assert_eq!(decoded.get_receiver(), MAX_WIRE_ID);
assert!(
CommunicationValue::new(CommunicationType::Ping)
.with_sender(MAX_WIRE_ID + 1)
.to_bytes()
.is_err()
);
assert!(
CommunicationValue::new(CommunicationType::Ping)
.with_receiver(MAX_WIRE_ID + 1)
.to_bytes()
.is_err()
);
Ok(())
}
#[cfg(feature = "registry")]
#[test]
fn missing_version_mappings_return_codec_errors() {
let v0 = TypeMap::new(Version(0, 0));
assert_eq!(DataType::AnotherType.try_to_id(&v0), None);
let data_error = CommunicationValue::from_comm(CommunicationType::Ping, &v0)
.add_typed(
DataType::AnotherType,
&v0,
DataValue::Str("not available in v0".into()),
)
.to_bytes();
assert_eq!(
data_error,
Err(CodecError::UnknownDataType("AnotherType".into()))
);
let unknown_version = TypeMap::new(Version(99, 0));
assert_eq!(CommunicationType::Ping.try_to_id(&unknown_version), None);
assert_eq!(
CommunicationValue::from_comm(CommunicationType::Ping, &unknown_version).to_bytes(),
Err(CodecError::UnknownCommunicationType("Ping".into()))
);
}
#[cfg(feature = "registry")]
#[test]
fn decoded_and_migrated_frames_use_the_source_version_map()
-> Result<(), Box<dyn std::error::Error>> {
let v1 = TypeMap::new(Version(1, 0));
let v2 = TypeMap::new(Version(2, 0));
let original = CommunicationValue::from_comm(CommunicationType::Ping, &v1).add_typed(
DataType::SomeType,
&v1,
DataValue::Str("v1 value".into()),
);
let bytes = original.to_bytes()?;
let decoded = CommunicationValue::from_bytes_with(&bytes, &v1)?;
assert_eq!(decoded.get_type_name(), Some("Ping"));
assert_eq!(
decoded.get_data(DataType::SomeType),
&DataValue::Str("v1 value".into())
);
assert_eq!(
decoded.type_map().map(|tm| &tm.version),
Some(&Version(1, 0))
);
let migrated = decoded.migrate(&v2)?;
assert_eq!(
migrated.get_data(DataType::SomeType).as_str(),
Some("v1 value")
);
assert_eq!(
migrated.data().get(
&DataType::SomeType
.try_to_id(&v2)
.expect("SomeType must be mapped in v2"),
),
Some(&DataValue::Str("v1 value".into()))
);
Ok(())
}
#[cfg(feature = "crypto")]
#[test]
fn test_plain_encrypted_payload_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
let ciphertext = vec![1, 2, 3, 4, 5];
let cv = CommunicationValue::new(CommunicationType::Ping)
.with_encrypted_payload(EncryptedPayload::Plain(ciphertext.clone()));
let bytes = cv.to_bytes()?;
assert_ne!(bytes[6] & FLAG_ENCRYPTED, 0);
assert_eq!(bytes[6] & FLAG_SIGNED_ENCRYPTED, 0);
let decoded = roundtrip(cv)?;
assert_eq!(
decoded.encrypted_payload(),
Some(&EncryptedPayload::Plain(ciphertext))
);
assert!(decoded.data().is_empty());
assert_eq!(decoded.data_len(), 0);
assert_eq!(decoded.payload_len(), 1);
assert_eq!(decoded.get_data(DataType::Version), &DataValue::Null);
Ok(())
}
#[cfg(feature = "crypto")]
#[test]
fn test_signed_encrypted_payload_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
let ciphertext = vec![9, 8, 7, 6];
let cv = CommunicationValue::new(CommunicationType::Ping)
.with_encrypted_payload(EncryptedPayload::Signed(ciphertext.clone()));
let bytes = cv.to_bytes()?;
assert_ne!(bytes[6] & FLAG_ENCRYPTED, 0);
assert_ne!(bytes[6] & FLAG_SIGNED_ENCRYPTED, 0);
let decoded = roundtrip(cv)?;
assert_eq!(
decoded.encrypted_payload(),
Some(&EncryptedPayload::Signed(ciphertext))
);
assert!(decoded.data().is_empty());
Ok(())
}
#[cfg(feature = "crypto")]
#[test]
fn test_decrypted_payload_serializes_as_cleartext() -> Result<(), Box<dyn std::error::Error>> {
let tm = TypeMap::latest();
let data_id = DataType::Version
.try_to_id(&tm)
.expect("built-in type must be mapped");
let mut cv = CommunicationValue::new(CommunicationType::Ping)
.with_encrypted_payload(EncryptedPayload::Plain(vec![1, 2, 3]));
cv.set_decrypted_container([(data_id, DataValue::Str("clear".into()))]);
assert!(!cv.is_encrypted());
assert_eq!(cv.payload_len(), 1);
assert_eq!(cv.get_data(DataType::Version).as_str(), Some("clear"));
let bytes = cv.to_bytes()?;
assert_eq!(bytes[6] & FLAG_ENCRYPTED, 0);
assert_eq!(bytes[6] & FLAG_SIGNED_ENCRYPTED, 0);
Ok(())
}
#[cfg(feature = "crypto")]
#[test]
fn test_nested_encrypted_value_remains_typed_data() -> Result<(), Box<dyn std::error::Error>> {
let cv = CommunicationValue::new(CommunicationType::Ping).add_typed_default(
DataType::Version,
DataValue::SignedEncryptedContainer(vec![4, 3, 2, 1]),
);
let bytes = cv.to_bytes()?;
assert_eq!(bytes[6] & FLAG_ENCRYPTED, 0);
assert_eq!(bytes[6] & FLAG_SIGNED_ENCRYPTED, 0);
let decoded = roundtrip(cv)?;
assert!(!decoded.is_encrypted());
assert!(matches!(
decoded.get_data(DataType::Version),
DataValue::SignedEncryptedContainer(bytes) if bytes == &[4, 3, 2, 1]
));
Ok(())
}
#[test]
fn test_signed_encrypted_flag_requires_encrypted_flag() {
let mut bytes = CommunicationValue::new(CommunicationType::Ping)
.to_bytes()
.expect("frame should encode");
bytes[6] |= FLAG_SIGNED_ENCRYPTED;
assert!(CommunicationValue::from_bytes(&bytes).is_err());
}
#[test] #[test]
fn test_corrupted_length_returns_none() { fn test_corrupted_length_returns_none() {
let mut bad = vec![0u8; 8]; let mut bad = vec![0u8; 8];
@ -874,6 +1220,16 @@ mod tests {
assert!(CommunicationValue::from_bytes(&bad).is_err()); assert!(CommunicationValue::from_bytes(&bad).is_err());
} }
#[test]
fn test_trailing_bytes_are_rejected() {
let mut bytes = CommunicationValue::new(CommunicationType::Ping)
.to_bytes()
.expect("frame should encode");
bytes.extend_from_slice(&[0xAA, 0xBB]);
assert!(CommunicationValue::from_bytes(&bytes).is_err());
}
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
#[test] #[test]
fn test_sign_verify_frame_roundtrip() -> Result<(), Box<dyn std::error::Error>> { fn test_sign_verify_frame_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
@ -918,4 +1274,22 @@ mod tests {
Ok(()) Ok(())
} }
#[cfg(all(feature = "crypto", feature = "registry"))]
#[test]
fn test_migrate_discards_frame_signature() -> Result<(), Box<dyn std::error::Error>> {
use mtp_crypto::{Ed25519Signer, SigAlgorithm};
use mtp_type_map::Version;
let (signer, _, _) = Ed25519Signer::generate();
let mut cv = CommunicationValue::new(CommunicationType::Ping)
.add_typed_default(DataType::Version, DataValue::Str("1.0".into()));
assert!(cv.sign_frame(SigAlgorithm::ED25519, &signer).is_some());
assert!(cv.get_frame_signature().is_some());
let migrated = cv.migrate(&TypeMap::new(Version(2, 0)))?;
assert!(migrated.get_frame_signature().is_none());
Ok(())
}
} }

View file

@ -61,7 +61,7 @@ impl fmt::Display for DataKind {
} }
} }
#[derive(Debug, Clone, Eq)] #[derive(Debug, Clone)]
pub enum DataValue { pub enum DataValue {
BoolTrue, BoolTrue,
BoolFalse, BoolFalse,
@ -69,7 +69,8 @@ pub enum DataValue {
SignedNumber(i128), SignedNumber(i128),
UnsignedNumber(u128), UnsignedNumber(u128),
Float(u8, u32), /// An IEEE-754 double-precision floating-point value.
Float(f64),
Str(String), Str(String),
Bytes(Vec<u8>), Bytes(Vec<u8>),
@ -120,6 +121,10 @@ pub enum DataValue {
impl DataValue { impl DataValue {
/* /*
* Top-level format:
* [1 byte kind]
* [remaining bytes payload] // interpreted according to kind
*
* Container format: * Container format:
* [2 bytes u16 entry_count] // number of entries * [2 bytes u16 entry_count] // number of entries
* [1 byte kind] // DataValue kind marker * [1 byte kind] // DataValue kind marker
@ -198,7 +203,7 @@ impl DataValue {
DataValue::Bool(_) | DataValue::BoolTrue | DataValue::BoolFalse => DataKind::Bool, DataValue::Bool(_) | DataValue::BoolTrue | DataValue::BoolFalse => DataKind::Bool,
DataValue::SignedNumber(_) => DataKind::SignedNumber, DataValue::SignedNumber(_) => DataKind::SignedNumber,
DataValue::UnsignedNumber(_) => DataKind::UnsignedNumber, DataValue::UnsignedNumber(_) => DataKind::UnsignedNumber,
DataValue::Float(_, _) => DataKind::Float, DataValue::Float(_) => DataKind::Float,
DataValue::Str(_) => DataKind::Str, DataValue::Str(_) => DataKind::Str,
DataValue::Array(a) => { DataValue::Array(a) => {
if let Some(first) = a.first() { if let Some(first) = a.first() {
@ -253,9 +258,9 @@ impl DataValue {
} }
} }
pub fn as_float(&self) -> Option<(u8, u32)> { pub fn as_float(&self) -> Option<f64> {
match self { match self {
DataValue::Float(a, b) => Some((*a, *b)), DataValue::Float(value) => Some(*value),
_ => None, _ => None,
} }
} }
@ -337,7 +342,7 @@ impl DataValue {
DataValue::Bool(_) | DataValue::BoolTrue | DataValue::BoolFalse => "Bool", DataValue::Bool(_) | DataValue::BoolTrue | DataValue::BoolFalse => "Bool",
DataValue::SignedNumber(_) => "SignedNumber", DataValue::SignedNumber(_) => "SignedNumber",
DataValue::UnsignedNumber(_) => "UnsignedNumber", DataValue::UnsignedNumber(_) => "UnsignedNumber",
DataValue::Float(_, _) => "Float", DataValue::Float(_) => "Float",
DataValue::Str(_) => "Str", DataValue::Str(_) => "Str",
DataValue::Bytes(_) => "Bytes", DataValue::Bytes(_) => "Bytes",
DataValue::Array(_) => "Array", DataValue::Array(_) => "Array",
@ -426,7 +431,7 @@ impl DataValue {
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
pub fn sign_container(&mut self, algorithm: u8, signer: &impl SignatureScheme) -> Option<()> { pub fn sign_container(&mut self, algorithm: u8, signer: &impl SignatureScheme) -> Option<()> {
let entries = self.as_container()?; let entries = self.as_container()?;
let container_bytes = Self::encode_container(&entries).ok()?; let container_bytes = DataValue::Container(entries).to_bytes().ok()?;
let sig = signer.sign(&container_bytes).ok()?; let sig = signer.sign(&container_bytes).ok()?;
@ -576,20 +581,17 @@ impl DataValue {
} }
pub fn to_bytes(&self) -> Result<Vec<u8>, CodecError> { pub fn to_bytes(&self) -> Result<Vec<u8>, CodecError> {
match self {
DataValue::Container(entries) => Self::encode_container(entries),
DataValue::Array(arr) => Self::encode_array(arr),
_ => {
let mut out = Vec::new(); let mut out = Vec::new();
out.push(Self::kind_marker(self));
Self::write_value_payload(&mut out, self)?; Self::write_value_payload(&mut out, self)?;
Ok(out) Ok(out)
} }
}
}
pub fn from_bytes(bytes: &[u8]) -> Option<Self> { pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
let mut cursor = Cursor::new(bytes); let mut cursor = Cursor::new(bytes);
let value = Self::read_value(&mut cursor, true)?; let kind = cursor.read_u8().ok()?;
let payload_len = bytes.len().checked_sub(1)?;
let value = Self::read_value_by_kind(&mut cursor, kind, Some(payload_len))?;
if cursor.position() as usize != bytes.len() { if cursor.position() as usize != bytes.len() {
return None; return None;
} }
@ -694,9 +696,8 @@ impl DataValue {
.map_err(|_| CodecError::InvalidEncoding)?; .map_err(|_| CodecError::InvalidEncoding)?;
Ok(()) Ok(())
} }
DataValue::Float(a, b) => { DataValue::Float(value) => {
buf.write_u8(*a).map_err(|_| CodecError::InvalidEncoding)?; buf.write_f64::<BigEndian>(*value)
buf.write_u32::<BigEndian>(*b)
.map_err(|_| CodecError::InvalidEncoding)?; .map_err(|_| CodecError::InvalidEncoding)?;
Ok(()) Ok(())
} }
@ -738,28 +739,6 @@ impl DataValue {
} }
} }
fn read_value(cursor: &mut Cursor<&[u8]>, top_level: bool) -> Option<Self> {
if top_level {
let start = cursor.position() as usize;
let remaining = cursor.get_ref().len().checked_sub(start)?;
if remaining < 2 {
return None;
}
let snapshot = cursor.clone();
if let Some(container) = Self::try_read_container(cursor) {
return Some(container);
}
*cursor = snapshot;
let array = Self::read_array(cursor)?;
return Some(array);
}
let kind = cursor.read_u8().ok()?;
Self::read_value_by_kind(cursor, kind, None)
}
fn try_read_container(cursor: &mut Cursor<&[u8]>) -> Option<Self> { fn try_read_container(cursor: &mut Cursor<&[u8]>) -> Option<Self> {
let count = cursor.read_u16::<BigEndian>().ok()? as usize; let count = cursor.read_u16::<BigEndian>().ok()? as usize;
let remaining = cursor let remaining = cursor
@ -837,11 +816,7 @@ impl DataValue {
Self::KIND_UNSIGNED_NUMBER => Some(DataValue::UnsignedNumber( Self::KIND_UNSIGNED_NUMBER => Some(DataValue::UnsignedNumber(
cursor.read_u128::<BigEndian>().ok()?, cursor.read_u128::<BigEndian>().ok()?,
)), )),
Self::KIND_FLOAT => { Self::KIND_FLOAT => Some(DataValue::Float(cursor.read_f64::<BigEndian>().ok()?)),
let a = cursor.read_u8().ok()?;
let b = cursor.read_u32::<BigEndian>().ok()?;
Some(DataValue::Float(a, b))
}
Self::KIND_STR => { Self::KIND_STR => {
let s = std::str::from_utf8(Self::read_payload_slice(cursor, payload_len?)?) let s = std::str::from_utf8(Self::read_payload_slice(cursor, payload_len?)?)
.ok()? .ok()?
@ -884,7 +859,7 @@ impl DataValue {
)), )),
Self::KIND_NULL => Some(DataValue::Null), Self::KIND_NULL => Some(DataValue::Null),
#[cfg(not(feature = "crypto"))] #[cfg(not(feature = "crypto"))]
0x0A | 0x0B | 0x0C => None, 0x0A..=0x0C => None,
_ => None, _ => None,
} }
} }
@ -902,7 +877,7 @@ impl DataValue {
} }
DataValue::SignedNumber(_) => Self::KIND_SIGNED_NUMBER, DataValue::SignedNumber(_) => Self::KIND_SIGNED_NUMBER,
DataValue::UnsignedNumber(_) => Self::KIND_UNSIGNED_NUMBER, DataValue::UnsignedNumber(_) => Self::KIND_UNSIGNED_NUMBER,
DataValue::Float(_, _) => Self::KIND_FLOAT, DataValue::Float(_) => Self::KIND_FLOAT,
DataValue::Str(_) => Self::KIND_STR, DataValue::Str(_) => Self::KIND_STR,
DataValue::Array(_) => Self::KIND_ARRAY, DataValue::Array(_) => Self::KIND_ARRAY,
DataValue::Bytes(_) => Self::KIND_BYTES, DataValue::Bytes(_) => Self::KIND_BYTES,
@ -953,7 +928,7 @@ impl fmt::Display for DataValue {
DataValue::Bool(v) => write!(f, "{}", v), DataValue::Bool(v) => write!(f, "{}", v),
DataValue::SignedNumber(n) => write!(f, "{}", n), DataValue::SignedNumber(n) => write!(f, "{}", n),
DataValue::UnsignedNumber(n) => write!(f, "{}", n), DataValue::UnsignedNumber(n) => write!(f, "{}", n),
DataValue::Float(exp, mant) => write!(f, "{}e{}", mant, exp), DataValue::Float(value) => write!(f, "{}", value),
DataValue::Str(s) => write!(f, "\"{}\"", s), DataValue::Str(s) => write!(f, "\"{}\"", s),
DataValue::Container(entries) => { DataValue::Container(entries) => {
write!(f, "{{")?; write!(f, "{{")?;
@ -997,7 +972,7 @@ impl PartialEq for DataValue {
(Bool(a), Bool(b)) => a == b, (Bool(a), Bool(b)) => a == b,
(SignedNumber(a), SignedNumber(b)) => a == b, (SignedNumber(a), SignedNumber(b)) => a == b,
(UnsignedNumber(a), UnsignedNumber(b)) => a == b, (UnsignedNumber(a), UnsignedNumber(b)) => a == b,
(Float(a, b), Float(c, d)) => a == c && b == d, (Float(a), Float(b)) => a.to_bits() == b.to_bits(),
(Str(a), Str(b)) => a == b, (Str(a), Str(b)) => a == b,
(Array(a), Array(b)) => a == b, (Array(a), Array(b)) => a == b,
(Bytes(a), Bytes(b)) => a == b, (Bytes(a), Bytes(b)) => a == b,
@ -1014,6 +989,8 @@ impl PartialEq for DataValue {
} }
} }
impl Eq for DataValue {}
impl Hash for DataValue { impl Hash for DataValue {
fn hash<H: Hasher>(&self, state: &mut H) { fn hash<H: Hasher>(&self, state: &mut H) {
use DataValue::*; use DataValue::*;
@ -1025,10 +1002,7 @@ impl Hash for DataValue {
BoolTrue | BoolFalse | Bool(_) | Null => {} BoolTrue | BoolFalse | Bool(_) | Null => {}
SignedNumber(n) => n.hash(state), SignedNumber(n) => n.hash(state),
UnsignedNumber(n) => n.hash(state), UnsignedNumber(n) => n.hash(state),
Float(n, m) => { Float(value) => value.to_bits().hash(state),
n.hash(state);
m.hash(state);
}
Str(s) => s.hash(state), Str(s) => s.hash(state),
Array(a) => a.hash(state), Array(a) => a.hash(state),
Bytes(a) => a.hash(state), Bytes(a) => a.hash(state),
@ -1200,7 +1174,9 @@ impl TryFrom<DataValue> for Vec<u8> {
mod tests { mod tests {
use super::*; use super::*;
fn container_roundtrip(values: Vec<(DataTypeId, DataValue)>) -> Result<(), Box<dyn std::error::Error>> { fn container_roundtrip(
values: Vec<(DataTypeId, DataValue)>,
) -> Result<(), Box<dyn std::error::Error>> {
let dv = DataValue::Container(values.clone()); let dv = DataValue::Container(values.clone());
let bytes = dv.to_bytes()?; let bytes = dv.to_bytes()?;
let decoded = DataValue::from_bytes(&bytes).ok_or("roundtrip failed")?; let decoded = DataValue::from_bytes(&bytes).ok_or("roundtrip failed")?;
@ -1216,12 +1192,97 @@ mod tests {
Ok(()) Ok(())
} }
fn value_roundtrip(value: DataValue) -> Result<(), Box<dyn std::error::Error>> {
let bytes = value.to_bytes()?;
assert_eq!(bytes.first(), Some(&DataValue::kind_marker(&value)));
let decoded = DataValue::from_bytes(&bytes).ok_or("roundtrip failed")?;
assert_eq!(value, decoded, "value roundtrip mismatch");
Ok(())
}
#[test]
fn test_every_top_level_variant_roundtrips() -> Result<(), Box<dyn std::error::Error>> {
let values = vec![
DataValue::BoolTrue,
DataValue::BoolFalse,
DataValue::Bool(true),
DataValue::Bool(false),
DataValue::SignedNumber(i128::MIN),
DataValue::UnsignedNumber(u128::MAX),
DataValue::Float(-0.125),
DataValue::Str("top level".to_string()),
DataValue::Bytes(vec![0x00, 0xFF, 0x42]),
DataValue::Array(vec![DataValue::Str("nested".to_string())]),
DataValue::Container(vec![(DataTypeId(7), DataValue::BoolTrue)]),
DataValue::Null,
];
for value in values {
value_roundtrip(value)?;
}
#[cfg(feature = "crypto")]
for value in [
DataValue::EncryptedContainer(vec![1, 2, 3]),
DataValue::SignedContainer(vec![4, 5, 6]),
DataValue::SignedEncryptedContainer(vec![7, 8, 9]),
] {
value_roundtrip(value)?;
}
Ok(())
}
#[test]
fn test_empty_container_and_array_have_distinct_framing() {
let container = DataValue::Container(vec![])
.to_bytes()
.expect("container should encode");
let array = DataValue::Array(vec![])
.to_bytes()
.expect("array should encode");
assert_ne!(container, array);
assert_eq!(
DataValue::from_bytes(&container),
Some(DataValue::Container(vec![]))
);
assert_eq!(
DataValue::from_bytes(&array),
Some(DataValue::Array(vec![]))
);
}
#[test]
fn test_top_level_trailing_bytes_are_rejected() {
let mut boolean = DataValue::BoolTrue
.to_bytes()
.expect("boolean should encode");
boolean.push(0x00);
assert!(DataValue::from_bytes(&boolean).is_none());
let mut number = DataValue::SignedNumber(42)
.to_bytes()
.expect("number should encode");
number.push(0x00);
assert!(DataValue::from_bytes(&number).is_none());
}
#[test] #[test]
fn test_bool_in_container() -> Result<(), Box<dyn std::error::Error>> { fn test_bool_in_container() -> Result<(), Box<dyn std::error::Error>> {
let tm = TypeMap::latest(); let tm = TypeMap::latest();
container_roundtrip(vec![ container_roundtrip(vec![
(DataType::Id.to_id(&tm), DataValue::BoolTrue), (
(DataType::ClientNonce.to_id(&tm), DataValue::BoolFalse), DataType::Id
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::BoolTrue,
),
(
DataType::ClientNonce
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::BoolFalse,
),
])?; ])?;
Ok(()) Ok(())
} }
@ -1245,18 +1306,34 @@ mod tests {
fn test_signed_number_in_container() -> Result<(), Box<dyn std::error::Error>> { fn test_signed_number_in_container() -> Result<(), Box<dyn std::error::Error>> {
let tm = TypeMap::latest(); let tm = TypeMap::latest();
container_roundtrip(vec![ container_roundtrip(vec![
(DataType::Version.to_id(&tm), DataValue::SignedNumber(0)),
(DataType::Id.to_id(&tm), DataValue::SignedNumber(42)),
( (
DataType::ClientNonce.to_id(&tm), DataType::Version
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::SignedNumber(0),
),
(
DataType::Id
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::SignedNumber(42),
),
(
DataType::ClientNonce
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::SignedNumber(-42), DataValue::SignedNumber(-42),
), ),
( (
DataType::ServerNonce.to_id(&tm), DataType::ServerNonce
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::SignedNumber(i128::MAX), DataValue::SignedNumber(i128::MAX),
), ),
( (
DataType::PublicKeys.to_id(&tm), DataType::PublicKeys
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::SignedNumber(i128::MIN), DataValue::SignedNumber(i128::MIN),
), ),
])?; ])?;
@ -1267,10 +1344,22 @@ mod tests {
fn test_unsigned_number_in_container() -> Result<(), Box<dyn std::error::Error>> { fn test_unsigned_number_in_container() -> Result<(), Box<dyn std::error::Error>> {
let tm = TypeMap::latest(); let tm = TypeMap::latest();
container_roundtrip(vec![ container_roundtrip(vec![
(DataType::Version.to_id(&tm), DataValue::UnsignedNumber(0)),
(DataType::Id.to_id(&tm), DataValue::UnsignedNumber(42)),
( (
DataType::ClientNonce.to_id(&tm), DataType::Version
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::UnsignedNumber(0),
),
(
DataType::Id
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::UnsignedNumber(42),
),
(
DataType::ClientNonce
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::UnsignedNumber(u128::MAX), DataValue::UnsignedNumber(u128::MAX),
), ),
])?; ])?;
@ -1281,11 +1370,23 @@ mod tests {
fn test_float_in_container() -> Result<(), Box<dyn std::error::Error>> { fn test_float_in_container() -> Result<(), Box<dyn std::error::Error>> {
let tm = TypeMap::latest(); let tm = TypeMap::latest();
container_roundtrip(vec![ container_roundtrip(vec![
(DataType::Version.to_id(&tm), DataValue::Float(0, 0)),
(DataType::Id.to_id(&tm), DataValue::Float(2, 12345)),
( (
DataType::ClientNonce.to_id(&tm), DataType::Version
DataValue::Float(255, 4294967295), .try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Float(0.0),
),
(
DataType::Id
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Float(1_234_500.0),
),
(
DataType::ClientNonce
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Float(f64::MAX),
), ),
])?; ])?;
Ok(()) Ok(())
@ -1295,10 +1396,22 @@ mod tests {
fn test_str_in_container() -> Result<(), Box<dyn std::error::Error>> { fn test_str_in_container() -> Result<(), Box<dyn std::error::Error>> {
let tm = TypeMap::latest(); let tm = TypeMap::latest();
container_roundtrip(vec![ container_roundtrip(vec![
(DataType::Version.to_id(&tm), DataValue::Str(String::new())),
(DataType::Id.to_id(&tm), DataValue::Str("hello".to_string())),
( (
DataType::ClientNonce.to_id(&tm), DataType::Version
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Str(String::new()),
),
(
DataType::Id
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Str("hello".to_string()),
),
(
DataType::ClientNonce
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Str("a".repeat(1000)), DataValue::Str("a".repeat(1000)),
), ),
])?; ])?;
@ -1309,13 +1422,22 @@ mod tests {
fn test_bytes_in_container() -> Result<(), Box<dyn std::error::Error>> { fn test_bytes_in_container() -> Result<(), Box<dyn std::error::Error>> {
let tm = TypeMap::latest(); let tm = TypeMap::latest();
container_roundtrip(vec![ container_roundtrip(vec![
(DataType::Version.to_id(&tm), DataValue::Bytes(vec![])),
( (
DataType::Id.to_id(&tm), DataType::Version
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Bytes(vec![]),
),
(
DataType::Id
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Bytes(vec![0x00, 0xFF, 0xAB]), DataValue::Bytes(vec![0x00, 0xFF, 0xAB]),
), ),
( (
DataType::ClientNonce.to_id(&tm), DataType::ClientNonce
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Bytes(vec![0x42; 100]), DataValue::Bytes(vec![0x42; 100]),
), ),
])?; ])?;
@ -1325,7 +1447,12 @@ mod tests {
#[test] #[test]
fn test_null_in_container() -> Result<(), Box<dyn std::error::Error>> { fn test_null_in_container() -> Result<(), Box<dyn std::error::Error>> {
let tm = TypeMap::latest(); let tm = TypeMap::latest();
container_roundtrip(vec![(DataType::Version.to_id(&tm), DataValue::Null)])?; container_roundtrip(vec![(
DataType::Version
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Null,
)])?;
Ok(()) Ok(())
} }
@ -1359,17 +1486,36 @@ mod tests {
fn test_container_mixed_roundtrip() -> Result<(), Box<dyn std::error::Error>> { fn test_container_mixed_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
let tm = TypeMap::latest(); let tm = TypeMap::latest();
container_roundtrip(vec![ container_roundtrip(vec![
(DataType::Version.to_id(&tm), DataValue::BoolTrue),
(DataType::Id.to_id(&tm), DataValue::SignedNumber(-100)),
( (
DataType::ClientNonce.to_id(&tm), DataType::Version
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::BoolTrue,
),
(
DataType::Id
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::SignedNumber(-100),
),
(
DataType::ClientNonce
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Str("test".to_string()), DataValue::Str("test".to_string()),
), ),
( (
DataType::ServerNonce.to_id(&tm), DataType::ServerNonce
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::UnsignedNumber(u128::MAX), DataValue::UnsignedNumber(u128::MAX),
), ),
(DataType::PublicKeys.to_id(&tm), DataValue::Null), (
DataType::PublicKeys
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Null,
),
])?; ])?;
Ok(()) Ok(())
} }
@ -1379,11 +1525,20 @@ mod tests {
let tm = TypeMap::latest(); let tm = TypeMap::latest();
container_roundtrip(vec![ container_roundtrip(vec![
( (
DataType::Version.to_id(&tm), DataType::Version
DataValue::Container(vec![(DataType::Error.to_id(&tm), DataValue::BoolTrue)]), .try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Container(vec![(
DataType::Error
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::BoolTrue,
)]),
), ),
( (
DataType::Id.to_id(&tm), DataType::Id
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Array(vec![DataValue::SignedNumber(1), DataValue::SignedNumber(2)]), DataValue::Array(vec![DataValue::SignedNumber(1), DataValue::SignedNumber(2)]),
), ),
])?; ])?;
@ -1394,7 +1549,9 @@ mod tests {
fn test_container_base64_roundtrip() { fn test_container_base64_roundtrip() {
let tm = TypeMap::latest(); let tm = TypeMap::latest();
let dv = DataValue::Container(vec![( let dv = DataValue::Container(vec![(
DataType::Description.to_id(&tm), DataType::Description
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF]), DataValue::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF]),
)]); )]);
let b64 = dv.to_base64().expect("encode failed"); let b64 = dv.to_base64().expect("encode failed");
@ -1411,7 +1568,7 @@ mod tests {
DataValue::UnsignedNumber(0).kind(), DataValue::UnsignedNumber(0).kind(),
DataKind::UnsignedNumber DataKind::UnsignedNumber
); );
assert_eq!(DataValue::Float(0, 0).kind(), DataKind::Float); assert_eq!(DataValue::Float(0.0).kind(), DataKind::Float);
assert_eq!(DataValue::Str(String::new()).kind(), DataKind::Str); assert_eq!(DataValue::Str(String::new()).kind(), DataKind::Str);
assert_eq!(DataValue::Bytes(vec![]).kind(), DataKind::Bytes); assert_eq!(DataValue::Bytes(vec![]).kind(), DataKind::Bytes);
assert_eq!( assert_eq!(
@ -1427,38 +1584,65 @@ mod tests {
let tm = TypeMap::latest(); let tm = TypeMap::latest();
let dv = DataValue::Container(vec![ let dv = DataValue::Container(vec![
( (
DataType::Version.to_id(&tm), DataType::Version
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Str("alice".to_string()), DataValue::Str("alice".to_string()),
), ),
(DataType::Id.to_id(&tm), DataValue::SignedNumber(42)),
( (
DataType::ClientNonce.to_id(&tm), DataType::Id
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::SignedNumber(42),
),
(
DataType::ClientNonce
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Bytes(vec![0x01, 0x02]), DataValue::Bytes(vec![0x01, 0x02]),
), ),
( (
DataType::ServerNonce.to_id(&tm), DataType::ServerNonce
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Array(vec![DataValue::BoolTrue]), DataValue::Array(vec![DataValue::BoolTrue]),
), ),
]); ]);
let map = dv.as_map().expect("should be a container"); let map = dv.as_map().expect("should be a container");
assert_eq!( assert_eq!(
map.get(&DataType::Version.to_id(&tm)) map.get(
&DataType::Version
.try_to_id(&tm)
.expect("test type must be mapped")
)
.and_then(|v| v.as_str()), .and_then(|v| v.as_str()),
Some("alice") Some("alice")
); );
assert_eq!( assert_eq!(
map.get(&DataType::Id.to_id(&tm)) map.get(
&DataType::Id
.try_to_id(&tm)
.expect("test type must be mapped")
)
.and_then(|v| v.as_signed_number()), .and_then(|v| v.as_signed_number()),
Some(42) Some(42)
); );
assert_eq!( assert_eq!(
map.get(&DataType::ClientNonce.to_id(&tm)) map.get(
&DataType::ClientNonce
.try_to_id(&tm)
.expect("test type must be mapped")
)
.and_then(|v| v.as_bytes()), .and_then(|v| v.as_bytes()),
Some(vec![0x01, 0x02]) Some(vec![0x01, 0x02])
); );
assert_eq!( assert_eq!(
map.get(&DataType::ServerNonce.to_id(&tm)) map.get(
&DataType::ServerNonce
.try_to_id(&tm)
.expect("test type must be mapped")
)
.and_then(|v| v.as_array()), .and_then(|v| v.as_array()),
Some(vec![DataValue::BoolTrue]) Some(vec![DataValue::BoolTrue])
); );
@ -1474,7 +1658,7 @@ mod tests {
#[test] #[test]
fn test_as_float() { fn test_as_float() {
assert_eq!(DataValue::Float(3, 14).as_float(), Some((3, 14))); assert_eq!(DataValue::Float(-0.125).as_float(), Some(-0.125));
assert_eq!(DataValue::Null.as_float(), None); assert_eq!(DataValue::Null.as_float(), None);
} }
@ -1482,8 +1666,18 @@ mod tests {
fn test_container_from_map() { fn test_container_from_map() {
let tm = TypeMap::latest(); let tm = TypeMap::latest();
let mut map = BTreeMap::new(); let mut map = BTreeMap::new();
map.insert(DataType::Version.to_id(&tm), DataValue::BoolTrue); map.insert(
map.insert(DataType::Id.to_id(&tm), DataValue::SignedNumber(99)); DataType::Version
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::BoolTrue,
);
map.insert(
DataType::Id
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::SignedNumber(99),
);
let dv = DataValue::container_from_map(&map); let dv = DataValue::container_from_map(&map);
let container = dv.as_container().expect("should be container"); let container = dv.as_container().expect("should be container");
assert_eq!(container.len(), 2); assert_eq!(container.len(), 2);
@ -1492,7 +1686,7 @@ mod tests {
#[test] #[test]
fn test_invalid_short_input() { fn test_invalid_short_input() {
assert!(DataValue::from_bytes(&[]).is_none()); assert!(DataValue::from_bytes(&[]).is_none());
assert!(DataValue::from_bytes(&[0x01]).is_none()); assert!(DataValue::from_bytes(&[DataValue::KIND_SIGNED_NUMBER]).is_none());
} }
#[test] #[test]
@ -1505,7 +1699,9 @@ mod tests {
fn test_truncated_container_rejected() { fn test_truncated_container_rejected() {
let tm = TypeMap::latest(); let tm = TypeMap::latest();
let dv = DataValue::Container(vec![( let dv = DataValue::Container(vec![(
DataType::Version.to_id(&tm), DataType::Version
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Str("hello".to_string()), DataValue::Str("hello".to_string()),
)]); )]);
let bytes = dv.to_bytes().expect("encode failed"); let bytes = dv.to_bytes().expect("encode failed");
@ -1521,13 +1717,12 @@ mod tests {
// capped against remaining bytes, so these decode attempts allocate at // capped against remaining bytes, so these decode attempts allocate at
// most a handful of slots before failing. // most a handful of slots before failing.
// Container path: count = 0xFFFF, no entries follow. // Container path: count = 0xFFFF, no entries follow.
assert!(DataValue::from_bytes(&[0xFF, 0xFF]).is_none()); assert!(DataValue::from_bytes(&[DataValue::KIND_CONTAINER, 0xFF, 0xFF]).is_none());
// Container path with one stray byte after the count. // Container path with one stray byte after the count.
assert!(DataValue::from_bytes(&[0xFF, 0xFF, 0x01]).is_none()); assert!(DataValue::from_bytes(&[DataValue::KIND_CONTAINER, 0xFF, 0xFF, 0x01]).is_none());
// Array path: force the container parse to fail first, then the array // Array path: the tagged array also declares 65535 entries but carries
// parse also sees the oversized count. A leading kind byte that is not a // only a single entry byte.
// valid container entry makes try_read_container bail to the array path. assert!(DataValue::from_bytes(&[DataValue::KIND_ARRAY, 0xFF, 0xFF, 0x01]).is_none());
assert!(DataValue::from_bytes(&[0xFF, 0xFF, 0x08, 0xFF, 0xFF]).is_none());
} }
#[test] #[test]
@ -1557,8 +1752,8 @@ mod tests {
#[test] #[test]
fn test_float_display() { fn test_float_display() {
let s = format!("{}", DataValue::Float(2, 12345)); let s = format!("{}", DataValue::Float(1.25));
assert_eq!(s, "12345e2"); assert_eq!(s, "1.25");
} }
#[test] #[test]
@ -1566,11 +1761,15 @@ mod tests {
let tm = TypeMap::latest(); let tm = TypeMap::latest();
let dv = DataValue::Container(vec![ let dv = DataValue::Container(vec![
( (
DataType::ServerNonce.to_id(&tm), DataType::ServerNonce
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Str("v2.0".to_string()), DataValue::Str("v2.0".to_string()),
), ),
( (
DataType::PqSignature.to_id(&tm), DataType::PqSignature
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::UnsignedNumber(42), DataValue::UnsignedNumber(42),
), ),
]); ]);
@ -1614,7 +1813,10 @@ mod tests {
assert_eq!(i64::try_from(DataValue::SignedNumber(10))?, 10i64); assert_eq!(i64::try_from(DataValue::SignedNumber(10))?, 10i64);
assert_eq!(u128::try_from(DataValue::UnsignedNumber(99))?, 99u128); assert_eq!(u128::try_from(DataValue::UnsignedNumber(99))?, 99u128);
assert_eq!(u64::try_from(DataValue::UnsignedNumber(7))?, 7u64); assert_eq!(u64::try_from(DataValue::UnsignedNumber(7))?, 7u64);
assert_eq!(Vec::<u8>::try_from(DataValue::Bytes(vec![0xAB]))?, vec![0xABu8]); assert_eq!(
Vec::<u8>::try_from(DataValue::Bytes(vec![0xAB]))?,
vec![0xABu8]
);
Ok(()) Ok(())
} }
@ -1646,10 +1848,17 @@ mod tests {
let mut dv = DataValue::Container(vec![ let mut dv = DataValue::Container(vec![
( (
DataType::Version.to_id(&tm), DataType::Version
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Str("secret".to_string()), DataValue::Str("secret".to_string()),
), ),
(DataType::Id.to_id(&tm), DataValue::UnsignedNumber(42)), (
DataType::Id
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::UnsignedNumber(42),
),
]); ]);
assert!( assert!(
@ -1675,7 +1884,9 @@ mod tests {
let keyring_b = Keyring::generate(); let keyring_b = Keyring::generate();
let mut dv = DataValue::Container(vec![( let mut dv = DataValue::Container(vec![(
DataType::Version.to_id(&tm), DataType::Version
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Str("secret".to_string()), DataValue::Str("secret".to_string()),
)]); )]);
@ -1698,7 +1909,9 @@ mod tests {
let keyring = Keyring::generate(); let keyring = Keyring::generate();
let mut dv = DataValue::Container(vec![( let mut dv = DataValue::Container(vec![(
DataType::Version.to_id(&tm), DataType::Version
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Str("secret".to_string()), DataValue::Str("secret".to_string()),
)]); )]);
@ -1740,7 +1953,9 @@ mod tests {
let (signer, sk, _pk) = Ed25519Signer::generate(); let (signer, sk, _pk) = Ed25519Signer::generate();
let mut dv = DataValue::Container(vec![( let mut dv = DataValue::Container(vec![(
DataType::Version.to_id(&tm), DataType::Version
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Str("signed data".to_string()), DataValue::Str("signed data".to_string()),
)]); )]);
@ -1782,7 +1997,9 @@ mod tests {
let wrong_verifier = Ed25519Signer::new(&sk2)?; let wrong_verifier = Ed25519Signer::new(&sk2)?;
let mut dv = DataValue::Container(vec![( let mut dv = DataValue::Container(vec![(
DataType::Version.to_id(&tm), DataType::Version
.try_to_id(&tm)
.expect("test type must be mapped"),
DataValue::Str("signed data".to_string()), DataValue::Str("signed data".to_string()),
)]); )]);

View file

@ -1,13 +1,15 @@
pub mod communication_value; pub mod communication_value;
pub mod data_value; pub mod data_value;
pub use communication_value::CommunicationValue; #[cfg(feature = "crypto")]
pub use communication_value::EncryptedPayload;
pub use communication_value::{CommunicationValue, MAX_WIRE_ID};
pub use data_value::{DataKind, DataValue}; pub use data_value::{DataKind, DataValue};
pub use mtp_common::CodecError; pub use mtp_common::CodecError;
pub use mtp_type_map::{ pub use mtp_type_map::{
CommunicationType, CommunicationTypeId, DataType, DataTypeId, PROTOCOL_VERSION, TypeMap, CommunicationType, CommunicationTypeId, DataType, DataTypeId, PROTOCOL_VERSION, TypeMap,
Version, communication_type_name, data_type_name, Version,
}; };
pub(crate) fn rand_u32() -> u32 { pub(crate) fn rand_u32() -> u32 {

2
common/Cargo.lock generated
View file

@ -4,7 +4,7 @@ version = 4
[[package]] [[package]]
name = "common" name = "common"
version = "0.1.0" version = "0.2.0"
quinn = { version = "0.11.9", default-features = false, features = [ quinn = { version = "0.11.9", default-features = false, features = [
"rustls-aws-lc-rs", "rustls-aws-lc-rs",
"rustls", "rustls",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "mtp-common" name = "mtp-common"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View file

@ -50,7 +50,6 @@ mod tests {
* wrappers) is available. On WASM only the transport-independent subset is * wrappers) is available. On WASM only the transport-independent subset is
* compiled. */ * compiled. */
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Error, Clone)] #[derive(Debug, Error, Clone)]
pub enum CommunicationError { pub enum CommunicationError {
#[error("Use after Closed")] #[error("Use after Closed")]
@ -66,6 +65,7 @@ pub enum CommunicationError {
ConnectionLost, ConnectionLost,
#[error("QUIC error: {0}")] #[error("QUIC error: {0}")]
#[cfg(not(target_arch = "wasm32"))]
Quinn(#[from] quinn::ConnectionError), Quinn(#[from] quinn::ConnectionError),
#[error("ParseCommunicationValue error")] #[error("ParseCommunicationValue error")]
@ -84,24 +84,29 @@ pub enum CommunicationError {
ParseError(String), ParseError(String),
#[error("Connection error: {0}")] #[error("Connection error: {0}")]
#[cfg(not(target_arch = "wasm32"))]
ConnectionError(#[from] wtransport::error::ConnectionError), ConnectionError(#[from] wtransport::error::ConnectionError),
#[error("Connecting error: {0}")] #[error("Connecting error: {0}")]
ConnectingError(String), ConnectingError(String),
#[error("ReadToEnd error: {0}")] #[error("ReadToEnd error: {0}")]
#[cfg(not(target_arch = "wasm32"))]
ReadToEndError(#[from] quinn::ReadToEndError), ReadToEndError(#[from] quinn::ReadToEndError),
#[error("Write error: {0}")] #[error("Write error: {0}")]
#[cfg(not(target_arch = "wasm32"))]
WriteError(#[from] quinn::WriteError), WriteError(#[from] quinn::WriteError),
#[error("Closed error: {0}")] #[error("Closed error: {0}")]
#[cfg(not(target_arch = "wasm32"))]
ClosedError(#[from] quinn::ClosedStream), ClosedError(#[from] quinn::ClosedStream),
#[error("Message too large")] #[error("Message too large")]
MessageTooLarge, MessageTooLarge,
#[error("ReadExactError: {0}")] #[error("ReadExactError: {0}")]
#[cfg(not(target_arch = "wasm32"))]
ReadExactError(#[from] quinn::ReadExactError), ReadExactError(#[from] quinn::ReadExactError),
#[error("Stream Closed")] #[error("Stream Closed")]
@ -111,9 +116,11 @@ pub enum CommunicationError {
StreamError, StreamError,
#[error("Stream Error: {0}")] #[error("Stream Error: {0}")]
#[cfg(not(target_arch = "wasm32"))]
StreamWriteError(#[from] wtransport::error::StreamWriteError), StreamWriteError(#[from] wtransport::error::StreamWriteError),
#[error("Read Exact Error: {0}")] #[error("Read Exact Error: {0}")]
#[cfg(not(target_arch = "wasm32"))]
StreamReadExactError(#[from] wtransport::error::StreamReadExactError), StreamReadExactError(#[from] wtransport::error::StreamReadExactError),
#[error("Crypto Provider Install Error")] #[error("Crypto Provider Install Error")]
@ -126,61 +133,8 @@ pub enum CommunicationError {
Other(String), Other(String),
} }
#[cfg(target_arch = "wasm32")]
#[derive(Debug, Error, Clone)]
pub enum CommunicationError {
#[error("Use after Closed")]
UseAfterClosed,
#[error("Connection closed by local shutdown")]
ClosedLocally,
#[error("Connection closed by peer")]
ClosedByPeer,
#[error("Connection terminated unexpectedly")]
ConnectionLost,
#[error("ParseCommunicationValue error")]
ParseCommunicationValue,
#[error("Encode error")]
Encode,
#[error("Parse Certificate error")]
CertificateParseFailed,
#[error("Loading Certificate error")]
CertificateLoadFailed,
#[error("Parse error: {0}")]
ParseError(String),
#[error("Connecting error: {0}")]
ConnectingError(String),
#[error("Message too large")]
MessageTooLarge,
#[error("Stream Closed")]
StreamClosed,
#[error("Stream Error")]
StreamError,
#[error("Crypto Provider Install Error")]
CryptoProviderInstallFailed,
#[error("Authentication failed: {0}")]
AuthenticationFailed(String),
#[error("Other: {0}")]
Other(String),
}
// ---- manual PartialEq (quinn / wtransport types don't impl PartialEq) ---- // ---- manual PartialEq (quinn / wtransport types don't impl PartialEq) ----
#[cfg(not(target_arch = "wasm32"))]
impl PartialEq for CommunicationError { impl PartialEq for CommunicationError {
fn eq(&self, other: &Self) -> bool { fn eq(&self, other: &Self) -> bool {
match (self, other) { match (self, other) {
@ -188,22 +142,30 @@ impl PartialEq for CommunicationError {
(Self::ClosedLocally, Self::ClosedLocally) => true, (Self::ClosedLocally, Self::ClosedLocally) => true,
(Self::ClosedByPeer, Self::ClosedByPeer) => true, (Self::ClosedByPeer, Self::ClosedByPeer) => true,
(Self::ConnectionLost, Self::ConnectionLost) => true, (Self::ConnectionLost, Self::ConnectionLost) => true,
#[cfg(not(target_arch = "wasm32"))]
(Self::Quinn(_), Self::Quinn(_)) => true, (Self::Quinn(_), Self::Quinn(_)) => true,
(Self::ParseCommunicationValue, Self::ParseCommunicationValue) => true, (Self::ParseCommunicationValue, Self::ParseCommunicationValue) => true,
(Self::Encode, Self::Encode) => true, (Self::Encode, Self::Encode) => true,
(Self::CertificateParseFailed, Self::CertificateParseFailed) => true, (Self::CertificateParseFailed, Self::CertificateParseFailed) => true,
(Self::CertificateLoadFailed, Self::CertificateLoadFailed) => true, (Self::CertificateLoadFailed, Self::CertificateLoadFailed) => true,
(Self::ParseError(a), Self::ParseError(b)) => a == b, (Self::ParseError(a), Self::ParseError(b)) => a == b,
#[cfg(not(target_arch = "wasm32"))]
(Self::ConnectionError(_), Self::ConnectionError(_)) => true, (Self::ConnectionError(_), Self::ConnectionError(_)) => true,
(Self::ConnectingError(a), Self::ConnectingError(b)) => a == b, (Self::ConnectingError(a), Self::ConnectingError(b)) => a == b,
#[cfg(not(target_arch = "wasm32"))]
(Self::ReadToEndError(_), Self::ReadToEndError(_)) => true, (Self::ReadToEndError(_), Self::ReadToEndError(_)) => true,
#[cfg(not(target_arch = "wasm32"))]
(Self::WriteError(_), Self::WriteError(_)) => true, (Self::WriteError(_), Self::WriteError(_)) => true,
#[cfg(not(target_arch = "wasm32"))]
(Self::ClosedError(_), Self::ClosedError(_)) => true, (Self::ClosedError(_), Self::ClosedError(_)) => true,
(Self::MessageTooLarge, Self::MessageTooLarge) => true, (Self::MessageTooLarge, Self::MessageTooLarge) => true,
#[cfg(not(target_arch = "wasm32"))]
(Self::ReadExactError(_), Self::ReadExactError(_)) => true, (Self::ReadExactError(_), Self::ReadExactError(_)) => true,
(Self::StreamClosed, Self::StreamClosed) => true, (Self::StreamClosed, Self::StreamClosed) => true,
(Self::StreamError, Self::StreamError) => true, (Self::StreamError, Self::StreamError) => true,
#[cfg(not(target_arch = "wasm32"))]
(Self::StreamWriteError(_), Self::StreamWriteError(_)) => true, (Self::StreamWriteError(_), Self::StreamWriteError(_)) => true,
#[cfg(not(target_arch = "wasm32"))]
(Self::StreamReadExactError(_), Self::StreamReadExactError(_)) => true, (Self::StreamReadExactError(_), Self::StreamReadExactError(_)) => true,
(Self::CryptoProviderInstallFailed, Self::CryptoProviderInstallFailed) => true, (Self::CryptoProviderInstallFailed, Self::CryptoProviderInstallFailed) => true,
(Self::AuthenticationFailed(a), Self::AuthenticationFailed(b)) => a == b, (Self::AuthenticationFailed(a), Self::AuthenticationFailed(b)) => a == b,
@ -213,35 +175,6 @@ impl PartialEq for CommunicationError {
} }
} }
#[cfg(target_arch = "wasm32")]
impl PartialEq for CommunicationError {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::UseAfterClosed, Self::UseAfterClosed) => true,
(Self::ClosedLocally, Self::ClosedLocally) => true,
(Self::ClosedByPeer, Self::ClosedByPeer) => true,
(Self::ConnectionLost, Self::ConnectionLost) => true,
(Self::ParseCommunicationValue, Self::ParseCommunicationValue) => true,
(Self::Encode, Self::Encode) => true,
(Self::CertificateParseFailed, Self::CertificateParseFailed) => true,
(Self::CertificateLoadFailed, Self::CertificateLoadFailed) => true,
(Self::ParseError(a), Self::ParseError(b)) => a == b,
(Self::ConnectingError(a), Self::ConnectingError(b)) => a == b,
(Self::MessageTooLarge, Self::MessageTooLarge) => true,
(Self::StreamClosed, Self::StreamClosed) => true,
(Self::StreamError, Self::StreamError) => true,
(Self::CryptoProviderInstallFailed, Self::CryptoProviderInstallFailed) => true,
(Self::AuthenticationFailed(a), Self::AuthenticationFailed(b)) => a == b,
(Self::Other(a), Self::Other(b)) => a == b,
_ => false,
}
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Eq for CommunicationError {}
#[cfg(target_arch = "wasm32")]
impl Eq for CommunicationError {} impl Eq for CommunicationError {}
/* ================================ PipeError ================================ */ /* ================================ PipeError ================================ */
@ -283,6 +216,53 @@ impl From<CommunicationError> for PipeError {
} }
} }
/* ===================== Handshake Outcome Types ===================== */
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RejectionReason {
BadVersion { supported_versions: Vec<String> },
AuthenticationFailed { detail: String },
RateLimited,
}
impl std::fmt::Display for RejectionReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RejectionReason::BadVersion { supported_versions } => {
write!(
f,
"unsupported protocol version; supported: {}",
supported_versions.join(", ")
)
}
RejectionReason::AuthenticationFailed { detail } => {
write!(f, "authentication failed: {detail}")
}
RejectionReason::RateLimited => write!(f, "rate limited"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HandshakeOutcome {
Accepted { version: String, assigned_id: u64 },
Rejected { reason: RejectionReason },
}
impl std::fmt::Display for HandshakeOutcome {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HandshakeOutcome::Accepted {
version,
assigned_id,
} => {
write!(f, "accepted (version={version}, id={assigned_id})")
}
HandshakeOutcome::Rejected { reason } => write!(f, "rejected: {reason}"),
}
}
}
/* ================================ TESTS ================================ */ /* ================================ TESTS ================================ */
#[cfg(test)] #[cfg(test)]
mod communication_error_tests { mod communication_error_tests {
@ -381,3 +361,81 @@ mod pipe_error_tests {
assert_eq!(pe, PipeError::IoError("Stream Error".into())); assert_eq!(pe, PipeError::IoError("Stream Error".into()));
} }
} }
/* ==================== HandshakeOutcome TESTS ==================== */
#[cfg(test)]
mod handshake_outcome_tests {
use super::*;
#[test]
fn test_accepted_display() {
let outcome = HandshakeOutcome::Accepted {
version: "1.0".into(),
assigned_id: 42,
};
assert_eq!(format!("{outcome}"), "accepted (version=1.0, id=42)");
}
#[test]
fn test_rejected_bad_version_display() {
let outcome = HandshakeOutcome::Rejected {
reason: RejectionReason::BadVersion {
supported_versions: vec!["1.0".into(), "2.0".into()],
},
};
let msg = format!("{outcome}");
assert!(msg.contains("1.0"));
assert!(msg.contains("2.0"));
}
#[test]
fn test_rejected_auth_failed_display() {
let outcome = HandshakeOutcome::Rejected {
reason: RejectionReason::AuthenticationFailed {
detail: "invalid signature".into(),
},
};
assert!(format!("{outcome}").contains("invalid signature"));
}
#[test]
fn test_rejected_rate_limited_display() {
let outcome = HandshakeOutcome::Rejected {
reason: RejectionReason::RateLimited,
};
assert_eq!(format!("{outcome}"), "rejected: rate limited");
}
#[test]
fn test_rejection_reason_display() {
assert!(
format!(
"{}",
RejectionReason::BadVersion {
supported_versions: vec!["1.0".into()]
}
)
.contains("1.0")
);
assert!(
format!(
"{}",
RejectionReason::AuthenticationFailed {
detail: "bad".into()
}
)
.contains("bad")
);
assert_eq!(format!("{}", RejectionReason::RateLimited), "rate limited");
}
#[test]
fn test_handshake_outcome_clone_eq() {
let a = HandshakeOutcome::Accepted {
version: "1.0".into(),
assigned_id: 1,
};
let b = a.clone();
assert_eq!(a, b);
}
}

2
crypto/Cargo.lock generated
View file

@ -4,4 +4,4 @@ version = 4
[[package]] [[package]]
name = "crypto" name = "crypto"
version = "0.1.0" version = "0.2.0"

View file

@ -1,10 +1,10 @@
[package] [package]
name = "mtp-crypto" name = "mtp-crypto"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
[package.metadata.cargo-machete] [package.metadata.cargo-machete]
ignored = ["getrandom"] ignored = ["rand_core"]
[dependencies] [dependencies]
chacha20poly1305 = { version = "0.10", optional = true } chacha20poly1305 = { version = "0.10", optional = true }
@ -18,11 +18,14 @@ sha2 = { version = "0.11", optional = true }
zeroize = { version = "1.9", features = ["derive"] } zeroize = { version = "1.9", features = ["derive"] }
thiserror = "1" thiserror = "1"
base64 = "0.22" base64 = "0.22"
rand_core = { version = "0.6", features = ["getrandom"] } rand_core = { version = "0.10.1" }
rand = "0.10.2"
getrandom = "0.4.3" getrandom = "0.4.3"
mlkem-tls = { version = "0.2", optional = true } mlkem-tls = { version = "0.2", optional = true }
ml-dsa = { version = "0.1.1", optional = true } ml-dsa = { version = "0.1.1", optional = true }
serde = { version = "1", optional = true, features = ["derive"] } serde = { version = "1", optional = true, features = ["derive"] }
rcgen = { version = "0.14", optional = true }
time = { version = "0.3", optional = true }
[features] [features]
default = ["chacha20poly1305", "ed25519-dalek", "hkdf", "sha2", "ml-dsa"] default = ["chacha20poly1305", "ed25519-dalek", "hkdf", "sha2", "ml-dsa"]
@ -32,3 +35,6 @@ full = ["default", "aes-gcm"]
pqc = ["mlkem-tls", "ml-dsa"] pqc = ["mlkem-tls", "ml-dsa"]
serde = ["dep:serde"] serde = ["dep:serde"]
wasm = ["getrandom/wasm_js"] wasm = ["getrandom/wasm_js"]
hkdf = ["dep:hkdf", "dep:sha2"]
sha2 = ["dep:sha2"]
tls = ["dep:rcgen", "dep:time"]

View file

@ -1,164 +0,0 @@
# mtp-crypto
Cryptographic primitives for the MTP protocol. Classical and post-quantum.
## Features
| Feature | Primitives |
|---------|-----------|
| `default` | XChaCha20-Poly1305, Ed25519, ML-DSA-65, HKDF-SHA-256, SHA-256 |
| `full` | default + AES-256-GCM |
| `pqc` | ML-KEM-768+X25519 hybrid KEM |
ML-DSA-65 is enabled by default so dual-signature support is always available
without a separate PQC feature flag in protocol crates.
## AEAD
XChaCha20-Poly1305 (default) and AES-256-GCM (`full` feature). Nonce is prepended to ciphertext.
```rust
use mtp_crypto::{ChaCha20Poly1305, AeadEncrypt, AeadDecrypt};
let cipher = ChaCha20Poly1305::new([0u8; 32]);
let ct = cipher.encrypt(b"hello", b"aad")?;
let pt = cipher.decrypt(&ct, b"aad")?;
```
## Signatures
### Ed25519
```rust
use mtp_crypto::{Ed25519Signer, SignatureScheme};
let (signer, sk, pk) = Ed25519Signer::generate();
let sig = signer.sign(b"message")?;
signer.verify(b"message", &sig)?;
```
### ML-DSA-65
```rust
use mtp_crypto::{MlDsaSigner, SignatureScheme};
let (signer, sk, pk) = MlDsaSigner::generate();
let sig = signer.sign(b"message")?;
signer.verify(b"message", &sig)?;
// Load from stored bytes
let signer = MlDsaSigner::new(&sk, &pk)?;
```
### Dual signatures
```rust
use mtp_crypto::{sign_dual, DualSignature, Ed25519Signer, MlDsaSigner};
let (ed_signer, _, _) = Ed25519Signer::generate();
let (ml_signer, _, _) = MlDsaSigner::generate();
let dual = sign_dual(ed_signer.signing_key(), ml_signer.signing_key(), b"msg");
dual.verify(ed_signer.verifying_key(), ml_signer.verifying_key(), b"msg")?;
```
## Hybrid KEM
X25519 + ML-KEM-768. 64-byte shared secret. Feed into HKDF before use.
```rust
use mtp_crypto::HybridKem;
let (sk, pk) = HybridKem::generate_keypair();
let enc = HybridKem::encapsulate(&pk)?;
let ss = HybridKem::decapsulate(&sk, &enc.ciphertext)?;
assert_eq!(enc.shared_secret, ss);
```
## Encrypted containers
Self-describing encrypted blobs with algorithm selection via `EncryptionType`.
Each blob begins with a marking byte so recipients can decrypt without
out-of-band agreement.
```rust
use mtp_crypto::{EncryptionType, Keyring, encrypt_for, decrypt_with};
let kr = Keyring::generate();
let blob = encrypt_for(EncryptionType::MlKemChaCha20Poly1305, &kr.public_key_bundle(), b"data", b"aad")?;
let pt = decrypt_with(&blob, &kr, b"aad")?;
```
## Multi-recipient encryption
Encrypt a payload for multiple recipients using a content-encryption key wrapped
per-recipient via Hybrid KEM.
```rust
use mtp_crypto::{Keyring, encrypt_multi, decrypt_multi};
let alice = Keyring::generate();
let bob = Keyring::generate();
let msg = encrypt_multi(b"secret", b"aad", &[alice.public_key_bundle(), bob.public_key_bundle()])?;
let pt = decrypt_multi(&msg, b"aad", &alice)?;
```
## Authentication handshake
Canonical domain-separated payloads for the challenge-response handshake.
```rust
use mtp_crypto::auth::{challenge_payload, login_proof_payload, register_proof_payload, host_final_payload};
```
Each payload type uses a distinct domain tag to prevent replay across protocol steps.
## KDF
```rust
use mtp_crypto::{hkdf_expand, hkdf_extract, derive_encryption_key};
let key = derive_encryption_key(b"ikm", b"salt", b"context")?;
let prk = hkdf_extract(b"ikm", b"salt");
```
## Hashing
```rust
use mtp_crypto::{sha256, sha256_double, Sha256Hasher};
let h = sha256(b"data");
let h2 = sha256_double(b"data");
let mut hasher = Sha256Hasher::new();
hasher.update(b"da");
hasher.update(b"ta");
let h3 = hasher.finalize();
```
## Key types
| Type | Secret | Zeroized |
|------|--------|----------|
| `EncryptionPrivateKey` | KEM/ECDH secret | Yes |
| `EncryptionPublicKey` | KEM/ECDH public | No |
| `SignaturePrivateKey` | Classical signing key | Yes |
| `SignaturePublicKey` | Classical verifying key | No |
| `KemPrivateKey` | Hybrid KEM secret | Yes |
| `KemPublicKey` | Hybrid KEM public | No |
| `SignaturePqPrivateKey` | PQC signing key | Yes |
| `SignaturePqPublicKey` | PQC verifying key | No |
`Keyring` holds all six keys (hybrid KEM + PQ sig + classical sig) plus
`generate()`, `to_bytes()`, and `from_bytes()` for serialization.
`PublicKeyBundle` holds the three public keys for distribution.
## Feature flags
```toml
[dependencies]
mtp-crypto = { path = "../crypto" } # classical + ML-DSA
mtp-crypto = { path = "../crypto", features = ["pqc"] } # adds hybrid KEM
mtp-crypto = { path = "../crypto", features = ["full", "pqc"] } # adds AES-256-GCM + hybrid KEM
mtp-crypto = { path = "../crypto", features = ["serde"] } # serde support
mtp-crypto = { path = "../crypto", features = ["wasm"] } # WASM compat
```

View file

@ -1,10 +1,10 @@
use crate::error::CryptoError; use crate::error::CryptoError;
#[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))] #[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))]
use rand_core::OsRng; use zeroize::Zeroizing;
#[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))] #[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))]
use rand_core::RngCore; use getrandom::fill;
pub trait AeadEncrypt { pub trait AeadEncrypt {
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError>; fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError>;
@ -28,13 +28,15 @@ fn prepend_nonce(nonce: &[u8], ciphertext: &mut Vec<u8>) -> Vec<u8> {
#[cfg(feature = "chacha20poly1305")] #[cfg(feature = "chacha20poly1305")]
pub struct ChaCha20Poly1305 { pub struct ChaCha20Poly1305 {
key: [u8; 32], key: Zeroizing<[u8; 32]>,
} }
#[cfg(feature = "chacha20poly1305")] #[cfg(feature = "chacha20poly1305")]
impl ChaCha20Poly1305 { impl ChaCha20Poly1305 {
pub fn new(key: [u8; 32]) -> Self { pub fn new(key: [u8; 32]) -> Self {
Self { key } Self {
key: Zeroizing::new(key),
}
} }
} }
@ -45,11 +47,11 @@ impl AeadEncrypt for ChaCha20Poly1305 {
use chacha20poly1305::XNonce; use chacha20poly1305::XNonce;
use chacha20poly1305::aead::{Aead, KeyInit, Payload}; use chacha20poly1305::aead::{Aead, KeyInit, Payload};
let key = chacha20poly1305::Key::from_slice(&self.key); let key = chacha20poly1305::Key::from_slice(self.key.as_ref());
let cipher = XChaCha20Poly1305::new(key); let cipher = XChaCha20Poly1305::new(key);
let mut nonce = [0u8; 24]; let mut nonce = [0u8; 24];
OsRng.fill_bytes(&mut nonce); fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?;
let nonce_ref = XNonce::from_slice(&nonce); let nonce_ref = XNonce::from_slice(&nonce);
let payload = Payload { let payload = Payload {
@ -77,7 +79,7 @@ impl AeadDecrypt for ChaCha20Poly1305 {
} }
let (nonce, ct) = ciphertext.split_at(24); let (nonce, ct) = ciphertext.split_at(24);
let key = chacha20poly1305::Key::from_slice(&self.key); let key = chacha20poly1305::Key::from_slice(self.key.as_ref());
let cipher = XChaCha20Poly1305::new(key); let cipher = XChaCha20Poly1305::new(key);
let nonce_ref = XNonce::from_slice(nonce); let nonce_ref = XNonce::from_slice(nonce);
@ -98,13 +100,15 @@ impl AeadCipher for ChaCha20Poly1305 {
#[cfg(feature = "aes-gcm")] #[cfg(feature = "aes-gcm")]
pub struct Aes256Gcm { pub struct Aes256Gcm {
key: [u8; 32], key: Zeroizing<[u8; 32]>,
} }
#[cfg(feature = "aes-gcm")] #[cfg(feature = "aes-gcm")]
impl Aes256Gcm { impl Aes256Gcm {
pub fn new(key: [u8; 32]) -> Self { pub fn new(key: [u8; 32]) -> Self {
Self { key } Self {
key: Zeroizing::new(key),
}
} }
} }
@ -115,11 +119,11 @@ impl AeadEncrypt for Aes256Gcm {
use aes_gcm::Nonce; use aes_gcm::Nonce;
use aes_gcm::aead::{Aead, KeyInit, Payload}; use aes_gcm::aead::{Aead, KeyInit, Payload};
let key = aes_gcm::Key::<AesGcmInner>::from_slice(&self.key); let key = aes_gcm::Key::<AesGcmInner>::from_slice(self.key.as_ref());
let cipher = AesGcmInner::new(key); let cipher = AesGcmInner::new(key);
let mut nonce = [0u8; 12]; let mut nonce = [0u8; 12];
OsRng.fill_bytes(&mut nonce); fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?;
let nonce_ref = Nonce::from_slice(&nonce); let nonce_ref = Nonce::from_slice(&nonce);
let payload = Payload { let payload = Payload {
@ -147,7 +151,7 @@ impl AeadDecrypt for Aes256Gcm {
} }
let (nonce, ct) = ciphertext.split_at(12); let (nonce, ct) = ciphertext.split_at(12);
let key = aes_gcm::Key::<AesGcmInner>::from_slice(&self.key); let key = aes_gcm::Key::<AesGcmInner>::from_slice(self.key.as_ref());
let cipher = AesGcmInner::new(key); let cipher = AesGcmInner::new(key);
let nonce_ref = Nonce::from_slice(nonce); let nonce_ref = Nonce::from_slice(nonce);

View file

@ -11,7 +11,7 @@
* Step 4. Host -> Client : IdentificationResponse { connected, id, host_sig } host_sig over host_final_payload * Step 4. Host -> Client : IdentificationResponse { connected, id, host_sig } host_sig over host_final_payload
*/ */
/// Domain-separation tags — a distinct leading byte per signed context. /// Domain-separation tags
pub mod domain { pub mod domain {
/// Host's signature over the challenge it issues (step 2). /// Host's signature over the challenge it issues (step 2).
pub const CHALLENGE: u8 = 0x10; pub const CHALLENGE: u8 = 0x10;

View file

@ -30,4 +30,6 @@ pub enum CryptoError {
InvalidHex, InvalidHex,
#[error("invalid base64 encoding")] #[error("invalid base64 encoding")]
InvalidBase64, InvalidBase64,
#[error("TLS error: {0}")]
Tls(String),
} }

View file

@ -9,7 +9,9 @@ use crate::kem::HybridKem;
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] #[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
use crate::keypair::{Keyring, PublicKeyBundle}; use crate::keypair::{Keyring, PublicKeyBundle};
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] #[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
use rand_core::RngCore; use rand::Rng;
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
use zeroize::Zeroizing;
pub struct RecipientEntry { pub struct RecipientEntry {
pub kem_ciphertext: Vec<u8>, pub kem_ciphertext: Vec<u8>,
@ -126,10 +128,10 @@ pub fn encrypt_multi(
aad: &[u8], aad: &[u8],
entities: &[PublicKeyBundle], entities: &[PublicKeyBundle],
) -> Result<MultiEncryptedMessage, CryptoError> { ) -> Result<MultiEncryptedMessage, CryptoError> {
let mut cek = [0u8; 32]; let mut cek = Zeroizing::new([0u8; 32]);
rand_core::OsRng.fill_bytes(&mut cek); rand::rng().fill_bytes(cek.as_mut());
let cipher = ChaCha20Poly1305::new(cek); let cipher = ChaCha20Poly1305::new(*cek);
let encrypted_payload = cipher.encrypt(plaintext, aad)?; let encrypted_payload = cipher.encrypt(plaintext, aad)?;
let nonce: [u8; 24] = encrypted_payload[..24] let nonce: [u8; 24] = encrypted_payload[..24]
@ -140,14 +142,14 @@ pub fn encrypt_multi(
let mut recipients = Vec::with_capacity(entities.len()); let mut recipients = Vec::with_capacity(entities.len());
for entity in entities { for entity in entities {
let enc = HybridKem::encapsulate(&entity.kem_public_key)?; let enc = HybridKem::encapsulate(&entity.kem_public_key)?;
let wrap_key = derive_encryption_key( let wrap_key = Zeroizing::new(derive_encryption_key(
&enc.shared_secret, &enc.shared_secret,
b"mtp-multi-key-wrap", b"mtp-multi-key-wrap",
b"multi-recipient", b"multi-recipient",
)?; )?);
let wrap_cipher = ChaCha20Poly1305::new(wrap_key); let wrap_cipher = ChaCha20Poly1305::new(*wrap_key);
let encrypted_key = wrap_cipher.encrypt(&cek, b"")?; let encrypted_key = wrap_cipher.encrypt(cek.as_ref(), b"")?;
recipients.push(RecipientEntry { recipients.push(RecipientEntry {
kem_ciphertext: enc.ciphertext, kem_ciphertext: enc.ciphertext,
@ -179,19 +181,27 @@ pub fn decrypt_multi(
Ok(s) => s, Ok(s) => s,
Err(_) => continue, Err(_) => continue,
}; };
let wrap_key = derive_encryption_key(&ss, b"mtp-multi-key-wrap", b"multi-recipient")?; let wrap_key = Zeroizing::new(derive_encryption_key(
let wrap_cipher = ChaCha20Poly1305::new(wrap_key); &ss,
b"mtp-multi-key-wrap",
b"multi-recipient",
)?);
let wrap_cipher = ChaCha20Poly1305::new(*wrap_key);
let cek = match wrap_cipher.decrypt(&entry.encrypted_key, b"") { let cek = match wrap_cipher.decrypt(&entry.encrypted_key, b"") {
Ok(k) => k, Ok(k) => Zeroizing::new(k),
Err(_) => continue, Err(_) => continue,
}; };
let cek_arr: [u8; 32] = cek.try_into().map_err(|_| CryptoError::DecryptionFailed)?; let cek_arr = Zeroizing::new(
cek.as_slice()
.try_into()
.map_err(|_| CryptoError::DecryptionFailed)?,
);
let mut full_ct = Vec::with_capacity(24 + msg.ciphertext.len()); let mut full_ct = Vec::with_capacity(24 + msg.ciphertext.len());
full_ct.extend_from_slice(&msg.nonce); full_ct.extend_from_slice(&msg.nonce);
full_ct.extend_from_slice(&msg.ciphertext); full_ct.extend_from_slice(&msg.ciphertext);
let data_cipher = ChaCha20Poly1305::new(cek_arr); let data_cipher = ChaCha20Poly1305::new(*cek_arr);
return data_cipher.decrypt(&full_ct, aad); return data_cipher.decrypt(&full_ct, aad);
} }
Err(CryptoError::DecryptionFailed) Err(CryptoError::DecryptionFailed)

View file

@ -1,9 +1,10 @@
use crate::error::CryptoError; use crate::error::CryptoError;
use crate::keypair::{KemPrivateKey, KemPublicKey}; use crate::keypair::{KemPrivateKey, KemPublicKey};
use zeroize::Zeroizing;
pub struct Encapsulated { pub struct Encapsulated {
pub ciphertext: Vec<u8>, pub ciphertext: Vec<u8>,
pub shared_secret: Vec<u8>, pub shared_secret: Zeroizing<Vec<u8>>,
} }
#[cfg(feature = "mlkem-tls")] #[cfg(feature = "mlkem-tls")]
@ -12,7 +13,10 @@ pub struct HybridKem;
#[cfg(feature = "mlkem-tls")] #[cfg(feature = "mlkem-tls")]
impl HybridKem { impl HybridKem {
pub fn generate_keypair() -> (KemPrivateKey, KemPublicKey) { pub fn generate_keypair() -> (KemPrivateKey, KemPublicKey) {
let (ek, dk) = mlkem_tls::X25519MlKem768::keygen(&mut rand_core::OsRng); /* Obviously: cannot find module or crate rand_core06 in this scope
use of unresolved module or unlinked crate rand_core06 (rustc E0433) */
let (ek, dk) =
mlkem_tls::X25519MlKem768::keygen(&mut chacha20poly1305::aead::rand_core::OsRng);
( (
KemPrivateKey::new(dk.as_bytes().to_vec()), KemPrivateKey::new(dk.as_bytes().to_vec()),
KemPublicKey::new(ek.as_bytes().to_vec()), KemPublicKey::new(ek.as_bytes().to_vec()),
@ -22,22 +26,25 @@ impl HybridKem {
pub fn encapsulate(recipient_pk: &KemPublicKey) -> Result<Encapsulated, CryptoError> { pub fn encapsulate(recipient_pk: &KemPublicKey) -> Result<Encapsulated, CryptoError> {
let ek = mlkem_tls::EncapsKey768::try_from(recipient_pk.as_bytes()) let ek = mlkem_tls::EncapsKey768::try_from(recipient_pk.as_bytes())
.map_err(|_| CryptoError::KemEncapsulationFailed)?; .map_err(|_| CryptoError::KemEncapsulationFailed)?;
let (ct, ss) = mlkem_tls::X25519MlKem768::encapsulate(&ek, &mut rand_core::OsRng); let (ct, ss) = mlkem_tls::X25519MlKem768::encapsulate(
&ek,
&mut chacha20poly1305::aead::rand_core::OsRng,
);
Ok(Encapsulated { Ok(Encapsulated {
ciphertext: ct.as_bytes().to_vec(), ciphertext: ct.as_bytes().to_vec(),
shared_secret: ss.as_bytes().to_vec(), shared_secret: Zeroizing::new(ss.as_bytes().to_vec()),
}) })
} }
pub fn decapsulate( pub fn decapsulate(
recipient_sk: &KemPrivateKey, recipient_sk: &KemPrivateKey,
ciphertext: &[u8], ciphertext: &[u8],
) -> Result<Vec<u8>, CryptoError> { ) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
let dk = mlkem_tls::DecapsKey768::try_from(recipient_sk.as_bytes()) let dk = mlkem_tls::DecapsKey768::try_from(recipient_sk.as_bytes())
.map_err(|_| CryptoError::KemDecapsulationFailed)?; .map_err(|_| CryptoError::KemDecapsulationFailed)?;
let ct = mlkem_tls::Ciphertext768Hybrid::try_from(ciphertext) let ct = mlkem_tls::Ciphertext768Hybrid::try_from(ciphertext)
.map_err(|_| CryptoError::KemDecapsulationFailed)?; .map_err(|_| CryptoError::KemDecapsulationFailed)?;
let ss = mlkem_tls::X25519MlKem768::decapsulate(&dk, &ct); let ss = mlkem_tls::X25519MlKem768::decapsulate(&dk, &ct);
Ok(ss.as_bytes().to_vec()) Ok(Zeroizing::new(ss.as_bytes().to_vec()))
} }
} }

View file

@ -2,179 +2,59 @@ use std::fmt;
use base64::Engine; use base64::Engine;
use base64::engine::general_purpose; use base64::engine::general_purpose;
use zeroize::{Zeroize, ZeroizeOnDrop}; use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
// --- Private key types --- // --- Private key types ---
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] macro_rules! impl_private_key {
#[cfg_attr(feature = "serde", serde(transparent))] ($name:ident) => {
#[derive(Zeroize, ZeroizeOnDrop)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct EncryptionPrivateKey(Vec<u8>); #[cfg_attr(feature = "serde", serde(transparent))]
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct $name(Vec<u8>);
impl EncryptionPrivateKey { impl $name {
pub fn new(bytes: Vec<u8>) -> Self { pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes) Self(bytes)
} }
pub fn as_bytes(&self) -> &[u8] { pub fn as_bytes(&self) -> &[u8] {
&self.0 &self.0
} }
} }
impl fmt::Debug for EncryptionPrivateKey { impl fmt::Debug for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("EncryptionPrivateKey") f.debug_struct(stringify!($name))
.field("len", &self.0.len()) .field("len", &self.0.len())
.field("data", &"[REDACTED]") .field("data", &"[REDACTED]")
.finish() .finish()
} }
} }
impl AsRef<[u8]> for EncryptionPrivateKey { impl AsRef<[u8]> for $name {
fn as_ref(&self) -> &[u8] { fn as_ref(&self) -> &[u8] {
&self.0 &self.0
} }
} }
impl From<Vec<u8>> for EncryptionPrivateKey { impl From<Vec<u8>> for $name {
fn from(bytes: Vec<u8>) -> Self { fn from(bytes: Vec<u8>) -> Self {
Self(bytes) Self(bytes)
} }
} }
impl From<&[u8]> for EncryptionPrivateKey { impl From<&[u8]> for $name {
fn from(bytes: &[u8]) -> Self { fn from(bytes: &[u8]) -> Self {
Self(bytes.to_vec()) Self(bytes.to_vec())
} }
}
};
} }
// --- impl_private_key!(EncryptionPrivateKey);
impl_private_key!(SignaturePrivateKey);
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] impl_private_key!(KemPrivateKey);
#[cfg_attr(feature = "serde", serde(transparent))] impl_private_key!(SignaturePqPrivateKey);
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct SignaturePrivateKey(Vec<u8>);
impl SignaturePrivateKey {
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
impl fmt::Debug for SignaturePrivateKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SignaturePrivateKey")
.field("len", &self.0.len())
.field("data", &"[REDACTED]")
.finish()
}
}
impl AsRef<[u8]> for SignaturePrivateKey {
fn as_ref(&self) -> &[u8] {
&self.0
}
}
impl From<Vec<u8>> for SignaturePrivateKey {
fn from(bytes: Vec<u8>) -> Self {
Self(bytes)
}
}
impl From<&[u8]> for SignaturePrivateKey {
fn from(bytes: &[u8]) -> Self {
Self(bytes.to_vec())
}
}
// ---
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct KemPrivateKey(Vec<u8>);
impl KemPrivateKey {
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
impl fmt::Debug for KemPrivateKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("KemPrivateKey")
.field("len", &self.0.len())
.field("data", &"[REDACTED]")
.finish()
}
}
impl AsRef<[u8]> for KemPrivateKey {
fn as_ref(&self) -> &[u8] {
&self.0
}
}
impl From<Vec<u8>> for KemPrivateKey {
fn from(bytes: Vec<u8>) -> Self {
Self(bytes)
}
}
impl From<&[u8]> for KemPrivateKey {
fn from(bytes: &[u8]) -> Self {
Self(bytes.to_vec())
}
}
// ---
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct SignaturePqPrivateKey(Vec<u8>);
impl SignaturePqPrivateKey {
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
impl fmt::Debug for SignaturePqPrivateKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SignaturePqPrivateKey")
.field("len", &self.0.len())
.field("data", &"[REDACTED]")
.finish()
}
}
impl AsRef<[u8]> for SignaturePqPrivateKey {
fn as_ref(&self) -> &[u8] {
&self.0
}
}
impl From<Vec<u8>> for SignaturePqPrivateKey {
fn from(bytes: Vec<u8>) -> Self {
Self(bytes)
}
}
impl From<&[u8]> for SignaturePqPrivateKey {
fn from(bytes: &[u8]) -> Self {
Self(bytes.to_vec())
}
}
// --- Public key types --- // --- Public key types ---
@ -210,14 +90,14 @@ fn base64_to_bytes(s: &str) -> Result<Vec<u8>, crate::error::CryptoError> {
.map_err(|_| crate::error::CryptoError::InvalidBase64) .map_err(|_| crate::error::CryptoError::InvalidBase64)
} }
// --- macro_rules! impl_public_key {
($name:ident) => {
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
#[derive(Clone)]
pub struct $name(Vec<u8>);
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] impl $name {
#[cfg_attr(feature = "serde", serde(transparent))]
#[derive(Clone)]
pub struct EncryptionPublicKey(Vec<u8>);
impl EncryptionPublicKey {
pub fn new(bytes: Vec<u8>) -> Self { pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes) Self(bytes)
} }
@ -230,193 +110,44 @@ impl EncryptionPublicKey {
pub fn from_hex(s: &str) -> Result<Self, crate::error::CryptoError> { pub fn from_hex(s: &str) -> Result<Self, crate::error::CryptoError> {
hex_to_bytes(s).map(Self) hex_to_bytes(s).map(Self)
} }
}
impl fmt::Debug for EncryptionPublicKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "EncryptionPublicKey({})", self.to_hex())
} }
}
impl AsRef<[u8]> for EncryptionPublicKey { impl fmt::Debug for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}({})", stringify!($name), self.to_hex())
}
}
impl AsRef<[u8]> for $name {
fn as_ref(&self) -> &[u8] { fn as_ref(&self) -> &[u8] {
&self.0 &self.0
} }
} }
impl From<Vec<u8>> for EncryptionPublicKey { impl From<Vec<u8>> for $name {
fn from(bytes: Vec<u8>) -> Self { fn from(bytes: Vec<u8>) -> Self {
Self(bytes) Self(bytes)
} }
} }
impl From<&[u8]> for EncryptionPublicKey { impl From<&[u8]> for $name {
fn from(bytes: &[u8]) -> Self { fn from(bytes: &[u8]) -> Self {
Self(bytes.to_vec()) Self(bytes.to_vec())
} }
} }
impl From<&EncryptionPublicKey> for Vec<u8> { impl From<&$name> for Vec<u8> {
fn from(key: &EncryptionPublicKey) -> Vec<u8> { fn from(key: &$name) -> Vec<u8> {
key.0.clone() key.0.clone()
} }
}
};
} }
// --- impl_public_key!(EncryptionPublicKey);
impl_public_key!(SignaturePublicKey);
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] impl_public_key!(KemPublicKey);
#[cfg_attr(feature = "serde", serde(transparent))] impl_public_key!(SignaturePqPublicKey);
#[derive(Clone)]
pub struct SignaturePublicKey(Vec<u8>);
impl SignaturePublicKey {
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
pub fn to_hex(&self) -> String {
bytes_to_hex(&self.0)
}
pub fn from_hex(s: &str) -> Result<Self, crate::error::CryptoError> {
hex_to_bytes(s).map(Self)
}
}
impl fmt::Debug for SignaturePublicKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "SignaturePublicKey({})", self.to_hex())
}
}
impl AsRef<[u8]> for SignaturePublicKey {
fn as_ref(&self) -> &[u8] {
&self.0
}
}
impl From<Vec<u8>> for SignaturePublicKey {
fn from(bytes: Vec<u8>) -> Self {
Self(bytes)
}
}
impl From<&[u8]> for SignaturePublicKey {
fn from(bytes: &[u8]) -> Self {
Self(bytes.to_vec())
}
}
impl From<&SignaturePublicKey> for Vec<u8> {
fn from(key: &SignaturePublicKey) -> Vec<u8> {
key.0.clone()
}
}
// ---
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
#[derive(Clone)]
pub struct KemPublicKey(Vec<u8>);
impl KemPublicKey {
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
pub fn to_hex(&self) -> String {
bytes_to_hex(&self.0)
}
pub fn from_hex(s: &str) -> Result<Self, crate::error::CryptoError> {
hex_to_bytes(s).map(Self)
}
}
impl fmt::Debug for KemPublicKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "KemPublicKey({})", self.to_hex())
}
}
impl AsRef<[u8]> for KemPublicKey {
fn as_ref(&self) -> &[u8] {
&self.0
}
}
impl From<Vec<u8>> for KemPublicKey {
fn from(bytes: Vec<u8>) -> Self {
Self(bytes)
}
}
impl From<&[u8]> for KemPublicKey {
fn from(bytes: &[u8]) -> Self {
Self(bytes.to_vec())
}
}
impl From<&KemPublicKey> for Vec<u8> {
fn from(key: &KemPublicKey) -> Vec<u8> {
key.0.clone()
}
}
// ---
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
#[derive(Clone)]
pub struct SignaturePqPublicKey(Vec<u8>);
impl SignaturePqPublicKey {
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
pub fn to_hex(&self) -> String {
bytes_to_hex(&self.0)
}
pub fn from_hex(s: &str) -> Result<Self, crate::error::CryptoError> {
hex_to_bytes(s).map(Self)
}
}
impl fmt::Debug for SignaturePqPublicKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "SignaturePqPublicKey({})", self.to_hex())
}
}
impl AsRef<[u8]> for SignaturePqPublicKey {
fn as_ref(&self) -> &[u8] {
&self.0
}
}
impl From<Vec<u8>> for SignaturePqPublicKey {
fn from(bytes: Vec<u8>) -> Self {
Self(bytes)
}
}
impl From<&[u8]> for SignaturePqPublicKey {
fn from(bytes: &[u8]) -> Self {
Self(bytes.to_vec())
}
}
impl From<&SignaturePqPublicKey> for Vec<u8> {
fn from(key: &SignaturePqPublicKey) -> Vec<u8> {
key.0.clone()
}
}
// --- Keyring --- // --- Keyring ---
@ -477,7 +208,7 @@ impl Keyring {
} }
} }
pub fn to_bytes(&self) -> Vec<u8> { pub fn to_bytes(&self) -> Zeroizing<Vec<u8>> {
let fields: &[&[u8]] = &[ let fields: &[&[u8]] = &[
self.kem_public_key.as_bytes(), self.kem_public_key.as_bytes(),
self.kem_secret_key.as_bytes(), self.kem_secret_key.as_bytes(),
@ -486,7 +217,7 @@ impl Keyring {
self.sig_cl_public_key.as_bytes(), self.sig_cl_public_key.as_bytes(),
self.sig_cl_secret_key.as_bytes(), self.sig_cl_secret_key.as_bytes(),
]; ];
let mut out = Vec::new(); let mut out = Zeroizing::new(Vec::new());
for f in fields { for f in fields {
out.extend_from_slice(&(f.len() as u16).to_be_bytes()); out.extend_from_slice(&(f.len() as u16).to_be_bytes());
out.extend_from_slice(f); out.extend_from_slice(f);
@ -549,12 +280,6 @@ impl TryFrom<&[u8]> for Keyring {
} }
} }
impl From<&Keyring> for Vec<u8> {
fn from(keyring: &Keyring) -> Vec<u8> {
keyring.to_bytes()
}
}
impl fmt::Debug for Keyring { impl fmt::Debug for Keyring {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Keyring") f.debug_struct("Keyring")
@ -787,7 +512,7 @@ mod tests {
SignaturePublicKey::new(vec![4u8; 16]), SignaturePublicKey::new(vec![4u8; 16]),
SignaturePrivateKey::new(vec![5u8; 16]), SignaturePrivateKey::new(vec![5u8; 16]),
); );
let bytes: Vec<u8> = Vec::from(&keyring); let bytes = keyring.to_bytes();
let recovered = Keyring::try_from(bytes.as_slice())?; let recovered = Keyring::try_from(bytes.as_slice())?;
assert_eq!(keyring.to_bytes(), recovered.to_bytes()); assert_eq!(keyring.to_bytes(), recovered.to_bytes());
Ok(()) Ok(())

View file

@ -22,6 +22,9 @@ pub mod enc;
pub mod helper; pub mod helper;
#[cfg(feature = "tls")]
pub mod tls;
pub use aead::{AeadCipher, AeadDecrypt, AeadEncrypt}; pub use aead::{AeadCipher, AeadDecrypt, AeadEncrypt};
pub use error::CryptoError; pub use error::CryptoError;
pub use keypair::{ pub use keypair::{
@ -85,7 +88,9 @@ mod tests {
use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305}; use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
let cipher_a = ChaCha20Poly1305::new([0xAB; 32]); let cipher_a = ChaCha20Poly1305::new([0xAB; 32]);
let cipher_b = ChaCha20Poly1305::new([0xCD; 32]); let cipher_b = ChaCha20Poly1305::new([0xCD; 32]);
let ct = cipher_a.encrypt(b"hello", b"").expect("encryption should succeed"); let ct = cipher_a
.encrypt(b"hello", b"")
.expect("encryption should succeed");
assert!(cipher_b.decrypt(&ct, b"").is_err()); assert!(cipher_b.decrypt(&ct, b"").is_err());
} }
@ -106,11 +111,15 @@ mod tests {
let (signer, sk, pk) = Ed25519Signer::generate(); let (signer, sk, pk) = Ed25519Signer::generate();
let msg = b"test message"; let msg = b"test message";
let sig = signer.sign(msg).expect("signing should succeed"); let sig = signer.sign(msg).expect("signing should succeed");
signer.verify(msg, &sig).expect("verification should succeed"); signer
.verify(msg, &sig)
.expect("verification should succeed");
verify_ed25519(&pk, msg, &sig).expect("verification should succeed"); verify_ed25519(&pk, msg, &sig).expect("verification should succeed");
let loaded = Ed25519Signer::new(&sk).expect("signer loading should succeed"); let loaded = Ed25519Signer::new(&sk).expect("signer loading should succeed");
loaded.verify(msg, &sig).expect("verification should succeed"); loaded
.verify(msg, &sig)
.expect("verification should succeed");
} }
#[cfg(feature = "ed25519-dalek")] #[cfg(feature = "ed25519-dalek")]
@ -128,11 +137,15 @@ mod tests {
let (signer, sk, pk) = MlDsaSigner::generate(); let (signer, sk, pk) = MlDsaSigner::generate();
let msg = b"test message"; let msg = b"test message";
let sig = signer.sign(msg).expect("signing should succeed"); let sig = signer.sign(msg).expect("signing should succeed");
signer.verify(msg, &sig).expect("verification should succeed"); signer
.verify(msg, &sig)
.expect("verification should succeed");
verify_ml_dsa(&pk, msg, &sig).expect("verification should succeed"); verify_ml_dsa(&pk, msg, &sig).expect("verification should succeed");
let loaded = MlDsaSigner::new(&sk, &pk).expect("signer loading should succeed"); let loaded = MlDsaSigner::new(&sk, &pk).expect("signer loading should succeed");
loaded.verify(msg, &sig).expect("verification should succeed"); loaded
.verify(msg, &sig)
.expect("verification should succeed");
} }
#[cfg(feature = "ml-dsa")] #[cfg(feature = "ml-dsa")]
@ -183,8 +196,8 @@ mod tests {
.expect("key derivation should succeed"); .expect("key derivation should succeed");
assert_eq!(key.len(), 32); assert_eq!(key.len(), 32);
let expanded = hkdf_expand(b"ikm", b"salt", b"info", 64) let expanded =
.expect("HKDF expansion should succeed"); hkdf_expand(b"ikm", b"salt", b"info", 64).expect("HKDF expansion should succeed");
assert_eq!(expanded.len(), 64); assert_eq!(expanded.len(), 64);
} }
@ -285,8 +298,7 @@ mod tests {
let kr = Keyring::generate(); let kr = Keyring::generate();
let bundle = kr.public_key_bundle(); let bundle = kr.public_key_bundle();
let bytes = bundle.as_bytes(); let bytes = bundle.as_bytes();
let loaded = let loaded = PublicKeyBundle::from_bytes(&bytes).expect("bundle roundtrip should succeed");
PublicKeyBundle::from_bytes(&bytes).expect("bundle roundtrip should succeed");
assert_eq!( assert_eq!(
bundle.kem_public_key.as_bytes(), bundle.kem_public_key.as_bytes(),
loaded.kem_public_key.as_bytes() loaded.kem_public_key.as_bytes()
@ -306,7 +318,8 @@ mod tests {
fn hybrid_kem_roundtrip() { fn hybrid_kem_roundtrip() {
let (sk, pk) = HybridKem::generate_keypair(); let (sk, pk) = HybridKem::generate_keypair();
let enc = HybridKem::encapsulate(&pk).expect("encapsulation should succeed"); let enc = HybridKem::encapsulate(&pk).expect("encapsulation should succeed");
let ss = HybridKem::decapsulate(&sk, &enc.ciphertext).expect("decapsulation should succeed"); let ss =
HybridKem::decapsulate(&sk, &enc.ciphertext).expect("decapsulation should succeed");
assert_eq!(enc.shared_secret, ss); assert_eq!(enc.shared_secret, ss);
} }

View file

@ -21,9 +21,6 @@ impl SigAlgorithm {
} }
} }
#[cfg(feature = "ed25519-dalek")]
use rand_core::RngCore;
#[cfg(feature = "ml-dsa")] #[cfg(feature = "ml-dsa")]
use crate::keypair::{SignaturePqPrivateKey, SignaturePqPublicKey}; use crate::keypair::{SignaturePqPrivateKey, SignaturePqPublicKey};
@ -50,9 +47,12 @@ impl Ed25519Signer {
Ok(Self { secret, public }) Ok(Self { secret, public })
} }
#[cfg(feature = "ed25519-dalek")]
pub fn generate() -> (Self, SignaturePrivateKey, SignaturePublicKey) { pub fn generate() -> (Self, SignaturePrivateKey, SignaturePublicKey) {
use rand::RngExt;
let mut bytes = [0u8; 32]; let mut bytes = [0u8; 32];
rand_core::OsRng.fill_bytes(&mut bytes); rand::rng().fill(&mut bytes);
let secret = ed25519_dalek::SigningKey::from_bytes(&bytes); let secret = ed25519_dalek::SigningKey::from_bytes(&bytes);
let public = secret.verifying_key(); let public = secret.verifying_key();
let priv_key = SignaturePrivateKey::new(secret.to_bytes().to_vec()); let priv_key = SignaturePrivateKey::new(secret.to_bytes().to_vec());

45
crypto/src/tls.rs Normal file
View file

@ -0,0 +1,45 @@
use rcgen::{CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, SanType};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use time::{Duration, OffsetDateTime};
use crate::CryptoError;
/// Generate a self-signed TLS certificate and private key for development.
///
/// Returns `(cert_pem, key_pem)` as byte vectors. The certificate is valid for
/// the given domain name plus `127.0.0.1` and `::1`, uses ECDSA P-256, and is
/// valid for 13 days from the time of generation.
///
/// Never panics; all errors are returned as [`CryptoError`].
pub fn generate_self_signed_cert(domain: &str) -> Result<(Vec<u8>, Vec<u8>), CryptoError> {
let key_pair = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
.map_err(|e| CryptoError::Tls(format!("key generation failed: {e}")))?;
let mut params = CertificateParams::new(vec![domain.to_string()])
.map_err(|e| CryptoError::Tls(format!("certificate params failed: {e}")))?;
params.not_before = OffsetDateTime::now_utc() - Duration::minutes(5);
params.not_after = OffsetDateTime::now_utc() + Duration::days(13);
params
.subject_alt_names
.push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
params
.subject_alt_names
.push(SanType::IpAddress(IpAddr::V6(Ipv6Addr::new(
0, 0, 0, 0, 0, 0, 0, 1,
))));
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
params.is_ca = IsCa::NoCa;
let cert = params
.self_signed(&key_pair)
.map_err(|e| CryptoError::Tls(format!("certificate signing failed: {e}")))?;
let cert_pem = cert.pem().into_bytes();
let key_pem = key_pair.serialize_pem().into_bytes();
Ok((cert_pem, key_pem))
}

48
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,48 @@
# MTP Architecture
MTP separates wire encoding, QUIC transport, connection policy, protocol negotiation, and application-facing clients.
```text
application
┌────────────────┴────────────────┐
│ │
Native client Browser SDK
mtp-client mtp + WASM
│ │
└──────────────┬──────────────────┘
│ MTP frames
┌─────────▼─────────┐
│ codec + type-map │
│ versions, values │
└─────────┬─────────┘
┌─────────▼─────────┐
│ QUIC transport │
│ framing, policy │
└─────────┬─────────┘
┌─────────────────┴─────────────────┐
│ │
MTPHost MTPWebServer
native QUIC HTTP/3 + WebTransport
│ │
└──────────────┬────────────────────┘
optional mtp-crypto
authentication and E2EE
```
`mtp-codec` owns `CommunicationValue` and `DataValue` serialization. A version-specific `TypeMap` translates generated type names to wire IDs.
`mtp-transport` writes each frame as a four-byte big-endian length followed by the frame bytes and applies message, timeout, queue, and stream limits.
The top row represents application entry points. Native Rust code calls the client or host crates directly. Browser code calls the TypeScript SDK, which uses generated WASM bindings for the same codec and WebTransport session.
Both clients exchange the same MTP frames with a host.
The middle row is shared protocol machinery. The type map determines numeric IDs, the codec serializes values, and transport framing places each serialized frame on a QUIC stream. This is why a type-map change must be compiled into both peers before the new message can be exchanged.
The bottom row shows the two server entry points. `MTPHost` owns a native QUIC endpoint. `MTPWebServer` owns an HTTP/3 endpoint that also accepts WebTransport MTP sessions. They cannot bind the same IP and port. `mtp-crypto` is an optional cross-cutting layer used by authenticated native connections and browser E2EE;
TLS remains the transport security layer in both paths.
`mtp-host` performs version negotiation and native authentication before returning an `MTPConnection`. `mtp-webserver` routes HTTP/3 requests and WebTransport sessions through its endpoint. It currently accepts only unauthenticated WebTransport MTP sessions.
The [native client](NATIVE-CLIENT.md), [WASM client](WASM-CLIENT.md), [native host](NATIVE-HOST.md), and [web server](NATIVE-HOST-WEB-SERVER.md) guides cover the public APIs for each boundary.

22
docs/CONNECTIONS.md Normal file
View file

@ -0,0 +1,22 @@
# MTP Connections
Native clients and hosts share the same connection shape after the opening handshake. The client creates the connection; the host receives it from `accept()`.
| Member | Native client | Native host |
| --- | --- | --- |
| `version` | Compiled client version accepted by the host | Version selected by the registry |
| `sender` | Sends `CommunicationValue` frames | Sends `CommunicationValue` frames |
| `receiver` | Receives application frames | Receives application frames |
| `description` | Optional label sent during setup | Optional label received from the client |
| `client_id` | Confirmed or assigned ID with `crypto` | Authenticated or guest client ID with `crypto` |
| `auth_state` | Authentication result with `crypto` | Authentication result with `crypto` |
The host connection also exposes a version-scoped `codec` and, for an authenticated client, its `client_public_key`. The native client connection also exposes these methods:
| Method | Behavior |
| --- | --- |
| `request` | Sends a frame and waits for a response with the same frame ID, subject to `request_timeout`. |
| `get_ping` | Returns the latest matched protocol Ping round-trip duration. |
| Pipe methods | Create, accept, deny, read, write, and close native pipe streams when the `pipes` feature is enabled. |
Connection lifecycle and keepalive behavior are defined in [Protocol Reference](PROTOCOL-REFERENCE.md). Pipe dispatch rules are in [Pipes](PIPES.md). Closing or dropping the connection stops its background tasks and closes the underlying QUIC session.

View file

@ -60,34 +60,10 @@ while let Some(conn) = host.accept().await? {
} }
``` ```
The host's `accept()` method: The host reads the reserved opening frame, extracts `DataType::Version`, calls `registry.negotiate`, and returns `AcceptError::UnsupportedVersion` when no registered version matches.
1. Accepts a QUIC connection
2. If authentication is required (crypto feature): performs login/register handshake
3. Reads the first `CommunicationValue` (always encoded with reserved type IDs)
4. Extracts the client's protocol version from `DataType::Version` (reserved data type ID 0)
5. Calls `registry.negotiate(&[client_version])`
6. Returns an `AcceptError` if the version is unsupported
7. Returns `Ok(Some(MTPConnection))` with the negotiated version otherwise
### Login/Register Handshake Authentication follows the version-bearing hello when the host enables it.
The sequence is defined in [Protocol Reference](PROTOCOL-REFERENCE.md).
When `authentication_policy` is `ForceAuthentication` or `AllowAuthentication`,
the parties run a mutually-authenticated
**challenge-response**. The client speaks first with an *unsigned* hello:
- **Login** (`CommunicationType::Identification`, reserved ID 0): version, client ID
- **Register** (`CommunicationType::Register`, reserved ID 2): version, public keys
The host then issues a fresh random `server_challenge` in a signed `Challenge`
(`CommunicationType::Challenge`, reserved ID 4, carrying `ServerNonce`). The client signs
that challenge, binding its id (login) or public keys (register), and returns a
`ChallengeResponse` (reserved ID 5). The host verifies the proof against the challenge it
issued and sends a signed final response, which the client verifies.
Because the client's proof covers the host-issued `server_challenge` (a one-time
value held only on the accepting task's stack), a captured proof cannot be
replayed on another connection. All signed payloads are domain-separated; see
`mtp::crypto::auth`.
--- ---
@ -111,7 +87,7 @@ let conn = MTPClient::auth_connect(pinned.with_client_id(8765), &keys, &host_pk)
let conn = MTPClient::auth_register(config, &keys, &host_pk).await?; let conn = MTPClient::auth_register(config, &keys, &host_pk).await?;
``` ```
The client's `PROTOCOL_VERSION` constant is set by `protocol_version` in `type-maps.yaml` and baked in at compile time. The client never imports the `registry` crate; it only uses `mtp::type_map` for enum types and `mtp::codec` for encoding. The client's `PROTOCOL_VERSION` constant is set by `protocol_version` in `type-maps.yaml` and baked in at compile time. The client uses one version and does not import the registry.
--- ---
@ -132,13 +108,22 @@ Client (v2.0) Host (v0.0, v1.0, v2.0)
| | registry.negotiate(&[Version(2,0)]) | | registry.negotiate(&[Version(2,0)])
| | -> Some(Version(2,0)) | | -> Some(Version(2,0))
| | | |
| Response | | Response | selected v2.0 TypeMap
|<-----------------------| (uses v2.0 TypeMap for encoding) |<-----------------------|
| Status, Nonces, | | Status, version |
| Signature |
| | | |
| (subsequent messages | | subsequent messages |
| use v2.0 TypeMap) | | use v2.0 TypeMap |
``` ```
If the client sends an unsupported version (e.g. v3.0 when the host only knows up to v2.0), `negotiate` returns `None` and the connection is closed. If the client sends an unsupported version (e.g. v3.0 when the host only knows up to v2.0), `negotiate` returns `None` and the connection is closed.
## Protocol Ping and Pong
See [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive).
## Protocol Version Changes
Add a protocol version by adding its type-map entry and `protocol_version` to the YAML configuration, then rebuild both peers. The type-map build script generates a version-specific `TypeMap` and keeps the enum as the union of all configured type names.
For a backward-compatible change, keep existing communication and data IDs stable and add new types with the new version. For a breaking change, add a new version and register both versions on the host while clients migrate. A client compiles one protocol version; it can connect only when that version is present in the host registry. Remove an old version only after its clients no longer connect, because the host closes connections whose version is unsupported.

46
docs/ERRORS.md Normal file
View file

@ -0,0 +1,46 @@
# Error Reference
MTP reports codec failures separately from connection and transport failures.
## CodecError
| Variant | Meaning |
| --- | --- |
| `UnknownVersion` | A codec was asked to use an unsupported protocol version. |
| `UnknownCommunicationType` | A communication type has no mapping in the selected type map. |
| `UnknownDataType` | A data type has no mapping in the selected type map. |
| `ReservedCommunicationType` | An application attempted to use a reserved communication type ID. |
| `InvalidEncoding` | Bytes do not match the MTP value or frame format. |
| `TooManyEntries` | A serialized value or frame exceeds its representable size. |
| `CryptoFailed` | Signing, verification, encryption, or decryption failed while encoding or decoding. |
| `MissingField` | A required typed field is absent. |
An application should select a `TypeMap` for the negotiated version and treat `UnknownCommunicationType` and `UnknownDataType` as a type-map compatibility failure. Do not send the unmapped variant again on that connection.
Retry guidance: retry `ConnectionLost`, `ConnectingError`, and transient stream errors after applying backoff. Correct the request before retrying `MessageTooLarge`, `InvalidEncoding`, `MissingField`, and type-map errors.
Retry `AuthenticationFailed` only after changing credentials or host policy;
repeating the same proof does not repair a validation failure.
## CommunicationError
| Variant | Typical cause |
| --- | --- |
| `UseAfterClosed` | A send or receive operation ran after the connection handle was closed. |
| `ClosedLocally` | The local endpoint initiated shutdown. |
| `ClosedByPeer` | The peer closed the connection. |
| `ConnectionLost` | The connection ended without a normal close. |
| `ParseCommunicationValue` or `ParseError` | An incoming frame or certificate could not be parsed. |
| `Encode` | An outgoing value could not be serialized. |
| `MessageTooLarge` | A frame exceeds `Policy::max_message_size` or the handshake limit. |
| `StreamClosed` or `StreamError` | A QUIC stream ended or returned an I/O error. |
| `ConnectingError` or `ConnectionError` | The endpoint could not establish or maintain QUIC. |
| `AuthenticationFailed` | A login, registration, signature, nonce, or host response failed validation. |
| `CertificateParseFailed` or `CertificateLoadFailed` | TLS certificate input is malformed or unavailable. |
| `CryptoProviderInstallFailed` | The native TLS crypto provider could not be installed. |
| `Other` | A component returned an error without a more specific variant. |
Native builds may expose additional variants wrapping QUIC and WebTransport errors. WASM builds expose the transport-independent subset.
## Authentication Rejections
The host reports unsupported or missing protocol versions through `AcceptError`. Authentication failures return `AcceptError::AuthenticationFailed` after the host sends a rejected handshake response. The authentication flow and its signed fields are defined in [Security](SECURITY.md).

View file

@ -2,22 +2,27 @@
The native client is a Rust library (`mtp-client`) for connecting to an MTP host over QUIC. It uses `wtransport` under the hood and provides both unauthenticated and authenticated (crypto handshake) connection modes. The native client is a Rust library (`mtp-client`) for connecting to an MTP host over QUIC. It uses `wtransport` under the hood and provides both unauthenticated and authenticated (crypto handshake) connection modes.
## Cargo Dependency ## Prerequisites
Add the `mtp` umbrella crate with the `client` feature (and optionally `crypto` for authentication): Add the `mtp` umbrella crate with `client`. Add `crypto` for authenticated connections, `pipes` for raw streams, and `tls` for development certificate generation. The `insecure-tls` feature applies only to the lower-level transport API. The feature table is in the [README](../README.md).
```toml ## Quick Start
[dependencies]
mtp = { path = "/path/to/mtp", features = ["client"] }
# Add crypto for auth_connect / auth_register: ```rust
mtp = { path = "/path/to/mtp", features = ["client", "crypto"] } use mtp::client::{ClientConfig, MTPClient};
use mtp::codec::{CommunicationType, CommunicationValue};
# Add pipes for raw binary streams: let conn = MTPClient::connect(
mtp = { path = "/path/to/mtp", features = ["client", "pipes"] } ClientConfig::new("https://host.example.com:4433").with_client_id(42),
).await?;
let request = CommunicationValue::new(CommunicationType::Ping).with_id(1);
conn.sender.send(&request).await?;
let response = conn.receive().await?;
println!("received {}", response.get_id());
conn.sender.close();
``` ```
## ClientConfig ## Configuration
```rust ```rust
use mtp::client::{ClientConfig, ClientTlsConfig}; use mtp::client::{ClientConfig, ClientTlsConfig};
@ -42,66 +47,39 @@ let config = ClientConfig::new("https://host.example.com:4433")
| `ping_jitter` | `Option<Duration>` | `None` | Random jitter added to each interval | | `ping_jitter` | `Option<Duration>` | `None` | Random jitter added to each interval |
| `max_missed_pings` | `usize` | `3` | Disconnect after this many unanswered Pings | | `max_missed_pings` | `usize` | `3` | Disconnect after this many unanswered Pings |
| `ping_timestamp` | `bool` | `true` | Include a `Timestamp` data entry in Ping | | `ping_timestamp` | `bool` | `true` | Include a `Timestamp` data entry in Ping |
| `request_timeout` | `Duration` | `30s` | Max time for `MTPConnection::request` |
| `auth_timeout` (crypto) | `Duration` | `30s` | Max time for auth handshake | | `auth_timeout` (crypto) | `Duration` | `30s` | Max time for auth handshake |
| `require_pq` (crypto) | `bool` | `true` | Require ML-DSA-65 during authentication |
### TLS Certificate Handling ### TLS Certificate Handling
When `tls` is `ClientTlsConfig::SystemRoots` (the default), the client loads the **system's `ClientTlsConfig::SystemRoots` is the default. Use `ClientTlsConfig::PinnedPem` or `ClientConfig::with_pinned_pem` for a supplied certificate chain. SPKI pinning and development or insecure transport configuration are available through lower-level transport APIs. See [Security](SECURITY.md) for trust models, certificate generation, rotation, and the insecure-mode gates.
native root certificate store** via `rustls_native_certs`. This works with
publicly-trusted CAs out of the box on Linux (using `openssl-probe`), macOS
(Keychain), and Windows (Root Store).
For development or self-signed certificates, provide one or more PEM-encoded ## Connecting
certificates:
```rust
let pem = std::fs::read("my-server-cert.pem")?;
let config = ClientConfig::new("https://host.example.com:4433").with_pinned_pem(pem);
```
When pinned, **only** the given certificate(s) are trusted for the TLS
handshake.
## Connection Methods
All methods return a `Result<MTPConnection, CommunicationError>`. All methods return a `Result<MTPConnection, CommunicationError>`.
### MTPConnection ### MTPConnection
Shared fields and lifecycle: [MTP Connections](CONNECTIONS.md).
Keepalive behavior is defined in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive).
### Requests
`MTPConnection::request` sends a `CommunicationValue` and waits for a response with the same frame ID. It uses `ClientConfig::request_timeout`; timeout and connection errors reject the request.
The request must have a non-zero ID. The response is removed from the pending request table and is not returned by a later `conn.receive()` call. A timeout removes the pending request and returns `CommunicationError`; a response with the wrong expected type also returns an error. Frames with other IDs remain available through `conn.receive()`.
```rust ```rust
pub struct MTPConnection { let response = conn
pub version: Version, .request(&request_value, Some(CommunicationType::Pong))
pub sender: Sender, .await?;
pub receiver: Receiver,
pub description: Option<String>,
#[cfg(feature = "crypto")]
pub auth_state: AuthState,
#[cfg(feature = "crypto")]
pub client_id: u64,
}
``` ```
- `version` -- the negotiated protocol version ### Protocol keepalive
- `sender` / `receiver` -- for message I/O
- `description` -- the label sent during handshake (set via `ClientConfig::with_description`)
- `client_id` -- the confirmed/assigned client identifier (crypto only)
When `ping_interval` is non-zero, MTP sends Ping frames in the background and Enable it with `ClientConfig` and inspect the latest matched round-trip time with `get_ping()`. See [Protocol Reference](PROTOCOL-REFERENCE.md).
consumes their Pong responses before application message handling. `get_ping()`
returns the round-trip duration of the latest matched Pong, or `None` until a
Pong arrives. A connection closes when the configured unanswered Ping limit is
reached.
### Ping-Pong
Ping/Pong is part of the protocol, not just a transport keepalive. Each Ping
frame is matched against a Pong with the same frame id, and the client uses the
response to update `get_ping()`. If the host does not answer within the
configured limit, the connection closes.
Enable it in `ClientConfig`, then inspect the latest round-trip time on the
connection. Pings start after the connection has been established; `None` is
normal until the first matching Pong arrives.
```rust ```rust
use mtp::client::{ClientConfig, MTPClient}; use mtp::client::{ClientConfig, MTPClient};
@ -120,11 +98,7 @@ if let Some(round_trip) = conn.get_ping() {
} }
``` ```
The client consumes the Pong frames used by this loop, so they are not returned Pong dispatch and missed-Ping behavior are defined in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive). Set `ping_interval` to `Duration::ZERO` (the default) to disable protocol pings.
by `conn.receiver.receive()`. Set `ping_interval` to `Duration::ZERO` (the
default) to disable protocol pings. `max_missed_pings` is the number of
outstanding Ping frames allowed before the client closes the connection; use a
host with automatic Pong responses, or provide an equivalent responder.
### Unauthenticated Connect ### Unauthenticated Connect
@ -136,8 +110,7 @@ let config = ClientConfig::new("https://host.example.com:4433").with_client_id(4
let conn = MTPClient::connect(config).await?; let conn = MTPClient::connect(config).await?;
``` ```
Sends an `Identification` frame with the compiled-in protocol version and Sends an `Identification` frame with the compiled-in protocol version and client ID. No cryptographic handshake is performed.
client ID. No cryptographic handshake is performed.
### Authenticated Login ### Authenticated Login
@ -154,21 +127,7 @@ let config = ClientConfig::new("https://host.example.com:4433")
let conn = MTPClient::auth_connect(config, &keys, &host_pk).await?; let conn = MTPClient::auth_connect(config, &keys, &host_pk).await?;
``` ```
Protocol (challenge-response, the host issues the freshness): Authentication uses the signed challenge flow in [Protocol Reference](PROTOCOL-REFERENCE.md#authentication-flow). Cryptographic fields and domain separation are defined in [Security](SECURITY.md).
1. Client sends an unsigned `Identification` hello (version, client ID)
2. Host replies with a `Challenge` carrying a fresh random `server_challenge`
and the host's signature over it; the client verifies that signature
3. Client generates a random `client_nonce` and signs
`version || client_id || server_challenge || client_nonce` with Ed25519
(and optionally ML-DSA-65)
4. Client sends a `ChallengeResponse` frame (nonce + signature(s))
5. Host verifies the proof against `server_challenge` and responds with
`IdentificationResponse` (echoed nonce + host signature)
6. Client verifies the host signature and nonce echo
Because the client's signature covers the host-issued `server_challenge`, a
captured proof cannot be replayed on another connection (each connection gets a
different challenge).
### Registration ### Registration
@ -186,8 +145,7 @@ let id = conn.client_id;
let keyring_bytes = keyring.to_bytes(); let keyring_bytes = keyring.to_bytes();
``` ```
When callers already know whether a saved client id exists, the convenience When callers already know whether a saved client ID exists, the convenience helper uses `Some(id)` for login and `None` for registration:
helper chooses login or registration:
```rust ```rust
let conn = MTPClient::auth_connect_or_register( let conn = MTPClient::auth_connect_or_register(
@ -198,17 +156,7 @@ let conn = MTPClient::auth_connect_or_register(
).await?; ).await?;
``` ```
Protocol (challenge-response): Registration uses the authentication flow in [Protocol Reference](PROTOCOL-REFERENCE.md#authentication-flow).
1. Client sends an unsigned `Register` hello (version, public key bundle)
2. Host replies with a `Challenge` carrying a fresh random `server_challenge`
(signed by the host); the client verifies that signature
3. Client generates a random `client_nonce` and signs
`version || server_challenge || client_nonce || public_key_bytes` with
Ed25519 (and optionally ML-DSA-65)
4. Client sends a `ChallengeResponse` frame (nonce + signature(s))
5. Host verifies the proof against `server_challenge`, assigns a new client ID,
and responds with `RegisterResponse` (the ID, echoed nonce, host signature)
6. Client verifies the host signature and nonce echo
## Key Material ## Key Material
@ -233,8 +181,7 @@ pub struct Keyring {
### PublicKeyBundle ### PublicKeyBundle
The public half of a keyring, used by the host for signature verification and The public half of a keyring, used by the host for signature verification and by the client for host signature verification:
by the client for host signature verification:
```rust ```rust
pub struct PublicKeyBundle { pub struct PublicKeyBundle {
@ -244,10 +191,11 @@ pub struct PublicKeyBundle {
} }
``` ```
Obtain the host's `PublicKeyBundle` out of band (e.g. from files exported by Obtain the host's `PublicKeyBundle` out of band (e.g. from files exported by the host, or from a trusted directory).
the host, or from a trusted directory).
## Sending and Receiving Messages ## Communicate
### Sending and Receiving Messages
### CommunicationValue ### CommunicationValue
@ -264,9 +212,7 @@ let msg = CommunicationValue::new(CommunicationType::Ping)
.to_bytes(); .to_bytes();
``` ```
When the `registry` feature is enabled (via the `host` feature), you can also When the `registry` feature is enabled (via the `host` feature), you can also use `add_typed` with a `TypeMap` to resolve data type names from your project's type-map configuration.
use `add_typed` with a `TypeMap` to resolve data type names from your project's
type-map configuration.
### Send ### Send
@ -274,9 +220,7 @@ type-map configuration.
conn.sender.send(&msg).await?; conn.sender.send(&msg).await?;
``` ```
For request/response flows, `MTPConnection::request` sends one frame and waits For request/response flows, `MTPConnection::request` sends one frame and waits for a response with the same non-zero frame id. An expected response type can be provided for validation:
for a response with the same non-zero frame id. An expected response type can be
provided for validation:
```rust ```rust
let response = conn let response = conn
@ -284,24 +228,22 @@ let response = conn
.await?; .await?;
``` ```
Frames with other ids are consumed by this helper. Applications that need Requests are routed by id through the connection's receive dispatcher. Frames with other ids remain available through `conn.receive()`.
subscriptions or broad routing should use one receive task and correlate there.
Two send modes (configured via `mtp::transport::Policy`): Two send modes (configured via `mtp::transport::Policy`):
- `PersistentStream` (default) -- reuses one QUIC uni-directional stream - `PersistentStream` (default): reuses one QUIC unidirectional stream
- `SingleStreamPerMessage` -- opens a new stream per message - `SingleStreamPerMessage`: opens a new stream per message
### Receive ### Receive
```rust ```rust
match conn.receiver.receive().await { match conn.receive().await {
Ok(msg) => { /* handle CommunicationValue */ } Ok(msg) => { /* handle CommunicationValue */ }
Err(e) => { /* connection closed or error */ } Err(e) => { /* connection closed or error */ }
} }
``` ```
Inbound frames are queued internally. The `receive()` method returns the next Inbound frames are queued internally. The `receive()` method returns the next available message. Do not read from `conn.receiver` directly because the connection dispatcher owns the shared transport receive loop.
available message.
### Close ### Close
@ -311,121 +253,15 @@ conn.sender.close();
conn.receiver.close(); conn.receiver.close();
``` ```
Sends a close frame and signals the peer. The `Sender::close()` spawns an async Sends a close frame and signals the peer. The `Sender::close()` spawns an async task that sends the frame, waits for `force_close_delay` (default 300ms), then force-closes the QUIC connection if the peer has not already done so.
task that sends the frame, waits for `force_close_delay` (default 300ms), then
force-closes the QUIC connection if the peer has not already done so.
## Pipes ### Pipes
With the `pipes` feature enabled, the client can open **raw binary streams** The complete pipe protocol, native API, browser API, lifecycle, and errors are documented in [Pipes](PIPES.md). Use the connection facade described there when the `pipes` feature is enabled.
to the host. A Pipe is a unidirectional QUIC stream that carries a lightweight
`PipeRequest` handshake frame, then transitions to raw bytes with zero per-frame
overhead.
### Enabling Pipes ## Appendix: Crypto Containers
Add the `pipes` feature to your dependency: With the `crypto` feature, `DataValue` supports encrypted, signed, and signed+encrypted containers. Encryption uses ML-KEM to encapsulate to a recipient's KEM public key (from their `PublicKeyBundle`); only the holder of the matching `Keyring` can decrypt. Signing uses the sender's Ed25519 key.
```toml
[dependencies]
mtp = { path = "/path/to/mtp", features = ["client", "pipes"] }
```
### Creating a Pipe
```rust
use mtp::client::MTPClient;
use tokio::io::AsyncWriteExt;
let conn = MTPClient::connect(config).await?;
// Initiate a pipe request
let handle = conn.create_pipe("file-transfer").await?;
// Wait for the host to accept or reject
match handle.wait().await? {
Some(mut writer) => {
writer.write_all(b"raw binary data").await?;
writer.finish().await?; // graceful close
}
None => {
println!("host rejected the pipe");
}
}
```
### PipeHandle
```rust
pub struct PipeHandle {
pipe_id: u32,
description: String,
}
```
| Method | Returns | Description |
|--------|---------|-------------|
| `wait()` | `Result<Option<PipeWriter>, PipeError>` | Block until the host responds. `Some(writer)` if accepted, `None` if rejected. |
`PipeHandle` consumes itself on `wait()`, so you cannot poll it multiple times.
### PipeWriter
```rust
pub struct PipeWriter {
// wraps a QUIC SendStream
}
```
`PipeWriter` implements `tokio::io::AsyncWrite`. After the handshake succeeds,
writes go directly to the QUIC stream with no framing overhead.
| Method | Returns | Description |
|--------|---------|-------------|
| `finish()` | `Result<(), CommunicationError>` | Gracefully close the stream (sends FIN) |
| `abort()` | `Result<(), ClosedStream>` | Abruptly reset the stream |
```rust
use tokio::io::AsyncWriteExt;
let mut writer = handle.wait().await?.unwrap();
writer.write_all(b"chunk 1").await?;
writer.write_all(b"chunk 2").await?;
writer.finish().await?;
```
### PipeError
```rust
pub enum PipeError {
Rejected, // pipe request was rejected
HandshakeTimeout, // pipe handshake timed out
StreamClosed, // pipe stream closed unexpectedly
IoError(String), // pipe I/O error
ConnectionClosed, // connection closed
}
```
`PipeError` implements `std::error::Error` and can be converted from
`CommunicationError` via `PipeError::from()`.
### Do Not Use `receiver.receive()` for Pipes
When the `pipes` feature is active, `conn.receiver.receive()` will **skip**
`PipeResponse` frames and may return them as ordinary messages if called from
the wrong task. Use the facade methods:
- `conn.receive()` to receive normal `CommunicationValue` messages
- `conn.create_pipe(description)` to initiate a new pipe
These methods are internally synchronised and safe to call from separate tasks.
## Crypto Containers
With the `crypto` feature, `DataValue` supports encrypted, signed, and
signed+encrypted containers. Encryption uses ML-KEM to encapsulate to a
recipient's KEM public key (from their `PublicKeyBundle`); only the holder of
the matching `Keyring` can decrypt. Signing uses the sender's Ed25519 key.
```rust ```rust
use mtp::crypto::{EncryptionType, Ed25519Signer, SigAlgorithm}; use mtp::crypto::{EncryptionType, Ed25519Signer, SigAlgorithm};
@ -454,10 +290,10 @@ let mut sec = DataValue::Container(vec![
]); ]);
sec.sign_and_encrypt_container(SigAlgorithm::ED25519, &signer, enc_type, &recipient, b"aad"); sec.sign_and_encrypt_container(SigAlgorithm::ED25519, &signer, enc_type, &recipient, b"aad");
``` ```
> Note: DataTypeId(1) maps intenally to the reserved DataType::Id, uncareful work with reserved DataTypes & CommunicationTypes (0 - 31) may lead to unexpected behaviour.
> Prefer registring your own.
On the receiving side, the recipient decrypts with its own `Keyring` (each blob On the receiving side, the recipient decrypts with its own `Keyring` (each blob is self-describing: its leading byte selects the algorithm and the matching KEM key from the keyring):
is self-describing: its leading byte selects the algorithm and the matching KEM
key from the keyring):
```rust ```rust
enc.decrypt_into_container(&keyring, b"aad"); // -> Container enc.decrypt_into_container(&keyring, b"aad"); // -> Container
@ -465,7 +301,7 @@ sig.verify_into_container(&verifier); // verifier: impl S
sec.decrypt_signed_encrypted_container(&keyring, b"aad"); // -> SignedContainer, then verify_into_container sec.decrypt_signed_encrypted_container(&keyring, b"aad"); // -> SignedContainer, then verify_into_container
``` ```
## Policy Configuration ### Policy Configuration
The `Policy` struct controls transport behaviour: The `Policy` struct controls transport behaviour:
@ -474,7 +310,8 @@ use mtp::transport::{Policy, SendMode};
let policy = Policy { let policy = Policy {
send_mode: SendMode::PersistentStream, send_mode: SendMode::PersistentStream,
max_message_size: 1_000_000_000, max_message_size: 16 * 1024 * 1024,
handshake_max_message_size: 64 * 1024,
open_stream_timeout: Duration::from_millis(2000), open_stream_timeout: Duration::from_millis(2000),
write_timeout: Duration::from_millis(2000), write_timeout: Duration::from_millis(2000),
read_timeout: Duration::from_millis(30_000), read_timeout: Duration::from_millis(30_000),
@ -484,41 +321,20 @@ let policy = Policy {
}; };
``` ```
To apply a custom policy, call `mtp_transport::connect()` directly instead of Apply a custom policy with `ClientConfig::with_policy`:
using `MTPClient`:
```rust ```rust
use mtp_transport::{connect, Policy}; let config = config.with_policy(policy);
let conn = MTPClient::connect(config).await?;
let server_cert = match &config.tls {
ClientTlsConfig::SystemRoots => None,
ClientTlsConfig::PinnedPem(pem) => Some(pem.clone()),
};
let (sender, receiver) = connect(&config.url, server_cert, policy).await?;
``` ```
Then build and send the initial `Identification` frame manually to complete ### Version
version negotiation.
## Version The client's protocol version is baked in at compile time via the `PROTOCOL_VERSION` constant from `mtp::codec`. The version is set by the `protocol_version` field in your `type-maps.yaml`.
The client's protocol version is baked in at compile time via the The client never imports the `registry` module; it uses a single compiled-in version and expects the host to negotiate a compatible version.
`PROTOCOL_VERSION` constant from `mtp::codec`. The version is set by the
`protocol_version` field in your `type-maps.yaml`.
The client never imports the `registry` module; it uses a single compiled-in ### Error Handling
version and expects the host to negotiate a compatible version.
## Error Handling `CommunicationError` is summarized in the [Error Reference](ERRORS.md).
Native builds can expose additional variants that wrap QUIC and WebTransport errors.
`CommunicationError` covers transport errors:
| Variant | Meaning |
|-------------------------|--------------------------------------------|
| `StreamClosed` | Connection was closed by peer or timed out |
| `StreamError` | Transport-level I/O error |
| `MessageTooLarge` | Frame exceeds `max_message_size` |
| `ParseCommunicationValue` | Failed to deserialize incoming frame |
| `AuthenticationFailed` | Nonce mismatch or invalid host signature |
| `ConnectionError` | QUIC connection failure |
| `UseAfterClosed` | Attempted send/receive after close |

View file

@ -0,0 +1,99 @@
# MTP Web Server
`MTPWebServer` serves ordinary HTTP/3 routes and WebTransport MTP sessions through one QUIC endpoint. HTTP/3 requests are handled inside the server;
WebTransport sessions are returned by `accept()` for application messages.
`MTPWebServer` and `MTPHost` cannot bind the same IP and port.
## WebServerConfig
| Builder | Default | Purpose |
| --- | --- | --- |
| `route(path, handler)` | None | Register an exact-path HTTP/3 handler. |
| `route_method(method, path, handler)` | None | Register a method-specific handler. |
| `fallback(handler)` | None | Handle requests that match no route. |
| `mtp_path(path)` | `/` | Path for WebTransport extended CONNECT. |
| `max_request_body(bytes)` | 4 MiB | Maximum buffered HTTP/3 request body. |
| `max_connections(count)` | 256 | Maximum concurrent HTTP/3 connections. |
| `request_timeout(duration)` | 30 seconds | HTTP/3 request handling timeout. |
| `drain_timeout(duration)` | 10 seconds | Shutdown drain period. |
| `with_metrics(metrics)` | None | Receive connection, request, and error callbacks. |
The route and fallback builders return `Result` because duplicate routes and duplicate fallback handlers are rejected.
## HTTP/3 Requests and Responses
`Http3Request` contains `method`, `uri`, `headers`, and an optional buffered `body` represented by `bytes::Bytes`. `Http3Response::status`, `header`, and `body` build a buffered response. `try_header` returns an error for invalid header names or values. `stream` takes a `tokio::sync::mpsc::Receiver<Bytes>` for incremental response chunks.
```rust
use bytes::Bytes;
use http::{Method, StatusCode};
use tokio::sync::mpsc;
use mtp::webserver::{Http3Request, Http3Response, WebServerConfig};
async fn health(_request: Http3Request, response: Http3Response) -> Http3Response {
response.status(StatusCode::OK).body("ok")
}
async fn stream_numbers(_request: Http3Request, response: Http3Response) -> Http3Response {
let (tx, rx) = mpsc::channel::<Bytes>(10);
tokio::spawn(async move {
for number in 0..10 {
if tx.send(Bytes::from(format!("{number}\n"))).await.is_err() {
break;
}
}
});
response
.status(StatusCode::OK)
.header("content-type", "text/plain")
.stream(rx)
}
let web = WebServerConfig::new()
.route("/health", health)?
.route_method(Method::GET, "/numbers", stream_numbers)?
.fallback(|_request, response| async move {
response.status(StatusCode::NOT_FOUND).body("not found")
})?
.mtp_path("/mtp");
```
## Starting and Accepting MTP Sessions
```rust
use mtp::{host::HostConfig, webserver::MTPWebServer};
let host = HostConfig::new(
"0.0.0.0".parse()?,
4433,
std::fs::read("cert.pem")?,
std::fs::read("key.pem")?,
);
let mut server = MTPWebServer::new(host, web).await?;
while let Some(connection) = server.accept().await? {
// connection: WebMTPConnection
while let Ok(message) = connection.receiver.receive().await {
println!("received MTP message {}", message.get_id());
}
}
```
`server.accept()` returns `Option<WebMTPConnection>` for each WebTransport session. HTTP/3 routes do not surface through `accept()` because the server dispatches them internally. `WebMTPConnection` retains the negotiated version, codec, request path, description, sender, and receiver used by native MTP connections.
WebTransport sessions are unauthenticated. With the `crypto` feature enabled, construction rejects any `AuthenticationPolicy` other than `Unauthenticated`. The connection has `AuthState::Unauthenticated` and a random 48-bit client ID when crypto fields are compiled in; `guest_id_generator` is not used by this adapter.
## Errors
`MTPWebServer::new` returns `CommunicationError` for certificate parsing, certificate loading, bind failures, and rejected authentication policy.
`accept()` returns `AcceptError` for a missing or unsupported version, a receive failure, or a send failure during the WebTransport opening handshake. HTTP route failures are reported through `WebServerMetrics::error_occurred` when metrics are configured. See [Errors](ERRORS.md) for shared error variants.
`WebServerMetrics` has these callbacks:
```rust
fn request_started(&self, path: &str)
fn request_completed(&self, path: &str, status: u16, duration: Duration)
fn error_occurred(&self, error: &WebServerError)
```
Errors include route misses, invalid requests, body-limit failures, handler timeouts, response construction failures, and transport failures. Supply the metrics object with `WebServerConfig::with_metrics`.

View file

@ -1,48 +1,21 @@
# MTP Native Host # MTP Native Host
The native host is a Rust library (`mtp-host`) that runs a QUIC server, accepts The native host is a Rust library (`mtp-host`) that runs a QUIC server, accepts MTP client connections, negotiates protocol versions, and optionally performs a mutual-authentication handshake (login/register) using Ed25519 and ML-DSA-65 signatures.
MTP client connections, negotiates protocol versions, and optionally performs a
mutual-authentication handshake (login/register) using Ed25519 and ML-DSA-65
signatures.
## Cargo Dependency ## Cargo Dependency
```toml Add the `mtp` umbrella crate with `host`. Add `crypto` for authenticated connections and `pipes` for raw streams. The feature table is in the [README](../README.md).
[dependencies]
mtp = { path = "/path/to/mtp", features = ["host"] }
# Add crypto for authenticated connections:
mtp = { path = "/path/to/mtp", features = ["host", "crypto"] }
# Add pipes for raw binary streams:
mtp = { path = "/path/to/mtp", features = ["host", "pipes"] }
```
## HostConfig ## HostConfig
```rust `HostConfig::new` takes the bind address, port, PEM certificate chain, and PEM private key. Configure authentication and transport behavior with builders:
use mtp::host::HostConfig;
use std::net::{IpAddr, Ipv4Addr};
let config = HostConfig::new( ```rust
IpAddr::V4(Ipv4Addr::UNSPECIFIED), let config = HostConfig::new(ip, port, certificate, private_key)
4433, .with_pongs(true)
std::fs::read("cert.pem")?, .with_policy(Policy::default())
std::fs::read("key.pem")?, .with_authentication(host_keyring, get_existing_client, complete_register)
) .with_authentication_policy(AuthenticationPolicy::ForceAuthentication);
.with_authentication(
/* Keyring */,
|client_id: u64| {
let db = CLIENT_DB.clone();
Box::pin(async move { db.lock().unwrap().get(&client_id).cloned() })
},
|bundle: PublicKeyBundle| {
let mut db = CLIENT_DB.lock().unwrap();
let id = next_id();
db.insert(id, bundle);
Box::pin(async move { id })
},
);
``` ```
| Field | Type | Description | | Field | Type | Description |
@ -54,61 +27,37 @@ let config = HostConfig::new(
| `send_pongs` | `bool` | Sends a Pong for each received Ping (default `true`) | | `send_pongs` | `bool` | Sends a Pong for each received Ping (default `true`) |
| `authentication_policy` | `AuthenticationPolicy` (crypto) | `ForceAuthentication`, `AllowAuthentication`, or `Unauthenticated` | | `authentication_policy` | `AuthenticationPolicy` (crypto) | `ForceAuthentication`, `AllowAuthentication`, or `Unauthenticated` |
| `host_keyring` | `Keyring` (crypto) | Host's signing and KEM keys | | `host_keyring` | `Keyring` (crypto) | Host's signing and KEM keys |
| `get_existing_user` | `Fn(u64) -> Pin<Box<dyn Future<Output = Option<PublicKeyBundle>> + Send>> + Send + Sync` (crypto) | Async lookup callback for login | | `get_existing_client` | Async callback returning `Option<PublicKeyBundle>` | Receives `(client_id, description)`. `Some` supplies the stored key bundle. `description = None` is used for guest-ID collision checks. |
| `complete_register` | `Fn(PublicKeyBundle) -> Pin<Box<dyn Future<Output = u64> + Send>> + Send + Sync` (crypto) | Async registration callback, returns new client ID | | `guest_id_generator` | Async callback returning `Option<u64>` | Custom guest ID assignment. The default generates random IDs. |
| `complete_register` | Async callback returning `u64` | Stores the public bundle and returns its assigned client ID. |
### AuthenticationPolicy ### AuthenticationPolicy
`ForceAuthentication` requires every client to complete the login/register handshake. `AllowAuthentication` accepts both authenticated and unauthenticated connections — unauthenticated clients get a random ID and `AuthState::Unauthenticated`. `Unauthenticated` rejects any client that tries to authenticate and is the default. `ForceAuthentication` requires every client to complete the login or registration handshake. `AllowAuthentication` accepts both authenticated and unauthenticated connections; unauthenticated clients receive an ID and `AuthState::Unauthenticated`. `Unauthenticated` rejects authentication attempts and is the default.
Authentication policy details are in [Security](SECURITY.md).
```rust
use mtp::host::AuthenticationPolicy;
// Force authentication (default was `require_authentication: true`):
let config = HostConfig::new(ip, port, cert, key)
.with_authentication(host_keyring, get_user, register);
// Allow both authenticated and unauthenticated:
let config = HostConfig::new(ip, port, cert, key)
.with_allow_authentication(host_keyring, get_user, register);
// Unauthenticated only (default):
let config = HostConfig::new(ip, port, cert, key);
```
### TLS ### TLS
The host requires a TLS certificate. For development, generate a self-signed `HostConfig::new` always uses the certificate and key supplied by the caller.
certificate using `rcgen`. For production, use a CA-signed certificate. Certificate trust and development settings are in [Security](SECURITY.md).
### Ping-Pong ### Ping-Pong
The host handles protocol Ping/Pong automatically unless you disable it with Keepalive behavior is defined in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive).
`with_pongs(false)`. Enable the default responder explicitly when constructing
the host if you want to make the choice visible in application configuration:
```rust ```rust
let config = HostConfig::new(ip, port, cert, key) let config = HostConfig::new(ip, port, cert, key)
.with_pongs(true); .with_pongs(true);
``` ```
For every received Ping, the responder sends a Pong with the same frame id and Disable automatic responses only when the application needs to handle Ping frames itself:
copies the optional `Timestamp` data entry. Ping and Pong frames handled this
way are not delivered by `conn.receiver.receive()`. This lets native clients
use `ClientConfig::with_ping_interval` and `MTPConnection::get_ping()` without
adding application-level handlers.
Disable it only when the application needs to handle Ping frames itself:
```rust ```rust
let config = HostConfig::new(ip, port, cert, key) let config = HostConfig::new(ip, port, cert, key)
.with_pongs(false); .with_pongs(false);
``` ```
With automatic responses disabled, Ping frames are delivered through the normal Follow the responder contract in [Protocol Reference](PROTOCOL-REFERENCE.md).
receiver and the application is responsible for sending a compatible Pong (the
same frame id, and normally the Ping's `Timestamp`) if it wants clients to
continue their protocol ping loop.
## Accepting Connections ## Accepting Connections
@ -125,50 +74,12 @@ while let Some(conn) = host.accept().await? {
### MTPConnection ### MTPConnection
Returned by `accept()` after version negotiation (and authentication if `accept()` returns the shared connection shape in [MTP Connections](CONNECTIONS.md)
enabled): after version negotiation and authentication, when enabled. The host-specific `codec` is scoped to the negotiated version, and `client_public_key` is set for authenticated clients.
```rust
pub struct MTPConnection {
pub version: Version,
pub codec: VersionedCodec,
pub sender: Sender,
pub receiver: Receiver,
pub description: Option<String>,
#[cfg(feature = "crypto")]
pub auth_state: AuthState,
#[cfg(feature = "crypto")]
pub client_id: u64,
#[cfg(feature = "crypto")]
pub client_public_key: Option<PublicKeyBundle>,
}
```
- `version` -- the negotiated protocol version
- `codec` -- a `VersionedCodec` scoped to the negotiated version (use for
version-aware encode/decode)
- `sender` / `receiver` -- for message I/O
- `description` -- optional client-provided label (e.g. `"phone"`, `"desktop"`)
- `client_id` -- the authenticated client's ID
- `client_public_key` -- the client's public key bundle (for signature
verification of subsequent messages)
## Version Negotiation ## Version Negotiation
When a client connects, `accept()` performs the following sequence: `accept()` uses the version-bearing opening frame and registry flow in [Connector](CONNECTOR.md). The host registry is built from the type maps in `type-maps.yaml` by `Registry::builtin()`.
1. Accept the QUIC connection
2. Read the client's first `CommunicationValue` (always encoded with reserved
type IDs)
3. Extract the protocol version from `DataType::Version` (reserved data type ID 0) as a
`DataValue::Str("major.minor")`
4. Call `registry.negotiate(&[client_version])` to find the highest mutually
supported version
5. Return an `AcceptError` (closing the connection) if no compatible version exists
6. Return `Ok(Some(MTPConnection))` with the negotiated version
The `Registry` is built automatically from all type maps defined in your
`type-maps.yaml` via `Registry::builtin()`.
### Registry ### Registry
@ -184,97 +95,9 @@ let negotiated = registry.negotiate(&[Version(1, 0), Version(2, 0)]);
## Authentication Flow ## Authentication Flow
When `authentication_policy` is `ForceAuthentication`, `accept()` runs a mutually-authenticated The connection lifecycle and authentication sequence are in [Protocol Reference](PROTOCOL-REFERENCE.md). Host callback contracts are documented below.
**challenge-response** handshake before returning the connection. The host issues
a fresh, random `server_challenge` that the client must sign, which is what makes
the client's proof unreplayable: a captured proof is bound to a one-time challenge
the host generates per connection and will never reissue. The challenge lives only
on the accepting task's stack; there is no replay database or shared state.
All signed payloads begin with a one-byte domain-separation tag (see After a successful handshake, `MTPConnection` exposes `AuthState::Authenticated`, the client ID, and the client's public key bundle when one is available.
`mtp::crypto::auth`) so a signature for one step can never be reused as another.
### Login
```
Client Host
| |
| QUIC connect |
|---------------------------------------->|
| |
| Identification { Version, Id } | (unsigned hello)
|---------------------------------------->|
| | lookup get_existing_user(id)
| | generate random server_challenge
| Challenge { |
| ServerNonce(server_challenge), |
| Signature, [PqSignature] | host signs the challenge
| } |
|<----------------------------------------|
| ChallengeResponse { |
| ClientNonce, Signature, [PqSignature]| client signs the challenge
| } |
|---------------------------------------->|
| | verify proof over server_challenge
| IdentificationResponse { |
| Connected=true, Id, |
| ClientNonce(echoed), |
| Signature, [PqSignature] |
| } |
|<----------------------------------------|
```
Payloads (`||` is concatenation, integers big-endian; `DS_*` are domain tags):
- Host challenge: `DS_CHALLENGE || id (8) || server_challenge (16)`
- Client proof: `DS_LOGIN_PROOF || version_string || id (8) || server_challenge (16) || client_nonce (16)`
- Host final: `DS_HOST_FINAL || assigned_id (8) || client_nonce (16) || server_challenge (16)`
### Register
```
Client Host
| |
| QUIC connect |
|---------------------------------------->|
| |
| Register { |
| Version, | (unsigned hello)
| PublicKeys (serialized PublicKeyBundle)
| } |
|---------------------------------------->|
| | generate random server_challenge
| Challenge { |
| ServerNonce(server_challenge), |
| Signature, [PqSignature] | (challenge binds id = 0)
| } |
|<----------------------------------------|
| ChallengeResponse { |
| ClientNonce, Signature, [PqSignature]|
| } |
|---------------------------------------->|
| | verify proof over server_challenge
| | call complete_register(bundle) -> new_id
| RegisterResponse { |
| Connected=true, Id(new_id), |
| ClientNonce(echoed), |
| Signature, [PqSignature] |
| } |
|<----------------------------------------|
```
The register client proof is:
`DS_REGISTER_PROOF || version_string || server_challenge (16) || client_nonce (16) || public_key_bytes`
After a successful handshake, `accept()` returns an `MTPConnection` with
`auth_state = Authenticated`, `client_id` set, and `client_public_key`
available for verifying subsequent signed messages from the client.
### Rejection
If verification fails or the client is not found (login), the host sends a
rejection response with `Connected=false` and closes the send stream, returning
`AcceptError::AuthenticationFailed` from `accept()`.
## Handling Messages ## Handling Messages
@ -298,8 +121,7 @@ while let Some(conn) = host.accept().await? {
### Versioned Codec ### Versioned Codec
The `conn.codec` is a `VersionedCodec` pre-configured with the negotiated The `conn.codec` is a `VersionedCodec` pre-configured with the negotiated version. Use it to encode/decode with version-specific type maps:
version. Use it to encode/decode with version-specific type maps:
```rust ```rust
let tm = conn.codec.registry().get(&conn.version).unwrap(); let tm = conn.codec.registry().get(&conn.version).unwrap();
@ -311,166 +133,66 @@ let value = msg.get_data(desc_id);
## Pipes ## Pipes
With the `pipes` feature enabled, the host can accept **raw binary streams** The complete pipe protocol and host API are documented in [Pipes](PIPES.md).
from clients. A Pipe is a unidirectional QUIC stream opened by the client that
carries a lightweight `PipeRequest` handshake frame, then transitions to raw
bytes with zero per-frame overhead.
### Enabling Pipes
Add the `pipes` feature to your dependency:
```toml
[dependencies]
mtp = { path = "/path/to/mtp", features = ["host", "pipes"] }
```
### Receiving Pipe Requests
When `pipes` is enabled, **do not call `conn.receiver.receive()` directly**.
Instead, use `conn.receive()` for normal messages and `conn.receive_pipe()`
for incoming pipe requests. A background dispatcher task routes events
internally so the two channels do not race.
```rust
use mtp::host::{MTPHost, PipeRequest};
use tokio::io::AsyncReadExt;
while let Some(conn) = host.accept().await? {
tokio::spawn(async move {
loop {
tokio::select! {
Ok(msg) = conn.receive() => {
// handle normal CommunicationValue
}
Ok(req) = conn.receive_pipe() => {
handle_pipe(req).await;
}
else => break,
}
}
});
}
async fn handle_pipe(req: PipeRequest) {
println!("Pipe {} requested: {}", req.id(), req.description());
// Accept or deny...
}
```
### PipeRequest
```rust
pub struct PipeRequest {
// pipe_id assigned by the creator
// description provided by the creator
}
```
| Method | Returns | Description |
|--------|---------|-------------|
| `id()` | `u32` | The pipe ID chosen by the creator |
| `description()` | `&str` | Creator-provided label (e.g. `"file-transfer"`) |
| `accept()` | `Result<PipeReader, PipeError>` | Accept the pipe; returns an `AsyncRead` stream |
| `deny()` | `Result<(), PipeError>` | Reject the pipe |
### Accepting a Pipe
```rust
use tokio::io::AsyncReadExt;
async fn handle_pipe(req: PipeRequest) {
match req.accept().await {
Ok(mut reader) => {
let mut buf = Vec::new();
if let Err(e) = reader.read_to_end(&mut buf).await {
eprintln!("pipe read error: {e}");
}
println!("received {} bytes", buf.len());
}
Err(e) => {
eprintln!("pipe accept failed: {e}");
}
}
}
```
`PipeReader` implements `tokio::io::AsyncRead`. The stream reads until the
creator calls `PipeWriter::finish()` or the connection closes.
### Rejecting a Pipe
```rust
async fn handle_pipe(req: PipeRequest) {
if !should_allow(&req) {
req.deny().await.ok();
return;
}
// ... accept
}
```
### PipeError
```rust
pub enum PipeError {
Rejected, // pipe request was rejected
HandshakeTimeout, // pipe handshake timed out
StreamClosed, // pipe stream closed unexpectedly
IoError(String), // pipe I/O error
ConnectionClosed, // connection closed
}
```
`PipeError` implements `std::error::Error` and can be converted from
`CommunicationError` via `PipeError::from()`.
### Important: Do Not Use `receiver.receive()` with Pipes
When the `pipes` feature is active, `conn.receiver.receive()` will **skip**
`PipeRequest` frames and may return them as ordinary messages if called from
the wrong task. Always use the facade methods:
- `conn.receive()` -- normal `CommunicationValue` messages
- `conn.receive_pipe()` -- incoming `PipeRequest` objects
These methods are internally synchronised and safe to call from separate tasks.
## Host Callbacks ## Host Callbacks
### get_existing_user ### get_existing_client
Called during login to retrieve a client's public key bundle for signature Called during login to retrieve a client's public key bundle for signature verification, and also during guest ID generation to check whether a random candidate collides with a registered client. When used for collision checking the `description` argument is `None`.
verification. Must return `Some(PublicKeyBundle)` if the client ID is known,
or `None` to reject. Must return `Some(PublicKeyBundle)` if the client ID is known, or `None` otherwise.
```rust ```rust
let get_existing_user = |id: u64| { // db: Arc<tokio::sync::Mutex<HashMap<u64, PublicKeyBundle>>>
let get_existing_client = |id: u64, _description: Option<String>| {
let db = db.clone(); let db = db.clone();
Box::pin(async move { db.lock().unwrap().get(&id).cloned() }) Box::pin(async move { db.lock().await.get(&id).cloned() })
}; };
``` ```
### guest_id_generator
Optional callback that controls how unauthenticated connections receive their client ID. When `None` (the default), the host generates a random 48-bit ID and checks it against `get_existing_client` to avoid collisions.
Return `Some(id)` to accept the guest with that ID, or `None` to reject the connection. The ID must fit in 48 bits (`id <= mtp_codec::MAX_WIRE_ID`);
values outside that range are rejected automatically and fall back to the built-in generator.
```rust
use std::sync::atomic::{AtomicU64, Ordering};
// Sequential guest IDs:
let counter = AtomicU64::new(1);
let guest_id_generator = Box::new(move || {
Box::pin(async move { Some(counter.fetch_add(1, Ordering::SeqCst)) })
});
// Reject all guests (no unauthenticated connections):
let guest_id_generator = Box::new(|| Box::pin(async { None }));
let config = HostConfig::new(ip, port, cert, key)
.with_authentication(host_keyring, get_existing_client, complete_register)
.with_authentication_policy(AuthenticationPolicy::AllowAuthentication)
.with_guest_id_generator(guest_id_generator);
```
### complete_register ### complete_register
Called during registration to persist a new client's public key bundle and Called during registration to persist a new client's public key bundle and assign a client ID. The returned `u64` becomes the client's permanent identifier.
assign a client ID. The returned `u64` becomes the client's permanent
identifier.
```rust ```rust
let complete_register = |bundle: PublicKeyBundle| { // db: Arc<tokio::sync::Mutex<HashMap<u64, PublicKeyBundle>>>
let complete_register = |bundle: PublicKeyBundle, _description: Option<String>| {
let db = db.clone(); let db = db.clone();
let id = next_id.fetch_add(1, Ordering::SeqCst); let id = next_id.fetch_add(1, Ordering::SeqCst);
Box::pin(async move { Box::pin(async move {
db.lock().unwrap().insert(id, bundle); db.lock().await.insert(id, bundle);
id id
}) })
}; };
``` ```
Both callbacks are called from within `accept()` and must be `Send + Sync`. They All callbacks are called from within `accept()` and must be `Send + Sync`. They are `async` (returning `Pin<Box<dyn Future<...>>`) and are `.await`ed by the host, so they can perform I/O or other async work as needed. The `complete_register` callback returns no error value. A panic aborts the normal callback flow; validate storage and ID allocation before returning the ID.
are `async` (returning `Pin<Box<dyn Future<...>>`) and are `.await`ed by the
host, so they can perform I/O or other async work as needed.
## Host Key Generation ## Host Key Generation
@ -502,21 +224,17 @@ std::fs::write("host_sig_pq_pk.bin", bundle.sig_pq_public_key.as_bytes())?;
## Policy ## Policy
The transport `Policy` is set to defaults internally. To customise (timeouts, Customize transport limits and timeouts through `HostConfig::with_policy`:
send mode, etc.), use `mtp_transport::host()` directly instead of `MTPHost`:
```rust ```rust
use mtp_transport::{host, Policy}; let config = HostConfig::new(ip, port, cert, key)
.with_policy(custom_policy);
let transport = host(ip, port, cert, key, custom_policy).await?; let host = MTPHost::new(config).await?;
// Then build version negotiation on top:
// - accept transport.next()
// - read first frame
// - registry.negotiate()
// - return MTPConnection
``` ```
## Graceful Shutdown ## Graceful Shutdown
Drop the `MTPHost` to stop accepting new connections. Active connections Drop the `MTPHost` to stop accepting new connections. Active connections continue until their `Sender`/`Receiver` are dropped or the peer disconnects.
continue until their `Sender`/`Receiver` are dropped or the peer disconnects.
Run one accept loop per `MTPHost` and spawn one task per accepted connection.
Stop the accept loop before dropping the host, then close active senders and wait for application tasks to finish. Use [Operations](OPERATIONS.md) for the deployment sequence and monitoring signals.

42
docs/OPERATIONS.md Normal file
View file

@ -0,0 +1,42 @@
# Operations
## Monitoring
Expose counters and gauges around the host and transport callbacks:
| Metric | Interpretation |
| --- | --- |
| Accepted and rejected connections | Compare admission failures with traffic volume. A rise in rejected connections points to certificate, version, policy, or authentication problems. |
| Active connections and active pipe streams | Capacity currently consumed by sessions and raw streams. |
| Authentication failures and timeouts | Credential, policy, or reachability failures during the handshake. |
| Unsupported protocol versions | Clients that require a version still absent from the registry. |
| Message-too-large and decode errors | Peer or schema mismatch, malformed input, or an overly small policy limit. |
| Request latency and request timeout count | Application handler time and transport deadline pressure. |
| Ping round-trip time and missed pings | Peer reachability and path latency. |
| Pipe accept, reject, EOF, and reset counts | Application admission and stream completion behavior. |
Implement `WebServerMetrics` for HTTP/3 request and error callbacks. Record the request path, status, duration, and `WebServerError` category without logging credentials, private keys, or message contents. Export host callback results through the application's metrics system for native deployments.
## Tuning
`Policy::default()` uses a 16 MiB message limit, a 64 KiB handshake limit, a 30 second read timeout, a 30 second idle timeout, a receiver queue capacity of 1000, and 128 concurrent stream tasks.
For low-latency request traffic, use `SendMode::SingleStreamPerMessage`, keep message sizes bounded, use shorter read and idle timeouts, and keep queue and concurrency limits near the amount of work the application can process.
For high-throughput bulk traffic, use persistent streams, raise `max_message_size` only when messages require it, and size `receiver_queue_capacity` and `max_concurrent_stream_tasks` for available memory and downstream processing capacity. Use pipes for large sequential byte streams instead of increasing message limits.
Every queued frame consumes memory until the application reads it. Test policy changes with realistic peer counts and payload sizes before deployment.
## Deployment
### Certificate Rotation
Publish the replacement certificate or pin before changing the server. Update clients to trust the replacement while the current certificate remains valid, switch the server, then remove the old trust value after clients migrate. Use system roots when certificate rotation is managed by the issuing authority.
### Key Backup
Back up host keyrings and client keyrings as protected secrets. Test restoring a backup before relying on it. Keep private key files owner-only on Unix, protect backup access, and store public key bundles separately from private material.
### Graceful Shutdown
Stop accepting new connections, reject new work at the application layer, and allow active requests and pipe writers to finish. Send a normal connection close, wait for the configured drain period, then force-close remaining QUIC sessions. For `MTPWebServer`, call `shutdown()` after the accept loop stops; Headits `drain_timeout` controls the drain period.

81
docs/PIPES.md Normal file
View file

@ -0,0 +1,81 @@
# MTP Pipes
Pipes are unidirectional QUIC streams for raw bytes. The creator sends a `PipeRequest` communication value, the peer accepts or rejects it, and the stream then carries bytes without an MTP frame around every write.
The creator owns the writer. The accepting peer owns the reader. A writer finishes with a stream FIN or aborts with a stream reset. A reader returns EOF after FIN and reports a connection or stream error when the peer closes unexpectedly.
## Opening a Pipe
The creator calls `create_pipe` or the corresponding SDK `createPipe` method with a description. MTP assigns a pipe ID and sends a `PipeRequest` frame. The creator receives a handle, not an active writer, because the peer must decide whether to accept the request.
The request description is application metadata. It does not grant access to the stream, authenticate the creator, or negotiate an application protocol.
Use the authenticated MTP connection and the host's admission policy when a pipe carries sensitive data.
## Accepting or Rejecting a Pipe
The receiving side reads pipe requests through `receive_pipe`, the host dispatcher, or the browser pipe callback. It calls `accept` to obtain a reader or `deny` to reject the request. A rejected request completes the creator's handle with `Rejected` and no raw byte stream becomes available.
With native pipes enabled, do not read the underlying `receiver` directly.
Normal messages and pipe requests share the transport and must pass through the connection facade so a dispatcher does not deliver one event to the wrong consumer.
## Closing a Pipe
The creator closes a successful pipe with `PipeWriter::finish` or the browser writer's `close`; this sends a QUIC FIN and lets the reader observe EOF. Use `abort` when the peer should discard the stream immediately; this resets the stream and the reader receives an error instead of a clean EOF. Dropping the connection closes all active pipes.
The accepting side closes its reader by consuming it or dropping it. A reader does not send an application-level acknowledgement for EOF. If the application needs completion metadata, send an ordinary MTP message before finishing the pipe.
## Pipe Errors
| Error | Meaning |
| --- | --- |
| `Rejected` | The peer denied the request. |
| `HandshakeTimeout` | The peer did not complete the pipe handshake in time. |
| `StreamClosed` | The pipe stream ended unexpectedly. |
| `IoError` | The underlying byte stream returned an I/O error. |
| `ConnectionClosed` | The MTP connection closed while the pipe was active. |
Native applications use the pipe APIs on `MTPConnection`; browser applications use the SDK methods in [WASM Client](WASM-CLIENT.md#pipes). With native pipes enabled, normal messages and pipe requests must be read through the connection facade so the dispatcher can route each event to the correct queue.
## Native File Upload and Processing
The creator streams a file in chunks. The accepting side processes each chunk without buffering the complete file:
```rust
// Client
use tokio::io::AsyncWriteExt;
let handle = conn.create_pipe("file-upload").await?;
if let Some(mut writer) = handle.wait().await? {
let mut file = tokio::fs::File::open("input.bin").await?;
tokio::io::copy(&mut file, &mut writer).await?;
writer.finish().await?;
}
```
```rust
// Host
use tokio::io::AsyncReadExt;
while let Ok(request) = conn.receive_pipe().await {
if request.description() != "file-upload" {
request.deny().await?;
continue;
}
let mut reader = request.accept().await?;
let mut hasher = sha2::Sha256::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let count = reader.read(&mut buffer).await?;
if count == 0 {
break;
}
hasher.update(&buffer[..count]);
process_chunk(&buffer[..count]).await?;
}
let digest = hasher.finalize();
println!("processed upload with digest {digest:x}");
}
```
Send completion metadata as an ordinary MTP message after the reader observes EOF. A stream FIN means the writer finished; it does not authenticate file contents or provide a digest.

View file

@ -0,0 +1,43 @@
# Protocol Reference
This document owns the connection lifecycle, protocol keepalive, and application authentication flow. API guides link here for configuration.
## Connection Lifecycle
```text
bind -> accept QUIC -> negotiate version -> authenticate if enabled
-> dispatch application frames -> close or drain
```
The opening version frame is processed before application messages. The host selects a registered type map. Authentication then completes according to the host policy. A connection is returned to the application only after these stages complete.
## Protocol Keepalive
The client sends an MTP `Ping` communication value with a frame ID. The host returns a `Pong` with the same ID when automatic responses are enabled. The client records the matched round-trip duration and closes after its configured missed-Ping limit. These frames are handled by the keepalive dispatcher and do not reach ordinary message handlers.
If automatic responses are disabled, the application must read Ping frames and send compatible Pong frames. Keepalive configuration is documented in the [native client](NATIVE-CLIENT.md) and [native host](NATIVE-HOST.md) guides.
## Authentication Flow
```text
Client Host
| |
| Identification or Register, unsigned |
|------------------------------------------>|
| | generate challenge
| Challenge plus host signature |
|<------------------------------------------|
| ChallengeResponse plus client signature |
|------------------------------------------>|
| | verify proof and assign identity
| IdentificationResponse plus host signature|
|<------------------------------------------|
```
Login proof binds the protocol version, client ID, host challenge, and client nonce. Registration proof binds the protocol version, public key bundle, host challenge, and client nonce. The host challenge is generated per connection.
`ForceAuthentication` requires login or registration. `AllowAuthentication` accepts authenticated and unauthenticated clients. `Unauthenticated` rejects authentication attempts. The connection states are `Pending`, `Authenticated`, `Unauthenticated`, and `Failed`.
## Version Negotiation
The client sends one compiled-in protocol version. The host compares it with the versions in its registry and returns the selected version in the opening response. Subsequent frames use that version's type map. An unsupported version closes the connection with `AcceptError::UnsupportedVersion`.

163
docs/SECURITY.md Normal file
View file

@ -0,0 +1,163 @@
# Security
This document describes the security controls implemented by MTP, the crypto APIs exposed by `mtp-crypto`, and the limits that operators and application developers must account for.
## Reporting Vulnerabilities
Report suspected vulnerabilities privately to the project maintainers. Include the affected crate, feature flags, protocol path, reproducible input, and the commit or release being tested. Do not include private keys or credentials in the report.
## Security Boundaries
MTP runs over QUIC and relies on TLS for transport confidentiality and peer authentication. The native transport uses `wtransport`; the browser client uses WebTransport. MTP authentication adds application-level signatures and does not replace TLS certificate verification.
MTP does not provide anonymity. Client identifiers and connection metadata are visible to the host. It also cannot protect data after a client or host endpoint has been compromised.
## TLS Certificate Verification
The native client uses the system root store by default. It also supports a pinned PEM certificate or an SPKI SHA-256 pin. Browser clients use the browser root store unless `serverCertificateHashes` is configured for WebTransport.
| Configuration | Trusts | Intended use |
| --- | --- | --- |
| System roots | Certificates trusted by the operating system or browser | Publicly trusted production certificates |
| Pinned PEM | The supplied PEM certificate chain | Private CA deployments and controlled environments |
| SPKI hash | The public key represented by the supplied certificate | A fixed server key, with planned rotation |
| Insecure verification | Any certificate | Local development only |
For rotation, publish the replacement certificate or key before changing the server, update clients to trust both values where the client API permits it, then remove the old value after all clients have migrated. A pin is a key constraint, not a substitute for a certificate rotation plan.
### Development Certificates
The `tls` feature exposes `mtp_crypto::tls::generate_self_signed_cert`. It creates an ECDSA P-256 server certificate for the requested domain, `127.0.0.1`, and `::1`; the certificate is valid for 13 days. `HostConfig::self_signed` provides a transport-level self-signed setup without the crypto certificate helper.
Self-signed certificates are for development. Production deployments should use a certificate trusted by the client or an explicitly pinned certificate.
### Insecure Verification
Native insecure verification has two gates:
1. Compile with the `insecure-tls` feature.
2. Set `MTP_INSECURE_TLS=1` at runtime.
Without the runtime variable, the connection fails rather than silently disabling verification. Do not use this mode on an untrusted network.
## Authentication Policies
Hosts choose one of three policies:
- `ForceAuthentication` requires login or registration.
- `AllowAuthentication` accepts authenticated and unauthenticated clients.
- `Unauthenticated` rejects authentication attempts and is the default.
An unauthenticated connection receives `AuthState::Unauthenticated`. Use `ForceAuthentication` when every client must have a registered identity.
The native host exposes four authentication states:
| State | Meaning |
| --- | --- |
| `Unauthenticated` | The connection completed without application authentication. |
| `Pending` | The authentication handshake is in progress. |
| `Authenticated` | The host verified the client proof and assigned or confirmed its identity. |
| `Failed` | Authentication started but validation failed or the handshake timed out. |
Authorize requests only after `Authenticated`. A failed handshake is reported through `AcceptError::AuthenticationFailed` or `AcceptError::AuthenticationTimedOut` on the host.
### Hybrid Signatures
Authenticated handshakes support Ed25519 and ML-DSA-65 dual signatures. The host and clients default to `require_pq = true`, so both signatures are required. Calling `with_require_pq(false)` permits Ed25519-only authentication and should be treated as an explicit compatibility decision.
The `ml-dsa` dependency is enabled by default in `mtp-crypto`. The project has not recorded an independent audit for `ml-dsa`; see [Cryptographic review status](#cryptographic-review-status).
### Challenge-Response Flow
The complete sequence is in [Protocol Reference](PROTOCOL-REFERENCE.md#authentication-flow). This section defines the signed fields and domain-separation tags used by that sequence.
### Domain Separation
Every signed handshake payload begins with a distinct byte:
| Tag | Payload |
| --- | --- |
| `0x10` | Host challenge |
| `0x11` | Client login proof |
| `0x12` | Client registration proof |
| `0x13` | Host final confirmation |
The tags prevent a valid signature for one handshake step from being accepted as a signature for another step.
## Cryptographic Primitives
`mtp-crypto` exposes the following building blocks:
| Area | Implementation | Availability |
| --- | --- | --- |
| AEAD | XChaCha20-Poly1305 | Default |
| AEAD | AES-256-GCM | `full` feature |
| Classical signatures | Ed25519 | Default |
| Post-quantum signatures | ML-DSA-65 | Default |
| KDF and hashing | HKDF-SHA-256, SHA-256 | Default |
| Hybrid KEM | X25519 plus ML-KEM-768 | `pqc` feature |
AEAD output stores the nonce before the authenticated ciphertext. Encrypted containers select their algorithm with a leading marking byte, derive an AEAD key from the KEM shared secret with HKDF, and authenticate caller-supplied AAD. Multi-recipient encryption wraps one content-encryption key separately for each recipient.
[mtp-crypto API](../crypto/), [native client](NATIVE-CLIENT.md), and [native host](NATIVE-HOST.md).
The crate's feature groups are:
| Feature | Adds |
| --- | --- |
| Default | XChaCha20-Poly1305, Ed25519, ML-DSA-65, HKDF, and SHA-256 |
| `full` | AES-256-GCM in addition to the default features |
| `pqc` | Hybrid X25519 and ML-KEM-768 support |
| `serde` | Serialization support for key types |
| `wasm` | `getrandom` support for WebAssembly |
| `tls` | Development certificate generation |
The main types are `Keyring`, `PublicKeyBundle`, `EncryptionType`, `HybridKem`, `ChaCha20Poly1305`, `Aes256Gcm`, `Ed25519Signer`, and `MlDsaSigner`. Hashing and KDF helpers include `sha256`, `sha256_double`, `hkdf_extract`, `hkdf_expand`, and `derive_encryption_key`. Handshake payload builders are in `mtp_crypto::auth`.
## Cryptographic Review Status
The project records the following status for its cryptographic dependencies:
| Crate | Audited? | Notes |
| --- | --- | --- |
| `ed25519-dalek` | Yes | Used by Signal and Diem |
| `chacha20poly1305` | Yes | NCC Group audit, December 2019 |
| `aes-gcm` | Yes | NCC Group audit, December 2019 |
| `ml-dsa` | No | NIST vectors pass in project tests |
| `mlkem-tls` | No | Uses an unaudited `mlkem-rs` backend |
| `hkdf` | No | Standard construction |
| `sha2` | No | Standard construction |
| `zeroize` | No | Used for secret-key containers |
The audit entries describe the dependency projects. MTP's crypto tests cover round trips, wrong-key failures, wrong-AAD failures, and signature failures;
they do not replace a review of protocol composition or deployment.
## Browser End-to-End Encryption
The browser SDK's optional E2EE session uses XChaCha20-Poly1305 with message keys derived from a one-way HKDF chain. Each send and receive operation advances its chain and authenticates the message header as AAD. Initial messages can carry a hybrid KEM ciphertext for session setup.
This is a single-chain ratchet. It has no Diffie-Hellman ratchet step and does not provide post-compromise security. Out-of-order messages can create skipped keys; the SDK accepts a receive gap of at most 100 messages and retains at most 100 skipped keys. Consumed or evicted keys are zeroed in the SDK state where the implementation owns the buffer.
The session root key comes from the authenticated handshake's KEM shared secret. The initiator and responder derive separate send and receive chains.
Each message consumes one chain key, derives one message key with HKDF, and increments its counter. `sessionStorage` stores browser session state for the current origin. `encryptedDeviceSecretProvider` supplies encrypted device secret storage when sessions must survive page reloads. The provider must protect its wrapping secret outside the SDK; the SDK does not recover a lost device secret or skipped message keys.
## Key Storage
`Keyring` contains three public and three private key values. Its private key fields use `ZeroizeOnDrop`, and serialized keyring output is held in a zeroizing buffer while it is constructed. Public key bundles contain only the three public values.
Applications remain responsible for storage at rest. The `files` feature writes passphrase-protected keyrings to `.mk` files and public bundles to `.mpkb` files. On Unix, keyring files are created with owner-only `0600` permissions.
Restrict those files to the owning account and protect backups. Browser applications should treat the configured credential storage as sensitive application data.
## Resource Limits and Operational Controls
`Policy::default()` sets a 16 MiB application message limit and a 64 KiB handshake message limit. It also sets a 30 second read timeout, a 30 second maximum idle timeout, a receiver queue capacity of 1000, and a maximum of 128 concurrent stream tasks. Tune these values for the deployment and peer trust level.
The host does not provide a general authentication-attempt rate limiter.
Deploy authentication endpoints behind a rate-limiting proxy or add admission control through the host callbacks, including `GuestIdGenerator` where guest connections are permitted.
## Security Limitations
- The first version-negotiation frame is sent before authentication and is not signed.
- `AllowAuthentication` intentionally permits unauthenticated clients; it is not an authenticated-only mode.
- Browser-side Rust panics cannot be recovered by JavaScript. The WASM client contains panic paths from internal `expect` calls.
- The browser E2EE ratchet does not provide post-compromise security.

108
docs/TROUBLESHOOTING.md Normal file
View file

@ -0,0 +1,108 @@
# Troubleshooting
Use the failure stage to narrow the cause. MTP connections pass through TLS, the opening version frame, optional authentication, and application framing in that order.
Each entry identifies the symptom, diagnosis, fix, and prevention. Security labels identify workarounds that change certificate verification or keepalive.
## Diagnosis Flow
```text
Connection fails?
-> TLS or WebTransport error? Check certificate, origin, and endpoint.
-> UnsupportedVersion? Check compiled client version and host registry.
-> AuthenticationFailed? Check policy, key lookup, and key bundles.
-> CodecError? Check generated type maps and negotiated version.
-> MessageTooLarge? Compare peer payload with Policy limits.
-> Ping or pipe failure? Check the protocol reference or Pipes guide.
```
## TLS Connection Failures
**Security impact:** Safe when the certificate or pin is corrected. Insecure TLS is restricted to Development Mode.
Check the certificate before investigating MTP frames.
- With native clients, confirm the certificate chains to the system roots or pass the expected PEM certificate with `ClientConfig::with_pinned_pem`.
- With browser clients, confirm WebTransport is supported and that `serverCertificateHashes` contains the expected certificate hash when using a pinned certificate.
- For local self-signed certificates, pin the generated PEM certificate. The lower-level insecure mode requires both the `insecure-tls` feature and `MTP_INSECURE_TLS=1`.
- Confirm the hostname or IP address is present in the certificate's subject alternative names. A valid certificate with the wrong name still fails TLS.
Use [Security](SECURITY.md) for certificate trust and rotation rules.
## Version Negotiation Failures
An `AcceptError::UnsupportedVersion` means the client's compiled `PROTOCOL_VERSION` is absent from the host registry. Check that both peers were built from compatible `type-maps.yaml` files and that the host includes the required generated version.
An `AcceptError::MissingVersion` means the opening frame did not contain a valid `DataType::Version` string in `major.minor` form. Do not send an application frame before the opening version frame completes.
Use [Connector](CONNECTOR.md) for registry and migration rules.
## Authentication Failures
Check the selected `AuthenticationPolicy` first.
- `Unauthenticated` rejects login and registration by configuration.
- `AllowAuthentication` accepts both authenticated and guest connections.
- `ForceAuthentication` requires a registered login or a successful registration callback.
For login, verify the client ID lookup returns the expected public key bundle, the client keyring matches that bundle, and the client has the host public key used to verify the host signature. For registration, verify that `complete_register` persists the submitted public bundle and returns a valid client ID.
When `require_pq` is true, both Ed25519 and ML-DSA-65 keys and signatures must be available. Set `with_require_pq(false)` only for an explicit Ed25519-only compatibility deployment. Authentication sequence: [Protocol Reference](PROTOCOL-REFERENCE.md#authentication-flow).
## CodecError Failures
**Symptom:** `UnknownCommunicationType` or `UnknownDataType`.
**Diagnosis:** The peers use different generated type maps or the selected version does not define the value.
**Fix:** Build both peers from the same type-map configuration and send only types defined by the negotiated version.
**Prevention:** Treat generated type maps as versioned build artifacts.
`CodecError::UnknownVersion` means the codec was created for a version absent from its registry. `UnknownCommunicationType` and `UnknownDataType` mean the selected `TypeMap` has no mapping for the value being encoded. Select the negotiated type map and do not send an unmapped variant.
`ReservedCommunicationType` means application code attempted to use a reserved wire ID. Use generated communication types instead of assigning protocol IDs manually. `MissingField` means a required typed field was not present.
`InvalidEncoding` indicates truncated, malformed, or structurally invalid bytes. `TooManyEntries` indicates that an array, container, or frame exceeds the codec's representable count or length. `CryptoFailed` indicates that signature verification or encrypted-container processing failed. The complete variant table is in [Errors](ERRORS.md).
## Frames and Message Limits
`MessageTooLarge` means the serialized frame exceeds the configured policy. Native transport defaults are a 16 MiB application message limit and a 64 KiB handshake limit. The browser SDK defaults `maxMessageSize` to 16 MiB.
`ParseCommunicationValue`, `ParseError`, or `CodecError::InvalidEncoding` means the received bytes do not match the length-prefixed frame and value format. Check that the sender writes the four-byte big-endian frame length exactly once and that the receiver reads exactly that many bytes. Frame layout: [Type Map](TYPE-MAP.md).
## Requests and Subscriptions
If `request()` times out, confirm that the peer sends a response with the same non-zero frame ID. If `responseType` is set, confirm that the response uses the expected communication type after the ID matches.
If a subscription callback does not run, confirm that the generated type map contains the message type and that the client is connected before the sender emits the frame. Enable the SDK logger to inspect state changes and errors.
## Protocol Pings
If `get_ping()` remains `None` or the connection closes after missed pings, check the keepalive configuration and responder mode in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive).
## Pipes
If a pipe handle resolves to `null` or `PipeError::Rejected`, the peer denied the request. If `receive_pipe()` never produces a request, use the connection facade instead of reading the underlying receiver directly. If a reader sees an error instead of EOF, the writer likely aborted the stream or the connection closed. Pipe lifecycle: [Pipes](PIPES.md).
## Browser Diagnostics
Use `MTPClient.isSupported()` before creating a browser client. Inspect browser console errors, WebTransport session state, certificate pins, and the SDK logger callback. Raw WASM bindings expose lower-level callbacks, but the SDK adds timeout and lifecycle handling.
| Browser or WebTransport signal | Meaning and next check |
| --- | --- |
| `WebTransportError.source = "stream"`, `streamErrorCode = 0` | The peer closed a one-frame stream normally in the supported browser behavior. Check session state before treating it as a failure. |
| `WebTransportError.source = "stream"`, non-zero `streamErrorCode` | A stream-level failure. Check whether the peer sent `STOP_SENDING` or reset the stream, then inspect the active pipe or frame. |
| `WebTransportError.source = "session"` | The WebTransport session failed. Check TLS, the endpoint, the `webtransport` CONNECT path, and server logs. |
| `WebTransportError` without `streamErrorCode` | The error is session-level or browser-specific. Inspect `error.message`, `error.source`, and the browser network panel. |
| Close code `0`, reason `mtp-webserver shutdown` | `MTPWebServer` performed an intentional shutdown. Reconnect after the process restarts. |
MTP logs stream-level `STOP_SENDING` and `RESET_STREAM` events with their `streamErrorCode`. QUIC transport error numbers are implementation-specific; use the browser's source, stream code, message, and server logs together.
## Development Mode
Use `mtp_crypto::tls::generate_self_signed_cert` for local certificates. Native insecure verification requires the `insecure-tls` feature and `MTP_INSECURE_TLS=1`; it disables certificate verification and is not a production fix.
## Getting More Help
Capture the negotiated version, connection state, error variant, endpoint, and relevant server log entries. Enable the SDK logger or Rust tracing, then remove credentials, private keys, and message contents before sharing a report.

View file

@ -1,6 +1,26 @@
# Type Map # Type Map
This file documents the Type Map & Registry configuration used by the MTP protocol. This file documents the Type Map & Registry configuration used by the MTP protocol. It will assume you are working with the [example-type-maps.yaml](./../example-type-maps.yaml).
## Binary Frame Format
Every transport frame is a four-byte big-endian length followed by one `CommunicationValue`. The length counts all bytes after the length field.
```text
u32 length
u16 communication_type
u8 flags
u32 id if flag 0x04 is set
u48 sender if flag 0x01 is set
u48 receiver if flag 0x02 is set
u8 signature_type if flag 0x10 is set
... signature if flag 0x10 is set, length depends on signature_type
... data container or encrypted payload
```
The flag values are `0x01` for sender, `0x02` for receiver, `0x04` for frame ID, `0x08` for encrypted data, `0x10` for a frame signature, and `0x20` for a signed encrypted container. Sender and receiver IDs are six-byte unsigned big-endian values. The `communication_type` and every container field use IDs from the negotiated `TypeMap`.
Data values begin with a one-byte kind marker. MTP assigns `0x01` and `0x02` to boolean true and false, `0x03` to signed integers, `0x04` to unsigned integers, `0x05` to floats, `0x06` to UTF-8 strings, `0x07` to bytes, `0x08` to arrays, `0x09` to containers, `0x0A` through `0x0C` to crypto containers, and `0xFF` to null. Length-prefixed values use a four-byte big-endian payload length; container and array counts use two-byte big-endian counts.
## TypeMap & Compile-Time Type Safety ## TypeMap & Compile-Time Type Safety
@ -34,7 +54,7 @@ let tm = TypeMap::v2_0();
let id = tm.data_id_enum(DataType::SomeType).unwrap(); let id = tm.data_id_enum(DataType::SomeType).unwrap();
``` ```
The enums are a **union across all versions**; every type name from every version is a variant. The version-specific `TypeMap` maps each variant to the correct wire ID for that version. Types not defined in a version return `None`: The enums are a **union across all versions**; every type name from every version is a variant. The version-specific `TypeMap` maps each variant to the correct wire ID for that version. For a type absent from a selected version, the lookup returns `None`.
Encoding/decoding uses a `TypeMap` to resolve type names to wire IDs: Encoding/decoding uses a `TypeMap` to resolve type names to wire IDs:
@ -58,6 +78,8 @@ let tm_v1 = TypeMap::v1_0();
assert!(tm_v1.data_id_enum(DataType::ExampleType).is_some()); // defined in v1.0 assert!(tm_v1.data_id_enum(DataType::ExampleType).is_some()); // defined in v1.0
``` ```
When communicating with a peer on another version, encode only variants that map in the negotiated version. If an incoming frame names a type absent from the selected map, reject it as a protocol or type-map compatibility error; do not reinterpret its wire ID using another version's map. Keep old IDs stable, register both versions during migration, and remove a version only after its clients have moved.
### Forward/Backward Compatibility Between Versions ### Forward/Backward Compatibility Between Versions
Because enums are a union of all types across versions, a variant might exist that has no wire mapping in the *negotiated* version: Because enums are a union of all types across versions, a variant might exist that has no wire mapping in the *negotiated* version:
@ -67,7 +89,7 @@ v2.0 client sends DataType::SomeType → host encodes with v2.0 TypeMap → w
v2.0 host receives DataType::ExampleType (from v1.0 client) → not in v2.0 TypeMap → None → Error v2.0 host receives DataType::ExampleType (from v1.0 client) → not in v2.0 TypeMap → None → Error
``` ```
This is by design: the host maps unknown types to `Error`, and the client should only send types that exist in its compiled-in version. Encoding a frame with an unmapped communication or data type returns `CodecError::UnknownCommunicationType` or `CodecError::UnknownDataType`. Select a mapped variant from the compiled-in version before sending it.
## Registry ## Registry
@ -95,20 +117,11 @@ let decoded = codec.decode(&bytes, Version(2, 0)).unwrap();
## Customizing Type Maps in Downstream Projects ## Customizing Type Maps in Downstream Projects
External projects must provide their own type map configuration. Browser projects should install `mtp` and configure `mtp/vite`; they do not need to publish, fork, or copy a generated WASM package. External projects must provide their own type map configuration. Browser projects use the Vite plugin from [Defining Type Maps](#defining-type-maps) and do not need to publish, fork, or copy a generated WASM package.
```typescript
import { defineConfig } from "vite";
import { mtp } from "mtp/vite";
export default defineConfig({
plugins: [mtp({ typeMaps: "./type-maps.yaml" })],
});
```
For Rust builds, or when invoking `wasm-pack` manually, set the `MTP_TYPE_MAPS` environment variable. If the variable points to an invalid file, the build fails. If `MTP_TYPE_MAPS` is not set, the build script emits a warning and generates reserved protocol types only; application-specific communication and data types will not be available. For Rust builds, or when invoking `wasm-pack` manually, set the `MTP_TYPE_MAPS` environment variable. If the variable points to an invalid file, the build fails. If `MTP_TYPE_MAPS` is not set, the build script emits a warning and generates reserved protocol types only; application-specific communication and data types will not be available.
1. Create a `type-maps.yaml` in your project root 1. Create a `type-maps.yaml` in your project root (or anywhere else, adapt the path accordingly)
2. Set the `MTP_TYPE_MAPS` environment variable in `.cargo/config.toml`: 2. Set the `MTP_TYPE_MAPS` environment variable in `.cargo/config.toml`:
```toml ```toml

View file

@ -2,6 +2,17 @@
The browser client is exposed through the `mtp` npm package. Most applications should use the SDK-first `MTPClient` API; direct generated WASM bindings remain available from `mtp/raw` for advanced integrations. The browser client is exposed through the `mtp` npm package. Most applications should use the SDK-first `MTPClient` API; direct generated WASM bindings remain available from `mtp/raw` for advanced integrations.
## Browser Compatibility
The SDK requires the browser to expose `WebTransport`. `MTPClient.isSupported()` is the runtime check. A browser without WebTransport cannot connect through this client.
| Requirement | Check |
| --- | --- |
| WebTransport API | `MTPClient.isSupported()` |
| Certificate trust | Browser validation or `serverCertificateHashes` |
| Secure context | Serve the application from HTTPS where required by the browser |
| Generated bindings | Run the Vite integration during development and build |
## Package Entry Points ## Package Entry Points
```typescript ```typescript
@ -17,20 +28,12 @@ import { mtp } from "mtp/vite";
## Vite Type-Map Workflow ## Vite Type-Map Workflow
Browser apps provide their own type map. The Vite plugin runs `wasm-pack` during dev/build with `MTP_TYPE_MAPS` set, writes generated output under `node_modules/.vite/mtp/` by default, and aliases `mtp/raw` plus `mtp/type-map` to that generated output. Browser apps provide their own type map. The Vite plugin runs `wasm-pack` during dev and build with `MTP_TYPE_MAPS` set, writes generated output under `node_modules/.vite/mtp/` by default, and aliases `mtp/raw` plus `mtp/type-map` to that generated output. Configuration: [Type Map](TYPE-MAP.md).
```typescript
// vite.config.ts
import { defineConfig } from "vite";
import { mtp } from "mtp/vite";
export default defineConfig({
plugins: [mtp({ typeMaps: "./type-maps.yaml" })],
});
```
You do not need to publish, fork, or copy an app-specific generated WASM package. You do not need to publish, fork, or copy an app-specific generated WASM package.
The [web client example](../example/web-client/src/main.ts) shows the entry point. Its [Vite configuration](../example/web-client/vite.config.ts) shows the generated binding integration.
## SDK Quick Start ## SDK Quick Start
```typescript ```typescript
@ -80,6 +83,41 @@ if (!MTPClient.isSupported()) {
} }
``` ```
## MTPClient Options
| Option | Default | Purpose |
| --- | --- | --- |
| `url` | Required | WebTransport endpoint. |
| `descriptor` | None | Client label sent during connection setup. |
| `hostPublicKey` | None | Host public key bundle for authenticated login or registration. |
| `credentials` | None | Existing client ID and serialized keyring. |
| `credentialsStorageKey` | `mtp:credentials` | Key used by configured credential storage. |
| `storage` | None | Sync or async credential storage adapter. |
| `serverCertificateHashes` | Omitted | WebTransport certificate pins. |
| `maxMessageSize` | 16 MiB | Inbound and outbound frame limit. Values below frame overhead are rejected by the transport. |
| `authTimeoutMs` | No SDK timeout | Login and registration timeout. `undefined` leaves the promise pending until transport or peer failure. |
| `requestTimeoutMs` | 30 seconds | Default `request()` timeout. |
| `pings` | `false` | Protocol pings, or an object with `intervalMs`. |
| `logger` | No-op | Receives SDK state and error events. |
| `sessionStorage` | In-memory | E2EE session state storage. |
| `encryptedDeviceSecretProvider` | In-memory | Device-secret storage for E2EE. |
`wasm` selects a custom generated WASM module. `MTPClient.create` validates positive safe-integer values for the numeric limits and timeout options.
## Differences from Native Client
The browser SDK uses WebTransport and JavaScript promises. The native client uses Rust futures, direct QUIC configuration, and `MTPConnection` handles. Browser pipes expose promise-based readers and writers; native pipes implement Tokio I/O traits.
### Native and Browser Credential Persistence
The `storage` option supplies the credential adapter. The adapter stores the client ID and serialized keyring after registration and returns them for later connections. The SDK does not select `localStorage` or IndexedDB for an application. Treat the serialized keyring as private key material.
`sessionStorage` and `encryptedDeviceSecretProvider` are separate E2EE session stores. The latter exchanges `EncryptedDeviceSecretRecord` values through `setEncryptedDeviceSecret` and `getEncryptedDeviceSecret`; the application chooses the backing store and protects its wrapping key.
### Native and Browser Certificate Checks
WebTransport certificate pins must match the server certificate hash. A pin mismatch is a TLS failure, not an MTP authentication failure. Check the browser network panel, endpoint origin, and WebTransport CONNECT path before inspecting frames.
## Credentials And Storage ## Credentials And Storage
Authenticated connections need stable key material. Pass `credentials` when you already have a client ID and serialized keyring, or pass a small `storage` object and let the SDK persist credentials after registration. Authenticated connections need stable key material. Pass `credentials` when you already have a client ID and serialized keyring, or pass a small `storage` object and let the SDK persist credentials after registration.
@ -148,12 +186,12 @@ If hashes are omitted, the browser uses its normal TLS root store.
`maxMessageSize` caps inbound and outbound MTP frames before buffering/sending. `maxMessageSize` caps inbound and outbound MTP frames before buffering/sending.
`authTimeoutMs` bounds connect/login/register promises at the SDK layer. `authTimeoutMs` bounds connect/login/register promises at the SDK layer.
`requestTimeoutMs` sets the default timeout for `request()` calls; a request can override it with `timeoutMs` in its options.
## Streams ## Streams
The browser client uses one WebTransport session per `MTPClient` instance. The browser client uses one WebTransport session per `MTPClient` instance.
`send()`, `request()`, and `subscribe()` all operate over that session; the SDK `send()`, `request()`, and `subscribe()` all operate over that session; the SDK does not expose browser stream objects directly.
does not expose browser stream objects directly.
Use the normal message APIs to send and receive over that session: Use the normal message APIs to send and receive over that session:
@ -169,18 +207,9 @@ await client.send("SomeType", { value: "hello" });
unsubscribe(); unsubscribe();
``` ```
Internally, each outbound MTP frame is written to a new WebTransport Internally, each outbound MTP frame is written to a new WebTransport unidirectional stream as a four-byte big-endian length followed by the frame, then that stream is closed. Incoming frames are read from the session's incoming unidirectional streams. The reader accepts both one-frame streams and native peers that place several frames on a persistent stream, so browser and native clients interoperate without stream configuration.
unidirectional stream as a four-byte big-endian length followed by the frame,
then that stream is closed. Incoming frames are read from the session's
incoming unidirectional streams. The reader accepts both one-frame streams and
native peers that place several frames on a persistent stream, so browser and
native clients interoperate without stream configuration.
The SDK deliberately owns stream lifetime and framing. Do not create browser The SDK owns stream lifetime and framing. Do not create browser streams for MTP frames yourself through the SDK. For direct generated bindings, use `client.raw.client` or import `WasmClient` from `mtp/raw`; a `WasmClient` still owns one active WebTransport session, so create another instance for an independent connection.
streams for MTP frames yourself through the SDK. For direct generated bindings,
use `client.raw.client` or import `WasmClient` from `mtp/raw`; a `WasmClient`
still owns one active WebTransport session, so create another instance for an
independent connection.
## Sending, Requests, Subscriptions, And Pings ## Sending, Requests, Subscriptions, And Pings
@ -200,7 +229,7 @@ await client.send("SomeType", { value: "hello" }, {
}); });
``` ```
`request` sends one frame and resolves with the parsed response carrying the same frame id. `responseType` is validated after the id match: `request` sends one frame and resolves with the parsed response carrying the same frame id. If the matching response has a different `responseType`, the promise rejects with a response-type error. A timeout rejects the promise and removes the pending request:
```typescript ```typescript
const response = await client.request( const response = await client.request(
@ -220,7 +249,7 @@ const unsubscribe = client.subscribe("SomeType", (message) => {
unsubscribe(); unsubscribe();
``` ```
Protocol pings are real MTP `Ping` frames sent by the WASM client, not just transport keepalives: Protocol ping behavior is defined in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive). The SDK configuration is:
```typescript ```typescript
await MTPClient.create({ await MTPClient.create({
@ -359,6 +388,8 @@ interface ParsedFrame {
} }
``` ```
### Frames
Raw message helpers that remain available include: Raw message helpers that remain available include:
- `build_frame(messageType, data, options?)` - `build_frame(messageType, data, options?)`
@ -377,6 +408,8 @@ const parsed = codec.decode(frame);
const display = codec.format(frame); const display = codec.format(frame);
``` ```
### Crypto
Raw crypto and key helpers include: Raw crypto and key helpers include:
- `ed25519_generate()` - `ed25519_generate()`
@ -409,48 +442,25 @@ const confirmedId = await rawClient.auth_connect(
); );
``` ```
### Raw Pipes ### Pipes
The raw `WasmClient` exposes the same pipe operations as the SDK wrapper: The raw `WasmClient` exposes the same pipe operations as the SDK wrapper. The shared lifecycle is in [Pipes](PIPES.md); raw bindings use snake_case names.
```typescript ```typescript
// Incoming pipe requests
rawClient.set_on_pipe_request((event) => { rawClient.set_on_pipe_request((event) => {
const { pipeId, description } = event; void rawClient.accept_pipe(event.pipeId);
// accept or deny
}); });
// Outgoing pipe
const handle = await rawClient.create_pipe("file-transfer"); const handle = await rawClient.create_pipe("file-transfer");
const writer = await handle.wait(); const writer = await handle.wait();
if (writer) { if (writer) {
await writer.write(new Uint8Array([0x01, 0x02])); await writer.write(chunk);
await writer.close(); await writer.close();
} }
// Accept incoming pipe
const reader = await rawClient.accept_pipe(pipeId);
const chunk = await reader.read();
// Deny incoming pipe
await rawClient.deny_pipe(pipeId);
```
Raw `PipeWriter` and `PipeReader` have the same interface as the SDK types:
```typescript
interface PipeWriter {
write(data: Uint8Array): Promise<void>;
close(): Promise<void>;
abort(): void;
readonly pipeId: number;
}
interface PipeReader {
read(): Promise<Uint8Array | null>;
readonly pipeId: number;
readonly description: string;
}
``` ```
A `WasmClient` manages one active WebTransport session. Create a new instance for independent connections, and call `free()` or `[Symbol.dispose]()` on raw WASM objects when you want to release memory eagerly. A `WasmClient` manages one active WebTransport session. Create a new instance for independent connections, and call `free()` or `[Symbol.dispose]()` on raw WASM objects when you want to release memory eagerly.
### State Management
A `WasmClient` owns one active WebTransport session. Create a separate client for each independent connection. Call `free()` or `[Symbol.dispose]()` on raw WASM objects when the application no longer needs them. SDK session and device secret persistence are documented in [Security](SECURITY.md#browser-end-to-end-encryption).

View file

@ -46,7 +46,7 @@ protocol_version: "0.0"
# ErrorMessage: 12 # ErrorMessage: 12
# Accepted: 13, # Accepted: 13,
# #
# If a Type can't be used it will be mapped to 0 # Types absent from a protocol version cannot be encoded for that version.
type_maps: type_maps:
"0.0": # Protocol version 0.0 "0.0": # Protocol version 0.0

714
example/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[workspace] [workspace]
members = [ members = [
"server", "server",
"client", "keygen", "client", "keygen", "webserver",
] ]
resolver = "3" resolver = "3"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "client" name = "client"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
[[bin]] [[bin]]
@ -8,6 +8,6 @@ name = "client"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
mtp = { version = "0.1.0", path = "../../", features = ["client", "crypto", "files", "pipes"] } mtp = { version = "0.2.0", path = "../../", features = ["client", "crypto", "files", "pipes"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
rand = "0.8" rand = "0.10.1"

View file

@ -4,7 +4,7 @@ use mtp::client::{ClientConfig, MTPClient, MTPConnection};
use mtp::crypto::{ use mtp::crypto::{
Ed25519Signer, KemPrivateKey, KemPublicKey, Keyring, MlDsaSigner, PublicKeyBundle, Ed25519Signer, KemPrivateKey, KemPublicKey, Keyring, MlDsaSigner, PublicKeyBundle,
}; };
use mtp::files::{load_keyring, save_keyring}; use mtp::files::{load_keyring_raw, save_keyring_raw};
pub async fn connect_or_register( pub async fn connect_or_register(
mut config: ClientConfig, mut config: ClientConfig,
@ -14,7 +14,10 @@ pub async fn connect_or_register(
let keyring_path = format!("{key_prefix}.mk"); let keyring_path = format!("{key_prefix}.mk");
let id_path = format!("{key_prefix}.id"); let id_path = format!("{key_prefix}.id");
if let (Ok(keyring), Ok(id)) = (load_keyring(&keyring_path), fs::read_to_string(&id_path)) { if let (Ok(keyring), Ok(id)) = (
load_keyring_raw(&keyring_path),
fs::read_to_string(&id_path),
) {
let client_id: u64 = id.trim().parse()?; let client_id: u64 = id.trim().parse()?;
println!("Loaded client keys (ID: {client_id})"); println!("Loaded client keys (ID: {client_id})");
@ -41,7 +44,7 @@ pub async fn connect_or_register(
let conn = MTPClient::auth_register(config, &keyring, &host_public_key).await?; let conn = MTPClient::auth_register(config, &keyring, &host_public_key).await?;
println!("Registered with ID: {}", conn.client_id); println!("Registered with ID: {}", conn.client_id);
save_keyring(&keyring, &keyring_path)?; save_keyring_raw(&keyring, &keyring_path)?;
fs::write(&id_path, conn.client_id.to_string())?; fs::write(&id_path, conn.client_id.to_string())?;
println!("Saved client keys -> {keyring_path}"); println!("Saved client keys -> {keyring_path}");

View file

@ -13,33 +13,33 @@ pub fn build_demo_message(
let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)?; let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)?;
let tm = TypeMap::latest(); let tm = TypeMap::latest();
let version_id = DataType::Version
.try_to_id(&tm)
.ok_or_else(|| std::io::Error::other("Version is absent from the type map"))?;
let id_id = DataType::Id
.try_to_id(&tm)
.ok_or_else(|| std::io::Error::other("Id is absent from the type map"))?;
let inner_enc = DataValue::Container(vec![ let inner_enc = DataValue::Container(vec![
( (version_id, DataValue::Str("secret inner data".into())),
DataType::Version.to_id(&tm), (id_id, DataValue::UnsignedNumber(42)),
DataValue::Str("secret inner data".into()),
),
(DataType::Id.to_id(&tm), DataValue::UnsignedNumber(42)),
]); ]);
let mut dv_enc = inner_enc; let mut dv_enc = inner_enc;
dv_enc.encrypt_container(enc_type, server_bundle, b"demo-aad"); dv_enc.encrypt_container(enc_type, server_bundle, b"demo-aad");
let inner_sig = DataValue::Container(vec![ let inner_sig = DataValue::Container(vec![
( (version_id, DataValue::Str("signed by client".into())),
DataType::Version.to_id(&tm), (id_id, DataValue::UnsignedNumber(99)),
DataValue::Str("signed by client".into()),
),
(DataType::Id.to_id(&tm), DataValue::UnsignedNumber(99)),
]); ]);
let mut dv_sig = inner_sig; let mut dv_sig = inner_sig;
dv_sig.sign_container(SigAlgorithm::ED25519, &signer); dv_sig.sign_container(SigAlgorithm::ED25519, &signer);
let inner_sec = DataValue::Container(vec![ let inner_sec = DataValue::Container(vec![
( (
DataType::Version.to_id(&tm), version_id,
DataValue::Str("signed+encrypted payload".into()), DataValue::Str("signed+encrypted payload".into()),
), ),
(DataType::Id.to_id(&tm), DataValue::UnsignedNumber(7)), (id_id, DataValue::UnsignedNumber(7)),
]); ]);
let mut dv_sec = inner_sec; let mut dv_sec = inner_sec;
dv_sec.sign_and_encrypt_container( dv_sec.sign_and_encrypt_container(
@ -65,7 +65,7 @@ pub fn build_demo_message(
) )
.add_typed_default(DataType::Data, DataValue::Str("Hello, MTP!".into())) .add_typed_default(DataType::Data, DataValue::Str("Hello, MTP!".into()))
.add_typed_default(DataType::Flags, DataValue::BoolTrue) .add_typed_default(DataType::Flags, DataValue::BoolTrue)
.add_typed_default(DataType::Value, DataValue::Float(2, 12345)) .add_typed_default(DataType::Value, DataValue::Float(1_234_500.0))
.add_typed_default( .add_typed_default(
DataType::BinaryData, DataType::BinaryData,
DataValue::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42]), DataValue::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42]),

View file

@ -1,7 +1,7 @@
[package] [package]
name = "keygen" name = "keygen"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
mtp = { version = "0.1.0", path = "../../", features = ["files"] } mtp = { version = "0.2.0", path = "../../", features = ["files"] }

View file

@ -2,8 +2,8 @@ use std::path::PathBuf;
use mtp::crypto::Keyring; use mtp::crypto::Keyring;
use mtp::files::{ use mtp::files::{
self, BUNDLE_EXTENSION, KEYRING_EXTENSION, load_keyring, load_public_key_bundle, save_keyring, self, BUNDLE_EXTENSION, KEYRING_EXTENSION, load_keyring_raw, load_public_key_bundle,
save_public_key_bundle, save_keyring_raw, save_public_key_bundle,
}; };
fn main() -> Result<(), files::FileError> { fn main() -> Result<(), files::FileError> {
@ -11,11 +11,11 @@ fn main() -> Result<(), files::FileError> {
let bundle_path = PathBuf::from(format!("bundle.{BUNDLE_EXTENSION}")); let bundle_path = PathBuf::from(format!("bundle.{BUNDLE_EXTENSION}"));
let keyring = Keyring::generate(); let keyring = Keyring::generate();
save_keyring(&keyring, &keyring_path)?; save_keyring_raw(&keyring, &keyring_path)?;
save_public_key_bundle(&keyring.public_key_bundle(), &bundle_path)?; save_public_key_bundle(&keyring.public_key_bundle(), &bundle_path)?;
/* Read both back to confirm the files round-trip through the on-disk format. */ /* Read both back to confirm the files round-trip through the on-disk format. */
let loaded_keyring = load_keyring(&keyring_path)?; let loaded_keyring = load_keyring_raw(&keyring_path)?;
let loaded_bundle = load_public_key_bundle(&bundle_path)?; let loaded_bundle = load_public_key_bundle(&bundle_path)?;
assert_eq!(keyring.to_bytes(), loaded_keyring.to_bytes()); assert_eq!(keyring.to_bytes(), loaded_keyring.to_bytes());
assert_eq!( assert_eq!(

View file

@ -1,6 +1,6 @@
[package] [package]
name = "server" name = "server"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
[[bin]] [[bin]]
@ -8,10 +8,8 @@ name = "server"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
mtp = { version = "0.1.0", path = "../../", features = ["crypto", "host", "files", "pipes"] } mtp = { version = "0.2.0", path = "../../", features = ["crypto", "tls", "host", "files", "pipes"] }
rcgen = "0.14"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
serde_json = { version = "1" } serde_json = { version = "1" }
hex = "0.4" hex = "0.4"
base64 = "0.22" base64 = "0.22"
time = "0.3"

View file

@ -1,7 +1,7 @@
use std::fs; use std::fs;
use mtp::crypto::Keyring; use mtp::crypto::Keyring;
use mtp::files::{load_keyring, save_keyring, save_public_key_bundle}; use mtp::files::{load_keyring_raw, save_keyring_raw, save_public_key_bundle};
/* Host id is fixed for the example; only the keyring itself is persisted. */ /* Host id is fixed for the example; only the keyring itself is persisted. */
const HOST_ID: u64 = 1; const HOST_ID: u64 = 1;
@ -9,13 +9,13 @@ const HOST_ID: u64 = 1;
pub fn load_or_generate_host_keys( pub fn load_or_generate_host_keys(
keyring_path: &str, keyring_path: &str,
) -> Result<(u64, Keyring), Box<dyn std::error::Error>> { ) -> Result<(u64, Keyring), Box<dyn std::error::Error>> {
if let Ok(keyring) = load_keyring(keyring_path) { if let Ok(keyring) = load_keyring_raw(keyring_path) {
println!("Loaded host keyring from {keyring_path}"); println!("Loaded host keyring from {keyring_path}");
return Ok((HOST_ID, keyring)); return Ok((HOST_ID, keyring));
} }
let keyring = Keyring::generate(); let keyring = Keyring::generate();
save_keyring(&keyring, keyring_path)?; save_keyring_raw(&keyring, keyring_path)?;
println!("Generated host keyring -> {keyring_path}"); println!("Generated host keyring -> {keyring_path}");
Ok((HOST_ID, keyring)) Ok((HOST_ID, keyring))
} }

View file

@ -86,13 +86,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (_host_id, host_keyring) = keys::load_or_generate_host_keys("host.mk")?; let (_host_id, host_keyring) = keys::load_or_generate_host_keys("host.mk")?;
keys::export_host_public_keys(&host_keyring)?; keys::export_host_public_keys(&host_keyring)?;
let decrypt_keyring = Arc::new(match mtp::crypto::Keyring::from_bytes(&host_keyring.to_bytes()) let decrypt_keyring = Arc::new(
{ match mtp::crypto::Keyring::from_bytes(&host_keyring.to_bytes()) {
Ok(keyring) => keyring, Ok(keyring) => keyring,
Err(e) => { Err(e) => {
return Err(format!("failed to re-load host keyring for decryption: {e}").into()); return Err(format!("failed to re-load host keyring for decryption: {e}").into());
} }
}); },
);
let (clients, next_id) = clients::load_client_db("clients.json")?; let (clients, next_id) = clients::load_client_db("clients.json")?;
@ -100,7 +101,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let get_existing_user = move |id: u64, _description: Option<String>| { let get_existing_user = move |id: u64, _description: Option<String>| {
let clients = clients_for_get.clone(); let clients = clients_for_get.clone();
Box::pin(async move { Box::pin(async move {
let result = clients.lock()?.get(&id).cloned(); let result = clients.lock().unwrap().get(&id).cloned();
if result.is_some() { if result.is_some() {
println!("Auth lookup: client ID {id} found"); println!("Auth lookup: client ID {id} found");
} else { } else {
@ -119,8 +120,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let nid_arc = next_id_for_register.clone(); let nid_arc = next_id_for_register.clone();
let path = clients_path.clone(); let path = clients_path.clone();
Box::pin(async move { Box::pin(async move {
let mut db = db_arc.lock()?; let mut db = db_arc.lock().unwrap();
let mut nid = nid_arc.lock()?; let mut nid = nid_arc.lock().unwrap();
let id = *nid; let id = *nid;
*nid += 1; *nid += 1;
db.insert(id, bundle); db.insert(id, bundle);
@ -166,7 +167,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
); );
println!("Client ID: {}", conn.client_id); println!("Client ID: {}", conn.client_id);
let tm: &TypeMap = conn.codec.registry().get(&conn.version)?; let tm: &TypeMap = conn.codec.registry().get(&conn.version).unwrap();
println!("Waiting for messages / pipe requests ..."); println!("Waiting for messages / pipe requests ...");
loop { loop {

View file

@ -1,9 +1,6 @@
use base64::Engine; use base64::Engine;
use rcgen::{CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, SanType};
use std::fs; use std::fs;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::path::Path; use std::path::Path;
use time::{Duration, OffsetDateTime};
pub fn load_or_generate_tls( pub fn load_or_generate_tls(
cert_path: &str, cert_path: &str,
@ -22,35 +19,13 @@ pub fn load_or_generate_tls(
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
} }
let key_pair = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)?; let (cert_pem, key_pem) = mtp::crypto::tls::generate_self_signed_cert("localhost")?;
let mut params = CertificateParams::new(vec!["localhost".into()])?; fs::write(cert_path, &cert_pem)?;
params.not_before = OffsetDateTime::now_utc() - Duration::minutes(5); fs::write(key_path, &key_pem)?;
params.not_after = OffsetDateTime::now_utc() + Duration::days(13);
params
.subject_alt_names
.push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
params
.subject_alt_names
.push(SanType::IpAddress(IpAddr::V6(Ipv6Addr::new(
0, 0, 0, 0, 0, 0, 0, 1,
))));
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
params.is_ca = IsCa::NoCa;
let cert = params.self_signed(&key_pair)?;
let cert_str = cert.pem();
let key_str = key_pair.serialize_pem();
fs::write(cert_path, cert_str.as_bytes())?;
fs::write(key_path, key_str.as_bytes())?;
println!("Wrote {cert_path} and {key_path}"); println!("Wrote {cert_path} and {key_path}");
Ok((cert_str.into_bytes(), key_str.into_bytes())) Ok((cert_pem, key_pem))
} }
pub fn certificate_sha256_hex(cert: &[u8]) -> Result<String, Box<dyn std::error::Error>> { pub fn certificate_sha256_hex(cert: &[u8]) -> Result<String, Box<dyn std::error::Error>> {

View file

@ -1,7 +1,7 @@
{ {
"name": "mtp-web-client", "name": "mtp-web-client",
"private": true, "private": true,
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"packageManager": "pnpm@11.8.0", "packageManager": "pnpm@11.8.0",
"scripts": { "scripts": {

View file

@ -0,0 +1,10 @@
[package]
name = "mtp-webserver-example"
version = "0.2.0"
edition = "2024"
publish = false
[dependencies]
http = "1"
mtp = { path = "../..", features = ["web-server"] }
tokio = { version = "1", features = ["full"] }

View file

@ -0,0 +1,38 @@
use http::StatusCode;
use mtp::{
host::HostConfig,
webserver::{Http3Request, Http3Response, MTPWebServer, WebServerConfig},
};
async fn health(_request: Http3Request, response: Http3Response) -> Http3Response {
response
.status(StatusCode::OK)
.header("content-type", "application/json")
.body(r#"{"status":"ok"}"#)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = HostConfig::new(
"0.0.0.0".parse()?,
4433,
std::fs::read("cert.pem")?,
std::fs::read("key.pem")?,
);
let web = WebServerConfig::new().route("/health", health)?.mtp_path("/mtp");
let mut server = MTPWebServer::new(config, web).await?;
println!("listening on {}", server.local_addr());
while let Some(connection) = server.accept().await? {
println!(
"MTP client connected: path={}, version={}, description={:?}",
connection.path, connection.version, connection.description
);
tokio::spawn(async move {
while let Ok(message) = connection.receiver.receive().await {
println!("received MTP message {}", message.get_id());
}
});
}
Ok(())
}

View file

@ -1,10 +1,13 @@
[package] [package]
name = "mtp-files" name = "mtp-files"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
# Only the plain key types (`Keyring`, `PublicKeyBundle`, `CryptoError`) are # Only the plain key types (`Keyring`, `PublicKeyBundle`, `CryptoError`) are
# needed here; those are always compiled, so no crypto features are required. # needed here; those are always compiled, so no crypto features are required.
mtp-crypto = { version = "0.1.0", path = "../crypto", default-features = false } mtp-crypto = { version = "0.2.0", path = "../crypto", default-features = false, features = ["chacha20poly1305", "hkdf"] }
rand = "0.10.2"
thiserror = "1" thiserror = "1"
zeroize = "1.9"

View file

@ -1,18 +1,22 @@
/* /*
* On-disk storage for methanium key material. * On-disk storage for methanium key material.
* *
* `.mk` files hold a full Keyring (public and secret keys) and are written with * `.mk` files hold a passphrase-protected Keyring and are written atomically
* owner-only permissions (0600) on Unix. `.mpkb` files hold a PublicKeyBundle * with owner-only permissions (0600) on Unix. `.mpkb` files hold a
* (public keys only) and are safe to share. Each file opens with a 4-byte magic * PublicKeyBundle (public keys only) and are safe to share. Each file opens
* that doubles as a type tag, so a bundle never loads as a keyring, followed by * with a 4-byte magic that doubles as a type tag, so a bundle never loads as a
* a version byte for future format changes. * keyring, followed by a version byte.
*/ */
use std::fs; use std::fs;
use std::io; use std::io;
use std::path::Path; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use mtp_crypto::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305, derive_encryption_key};
use rand::RngExt;
use thiserror::Error; use thiserror::Error;
use zeroize::Zeroizing;
pub use mtp_crypto::{CryptoError, Keyring, PublicKeyBundle}; pub use mtp_crypto::{CryptoError, Keyring, PublicKeyBundle};
@ -24,8 +28,12 @@ pub const BUNDLE_EXTENSION: &str = "mpkb";
/* Container layout: magic (4 bytes) || version (1 byte) || payload. */ /* Container layout: magic (4 bytes) || version (1 byte) || payload. */
const KEYRING_MAGIC: [u8; 4] = *b"MTMK"; /* Methanium Keyring */ const KEYRING_MAGIC: [u8; 4] = *b"MTMK"; /* Methanium Keyring */
const BUNDLE_MAGIC: [u8; 4] = *b"MPKB"; /* Methanium Public Key Bundle */ const BUNDLE_MAGIC: [u8; 4] = *b"MPKB"; /* Methanium Public Key Bundle */
const FORMAT_VERSION: u8 = 1; const RAW_FORMAT_VERSION: u8 = 1;
const PROTECTED_FORMAT_VERSION: u8 = 2;
const BUNDLE_FORMAT_VERSION: u8 = 1;
const HEADER_LEN: usize = 4 + 1; const HEADER_LEN: usize = 4 + 1;
const SALT_LEN: usize = 32;
const KEYRING_KDF_CONTEXT: &[u8] = b"mtp-keyring-at-rest-v2";
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum FileError { pub enum FileError {
@ -35,69 +43,187 @@ pub enum FileError {
Crypto(#[from] CryptoError), Crypto(#[from] CryptoError),
#[error("not a valid methanium {expected} file (bad magic)")] #[error("not a valid methanium {expected} file (bad magic)")]
BadMagic { expected: &'static str }, BadMagic { expected: &'static str },
#[error("unsupported {kind} format version {found} (expected {FORMAT_VERSION})")] #[error("unsupported {kind} format version {found}")]
UnsupportedVersion { kind: &'static str, found: u8 }, UnsupportedVersion { kind: &'static str, found: u8 },
#[error("file is truncated: {0} bytes, need at least {HEADER_LEN}")] #[error("file is truncated: {0} bytes, need at least {HEADER_LEN}")]
Truncated(usize), Truncated(usize),
#[error("passphrase must not be empty")]
EmptyPassphrase,
#[error(
"keyring is stored in the unprotected raw format; use load_keyring_raw only for trusted development or migration"
)]
UnprotectedKeyring,
#[error("keyring is passphrase-protected and cannot be loaded as raw")]
ProtectedKeyring,
} }
fn encode(magic: [u8; 4], payload: &[u8]) -> Vec<u8> { fn encode(magic: [u8; 4], version: u8, payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(HEADER_LEN + payload.len()); let mut out = Vec::with_capacity(HEADER_LEN + payload.len());
out.extend_from_slice(&magic); out.extend_from_slice(&magic);
out.push(FORMAT_VERSION); out.push(version);
out.extend_from_slice(payload); out.extend_from_slice(payload);
out out
} }
fn decode<'a>(bytes: &'a [u8], magic: [u8; 4], kind: &'static str) -> Result<&'a [u8], FileError> { fn decode<'a>(
bytes: &'a [u8],
magic: [u8; 4],
kind: &'static str,
) -> Result<(u8, &'a [u8]), FileError> {
if bytes.len() < HEADER_LEN { if bytes.len() < HEADER_LEN {
return Err(FileError::Truncated(bytes.len())); return Err(FileError::Truncated(bytes.len()));
} }
if bytes[..4] != magic { if bytes[..4] != magic {
return Err(FileError::BadMagic { expected: kind }); return Err(FileError::BadMagic { expected: kind });
} }
let found = bytes[4]; Ok((bytes[4], &bytes[HEADER_LEN..]))
if found != FORMAT_VERSION {
return Err(FileError::UnsupportedVersion { kind, found });
}
Ok(&bytes[HEADER_LEN..])
} }
/* /* The temporary secret file is owner-only from the instant it is created. */
* `OpenOptions::mode` only applies when the file is created, so the mode is
* re-set afterwards to also tighten a pre-existing, more-permissive file.
*/
#[cfg(unix)] #[cfg(unix)]
fn write_secret(path: &Path, bytes: &[u8]) -> io::Result<()> { fn create_secret_file(path: &Path) -> io::Result<fs::File> {
use std::io::Write; use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut file = fs::OpenOptions::new() fs::OpenOptions::new()
.write(true) .write(true)
.create(true) .create_new(true)
.truncate(true)
.mode(0o600) .mode(0o600)
.open(path)?; .open(path)
file.set_permissions(fs::Permissions::from_mode(0o600))?;
file.write_all(bytes)?;
file.sync_all()
} }
#[cfg(not(unix))] #[cfg(not(unix))]
fn write_secret(path: &Path, bytes: &[u8]) -> io::Result<()> { fn create_secret_file(path: &Path) -> io::Result<fs::File> {
fs::write(path, bytes) fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
} }
// Writes secret keys, so the file is created owner-only (0600) on Unix. fn temporary_path(path: &Path, attempt: u64) -> io::Result<PathBuf> {
pub fn save_keyring(keyring: &Keyring, path: impl AsRef<Path>) -> Result<(), FileError> { let parent = path.parent().unwrap_or_else(|| Path::new("."));
let bytes = encode(KEYRING_MAGIC, &keyring.to_bytes()); let name = path
write_secret(path.as_ref(), &bytes)?; .file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?;
let mut temporary_name = name.to_os_string();
temporary_name.push(format!(
".tmp-{}-{}-{attempt}",
std::process::id(),
TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
));
Ok(parent.join(temporary_name))
}
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn write_secret_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
use std::io::Write;
let (temporary, mut file) = (0..100)
.find_map(|attempt| {
let temporary = temporary_path(path, attempt).ok()?;
match create_secret_file(&temporary) {
Ok(file) => Some(Ok((temporary, file))),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => None,
Err(error) => Some(Err(error)),
}
})
.transpose()?
.ok_or_else(|| {
io::Error::new(io::ErrorKind::AlreadyExists, "no temporary name available")
})?;
if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
drop(file);
let _ = fs::remove_file(&temporary);
return Err(error);
}
drop(file);
if let Err(error) = fs::rename(&temporary, path) {
let _ = fs::remove_file(&temporary);
return Err(error);
}
Ok(()) Ok(())
} }
pub fn load_keyring(path: impl AsRef<Path>) -> Result<Keyring, FileError> { /// Save a keyring encrypted with XChaCha20-Poly1305 under an HKDF-derived key.
pub fn save_keyring(
keyring: &Keyring,
path: impl AsRef<Path>,
passphrase: &[u8],
) -> Result<(), FileError> {
if passphrase.is_empty() {
return Err(FileError::EmptyPassphrase);
}
let mut salt = [0u8; SALT_LEN];
rand::rng().fill(&mut salt);
let key = Zeroizing::new(derive_encryption_key(
passphrase,
&salt,
KEYRING_KDF_CONTEXT,
)?);
let cipher = ChaCha20Poly1305::new(*key);
let plaintext = keyring.to_bytes();
let encrypted = cipher.encrypt(&plaintext, &KEYRING_MAGIC)?;
let mut payload = Vec::with_capacity(SALT_LEN + encrypted.len());
payload.extend_from_slice(&salt);
payload.extend_from_slice(&encrypted);
let bytes = encode(KEYRING_MAGIC, PROTECTED_FORMAT_VERSION, &payload);
write_secret_atomic(path.as_ref(), &bytes)?;
Ok(())
}
pub fn load_keyring(path: impl AsRef<Path>, passphrase: &[u8]) -> Result<Keyring, FileError> {
if passphrase.is_empty() {
return Err(FileError::EmptyPassphrase);
}
let bytes = fs::read(path)?; let bytes = fs::read(path)?;
let payload = decode(&bytes, KEYRING_MAGIC, "keyring")?; let (version, payload) = decode(&bytes, KEYRING_MAGIC, "keyring")?;
if version == RAW_FORMAT_VERSION {
return Err(FileError::UnprotectedKeyring);
}
if version != PROTECTED_FORMAT_VERSION {
return Err(FileError::UnsupportedVersion {
kind: "keyring",
found: version,
});
}
let salt = payload
.get(..SALT_LEN)
.ok_or(FileError::Truncated(bytes.len()))?;
let encrypted = payload
.get(SALT_LEN..)
.ok_or(FileError::Truncated(bytes.len()))?;
let key = Zeroizing::new(derive_encryption_key(
passphrase,
salt,
KEYRING_KDF_CONTEXT,
)?);
let cipher = ChaCha20Poly1305::new(*key);
let plaintext = Zeroizing::new(cipher.decrypt(encrypted, &KEYRING_MAGIC)?);
Ok(Keyring::from_bytes(&plaintext)?)
}
/// Explicitly save the legacy plaintext format for tests and development.
pub fn save_keyring_raw(keyring: &Keyring, path: impl AsRef<Path>) -> Result<(), FileError> {
let payload = keyring.to_bytes();
let bytes = Zeroizing::new(encode(KEYRING_MAGIC, RAW_FORMAT_VERSION, &payload));
write_secret_atomic(path.as_ref(), &bytes)?;
Ok(())
}
/// Explicitly load the legacy plaintext format for tests and development.
pub fn load_keyring_raw(path: impl AsRef<Path>) -> Result<Keyring, FileError> {
let bytes = Zeroizing::new(fs::read(path)?);
let (version, payload) = decode(&bytes, KEYRING_MAGIC, "keyring")?;
if version == PROTECTED_FORMAT_VERSION {
return Err(FileError::ProtectedKeyring);
}
if version != RAW_FORMAT_VERSION {
return Err(FileError::UnsupportedVersion {
kind: "keyring",
found: version,
});
}
Ok(Keyring::from_bytes(payload)?) Ok(Keyring::from_bytes(payload)?)
} }
@ -105,14 +231,20 @@ pub fn save_public_key_bundle(
bundle: &PublicKeyBundle, bundle: &PublicKeyBundle,
path: impl AsRef<Path>, path: impl AsRef<Path>,
) -> Result<(), FileError> { ) -> Result<(), FileError> {
let bytes = encode(BUNDLE_MAGIC, &bundle.as_bytes()); let bytes = encode(BUNDLE_MAGIC, BUNDLE_FORMAT_VERSION, &bundle.as_bytes());
fs::write(path, bytes)?; fs::write(path, bytes)?;
Ok(()) Ok(())
} }
pub fn load_public_key_bundle(path: impl AsRef<Path>) -> Result<PublicKeyBundle, FileError> { pub fn load_public_key_bundle(path: impl AsRef<Path>) -> Result<PublicKeyBundle, FileError> {
let bytes = fs::read(path)?; let bytes = fs::read(path)?;
let payload = decode(&bytes, BUNDLE_MAGIC, "public key bundle")?; let (version, payload) = decode(&bytes, BUNDLE_MAGIC, "public key bundle")?;
if version != BUNDLE_FORMAT_VERSION {
return Err(FileError::UnsupportedVersion {
kind: "public key bundle",
found: version,
});
}
Ok(PublicKeyBundle::from_bytes(payload)?) Ok(PublicKeyBundle::from_bytes(payload)?)
} }
@ -149,8 +281,8 @@ mod tests {
fn keyring_save_load_roundtrip() -> Result<(), Box<dyn std::error::Error>> { fn keyring_save_load_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(KEYRING_EXTENSION); let path = temp_path(KEYRING_EXTENSION);
let keyring = sample_keyring(); let keyring = sample_keyring();
save_keyring(&keyring, &path)?; save_keyring(&keyring, &path, b"correct horse battery staple")?;
let loaded = load_keyring(&path)?; let loaded = load_keyring(&path, b"correct horse battery staple")?;
assert_eq!(keyring.to_bytes(), loaded.to_bytes()); assert_eq!(keyring.to_bytes(), loaded.to_bytes());
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
Ok(()) Ok(())
@ -172,7 +304,7 @@ mod tests {
let path = temp_path(BUNDLE_EXTENSION); let path = temp_path(BUNDLE_EXTENSION);
save_public_key_bundle(&sample_keyring().public_key_bundle(), &path)?; save_public_key_bundle(&sample_keyring().public_key_bundle(), &path)?;
assert!(matches!( assert!(matches!(
load_keyring(&path), load_keyring(&path, b"passphrase"),
Err(FileError::BadMagic { .. }) Err(FileError::BadMagic { .. })
)); ));
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
@ -183,7 +315,10 @@ mod tests {
fn truncated_file_is_rejected() -> Result<(), Box<dyn std::error::Error>> { fn truncated_file_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(KEYRING_EXTENSION); let path = temp_path(KEYRING_EXTENSION);
fs::write(&path, b"MT")?; fs::write(&path, b"MT")?;
assert!(matches!(load_keyring(&path), Err(FileError::Truncated(2)))); assert!(matches!(
load_keyring(&path, b"passphrase"),
Err(FileError::Truncated(2))
));
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
Ok(()) Ok(())
} }
@ -193,10 +328,53 @@ mod tests {
fn keyring_file_is_owner_only() -> Result<(), Box<dyn std::error::Error>> { fn keyring_file_is_owner_only() -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let path = temp_path(KEYRING_EXTENSION); let path = temp_path(KEYRING_EXTENSION);
save_keyring(&sample_keyring(), &path)?; save_keyring(&sample_keyring(), &path, b"passphrase")?;
let mode = fs::metadata(&path)?.permissions().mode(); let mode = fs::metadata(&path)?.permissions().mode();
assert_eq!(mode & 0o777, 0o600); assert_eq!(mode & 0o777, 0o600);
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
Ok(()) Ok(())
} }
#[test]
fn wrong_passphrase_cannot_load_keyring() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(KEYRING_EXTENSION);
save_keyring(&sample_keyring(), &path, b"right passphrase")?;
assert!(matches!(
load_keyring(&path, b"wrong passphrase"),
Err(FileError::Crypto(CryptoError::DecryptionFailed))
));
let _ = fs::remove_file(&path);
Ok(())
}
#[test]
fn raw_keyring_requires_explicit_api() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(KEYRING_EXTENSION);
let keyring = sample_keyring();
save_keyring_raw(&keyring, &path)?;
assert!(matches!(
load_keyring(&path, b"passphrase"),
Err(FileError::UnprotectedKeyring)
));
let loaded = load_keyring_raw(&path)?;
assert_eq!(keyring.to_bytes(), loaded.to_bytes());
let _ = fs::remove_file(&path);
Ok(())
}
#[test]
fn protected_keyring_is_not_plaintext() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(KEYRING_EXTENSION);
let keyring = sample_keyring();
let serialized = keyring.to_bytes();
save_keyring(&keyring, &path, b"passphrase")?;
let stored = fs::read(&path)?;
assert!(
!stored
.windows(serialized.len())
.any(|window| window == serialized.as_slice())
);
let _ = fs::remove_file(&path);
Ok(())
}
} }

View file

@ -40,7 +40,7 @@
name = "mtp-clippy"; name = "mtp-clippy";
runtimeInputs = [rustToolchain]; runtimeInputs = [rustToolchain];
text = '' text = ''
export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}" export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example-type-maps.yaml}"
cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings -W unreachable-pub cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings -W unreachable-pub
''; '';
}; };
@ -57,7 +57,7 @@
name = "mtp-build-all"; name = "mtp-build-all";
runtimeInputs = [rustToolchain pkgs.cargo-deny pkgs.wasm-pack pkgs.pnpm pkgs.coreutils clippyCheck macheteCheck]; runtimeInputs = [rustToolchain pkgs.cargo-deny pkgs.wasm-pack pkgs.pnpm pkgs.coreutils clippyCheck macheteCheck];
text = '' text = ''
export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}" export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example-type-maps.yaml}"
timeout 60s pnpm install --frozen-lockfile timeout 60s pnpm install --frozen-lockfile
cargo fmt --all --check cargo fmt --all --check
@ -95,7 +95,7 @@
openssl openssl
]; ];
MTP_TYPE_MAPS = "${toString ./example/type-maps.yaml}"; MTP_TYPE_MAPS = "${toString ./example-type-maps.yaml}";
shellHook = '' shellHook = ''
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"

2
host/Cargo.lock generated
View file

@ -4,4 +4,4 @@ version = 4
[[package]] [[package]]
name = "host" name = "host"
version = "0.1.0" version = "0.2.0"

View file

@ -1,15 +1,16 @@
[package] [package]
name = "mtp-host" name = "mtp-host"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
mtp-common = { version = "0.1.0", path = "../common" } mtp-common = { version = "0.2.0", path = "../common" }
mtp-codec = { version = "0.1.0", path = "../codec", features = ["registry"] } mtp-codec = { version = "0.2.0", path = "../codec", features = ["registry"] }
mtp-transport = { version = "0.1.0", path = "../transport", features = ["host"] } mtp-transport = { version = "0.2.0", path = "../transport", features = ["host"] }
mtp-crypto = { version = "0.1.0", path = "../crypto", optional = true } mtp-crypto = { version = "0.2.0", path = "../crypto", optional = true }
rand = "0.8" rand = "0.8"
tokio = { version = "1", features = ["time", "sync"] } tokio = { version = "1", features = ["macros", "rt", "time", "sync"] }
tracing = "0.1"
[features] [features]

163
host/src/config.rs Normal file
View file

@ -0,0 +1,163 @@
use std::net::IpAddr;
#[cfg(feature = "crypto")]
use std::pin::Pin;
#[cfg(feature = "crypto")]
use tokio::time::Duration;
pub use mtp_transport::Policy;
/// Callback that looks up a registered client by ID.
///
/// Called during login to retrieve a client's public key bundle for signature
/// verification, and also during guest ID generation to check whether a random
/// candidate collides with a registered client. When used for collision
/// checking the `description` argument is `None`.
#[cfg(feature = "crypto")]
pub type GetExistingClient = Box<
dyn Fn(
u64,
Option<String>,
)
-> Pin<Box<dyn std::future::Future<Output = Option<mtp_crypto::PublicKeyBundle>> + Send>>
+ Send
+ Sync,
>;
/// Callback that assigns a guest (unauthenticated) client ID.
///
/// Return `Some(id)` to accept the guest with the given ID, or `None` to reject
/// the connection. The returned ID must fit in 48 bits
/// (`id <= mtp_codec::MAX_WIRE_ID`); values outside that range are rejected
/// automatically.
///
/// When set to `None` on `HostConfig`, the built-in generator produces a random
/// 48-bit ID that avoids collisions with registered clients.
#[cfg(feature = "crypto")]
pub type GuestIdGenerator =
Box<dyn Fn() -> Pin<Box<dyn std::future::Future<Output = Option<u64>> + Send>> + Send + Sync>;
#[cfg(feature = "crypto")]
pub type CompleteRegister = Box<
dyn Fn(
mtp_crypto::PublicKeyBundle,
Option<String>,
) -> Pin<Box<dyn std::future::Future<Output = u64> + Send>>
+ Send
+ Sync,
>;
#[cfg(feature = "crypto")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthenticationPolicy {
ForceAuthentication,
AllowAuthentication,
Unauthenticated,
}
pub struct HostConfig {
pub ip: IpAddr,
pub port: u16,
pub tls_fullchain: Vec<u8>,
pub tls_key: Vec<u8>,
pub policy: Policy,
pub send_pongs: bool,
#[cfg(feature = "crypto")]
pub authentication_policy: AuthenticationPolicy,
#[cfg(feature = "crypto")]
pub auth_timeout: Duration,
#[cfg(feature = "crypto")]
pub require_pq: bool,
#[cfg(feature = "crypto")]
pub host_keyring: mtp_crypto::Keyring,
#[cfg(feature = "crypto")]
pub get_existing_client: GetExistingClient,
#[cfg(feature = "crypto")]
pub guest_id_generator: Option<GuestIdGenerator>,
#[cfg(feature = "crypto")]
pub complete_register: CompleteRegister,
}
impl HostConfig {
pub fn new(ip: IpAddr, port: u16, tls_fullchain: Vec<u8>, tls_key: Vec<u8>) -> Self {
Self {
ip,
port,
tls_fullchain,
tls_key,
policy: Policy::default(),
send_pongs: true,
#[cfg(feature = "crypto")]
authentication_policy: AuthenticationPolicy::Unauthenticated,
#[cfg(feature = "crypto")]
auth_timeout: Duration::from_secs(30),
#[cfg(feature = "crypto")]
require_pq: true,
#[cfg(feature = "crypto")]
host_keyring: mtp_crypto::Keyring::new(
mtp_crypto::KemPublicKey::new(Vec::new()),
mtp_crypto::KemPrivateKey::new(Vec::new()),
mtp_crypto::SignaturePqPublicKey::new(Vec::new()),
mtp_crypto::SignaturePqPrivateKey::new(Vec::new()),
mtp_crypto::SignaturePublicKey::new(Vec::new()),
mtp_crypto::SignaturePrivateKey::new(Vec::new()),
),
#[cfg(feature = "crypto")]
get_existing_client: Box::new(|_, _| Box::pin(async { None })),
#[cfg(feature = "crypto")]
guest_id_generator: None,
#[cfg(feature = "crypto")]
complete_register: Box::new(|_, _| Box::pin(async { 0 })),
}
}
pub fn with_policy(mut self, policy: Policy) -> Self {
self.policy = policy;
self
}
pub fn with_pongs(mut self, send_pongs: bool) -> Self {
self.send_pongs = send_pongs;
self
}
#[cfg(feature = "crypto")]
pub fn with_authentication(
mut self,
host_keyring: mtp_crypto::Keyring,
get_existing_client: GetExistingClient,
complete_register: CompleteRegister,
) -> Self {
self.authentication_policy = AuthenticationPolicy::ForceAuthentication;
self.host_keyring = host_keyring;
self.get_existing_client = Box::new(get_existing_client);
self.complete_register = Box::new(complete_register);
self
}
#[cfg(feature = "crypto")]
pub fn with_authentication_policy(mut self, policy: AuthenticationPolicy) -> Self {
self.authentication_policy = policy;
self
}
#[cfg(feature = "crypto")]
pub fn with_auth_timeout(mut self, timeout: Duration) -> Self {
self.auth_timeout = timeout;
self
}
#[cfg(feature = "crypto")]
pub fn with_require_pq(mut self, require_pq: bool) -> Self {
self.require_pq = require_pq;
self
}
#[cfg(feature = "crypto")]
pub fn with_guest_id_generator(mut self, generator: GuestIdGenerator) -> Self {
self.guest_id_generator = Some(generator);
self
}
}

126
host/src/connection.rs Normal file
View file

@ -0,0 +1,126 @@
use mtp_codec::{CommunicationValue, Version, registry::VersionedCodec};
use mtp_common::CommunicationError;
#[cfg(feature = "pipes")]
use std::sync::Arc;
#[cfg(feature = "pipes")]
use tokio::sync::{Mutex, mpsc};
#[cfg(feature = "crypto")]
use crate::error::random_client_id;
#[cfg(feature = "pipes")]
use crate::pipe::{PipeDispatcher, PipeRequest};
mod connection_capability {
pub trait Sealed {}
}
pub trait MtpSenderLike: connection_capability::Sealed + Clone + Send + Sync {}
pub trait MtpReceiverLike: connection_capability::Sealed + Clone + Send + Sync {
fn receive_message(
&self,
) -> impl std::future::Future<Output = Result<CommunicationValue, CommunicationError>> + Send;
}
impl connection_capability::Sealed for mtp_transport::Sender {}
impl MtpSenderLike for mtp_transport::Sender {}
impl connection_capability::Sealed for mtp_transport::Receiver {}
impl MtpReceiverLike for mtp_transport::Receiver {
async fn receive_message(&self) -> Result<CommunicationValue, CommunicationError> {
self.receive().await
}
}
impl<C: mtp_transport::TransportConnection> connection_capability::Sealed
for mtp_transport::GenericSender<C>
{
}
impl<C: mtp_transport::TransportConnection> MtpSenderLike for mtp_transport::GenericSender<C> {}
impl<C: mtp_transport::TransportConnection> connection_capability::Sealed
for mtp_transport::GenericReceiver<C>
{
}
impl<C: mtp_transport::TransportConnection> MtpReceiverLike for mtp_transport::GenericReceiver<C> {
async fn receive_message(&self) -> Result<CommunicationValue, CommunicationError> {
self.receive().await
}
}
pub struct MTPConnection<S = mtp_transport::Sender, R = mtp_transport::Receiver> {
pub version: Version,
pub codec: VersionedCodec,
pub sender: S,
pub receiver: R,
/// The WebTransport request path used to establish this connection.
///
/// Legacy `MTPHost` connections do not have an HTTP router in front of
/// them, so they always use the root path. Alternative hosts can retain
/// the CONNECT request path when constructing an MTP connection.
pub path: String,
#[cfg(feature = "pipes")]
pub(crate) app_rx: Mutex<mpsc::Receiver<Result<CommunicationValue, CommunicationError>>>,
#[cfg(feature = "pipes")]
pub(crate) pipe_req_rx: Mutex<mpsc::Receiver<PipeRequest>>,
#[cfg(feature = "pipes")]
pub(crate) pipe_dispatcher: Arc<PipeDispatcher>,
pub description: Option<String>,
pub(crate) _dispatcher_task: tokio::task::JoinHandle<()>,
#[cfg(feature = "crypto")]
pub auth_state: crate::error::AuthState,
#[cfg(feature = "crypto")]
pub client_id: u64,
#[cfg(feature = "crypto")]
pub client_public_key: Option<mtp_crypto::PublicKeyBundle>,
}
impl<S, R> MTPConnection<S, R> {
/// Construct an MTP connection from an alternative transport backend.
///
/// Native `MTPHost` users continue to receive the default
/// `MTPConnection<Sender, Receiver>` type. HTTP/3 WebTransport hosts use
/// this constructor with their stream adapters while retaining the shared
/// version, codec, path, and metadata representation.
pub fn from_transport_parts(
version: Version,
codec: VersionedCodec,
sender: S,
receiver: R,
path: String,
description: Option<String>,
) -> Self {
#[cfg(feature = "pipes")]
let (_, app_rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(1);
#[cfg(feature = "pipes")]
let (_, pipe_req_rx) = mpsc::channel::<PipeRequest>(1);
#[cfg(feature = "pipes")]
let dispatcher = Arc::new(PipeDispatcher::default_for_external());
Self {
version,
codec,
sender,
receiver,
path,
#[cfg(feature = "pipes")]
app_rx: Mutex::new(app_rx),
#[cfg(feature = "pipes")]
pipe_req_rx: Mutex::new(pipe_req_rx),
#[cfg(feature = "pipes")]
pipe_dispatcher: dispatcher,
description,
_dispatcher_task: tokio::spawn(async {}),
#[cfg(feature = "crypto")]
auth_state: crate::error::AuthState::Unauthenticated,
#[cfg(feature = "crypto")]
client_id: random_client_id(),
#[cfg(feature = "crypto")]
client_public_key: None,
}
}
}
#[cfg(not(feature = "pipes"))]
impl<S: MtpSenderLike, R: MtpReceiverLike> MTPConnection<S, R> {
pub async fn receive(&self) -> Result<CommunicationValue, CommunicationError> {
let mut message = self.receiver.receive_message().await?;
message.set_type_map(self.codec.type_map());
Ok(message)
}
}

88
host/src/error.rs Normal file
View file

@ -0,0 +1,88 @@
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, Version};
use mtp_common::{CommunicationError, RejectionReason};
use mtp_transport::Sender;
use std::{error::Error, fmt};
#[cfg(feature = "crypto")]
pub(crate) fn random_client_id() -> u64 {
rand::random::<u64>() & mtp_codec::MAX_WIRE_ID
}
pub(crate) async fn send_rejection(sender: &Sender, reason: RejectionReason) {
let response = match &reason {
RejectionReason::BadVersion { supported_versions } => {
CommunicationValue::new(CommunicationType::ErrorBadVersion)
.add_typed_default(
DataType::Version,
DataValue::Str(supported_versions.join(",")),
)
.add_typed_default(DataType::ErrorMessage, DataValue::Str(reason.to_string()))
}
_ => CommunicationValue::new(CommunicationType::IdentificationResponse)
.add_typed_default(DataType::Connected, DataValue::BoolFalse)
.add_typed_default(DataType::ErrorMessage, DataValue::Str(reason.to_string())),
};
let _ = sender.send(&response).await;
}
pub(crate) async fn send_accepted(
sender: &Sender,
version: &Version,
assigned_id: Option<u64>,
) -> Result<(), CommunicationError> {
let mut response = CommunicationValue::new(CommunicationType::IdentificationResponse)
.add_typed_default(DataType::Connected, DataValue::BoolTrue)
.add_typed_default(DataType::Version, DataValue::Str(version.to_string()));
if let Some(id) = assigned_id {
response = response.add_typed_default(DataType::Id, DataValue::UnsignedNumber(id as u128));
}
sender.send(&response).await?;
sender.finish_stream().await
}
pub(crate) fn extract_version(msg: &CommunicationValue) -> Option<Version> {
let value = msg.get_data(DataType::Version);
match value {
DataValue::Str(s) => Version::parse(s.as_str()),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AcceptError {
Receive(CommunicationError),
MissingVersion,
UnsupportedVersion(Version),
AuthenticationFailed(String),
AuthenticationTimedOut,
Send(CommunicationError),
}
impl fmt::Display for AcceptError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Receive(error) => write!(f, "failed to receive opening message: {error}"),
Self::MissingVersion => write!(
f,
"opening message did not include a valid protocol version"
),
Self::UnsupportedVersion(version) => {
write!(f, "unsupported protocol version: {version}")
}
Self::AuthenticationFailed(reason) => write!(f, "authentication failed: {reason}"),
Self::AuthenticationTimedOut => write!(f, "authentication handshake timed out"),
Self::Send(error) => write!(f, "failed to send handshake message: {error}"),
}
}
}
impl Error for AcceptError {}
#[cfg(feature = "crypto")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthState {
Unauthenticated,
Pending,
Authenticated,
Failed,
}

1054
host/src/handshake.rs Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

196
host/src/pipe.rs Normal file
View file

@ -0,0 +1,196 @@
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp_common::PipeError;
use mtp_transport::{PipeReader, Policy, Receiver, Sender};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
use tracing::debug;
pub struct PipeHandle {
pub(crate) pipe_id: u32,
pub(crate) description: String,
pub(crate) sender: Sender,
pub(crate) response_rx: tokio::sync::oneshot::Receiver<Result<bool, PipeError>>,
}
impl PipeHandle {
pub fn pipe_id(&self) -> u32 {
self.pipe_id
}
pub fn description(&self) -> &str {
&self.description
}
pub async fn wait(self) -> Result<Option<mtp_transport::PipeWriter>, PipeError> {
match self.response_rx.await {
Ok(Ok(true)) => {
let writer = self
.sender
.open_pipe(self.pipe_id, &self.description)
.await
.map_err(PipeError::from)?;
Ok(Some(writer))
}
Ok(Ok(false)) => Ok(None),
Ok(Err(e)) => Err(e),
Err(_) => Err(PipeError::StreamClosed),
}
}
}
pub struct PipeRequest {
pub(crate) pipe_id: u32,
pub(crate) description: String,
pub(crate) sender: Sender,
pub(crate) dispatcher: Arc<PipeDispatcher>,
}
impl PipeRequest {
pub fn id(&self) -> u32 {
self.pipe_id
}
pub fn description(&self) -> &str {
&self.description
}
pub async fn accept(self) -> Result<PipeReader, PipeError> {
let (pipe_tx, pipe_rx) = tokio::sync::oneshot::channel();
{
let mut pending = self.dispatcher.pending_pipes.lock().await;
pending.insert(self.pipe_id, pipe_tx);
}
let resp = CommunicationValue::new(CommunicationType::PipeResponse)
.with_id(self.pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolTrue);
self.sender.send(&resp).await.map_err(PipeError::from)?;
let timeout = self.dispatcher.policy.read_timeout;
tokio::time::timeout(timeout, pipe_rx)
.await
.map_err(|_| PipeError::HandshakeTimeout)?
.map_err(|_| PipeError::StreamClosed)
}
pub async fn deny(self) -> Result<(), PipeError> {
let resp = CommunicationValue::new(CommunicationType::PipeResponse)
.with_id(self.pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
self.sender.send(&resp).await.map_err(PipeError::from)?;
Ok(())
}
}
pub(crate) struct PipeDispatcher {
pub(crate) pending_creations:
Mutex<HashMap<u32, tokio::sync::oneshot::Sender<Result<bool, PipeError>>>>,
pub(crate) pending_pipes: Mutex<HashMap<u32, tokio::sync::oneshot::Sender<PipeReader>>>,
pub(crate) policy: Arc<Policy>,
}
impl PipeDispatcher {
pub(crate) fn default_for_external() -> Self {
Self {
pending_creations: Mutex::new(HashMap::new()),
pending_pipes: Mutex::new(HashMap::new()),
policy: Arc::new(Policy::default()),
}
}
}
pub(crate) async fn run_dispatcher(
receiver: Receiver,
sender: Sender,
app_tx: mpsc::Sender<Result<CommunicationValue, mtp_common::CommunicationError>>,
pipe_req_tx: mpsc::Sender<PipeRequest>,
dispatcher: Arc<PipeDispatcher>,
) {
let pipe_req_type = CommunicationType::PipeRequest.try_to_id(&mtp_codec::TypeMap::latest());
let pipe_resp_type = CommunicationType::PipeResponse.try_to_id(&mtp_codec::TypeMap::latest());
loop {
match receiver.receive_event().await {
Ok(mtp_transport::TransportEvent::Message(msg)) => {
debug!(
target = "mtp.host",
message_type = ?msg.get_type(),
message_id = msg.get_id(),
"dispatcher received message"
);
if Some(msg.get_type()) == pipe_req_type {
let pipe_id = msg.get_id();
let description = msg.get_str(DataType::Description).unwrap_or("").to_string();
debug!(
target = "mtp.host",
pipe_id, description, "dispatcher classified pipe request"
);
let req = PipeRequest {
pipe_id,
description,
sender: sender.clone(),
dispatcher: dispatcher.clone(),
};
let _ = pipe_req_tx.send(req).await;
continue;
}
if Some(msg.get_type()) == pipe_resp_type {
let pipe_id = msg.get_id();
let accepted = msg.get_bool(DataType::Accepted).unwrap_or(false);
debug!(
target = "mtp.host",
pipe_id, accepted, "dispatcher classified pipe response"
);
let mut pending = dispatcher.pending_creations.lock().await;
if let Some(tx) = pending.remove(&pipe_id) {
let _ = tx.send(Ok(accepted));
}
continue;
}
if app_tx.send(Ok(msg)).await.is_err() {
break;
}
}
Ok(mtp_transport::TransportEvent::Pipe(reader)) => {
let pipe_id = reader.pipe_id();
debug!(
target = "mtp.host",
pipe_id,
description = reader.description(),
"dispatcher received pipe stream"
);
let mut pending = dispatcher.pending_pipes.lock().await;
if let Some(tx) = pending.remove(&pipe_id) {
let _ = tx.send(reader);
continue;
}
debug!(
target = "mtp.host",
pipe_id, "dispatcher treating pipe stream as pipe request"
);
let req = PipeRequest {
pipe_id,
description: reader.description().to_string(),
sender: sender.clone(),
dispatcher: dispatcher.clone(),
};
let _ = pipe_req_tx.send(req).await;
}
Err(e) => {
if app_tx.send(Err(e)).await.is_err() {
break;
}
}
}
}
}

29
mtp-webserver/Cargo.toml Normal file
View file

@ -0,0 +1,29 @@
[package]
name = "mtp-webserver"
version = "0.2.0"
edition = "2024"
[dependencies]
mtp-common = { version = "0.2.0", path = "../common" }
mtp-codec = { version = "0.2.0", path = "../codec", features = ["registry"] }
mtp-host = { version = "0.2.0", path = "../host" }
mtp-transport = { version = "0.2.0", path = "../transport" }
bytes = "1"
http = "1"
tokio = { version = "1", features = ["io-util", "macros", "net", "rt", "sync", "time"] }
h3 = "0.0.8"
h3-quinn = { version = "0.0.10", features = ["datagram"] }
h3-webtransport = "0.1.2"
quinn = "0.11"
rustls = "0.23"
tracing = "0.1"
thiserror = "2"
async-trait = "0.1"
[dev-dependencies]
rcgen = "0.14"
[features]
default = []
crypto = ["mtp-host/crypto"]
pipes = ["mtp-host/pipes", "mtp-transport/pipes"]

View file

@ -0,0 +1,37 @@
use mtp_common::CommunicationError;
use std::fmt;
/// Unified error type for the webserver transport adapter.
#[derive(Debug)]
pub enum WebServerError {
Transport(CommunicationError),
WebTransport(String),
Http(String),
NotFound(String),
}
impl fmt::Display for WebServerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Transport(e) => write!(f, "transport error: {e}"),
Self::WebTransport(msg) => write!(f, "webtransport error: {msg}"),
Self::Http(msg) => write!(f, "HTTP error: {msg}"),
Self::NotFound(route) => write!(f, "route not found: {route}"),
}
}
}
impl std::error::Error for WebServerError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Transport(e) => Some(e),
_ => None,
}
}
}
impl From<CommunicationError> for WebServerError {
fn from(e: CommunicationError) -> Self {
Self::Transport(e)
}
}

20
mtp-webserver/src/lib.rs Normal file
View file

@ -0,0 +1,20 @@
//! HTTP/3 routing primitives and the combined MTP web-server API.
//!
//! The public routing API is transport-independent. The HTTP/3 driver is
//! intentionally kept behind the crate's implementation boundary so callers
//! do not need to depend on a particular QUIC implementation.
mod error;
mod router;
mod server;
mod stream;
mod transport;
pub use error::WebServerError;
pub use router::{HttpHandler, Router, RouterError};
pub use server::{MTPWebServer, WebServerConfig, WebServerMetrics};
pub use stream::{Http3Request, Http3Response};
pub use transport::{
H3TransportConnection, H3TransportReceiver, H3TransportSender, WebMTPConnection,
WebMtpReceiver, WebMtpSender,
};

129
mtp-webserver/src/router.rs Normal file
View file

@ -0,0 +1,129 @@
use crate::{Http3Request, Http3Response};
use http::Method;
use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc};
/// An asynchronous HTTP/3 route handler.
pub type HttpHandler = Arc<
dyn Fn(Http3Request, Http3Response) -> Pin<Box<dyn Future<Output = Http3Response> + Send>>
+ Send
+ Sync,
>;
/// Errors returned by [`Router`] route registration.
#[derive(Debug, thiserror::Error)]
pub enum RouterError {
#[error("duplicate route registration for {0}")]
DuplicateRoute(String),
#[error("a router fallback is already registered")]
DuplicateFallback,
}
/// Exact-path HTTP route table used by [`MTPWebServer`](crate::MTPWebServer).
#[derive(Clone, Default)]
pub struct Router {
routes: HashMap<(Option<Method>, String), HttpHandler>,
fallback: Option<HttpHandler>,
}
impl Router {
pub fn new() -> Self {
Self::default()
}
pub fn route<F, Fut>(self, path: impl Into<String>, handler: F) -> Result<Self, RouterError>
where
F: Fn(Http3Request, Http3Response) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Http3Response> + Send + 'static,
{
self.route_inner(
None,
path.into(),
Arc::new(move |request, response| Box::pin(handler(request, response))),
)
}
pub fn route_method<F, Fut>(
self,
method: Method,
path: impl Into<String>,
handler: F,
) -> Result<Self, RouterError>
where
F: Fn(Http3Request, Http3Response) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Http3Response> + Send + 'static,
{
self.route_inner(
Some(method),
path.into(),
Arc::new(move |request, response| Box::pin(handler(request, response))),
)
}
pub fn fallback<F, Fut>(mut self, handler: F) -> Result<Self, RouterError>
where
F: Fn(Http3Request, Http3Response) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Http3Response> + Send + 'static,
{
if self.fallback.is_some() {
return Err(RouterError::DuplicateFallback);
}
self.fallback = Some(Arc::new(move |request, response| {
Box::pin(handler(request, response))
}));
Ok(self)
}
fn route_inner(
mut self,
method: Option<Method>,
path: String,
handler: HttpHandler,
) -> Result<Self, RouterError> {
if self
.routes
.insert((method, path.clone()), handler)
.is_some()
{
return Err(RouterError::DuplicateRoute(path));
}
Ok(self)
}
pub(crate) fn handler(&self, method: &Method, path: &str) -> Option<HttpHandler> {
self.routes
.get(&(Some(method.clone()), path.to_string()))
.or_else(|| self.routes.get(&(None, path.to_string())))
.cloned()
}
pub(crate) fn fallback_handler(&self) -> Option<HttpHandler> {
self.fallback.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
use http::{Method, StatusCode, Uri};
#[tokio::test]
async fn route_dispatches_an_exact_path() {
let router = Router::new()
.route("/health", |_, response| async move {
response.status(StatusCode::NO_CONTENT)
})
.unwrap();
let request = Http3Request {
method: Method::GET,
uri: Uri::from_static("/health"),
headers: Default::default(),
body: Some(Bytes::new()),
};
let response =
router.handler(&Method::GET, "/health").unwrap()(request, Http3Response::default())
.await;
assert_eq!(response.status, StatusCode::NO_CONTENT);
assert!(router.handler(&Method::GET, "/missing").is_none());
}
}

577
mtp-webserver/src/server.rs Normal file
View file

@ -0,0 +1,577 @@
use crate::{
Http3Request, Http3Response, Router, RouterError, WebMTPConnection, WebServerError,
transport::accept_web_connection,
};
use bytes::{Buf, Bytes};
use http::{Method, Request, Response, StatusCode};
use mtp_common::CommunicationError;
use mtp_host::HostConfig;
use rustls::pki_types::{PrivateKeyDer, pem::PemObject};
use std::{net::SocketAddr, sync::Arc, time::Duration};
use tokio::sync::{Semaphore, watch};
/// Observability hooks for the web server.
///
/// Implement this trait to receive metrics about connections, requests, and
/// errors. All methods have default no-op implementations so callers only
/// need to override the hooks they care about.
pub trait WebServerMetrics: Send + Sync {
fn connection_accepted(&self) {}
fn connection_closed(&self, _duration: Duration, _reason: &str) {}
fn request_started(&self, _path: &str) {}
fn request_completed(&self, _path: &str, _status: u16, _duration: Duration) {}
fn error_occurred(&self, _error: &WebServerError) {}
}
/// Configuration for the HTTP/3 server and MTP routing.
///
/// Use the builder methods to customise behaviour. All fields have sensible
/// defaults so `WebServerConfig::new()` gives a usable production-ready
/// configuration.
#[derive(Clone)]
pub struct WebServerConfig {
pub(crate) router: Router,
pub(crate) mtp_path: String,
pub max_request_body: usize,
pub max_connections: usize,
pub request_timeout: Duration,
pub drain_timeout: Duration,
pub(crate) metrics: Option<Arc<dyn WebServerMetrics>>,
}
impl Default for WebServerConfig {
fn default() -> Self {
Self::new()
}
}
impl WebServerConfig {
pub fn new() -> Self {
Self {
router: Router::new(),
mtp_path: "/".to_string(),
max_request_body: 4 * 1024 * 1024,
max_connections: 256,
request_timeout: Duration::from_secs(30),
drain_timeout: Duration::from_secs(10),
metrics: None,
}
}
pub fn route<F, Fut>(mut self, path: impl Into<String>, handler: F) -> Result<Self, RouterError>
where
F: Fn(Http3Request, Http3Response) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Http3Response> + Send + 'static,
{
self.router = self.router.route(path, handler)?;
Ok(self)
}
pub fn route_method<F, Fut>(
mut self,
method: Method,
path: impl Into<String>,
handler: F,
) -> Result<Self, RouterError>
where
F: Fn(Http3Request, Http3Response) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Http3Response> + Send + 'static,
{
self.router = self.router.route_method(method, path, handler)?;
Ok(self)
}
pub fn fallback<F, Fut>(mut self, handler: F) -> Result<Self, RouterError>
where
F: Fn(Http3Request, Http3Response) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Http3Response> + Send + 'static,
{
self.router = self.router.fallback(handler)?;
Ok(self)
}
pub fn mtp_path(mut self, path: impl Into<String>) -> Self {
self.mtp_path = path.into();
self
}
pub fn max_request_body(mut self, bytes: usize) -> Self {
self.max_request_body = bytes;
self
}
pub fn max_connections(mut self, max: usize) -> Self {
self.max_connections = max;
self
}
pub fn request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = timeout;
self
}
pub fn drain_timeout(mut self, timeout: Duration) -> Self {
self.drain_timeout = timeout;
self
}
pub fn with_metrics(mut self, metrics: Arc<dyn WebServerMetrics>) -> Self {
self.metrics = Some(metrics);
self
}
}
/// An HTTP/3 server bound to MTP's configured address and certificate.
///
/// One task owns the Quinn endpoint and dispatches all HTTP/3 requests. This
/// is the required ownership model for adding WebTransport MTP sessions on the
/// same UDP socket without competing endpoint accept loops.
pub struct MTPWebServer {
endpoint: quinn::Endpoint,
mtp_incoming: tokio::sync::mpsc::Receiver<Result<WebMTPConnection, mtp_host::AcceptError>>,
shutdown_tx: watch::Sender<()>,
_driver: Option<tokio::task::JoinHandle<()>>,
}
impl MTPWebServer {
pub async fn new(
host_config: HostConfig,
web_config: WebServerConfig,
) -> Result<Self, CommunicationError> {
#[cfg(feature = "crypto")]
if !matches!(
host_config.authentication_policy,
mtp_host::AuthenticationPolicy::Unauthenticated
) {
return Err(CommunicationError::Other(
"web authentication is not supported yet; use Unauthenticated".into(),
));
}
let endpoint = build_endpoint(&host_config)?;
let driver_endpoint = endpoint.clone();
let (mtp_tx, mtp_incoming) = tokio::sync::mpsc::channel(16);
let (shutdown_tx, shutdown_rx) = watch::channel(());
let connection_semaphore = Arc::new(Semaphore::new(web_config.max_connections));
let driver_config = DriverConfig {
router: web_config.router,
mtp_path: web_config.mtp_path,
max_request_body: web_config.max_request_body,
request_timeout: web_config.request_timeout,
drain_timeout: web_config.drain_timeout,
send_pongs: host_config.send_pongs,
policy: host_config.policy,
metrics: web_config.metrics,
};
let driver = tokio::spawn(run_driver(
driver_endpoint,
driver_config,
mtp_tx,
connection_semaphore,
shutdown_rx,
));
Ok(Self {
endpoint,
mtp_incoming,
shutdown_tx,
_driver: Some(driver),
})
}
pub fn local_addr(&self) -> SocketAddr {
self.endpoint
.local_addr()
.expect("endpoint has a local address")
}
pub async fn accept(&mut self) -> Result<Option<WebMTPConnection>, mtp_host::AcceptError> {
match self.mtp_incoming.recv().await {
Some(result) => result.map(Some),
None => Ok(None),
}
}
/// Signal the server to shut down gracefully.
///
/// Stops accepting new QUIC connections, waits for the driver task to
/// finish its accept loop, then allows a configurable drain period for
/// in-flight requests to complete before closing the endpoint.
pub async fn shutdown(mut self) {
let _ = self.shutdown_tx.send(());
if let Some(driver) = self._driver.take() {
let _ = driver.await;
}
self.endpoint
.close(quinn::VarInt::from_u32(0), b"mtp-webserver shutdown");
}
/// Stop accepting new QUIC connections and wait briefly for the driver to stop.
pub async fn close(mut self) {
self.endpoint
.close(quinn::VarInt::from_u32(0), b"mtp-webserver shutdown");
if let Some(driver) = self._driver.take() {
driver.abort();
}
}
}
impl Drop for MTPWebServer {
fn drop(&mut self) {
if let Some(driver) = self._driver.take() {
driver.abort();
}
}
}
struct DriverConfig {
router: Router,
mtp_path: String,
max_request_body: usize,
request_timeout: Duration,
drain_timeout: Duration,
send_pongs: bool,
policy: mtp_transport::Policy,
metrics: Option<Arc<dyn WebServerMetrics>>,
}
fn build_endpoint(config: &HostConfig) -> Result<quinn::Endpoint, CommunicationError> {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let certificates = rustls::pki_types::CertificateDer::pem_slice_iter(&config.tls_fullchain)
.collect::<Result<Vec<_>, _>>()
.map_err(|_| CommunicationError::CertificateLoadFailed)?;
let key = PrivateKeyDer::from_pem_slice(&config.tls_key)
.map_err(|_| CommunicationError::CertificateParseFailed)?;
let mut tls = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certificates, key)
.map_err(|_| CommunicationError::CertificateLoadFailed)?;
tls.alpn_protocols = vec![b"h3".to_vec()];
let server = quinn::ServerConfig::with_crypto(Arc::new(
quinn::crypto::rustls::QuicServerConfig::try_from(tls)
.map_err(|error| CommunicationError::Other(error.to_string()))?,
));
quinn::Endpoint::server(server, SocketAddr::new(config.ip, config.port))
.map_err(|error| CommunicationError::Other(error.to_string()))
}
async fn run_driver(
endpoint: quinn::Endpoint,
config: DriverConfig,
mtp_tx: tokio::sync::mpsc::Sender<Result<WebMTPConnection, mtp_host::AcceptError>>,
connection_semaphore: Arc<Semaphore>,
mut shutdown_rx: watch::Receiver<()>,
) {
let DriverConfig {
router,
mtp_path,
max_request_body,
request_timeout,
drain_timeout,
send_pongs,
policy,
metrics,
} = config;
let mut connection_tasks = tokio::task::JoinSet::new();
loop {
tokio::select! {
biased;
_ = shutdown_rx.changed() => {
break;
}
incoming = endpoint.accept() => {
let Some(incoming) = incoming else {
break;
};
let permit = match connection_semaphore.clone().acquire_owned().await {
Ok(permit) => permit,
Err(_) => break,
};
let router = router.clone();
let mtp_path = mtp_path.clone();
let mtp_tx = mtp_tx.clone();
let metrics = metrics.clone();
connection_tasks.spawn(async move {
let _permit = permit;
let connect_start = std::time::Instant::now();
let connection = match incoming.await {
Ok(connection) => connection,
Err(error) => {
tracing::debug!(%error, "QUIC connection failed during handshake");
return;
}
};
let mut builder = h3::server::builder();
builder.enable_extended_connect(true);
builder.enable_webtransport(true);
builder.enable_datagram(true);
builder.max_webtransport_sessions(16);
let mut h3 = match builder
.build(h3_quinn::Connection::new(connection.clone()))
.await
{
Ok(connection) => connection,
Err(error) => {
tracing::debug!(%error, "HTTP/3 connection setup failed");
return;
}
};
if let Some(ref m) = metrics {
m.connection_accepted();
}
let mut tasks = tokio::task::JoinSet::new();
loop {
let resolver = match h3.accept().await {
Ok(Some(resolver)) => resolver,
Ok(None) => break,
Err(error) => {
tracing::debug!(%error, "HTTP/3 request accept failed");
break;
}
};
let (request, mut stream) = match resolver.resolve_request().await {
Ok(request) => request,
Err(error) => {
tracing::debug!(%error, "HTTP/3 request parse failed");
continue;
}
};
if request.method() == http::Method::CONNECT && request.uri().path() == mtp_path {
if request.extensions().get::<h3::ext::Protocol>()
!= Some(&h3::ext::Protocol::WEB_TRANSPORT)
{
let _ = stream
.send_response(
Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.body(())
.unwrap(),
)
.await;
let _ = stream.finish().await;
continue;
}
let session = match h3_webtransport::server::WebTransportSession::accept(
request, stream, h3,
)
.await
{
Ok(session) => Arc::new(session),
Err(error) => {
tracing::debug!(%error, "WebTransport session accept failed");
return;
}
};
tasks.spawn(run_session_requests(
session.clone(),
router.clone(),
max_request_body,
request_timeout,
metrics.clone(),
));
let result =
accept_web_connection(session, mtp_path, connection, send_pongs, policy)
.await;
let _ = mtp_tx.send(result).await;
return;
}
let router = router.clone();
let metrics = metrics.clone();
tasks.spawn(async move {
let path = request.uri().path().to_string();
if let Some(ref m) = metrics {
m.request_started(&path);
}
let req_start = std::time::Instant::now();
let (response, status) = match tokio::time::timeout(
request_timeout,
handle_http_request(request, &mut stream, &router, max_request_body),
)
.await
{
Ok(Ok((resp, status))) => (resp, status),
Ok(Err(_)) => (
Http3Response::new(StatusCode::BAD_GATEWAY),
StatusCode::BAD_GATEWAY,
),
Err(_) => (
Http3Response::new(StatusCode::REQUEST_TIMEOUT),
StatusCode::REQUEST_TIMEOUT,
),
};
let _ = write_response(&mut stream, response).await;
if let Some(ref m) = metrics {
m.request_completed(&path, status.as_u16(), req_start.elapsed());
}
});
}
tasks.join_all().await;
if let Some(ref m) = metrics {
m.connection_closed(connect_start.elapsed(), "normal");
}
});
}
}
}
// --- Drain phase: wait for in-flight connections ---
endpoint.close(quinn::VarInt::from_u32(0), b"mtp-webserver shutdown");
let drain_start = std::time::Instant::now();
while !connection_tasks.is_empty() {
tokio::select! {
Some(result) = connection_tasks.join_next() => {
if let Err(e) = result {
tracing::warn!("Connection task panicked: {}", e);
}
}
_ = tokio::time::sleep(drain_timeout.saturating_sub(drain_start.elapsed())) => {
tracing::warn!(
"Drain timeout expired with {} connections still in flight",
connection_tasks.len()
);
break;
}
}
}
connection_tasks.shutdown().await;
}
async fn handle_http_request<S>(
request: Request<()>,
stream: &mut h3::server::RequestStream<S, Bytes>,
router: &Router,
max_request_body: usize,
) -> Result<(Http3Response, StatusCode), WebServerError>
where
S: h3::quic::BidiStream<Bytes>,
{
let (request, too_large) = read_request(request, stream, max_request_body)
.await
.map_err(|e| WebServerError::Http(format!("request body read failed: {e}")))?;
if too_large {
return Ok((
Http3Response::new(StatusCode::PAYLOAD_TOO_LARGE),
StatusCode::PAYLOAD_TOO_LARGE,
));
}
let path = request.uri.path().to_string();
let handler = router
.handler(&request.method, &path)
.or_else(|| router.fallback_handler());
match handler {
Some(handler) => {
let response = handler(request, Http3Response::default()).await;
let status = response.status;
Ok((response, status))
}
None => Ok((
Http3Response::new(StatusCode::NOT_FOUND),
StatusCode::NOT_FOUND,
)),
}
}
async fn run_session_requests(
session: Arc<h3_webtransport::server::WebTransportSession<h3_quinn::Connection, Bytes>>,
router: Router,
max_request_body: usize,
request_timeout: Duration,
metrics: Option<Arc<dyn WebServerMetrics>>,
) {
loop {
match session.accept_bi().await {
Ok(Some(h3_webtransport::server::AcceptedBi::Request(request, mut stream))) => {
let router = router.clone();
let metrics = metrics.clone();
tokio::spawn(async move {
let path = request.uri().path().to_string();
if let Some(ref m) = metrics {
m.request_started(&path);
}
let req_start = std::time::Instant::now();
let (response, status) = match tokio::time::timeout(
request_timeout,
handle_http_request(request, &mut stream, &router, max_request_body),
)
.await
{
Ok(Ok((resp, status))) => (resp, status),
Ok(Err(_)) => (
Http3Response::new(StatusCode::BAD_GATEWAY),
StatusCode::BAD_GATEWAY,
),
Err(_) => (
Http3Response::new(StatusCode::REQUEST_TIMEOUT),
StatusCode::REQUEST_TIMEOUT,
),
};
let _ = write_response(&mut stream, response).await;
if let Some(ref m) = metrics {
m.request_completed(&path, status.as_u16(), req_start.elapsed());
}
});
}
Ok(Some(h3_webtransport::server::AcceptedBi::BidiStream(_, _))) => {}
Ok(None) | Err(_) => break,
}
}
}
async fn read_request<S>(
request: Request<()>,
stream: &mut h3::server::RequestStream<S, Bytes>,
max_body: usize,
) -> Result<(Http3Request, bool), h3::error::StreamError>
where
S: h3::quic::BidiStream<Bytes>,
{
let (parts, _) = request.into_parts();
let mut body = Vec::new();
let mut too_large = false;
while let Some(chunk) = stream.recv_data().await? {
if body.len().saturating_add(chunk.remaining()) > max_body {
too_large = true;
break;
}
body.extend_from_slice(chunk.chunk());
}
Ok((
Http3Request {
method: parts.method,
uri: parts.uri,
headers: parts.headers,
body: (!body.is_empty()).then(|| Bytes::from(body)),
},
too_large,
))
}
async fn write_response<S>(
stream: &mut h3::server::RequestStream<S, Bytes>,
response: Http3Response,
) -> Result<(), h3::error::StreamError>
where
S: h3::quic::BidiStream<Bytes>,
{
let mut builder = Response::builder().status(response.status);
for (name, value) in &response.headers {
builder = builder.header(name, value);
}
stream
.send_response(builder.body(()).expect("valid HTTP response"))
.await?;
for chunk in response.body {
stream.send_data(chunk).await?;
}
if let Some(mut chunks) = response.stream {
while let Some(chunk) = chunks.recv().await {
stream.send_data(chunk).await?;
}
}
stream.finish().await
}

View file

@ -0,0 +1,92 @@
use bytes::Bytes;
use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri};
use tokio::sync::mpsc;
/// An owned HTTP/3 request passed to a route handler.
#[derive(Clone, Debug)]
pub struct Http3Request {
pub method: Method,
pub uri: Uri,
pub headers: HeaderMap,
pub body: Option<Bytes>,
}
/// A buffered HTTP/3 response returned from a route handler.
pub struct Http3Response {
pub status: StatusCode,
pub headers: HeaderMap,
pub body: Vec<Bytes>,
pub(crate) stream: Option<mpsc::Receiver<Bytes>>,
}
impl Http3Response {
pub fn new(status: StatusCode) -> Self {
Self {
status,
headers: HeaderMap::new(),
body: Vec::new(),
stream: None,
}
}
pub fn status(mut self, status: StatusCode) -> Self {
self.status = status;
self
}
pub fn header(mut self, key: &str, value: &str) -> Self {
match (key.parse::<HeaderName>(), value.parse::<HeaderValue>()) {
(Ok(key), Ok(value)) => {
self.headers.insert(key, value);
}
(Err(error), _) => {
tracing::warn!(%error, key, "discarding invalid HTTP response header")
}
(_, Err(error)) => {
tracing::warn!(%error, key, "discarding invalid HTTP response header")
}
}
self
}
pub fn try_header(mut self, key: &str, value: &str) -> Result<Self, String> {
let key = key.parse::<HeaderName>().map_err(|e| e.to_string())?;
let value = value.parse::<HeaderValue>().map_err(|e| e.to_string())?;
self.headers.insert(key, value);
Ok(self)
}
pub fn body(mut self, chunk: impl Into<Bytes>) -> Self {
self.body.push(chunk.into());
self
}
/// Stream response chunks as they become available instead of buffering them.
pub fn stream(mut self, chunks: mpsc::Receiver<Bytes>) -> Self {
self.stream = Some(chunks);
self
}
}
impl Default for Http3Response {
fn default() -> Self {
Self::new(StatusCode::OK)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn response_collects_headers_and_body_chunks() {
let response = Http3Response::new(StatusCode::CREATED)
.header("content-type", "text/plain")
.body("hello")
.body(" world");
assert_eq!(response.status, StatusCode::CREATED);
assert_eq!(response.headers["content-type"], "text/plain");
assert_eq!(
response.body,
vec![Bytes::from("hello"), Bytes::from(" world")]
);
}
}

View file

@ -0,0 +1,198 @@
use bytes::Bytes;
use mtp_codec::{
DataType, DataValue, Version,
registry::{Registry, VersionedCodec},
};
use mtp_common::CommunicationError;
use mtp_host::AcceptError;
use mtp_transport::{
GenericReceiver, GenericSender, Policy, TransportConnection, TransportRecvStream,
TransportSendStream,
};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
type Session = h3_webtransport::server::WebTransportSession<h3_quinn::Connection, Bytes>;
type H3SendStream = h3_webtransport::stream::SendStream<h3_quinn::SendStream<Bytes>, Bytes>;
type H3RecvStream = h3_webtransport::stream::RecvStream<h3_quinn::RecvStream, Bytes>;
/// h3-webtransport implementation of MTP's transport connection boundary.
///
/// This is intentionally separate from [`WebMTPConnection`]: it is the
/// adapter used by the in-progress migration of `mtp_transport::Sender` and
/// `Receiver` away from concrete wtransport stream types.
#[derive(Clone)]
pub struct H3TransportConnection {
session: Arc<Session>,
quinn: quinn::Connection,
}
pub struct H3TransportSender {
stream: H3SendStream,
}
pub struct H3TransportReceiver {
stream: H3RecvStream,
}
impl H3TransportConnection {
pub(crate) fn new(session: Arc<Session>, quinn: quinn::Connection) -> Self {
Self { session, quinn }
}
}
#[async_trait::async_trait]
impl TransportSendStream for H3TransportSender {
async fn write_all(&mut self, buf: &[u8]) -> Result<(), CommunicationError> {
self.stream
.write_all(buf)
.await
.map_err(|_| CommunicationError::StreamError)
}
async fn finish(&mut self) -> Result<(), CommunicationError> {
self.stream
.shutdown()
.await
.map_err(|_| CommunicationError::StreamError)
}
}
#[async_trait::async_trait]
impl TransportRecvStream for H3TransportReceiver {
async fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), CommunicationError> {
self.stream
.read_exact(buf)
.await
.map(|_| ())
.map_err(|_| CommunicationError::StreamError)
}
async fn read_chunk(&mut self, max: usize) -> Result<Option<Vec<u8>>, CommunicationError> {
let mut buf = vec![0; max];
match self.stream.read(&mut buf).await {
Ok(0) => Ok(None),
Ok(size) => {
buf.truncate(size);
Ok(Some(buf))
}
Err(_) => Err(CommunicationError::StreamError),
}
}
}
impl tokio::io::AsyncWrite for H3TransportSender {
fn poll_write(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
std::pin::Pin::new(&mut self.stream).poll_write(cx, buf)
}
fn poll_flush(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.stream).poll_flush(cx)
}
fn poll_shutdown(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.stream).poll_shutdown(cx)
}
}
impl tokio::io::AsyncRead for H3TransportReceiver {
fn poll_read(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::pin::Pin::new(&mut self.stream).poll_read(cx, buf)
}
}
#[async_trait::async_trait]
impl TransportConnection for H3TransportConnection {
type SendStream = H3TransportSender;
type RecvStream = H3TransportReceiver;
async fn open_uni(&self) -> Result<Self::SendStream, CommunicationError> {
self.session
.open_uni(self.session.session_id())
.await
.map(|stream| H3TransportSender { stream })
.map_err(|_| CommunicationError::StreamError)
}
async fn accept_uni(&self) -> Result<Self::RecvStream, CommunicationError> {
loop {
match self.session.accept_uni().await {
Ok(Some((id, stream))) if id == self.session.session_id() => {
return Ok(H3TransportReceiver { stream });
}
Ok(Some(_)) => continue,
Ok(None) => return Err(CommunicationError::StreamClosed),
Err(_) => return Err(CommunicationError::StreamError),
}
}
}
fn close_reason(&self) -> Option<CommunicationError> {
self.quinn
.close_reason()
.map(|_| CommunicationError::StreamClosed)
}
fn close(&self, code: u32, reason: &[u8]) {
self.quinn.close(quinn::VarInt::from_u32(code), reason);
}
}
/// Shared host MTP connection instantiated with HTTP/3 stream adapters.
pub type WebMtpSender = GenericSender<H3TransportConnection>;
pub type WebMtpReceiver = GenericReceiver<H3TransportConnection>;
pub type WebMTPConnection = mtp_host::MTPConnection<WebMtpSender, WebMtpReceiver>;
pub(crate) async fn accept_web_connection(
session: Arc<Session>,
path: String,
quinn: quinn::Connection,
send_pongs: bool,
policy: Policy,
) -> Result<WebMTPConnection, AcceptError> {
let transport = H3TransportConnection::new(session, quinn);
let policy = Arc::new(policy);
let receiver = WebMtpReceiver::new(transport.clone(), policy.clone());
let first = receiver.receive().await.map_err(AcceptError::Receive)?;
let version = match first.get_data(DataType::Version) {
DataValue::Str(value) => Version::parse(value).ok_or(AcceptError::MissingVersion)?,
_ => return Err(AcceptError::MissingVersion),
};
let registry = Registry::builtin();
let negotiated = registry
.negotiate(std::slice::from_ref(&version))
.ok_or_else(|| AcceptError::UnsupportedVersion(version.clone()))?;
let codec = VersionedCodec::for_version(registry, negotiated.clone())
.ok_or_else(|| AcceptError::UnsupportedVersion(negotiated.clone()))?;
let description = match first.get_data(DataType::Description) {
DataValue::Str(value) => Some(value.clone()),
_ => None,
};
let sender = WebMtpSender::new(transport, policy);
if send_pongs {
receiver.respond_to_pings(sender.clone()).await;
}
Ok(mtp_host::MTPConnection::from_transport_parts(
negotiated,
codec,
sender,
receiver,
path,
description,
))
}

View file

@ -0,0 +1,205 @@
use http::StatusCode;
use mtp_webserver::{MTPWebServer, WebServerConfig, WebServerError, WebServerMetrics};
use std::net::IpAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
fn generate_self_signed_cert() -> (Vec<u8>, Vec<u8>) {
let key_pair = rcgen::KeyPair::generate().expect("failed to generate self-signed key pair");
let params = rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()])
.expect("failed to build self-signed certificate params");
let cert = params
.self_signed(&key_pair)
.expect("failed to self-sign certificate");
(
cert.pem().into_bytes(),
key_pair.serialize_pem().into_bytes(),
)
}
#[test]
fn config_builder_defaults() {
let config = WebServerConfig::new();
assert_eq!(config.max_request_body, 4 * 1024 * 1024);
assert_eq!(config.max_connections, 256);
assert_eq!(config.request_timeout, Duration::from_secs(30));
}
#[test]
fn config_builder_chain() {
let config = WebServerConfig::new()
.max_connections(64)
.max_request_body(1024)
.request_timeout(Duration::from_secs(5))
.mtp_path("/ws");
assert_eq!(config.max_connections, 64);
assert_eq!(config.max_request_body, 1024);
assert_eq!(config.request_timeout, Duration::from_secs(5));
}
#[test]
fn config_builder_routes() {
let config = WebServerConfig::new()
.route(
"/health",
|_, resp| async move { resp.status(StatusCode::OK) },
)
.unwrap()
.route("/data", |_, resp| async move {
resp.status(StatusCode::NO_CONTENT)
})
.unwrap();
let config = config.mtp_path("/");
drop(config);
}
#[test]
fn config_duplicate_route_errors() {
let result = WebServerConfig::new()
.route("/dup", |_, resp| async move { resp })
.unwrap()
.route("/dup", |_, resp| async move { resp });
assert!(result.is_err());
}
#[test]
fn error_display() {
let err = WebServerError::WebTransport("session rejected".into());
assert_eq!(err.to_string(), "webtransport error: session rejected");
let err = WebServerError::NotFound("/api".into());
assert_eq!(err.to_string(), "route not found: /api");
let err = WebServerError::Http("body too large".into());
assert_eq!(err.to_string(), "HTTP error: body too large");
let err = WebServerError::Transport(mtp_common::CommunicationError::StreamClosed);
assert_eq!(err.to_string(), "transport error: Stream Closed");
}
#[test]
fn error_from_communication_error() {
let comm_err = mtp_common::CommunicationError::StreamError;
let web_err: WebServerError = comm_err.into();
assert!(matches!(web_err, WebServerError::Transport(_)));
}
#[test]
fn error_source_chain() {
let inner = mtp_common::CommunicationError::StreamClosed;
let err = WebServerError::Transport(inner);
let source = std::error::Error::source(&err);
assert!(source.is_some());
}
struct TestMetrics {
connections_accepted: AtomicUsize,
connections_closed: AtomicUsize,
requests_started: AtomicUsize,
requests_completed: AtomicUsize,
errors: AtomicUsize,
}
impl TestMetrics {
fn new() -> Self {
Self {
connections_accepted: AtomicUsize::new(0),
connections_closed: AtomicUsize::new(0),
requests_started: AtomicUsize::new(0),
requests_completed: AtomicUsize::new(0),
errors: AtomicUsize::new(0),
}
}
}
impl WebServerMetrics for TestMetrics {
fn connection_accepted(&self) {
self.connections_accepted.fetch_add(1, Ordering::SeqCst);
}
fn connection_closed(&self, _duration: Duration, _reason: &str) {
self.connections_closed.fetch_add(1, Ordering::SeqCst);
}
fn request_started(&self, _path: &str) {
self.requests_started.fetch_add(1, Ordering::SeqCst);
}
fn request_completed(&self, _path: &str, _status: u16, _duration: Duration) {
self.requests_completed.fetch_add(1, Ordering::SeqCst);
}
fn error_occurred(&self, _error: &WebServerError) {
self.errors.fetch_add(1, Ordering::SeqCst);
}
}
#[test]
fn metrics_trait_defaults_compile() {
struct NoopMetrics;
impl WebServerMetrics for NoopMetrics {}
let m = NoopMetrics;
m.connection_accepted();
m.connection_closed(Duration::from_secs(1), "test");
m.request_started("/test");
m.request_completed("/test", 200, Duration::from_millis(50));
m.error_occurred(&WebServerError::NotFound("x".into()));
}
#[test]
fn config_with_metrics() {
let metrics: Arc<dyn WebServerMetrics> = Arc::new(TestMetrics::new());
let config = WebServerConfig::new().with_metrics(metrics);
let metrics: Arc<dyn WebServerMetrics> = Arc::new(TestMetrics::new());
let config = config.with_metrics(metrics);
drop(config);
}
#[tokio::test]
async fn server_constructs_with_self_signed_cert() {
let (cert_pem, key_pem) = generate_self_signed_cert();
let host_config = mtp_host::HostConfig::new(
IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
0,
cert_pem,
key_pem,
);
let web_config = WebServerConfig::new();
let server = MTPWebServer::new(host_config, web_config).await;
assert!(server.is_ok());
let server = server.unwrap();
let addr = server.local_addr();
assert!(addr.port() > 0);
server.close().await;
}
#[tokio::test]
async fn server_with_metrics_constructs() {
let (cert_pem, key_pem) = generate_self_signed_cert();
let host_config = mtp_host::HostConfig::new(
IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
0,
cert_pem,
key_pem,
);
let metrics: Arc<dyn WebServerMetrics> = Arc::new(TestMetrics::new());
let web_config = WebServerConfig::new()
.max_connections(10)
.request_timeout(Duration::from_secs(10))
.with_metrics(metrics);
let server = MTPWebServer::new(host_config, web_config).await;
assert!(server.is_ok());
server.unwrap().close().await;
}
#[tokio::test]
async fn graceful_shutdown_completes() {
let (cert_pem, key_pem) = generate_self_signed_cert();
let host_config = mtp_host::HostConfig::new(
IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
0,
cert_pem,
key_pem,
);
let server = MTPWebServer::new(host_config, WebServerConfig::new())
.await
.unwrap();
server.shutdown().await;
}

View file

@ -1,6 +1,6 @@
{ {
"name": "mtp", "name": "mtp",
"version": "0.1.0", "version": "0.2.0",
"description": "MTP TypeScript SDK", "description": "MTP TypeScript SDK",
"type": "module", "type": "module",
"packageManager": "pnpm@11.8.0", "packageManager": "pnpm@11.8.0",
@ -48,13 +48,16 @@
], ],
"scripts": { "scripts": {
"example": "pnpm install && pnpm run build:all && nix develop .#autoStart", "example": "pnpm install && pnpm run build:all && nix develop .#autoStart",
"build": "MTP_TYPE_MAPS=$PWD/example/type-maps.yaml RUSTFLAGS='--cfg web_sys_unstable_apis' wasm-pack build wasm --target web --out-dir pkg --release && tsc", "build": "MTP_TYPE_MAPS=$PWD/example-type-maps.yaml RUSTFLAGS='--cfg web_sys_unstable_apis' wasm-pack build wasm --target web --out-dir pkg --release && tsc",
"build:all": "nix run .#build-all", "build:all": "nix run .#build-all",
"dup": "jscpd --pattern '**/*.rs' --ignore 'target/**' --ignore 'wasm/pkg/**' --ignore '.git/**' --min-lines 8 --min-tokens 80 --threshold 4 --reporters console --noTips ." "dup": "jscpd --pattern '**/*.{rs,ts}' --ignore 'target/**' --ignore 'wasm/pkg/**' --ignore '.git/**' --min-lines 8 --min-tokens 80 --threshold 4 --reporters console --noTips ."
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.0.1", "@types/node": "^26.0.1",
"jscpd": "4.2.5", "jscpd": "4.2.5",
"typescript": "^6.0.3" "typescript": "^6.0.3"
},
"dependencies": {
"yaml": "^2.8.1"
} }
} }

16
pnpm-lock.yaml generated
View file

@ -7,6 +7,10 @@ settings:
importers: importers:
.: .:
dependencies:
yaml:
specifier: ^2.8.1
version: 2.9.0
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: ^26.0.1 specifier: ^26.0.1
@ -29,7 +33,7 @@ importers:
version: 6.0.3 version: 6.0.3
vite: vite:
specifier: ^8.1.0 specifier: ^8.1.0
version: 8.1.0(@types/node@26.0.1) version: 8.1.0(@types/node@26.0.1)(yaml@2.9.0)
packages: packages:
@ -757,6 +761,11 @@ packages:
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'}
hasBin: true
snapshots: snapshots:
'@babel/helper-string-parser@7.29.7': {} '@babel/helper-string-parser@7.29.7': {}
@ -1405,7 +1414,7 @@ snapshots:
universalify@2.0.1: {} universalify@2.0.1: {}
vite@8.1.0(@types/node@26.0.1): vite@8.1.0(@types/node@26.0.1)(yaml@2.9.0):
dependencies: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0
picomatch: 4.0.4 picomatch: 4.0.4
@ -1415,6 +1424,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/node': 26.0.1 '@types/node': 26.0.1
fsevents: 2.3.3 fsevents: 2.3.3
yaml: 2.9.0
void-elements@3.1.0: {} void-elements@3.1.0: {}
@ -1430,3 +1440,5 @@ snapshots:
babel-walk: 3.0.0-canary-5 babel-walk: 3.0.0-canary-5
wrappy@1.0.2: {} wrappy@1.0.2: {}
yaml@2.9.0: {}

View file

@ -5,7 +5,7 @@ pub use mtp_type_map as type_map;
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
pub use mtp_crypto as crypto; pub use mtp_crypto as crypto;
#[cfg(feature = "host")] #[cfg(any(feature = "host", feature = "web-server"))]
pub use mtp_host as host; pub use mtp_host as host;
#[cfg(feature = "client")] #[cfg(feature = "client")]
@ -13,3 +13,6 @@ pub use mtp_client as client;
#[cfg(feature = "files")] #[cfg(feature = "files")]
pub use mtp_files as files; pub use mtp_files as files;
#[cfg(feature = "web-server")]
pub use mtp_webserver as webserver;

View file

@ -1,6 +1,7 @@
import * as bindings from "mtp/raw"; import * as bindings from "mtp/raw";
import { MTPRatchet } from "./ratchet.js"; import { MTPRatchet } from "./ratchet.js";
import type { MTPSessionState } from "./session"; import type { MTPSessionState } from "./session";
import { concatBytes, writeU64BE } from "./utils.js";
export const MTP_E2EE_VERSION = 1; export const MTP_E2EE_VERSION = 1;
export const FLAG_INIT = 0x01; export const FLAG_INIT = 0x01;
@ -37,18 +38,6 @@ export interface SerializedEncryptedMessage {
aeadPayload: Uint8Array; aeadPayload: Uint8Array;
} }
function writeU64BE(value: bigint): Uint8Array {
if (value < 0n || value > 0xffff_ffff_ffff_ffffn) {
throw new Error("u64 value out of range");
}
const buf = new Uint8Array(8);
for (let i = 7; i >= 0; i--) {
buf[i] = Number(value & 0xffn);
value >>= 8n;
}
return buf;
}
function readU64BE(bytes: Uint8Array, offset: number): bigint { function readU64BE(bytes: Uint8Array, offset: number): bigint {
let value = 0n; let value = 0n;
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
@ -69,16 +58,6 @@ function writeU32BE(value: number): Uint8Array {
]); ]);
} }
function concatBytes(parts: Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
function assertSupported(message: ParsedEncryptedMessage): void { function assertSupported(message: ParsedEncryptedMessage): void {
if (message.version !== MTP_E2EE_VERSION) { if (message.version !== MTP_E2EE_VERSION) {
throw new Error( throw new Error(
@ -111,10 +90,10 @@ export function serializeEncryptedMessage(
): Uint8Array { ): Uint8Array {
const normalized: ParsedEncryptedMessage = const normalized: ParsedEncryptedMessage =
"header" in message "header" in message
? { ? ({
...message.header, ...message.header,
ciphertext: message.aeadPayload, ciphertext: message.aeadPayload,
} } as ParsedEncryptedMessage)
: message; : message;
assertSupported(normalized); assertSupported(normalized);
@ -293,12 +272,25 @@ export async function decryptPayload(args: {
if (parsed.senderClientId !== args.session.peerClientId) { if (parsed.senderClientId !== args.session.peerClientId) {
throw new Error("Encrypted message sender mismatch"); throw new Error("Encrypted message sender mismatch");
} }
if (parsed.messageNumber < args.session.recvCount) {
throw new Error("Encrypted message replay or out-of-order message number"); const existingSkippedMessageKeys = args.session.skippedMessageKeys ?? [];
const cachedKeyIndex = existingSkippedMessageKeys.findIndex(
(skipped) => skipped.messageNumber === parsed.messageNumber,
);
if (parsed.messageNumber < args.session.recvCount && cachedKeyIndex < 0) {
throw new Error("Encrypted message replay message number");
} }
let chainKey = args.session.recvChainKey; let chainKey = args.session.recvChainKey;
let messageKey: Uint8Array | undefined; let messageKey: Uint8Array | undefined;
let skippedMessageKeys = existingSkippedMessageKeys.slice();
const newlyDerivedKeys: Uint8Array[] = [];
let nextRecvCount = args.session.recvCount;
if (cachedKeyIndex >= 0) {
// Work on a copy so an invalid ciphertext cannot consume the cached key.
messageKey = skippedMessageKeys[cachedKeyIndex].key.slice();
} else {
const gap = parsed.messageNumber - args.session.recvCount; const gap = parsed.messageNumber - args.session.recvCount;
if (gap > MAX_RATCHET_SKIP) { if (gap > MAX_RATCHET_SKIP) {
throw new Error( throw new Error(
@ -312,11 +304,17 @@ export async function decryptPayload(args: {
if (i === steps - 1) { if (i === steps - 1) {
messageKey = step.key; messageKey = step.key;
} else { } else {
step.key.fill(0); skippedMessageKeys.push({
messageNumber: args.session.recvCount + i,
key: step.key,
});
newlyDerivedKeys.push(step.key);
} }
if (chainKey !== args.session.recvChainKey) chainKey.fill(0); if (chainKey !== args.session.recvChainKey) chainKey.fill(0);
chainKey = step.chainKey; chainKey = step.chainKey;
} }
nextRecvCount = parsed.messageNumber + 1;
}
if (!messageKey) { if (!messageKey) {
throw new Error("Failed to derive receive message key"); throw new Error("Failed to derive receive message key");
} }
@ -334,17 +332,31 @@ export async function decryptPayload(args: {
let plaintext: Uint8Array; let plaintext: Uint8Array;
try { try {
plaintext = cipher.decrypt(parsed.ciphertext, aad); plaintext = cipher.decrypt(parsed.ciphertext, aad);
} catch (error) {
for (const key of newlyDerivedKeys) key.fill(0);
if (chainKey !== args.session.recvChainKey) chainKey.fill(0);
throw error;
} finally { } finally {
cipher.free(); cipher.free();
messageKey.fill(0); messageKey.fill(0);
} }
if (cachedKeyIndex >= 0) {
const [consumed] = skippedMessageKeys.splice(cachedKeyIndex, 1);
consumed.key.fill(0);
}
while (skippedMessageKeys.length > MAX_RATCHET_SKIP) {
const evicted = skippedMessageKeys.shift();
evicted?.key.fill(0);
}
return { return {
plaintext, plaintext,
session: { session: {
...args.session, ...args.session,
recvChainKey: chainKey, recvChainKey: chainKey,
recvCount: parsed.messageNumber + 1, recvCount: nextRecvCount,
skippedMessageKeys,
updatedAt: Date.now(), updatedAt: Date.now(),
}, },
}; };

View file

@ -6,6 +6,7 @@ import initWasm, {
keyring_generate, keyring_generate,
} from "mtp/raw"; } from "mtp/raw";
import * as bindings from "mtp/raw"; import * as bindings from "mtp/raw";
import { utf8Encode } from "./utils.js";
import type * as RawBindings from "../raw/index"; import type * as RawBindings from "../raw/index";
import type { MTPCommunicationType } from "../type-map/index"; import type { MTPCommunicationType } from "../type-map/index";
import type { MTPSessionStorage, MTPSessionState } from "./session"; import type { MTPSessionStorage, MTPSessionState } from "./session";
@ -264,6 +265,7 @@ export interface MTPClientOptions {
serverCertificateHashes?: string[]; serverCertificateHashes?: string[];
maxMessageSize?: number; maxMessageSize?: number;
authTimeoutMs?: number; authTimeoutMs?: number;
requestTimeoutMs?: number;
pings?: boolean | { intervalMs?: number }; pings?: boolean | { intervalMs?: number };
wasm?: wasm?:
| RawBindings.InitInput | RawBindings.InitInput
@ -286,6 +288,7 @@ export interface MTPSendOptions {
export interface MTPRequestOptions extends MTPSendOptions { export interface MTPRequestOptions extends MTPSendOptions {
responseType?: MTPCommunicationType; responseType?: MTPCommunicationType;
timeoutMs?: number;
} }
export interface MTPPipeWriter { export interface MTPPipeWriter {
@ -328,13 +331,21 @@ type NormalizedMTPClientOptions = Omit<MTPClientOptions, "hostPublicKey"> & {
const DEFAULT_CREDENTIALS_KEY = "mtp:credentials"; const DEFAULT_CREDENTIALS_KEY = "mtp:credentials";
let wasmInitPromise: Promise<Awaited<ReturnType<typeof initWasm>>> | undefined; let wasmInitPromise: Promise<Awaited<ReturnType<typeof initWasm>>> | undefined;
function emit(logger, event) { function createMessageId(): string {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
}
function emit(logger: MTPClientOptions["logger"] | undefined, event: MTPLogEvent): void {
if (typeof logger === "function") { if (typeof logger === "function") {
logger(event); logger(event);
} }
} }
function isErrorType(type) { function isErrorType(type: string): boolean {
return ( return (
type === "Error" || type === "Error" ||
type.startsWith("Error") || type.startsWith("Error") ||
@ -352,7 +363,7 @@ function isErrorType(type) {
); );
} }
function errorMessage(frame) { function errorMessage(frame: Pick<ParsedFrame, "type" | "data"> | null | undefined): string {
const data = frame?.data ?? {}; const data = frame?.data ?? {};
return String( return String(
data.ErrorMessage ?? data.ErrorMessage ??
@ -362,27 +373,27 @@ function errorMessage(frame) {
); );
} }
async function storageGet(storage, key) { async function storageGet(storage: MTPCredentialStorage | undefined, key: string): Promise<StorageValue> {
return storage ? await storage.getItem(key) : null; return storage ? await storage.getItem(key) : null;
} }
async function storageSet(storage, key, value) { async function storageSet(storage: MTPCredentialStorage | undefined, key: string, value: string): Promise<void> {
if (storage) { if (storage) {
await storage.setItem(key, value); await storage.setItem(key, value);
} }
} }
async function storageRemove(storage, key) { async function storageRemove(storage: MTPCredentialStorage | undefined, key: string): Promise<void> {
if (storage) { if (storage) {
await storage.removeItem(key); await storage.removeItem(key);
} }
} }
function isBytes(value) { function isBytes(value: unknown): value is MTPBytesInput {
return value instanceof Uint8Array || Array.isArray(value); return value instanceof Uint8Array || Array.isArray(value);
} }
function bytesFrom(value, name) { function bytesFrom(value: MTPBytesInput, name: string): Uint8Array {
if (value instanceof Uint8Array) { if (value instanceof Uint8Array) {
return value; return value;
} }
@ -392,7 +403,7 @@ function bytesFrom(value, name) {
throw new TypeError(`${name} must be a Uint8Array or number[]`); throw new TypeError(`${name} must be a Uint8Array or number[]`);
} }
function bytesFromString(value, name) { function bytesFromString(value: string, name: string): Uint8Array {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) { if (!trimmed) {
throw new TypeError(`${name} must not be empty`); throw new TypeError(`${name} must not be empty`);
@ -428,7 +439,7 @@ function bytesFromString(value, name) {
const HEX_DIGITS = "0123456789abcdef"; const HEX_DIGITS = "0123456789abcdef";
function bytesToHex(bytes) { function bytesToHex(bytes: Uint8Array): string {
let out = ""; let out = "";
for (let i = 0; i < bytes.length; i += 1) { for (let i = 0; i < bytes.length; i += 1) {
out += HEX_DIGITS[(bytes[i] >> 4) & 0xf] + HEX_DIGITS[bytes[i] & 0xf]; out += HEX_DIGITS[(bytes[i] >> 4) & 0xf] + HEX_DIGITS[bytes[i] & 0xf];
@ -436,7 +447,7 @@ function bytesToHex(bytes) {
return out; return out;
} }
export function bytesToBase64(bytes) { export function bytesToBase64(bytes: Uint8Array): string {
if (typeof btoa === "function") { if (typeof btoa === "function") {
let binary = ""; let binary = "";
for (let i = 0; i < bytes.length; i += 1) { for (let i = 0; i < bytes.length; i += 1) {
@ -450,7 +461,7 @@ export function bytesToBase64(bytes) {
throw new TypeError("base64 encoding is not available in this environment"); throw new TypeError("base64 encoding is not available in this environment");
} }
export function base64ToBytes(input) { export function base64ToBytes(input: string): Uint8Array {
if (typeof atob === "function") { if (typeof atob === "function") {
const binary = atob(input); const binary = atob(input);
const bytes = new Uint8Array(binary.length); const bytes = new Uint8Array(binary.length);
@ -465,38 +476,7 @@ export function base64ToBytes(input) {
throw new TypeError("base64 decoding is not available in this environment"); throw new TypeError("base64 decoding is not available in this environment");
} }
function utf8Encode(text) { function utf8Decode(bytes: Uint8Array): string {
if (typeof TextEncoder !== "undefined") {
return new TextEncoder().encode(text);
}
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(text, "utf-8"));
}
const bytes = new Uint8Array(text.length * 4);
let len = 0;
for (let i = 0; i < text.length; i += 1) {
const code = text.codePointAt(i);
if (code < 0x80) {
bytes[len++] = code;
} else if (code < 0x800) {
bytes[len++] = 0xc0 | (code >> 6);
bytes[len++] = 0x80 | (code & 0x3f);
} else if (code < 0x10000) {
bytes[len++] = 0xe0 | (code >> 12);
bytes[len++] = 0x80 | ((code >> 6) & 0x3f);
bytes[len++] = 0x80 | (code & 0x3f);
} else {
bytes[len++] = 0xf0 | (code >> 18);
bytes[len++] = 0x80 | ((code >> 12) & 0x3f);
bytes[len++] = 0x80 | ((code >> 6) & 0x3f);
bytes[len++] = 0x80 | (code & 0x3f);
i += 1;
}
}
return bytes.subarray(0, len);
}
function utf8Decode(bytes) {
if (typeof TextDecoder !== "undefined") { if (typeof TextDecoder !== "undefined") {
return new TextDecoder().decode(bytes); return new TextDecoder().decode(bytes);
} }
@ -537,7 +517,7 @@ function utf8Decode(bytes) {
const SYMMETRIC_KEY_SALT = utf8Encode("mtp-symmetric-key"); const SYMMETRIC_KEY_SALT = utf8Encode("mtp-symmetric-key");
export function secretKeyFromString(secret) { export function secretKeyFromString(secret: string): Uint8Array {
if (typeof secret !== "string" || !secret.trim()) { if (typeof secret !== "string" || !secret.trim()) {
throw new TypeError("secret must be a non-empty string"); throw new TypeError("secret must be a non-empty string");
} }
@ -571,14 +551,14 @@ export function secretKeyFromString(secret) {
); );
} }
function normalizeBytes(value, name) { function normalizeBytes(value: string | MTPBytesInput, name: string): Uint8Array {
if (typeof value === "string") { if (typeof value === "string") {
return bytesFromString(value, name); return bytesFromString(value, name);
} }
return bytesFrom(value, name); return bytesFrom(value, name);
} }
function normalizeCredentials(value) { function normalizeCredentials(value: MTPCredentials | string | null): MTPCredentials | null {
if (!value) { if (!value) {
return null; return null;
} }
@ -590,7 +570,7 @@ function normalizeCredentials(value) {
return value; return value;
} }
function toBigInt(value) { function toBigInt(value: bigint | string | number | null | undefined): bigint | null {
if (value == null || value === "") { if (value == null || value === "") {
return null; return null;
} }
@ -601,7 +581,7 @@ function generateKeyringBytes() {
return keyring_generate(); return keyring_generate();
} }
export function keyringToKeys(keyring) { export function keyringToKeys(keyring: string | MTPBytesInput): MTPKeyringKeys {
const bytes = const bytes =
typeof keyring === "string" typeof keyring === "string"
? bytesFromString(keyring, "keyring") ? bytesFromString(keyring, "keyring")
@ -630,7 +610,7 @@ export function keyringToKeys(keyring) {
}; };
} }
export function publicKeyBundleToKeys(publicKeyBundle) { export function publicKeyBundleToKeys(publicKeyBundle: string | MTPBytesInput): MTPPublicKeyBundleKeys {
const bytes = const bytes =
typeof publicKeyBundle === "string" typeof publicKeyBundle === "string"
? bytesFromString(publicKeyBundle, "publicKeyBundle") ? bytesFromString(publicKeyBundle, "publicKeyBundle")
@ -741,6 +721,13 @@ function validateOptions(options) {
) { ) {
throw new TypeError("authTimeoutMs must be a positive safe integer"); throw new TypeError("authTimeoutMs must be a positive safe integer");
} }
if (
options.requestTimeoutMs != null &&
(!Number.isSafeInteger(options.requestTimeoutMs) ||
options.requestTimeoutMs <= 0)
) {
throw new TypeError("requestTimeoutMs must be a positive safe integer");
}
} }
async function withTimeout(promise, timeoutMs, message) { async function withTimeout(promise, timeoutMs, message) {
@ -1093,6 +1080,10 @@ export class MTPClient {
data?: Record<string, unknown>, data?: Record<string, unknown>,
options: MTPRequestOptions = {}, options: MTPRequestOptions = {},
): Promise<ParsedFrame> { ): Promise<ParsedFrame> {
const timeoutMs = options.timeoutMs ?? this.#options.requestTimeoutMs ?? 30_000;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
throw new TypeError("request timeoutMs must be a positive safe integer");
}
const frame = this.#buildFrame(typeOrFrame, data, options); const frame = this.#buildFrame(typeOrFrame, data, options);
try { try {
const parsed = this.raw.bindings.parse_frame(frame); const parsed = this.raw.bindings.parse_frame(frame);
@ -1121,7 +1112,11 @@ export class MTPClient {
direction: "send", direction: "send",
}); });
} }
return await this.raw.client.request(frame, options.responseType ?? null); return await withTimeout(
this.raw.client.request(frame, options.responseType ?? null, timeoutMs),
timeoutMs,
`request timed out after ${timeoutMs}ms`,
);
} }
subscribe( subscribe(
@ -1245,7 +1240,7 @@ export class MTPClient {
await this.sessionManager.saveSession(newSession); await this.sessionManager.saveSession(newSession);
const messageId = String(Date.now()); const messageId = createMessageId();
const createdAt = Date.now(); const createdAt = Date.now();
const senderUserId = (options as { senderUserId?: string }).senderUserId; const senderUserId = (options as { senderUserId?: string }).senderUserId;
const frame = this.raw.bindings.build_frame( const frame = this.raw.bindings.build_frame(
@ -1346,10 +1341,7 @@ export class MTPClient {
const sub = this.raw.client.subscribe( const sub = this.raw.client.subscribe(
"EncryptedMessage", "EncryptedMessage",
async (frame: ParsedFrame) => { async (frame: ParsedFrame) => {
const raw = const raw = frame.data?.["EncryptedPayload"];
frame.data?.["encryptedPayload"] ??
frame.data?.["EncryptedPayload"] ??
frame.data?.["encrypted_payload"];
if (!raw) return; if (!raw) return;
let payloadBytes: Uint8Array; let payloadBytes: Uint8Array;
@ -1457,10 +1449,7 @@ export class MTPClient {
async decryptEncryptedRecord( async decryptEncryptedRecord(
frameData: Record<string, unknown>, frameData: Record<string, unknown>,
): Promise<ParsedFrame> { ): Promise<ParsedFrame> {
const raw = const raw = frameData["EncryptedPayload"];
frameData["encryptedPayload"] ??
frameData["EncryptedPayload"] ??
frameData["encrypted_payload"];
if (!raw) throw new Error("EncryptedPayload is required"); if (!raw) throw new Error("EncryptedPayload is required");
const payloadBytes = const payloadBytes =

View file

@ -1,35 +1,5 @@
import * as bindings from "mtp/raw"; import * as bindings from "mtp/raw";
import { utf8Encode } from "./utils.js";
function utf8Encode(text: string): Uint8Array {
if (typeof TextEncoder !== "undefined") {
return new TextEncoder().encode(text);
}
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(text, "utf-8"));
}
const bytes = new Uint8Array(text.length * 4);
let len = 0;
for (let i = 0; i < text.length; i += 1) {
const code = text.codePointAt(i) as number;
if (code < 0x80) {
bytes[len++] = code;
} else if (code < 0x800) {
bytes[len++] = 0xc0 | (code >> 6);
bytes[len++] = 0x80 | (code & 0x3f);
} else if (code < 0x10000) {
bytes[len++] = 0xe0 | (code >> 12);
bytes[len++] = 0x80 | ((code >> 6) & 0x3f);
bytes[len++] = 0x80 | (code & 0x3f);
} else {
bytes[len++] = 0xf0 | (code >> 18);
bytes[len++] = 0x80 | ((code >> 12) & 0x3f);
bytes[len++] = 0x80 | ((code >> 6) & 0x3f);
bytes[len++] = 0x80 | (code & 0x3f);
i += 1;
}
}
return bytes.subarray(0, len);
}
const HKDF_MESSAGE_KEY = "mtp-e2ee-v1-message-key"; const HKDF_MESSAGE_KEY = "mtp-e2ee-v1-message-key";
const HKDF_NEXT_CHAIN = "mtp-e2ee-v1-next-chain"; const HKDF_NEXT_CHAIN = "mtp-e2ee-v1-next-chain";

View file

@ -1,35 +1,5 @@
import * as bindings from "mtp/raw"; import * as bindings from "mtp/raw";
import { concatBytes, utf8Encode, writeU64BE } from "./utils.js";
function utf8Encode(text: string): Uint8Array {
if (typeof TextEncoder !== "undefined") {
return new TextEncoder().encode(text);
}
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(text, "utf-8"));
}
const bytes = new Uint8Array(text.length * 4);
let len = 0;
for (let i = 0; i < text.length; i += 1) {
const code = text.codePointAt(i) as number;
if (code < 0x80) {
bytes[len++] = code;
} else if (code < 0x800) {
bytes[len++] = 0xc0 | (code >> 6);
bytes[len++] = 0x80 | (code & 0x3f);
} else if (code < 0x10000) {
bytes[len++] = 0xe0 | (code >> 12);
bytes[len++] = 0x80 | ((code >> 6) & 0x3f);
bytes[len++] = 0x80 | (code & 0x3f);
} else {
bytes[len++] = 0xf0 | (code >> 18);
bytes[len++] = 0x80 | ((code >> 12) & 0x3f);
bytes[len++] = 0x80 | ((code >> 6) & 0x3f);
bytes[len++] = 0x80 | (code & 0x3f);
i += 1;
}
}
return bytes.subarray(0, len);
}
export const HKDF_SALT_ROOT = "mtp-e2ee-v1-root"; export const HKDF_SALT_ROOT = "mtp-e2ee-v1-root";
const HKDF_INITIATOR_SEND = "mtp-e2ee-v1-initiator-send"; const HKDF_INITIATOR_SEND = "mtp-e2ee-v1-initiator-send";
@ -55,10 +25,17 @@ export interface MTPSessionState {
recvChainKey: Uint8Array; recvChainKey: Uint8Array;
sendCount: number; sendCount: number;
recvCount: number; recvCount: number;
/** Derived receive keys retained for bounded out-of-order delivery. */
skippedMessageKeys?: SkippedMessageKey[];
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
} }
export interface SkippedMessageKey {
messageNumber: number;
key: Uint8Array;
}
export interface MTPSessionStorage { export interface MTPSessionStorage {
getSession(conversationId: string): Promise<MTPSessionState | null>; getSession(conversationId: string): Promise<MTPSessionState | null>;
setSession(state: MTPSessionState): Promise<void>; setSession(state: MTPSessionState): Promise<void>;
@ -68,15 +45,40 @@ export interface MTPSessionStorage {
export class InMemorySessionStorage implements MTPSessionStorage { export class InMemorySessionStorage implements MTPSessionStorage {
private store = new Map<string, MTPSessionState>(); private store = new Map<string, MTPSessionState>();
private cloneSession(state: MTPSessionState): MTPSessionState {
return {
...state,
peerPublicKey: state.peerPublicKey.slice(),
sendChainKey: state.sendChainKey.slice(),
recvChainKey: state.recvChainKey.slice(),
skippedMessageKeys: (state.skippedMessageKeys ?? []).map((skipped) => ({
messageNumber: skipped.messageNumber,
key: skipped.key.slice(),
})),
};
}
private zeroizeSession(state: MTPSessionState): void {
state.sendChainKey.fill(0);
state.recvChainKey.fill(0);
for (const skipped of state.skippedMessageKeys ?? []) skipped.key.fill(0);
}
async getSession(conversationId: string): Promise<MTPSessionState | null> { async getSession(conversationId: string): Promise<MTPSessionState | null> {
return this.store.get(conversationId) ?? null; const state = this.store.get(conversationId);
return state ? this.cloneSession(state) : null;
} }
async setSession(state: MTPSessionState): Promise<void> { async setSession(state: MTPSessionState): Promise<void> {
this.store.set(state.conversationId, { ...state }); const replacement = this.cloneSession(state);
const previous = this.store.get(state.conversationId);
if (previous) this.zeroizeSession(previous);
this.store.set(state.conversationId, replacement);
} }
async deleteSession(conversationId: string): Promise<void> { async deleteSession(conversationId: string): Promise<void> {
const previous = this.store.get(conversationId);
if (previous) this.zeroizeSession(previous);
this.store.delete(conversationId); this.store.delete(conversationId);
} }
} }
@ -90,27 +92,6 @@ function writeU32BE(value: number): Uint8Array {
]); ]);
} }
function writeU64BE(value: bigint): Uint8Array {
if (value < 0n || value > 0xffff_ffff_ffff_ffffn)
throw new Error("u64 out of range");
const buf = new Uint8Array(8);
for (let i = 7; i >= 0; i -= 1) {
buf[i] = Number(value & 0xffn);
value >>= 8n;
}
return buf;
}
function concatBytes(parts: Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
function transcriptField(label: string, value: Uint8Array): Uint8Array { function transcriptField(label: string, value: Uint8Array): Uint8Array {
const labelBytes = utf8Encode(label); const labelBytes = utf8Encode(label);
return concatBytes([ return concatBytes([
@ -240,6 +221,7 @@ export class MTPSessionManager {
recvChainKey: args.role === "initiator" ? initiatorRecv : initiatorSend, recvChainKey: args.role === "initiator" ? initiatorRecv : initiatorSend,
sendCount: 0, sendCount: 0,
recvCount: 0, recvCount: 0,
skippedMessageKeys: [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };

28
src/sdk/utils.ts Normal file
View file

@ -0,0 +1,28 @@
export function utf8Encode(text: string): Uint8Array {
if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(text);
if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(text, "utf-8"));
const bytes = new Uint8Array(text.length * 4);
let len = 0;
for (let i = 0; i < text.length; i += 1) {
const code = text.codePointAt(i) as number;
if (code < 0x80) bytes[len++] = code;
else if (code < 0x800) { bytes[len++] = 0xc0 | (code >> 6); bytes[len++] = 0x80 | (code & 0x3f); }
else if (code < 0x10000) { bytes[len++] = 0xe0 | (code >> 12); bytes[len++] = 0x80 | ((code >> 6) & 0x3f); bytes[len++] = 0x80 | (code & 0x3f); }
else { bytes[len++] = 0xf0 | (code >> 18); bytes[len++] = 0x80 | ((code >> 12) & 0x3f); bytes[len++] = 0x80 | ((code >> 6) & 0x3f); bytes[len++] = 0x80 | (code & 0x3f); i += 1; }
}
return bytes.subarray(0, len);
}
export function writeU64BE(value: bigint): Uint8Array {
if (value < 0n || value > 0xffff_ffff_ffff_ffffn) throw new Error("u64 value out of range");
const out = new Uint8Array(8);
for (let i = 7; i >= 0; i -= 1) { out[i] = Number(value & 0xffn); value >>= 8n; }
return out;
}
export function concatBytes(parts: Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
let offset = 0;
for (const part of parts) { out.set(part, offset); offset += part.length; }
return out;
}

16
src/type-map/reserved.ts Normal file
View file

@ -0,0 +1,16 @@
export const RESERVED_COMMUNICATION_TYPES = [
"Identification", "IdentificationResponse", "Register", "RegisterResponse",
"Challenge", "ChallengeResponse", "Ping", "Pong", "Disconnect", "Redirect",
"Shutdown", "Error", "ErrorParsing", "ErrorBadVersion", "BadRequest",
"Unauthorized", "Forbidden", "NotFound", "TooManyRequests", "InternalServerError",
"BadGateway", "ServiceUnavailable", "GatewayTimeout", "PipeRequest", "PipeResponse",
"PipeAbort",
] as const;
export const RESERVED_DATA_TYPES = [
"Version", "Id", "ClientNonce", "ServerNonce", "PublicKeys", "Signature",
"PqSignature", "Description", "Connected", "Timestamp", "Error", "ErrorParsing",
"ErrorMessage", "Accepted", "RequirePq",
] as const;
export const FIRST_USER_TYPE_ID = 32;

View file

@ -4,6 +4,12 @@ import { spawn } from "node:child_process";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import YAML from "yaml";
import {
FIRST_USER_TYPE_ID,
RESERVED_COMMUNICATION_TYPES,
RESERVED_DATA_TYPES,
} from "../type-map/reserved.js";
export interface MTPVitePluginOptions { export interface MTPVitePluginOptions {
typeMaps: string; typeMaps: string;
@ -17,6 +23,7 @@ export interface VitePlugin {
config?: (...args: any[]) => unknown; config?: (...args: any[]) => unknown;
buildStart?: (...args: any[]) => unknown; buildStart?: (...args: any[]) => unknown;
configureServer?: (...args: any[]) => unknown; configureServer?: (...args: any[]) => unknown;
addWatchFile?: (file: string) => void;
} }
const packageRoot = process.env.MTP_PACKAGE_ROOT const packageRoot = process.env.MTP_PACKAGE_ROOT
@ -26,51 +33,11 @@ const rawEntryName = "mtp_wasm.js";
const wasmEntryName = "mtp_wasm_bg.wasm"; const wasmEntryName = "mtp_wasm_bg.wasm";
const typeMapEntryName = "mtp_type_map.js"; const typeMapEntryName = "mtp_type_map.js";
const reservedCommunicationTypes = [
"Identification",
"IdentificationResponse",
"Register",
"RegisterResponse",
"Challenge",
"ChallengeResponse",
"Ping",
"Pong",
"Disconnect",
"Redirect",
"Shutdown",
"Error",
"ErrorParsing",
"ErrorBadVersion",
"BadRequest",
"Unauthorized",
"Forbidden",
"NotFound",
"TooManyRequests",
"InternalServerError",
"BadGateway",
"ServiceUnavailable",
"GatewayTimeout",
];
const reservedDataTypes = [
"Version",
"Id",
"ClientNonce",
"ServerNonce",
"PublicKeys",
"Signature",
"PqSignature",
"Description",
"Connected",
"Timestamp",
"Error",
"ErrorParsing",
"ErrorMessage",
];
function normalizeOptions(options) { function normalizeOptions(options) {
if (!options?.typeMaps) { if (!options?.typeMaps) {
throw new Error("mtp/vite requires a typeMaps option, for example mtp({ typeMaps: './type-maps.yaml' })"); throw new Error(
"mtp/vite requires a typeMaps option, for example mtp({ typeMaps: './type-maps.yaml' })",
);
} }
return options; return options;
@ -136,62 +103,69 @@ async function hashPackageInputs() {
return hash.digest("hex"); return hash.digest("hex");
} }
function quoteList(values) { function quoteList(values: string[]): string {
return values.length === 0 return values.length === 0
? "never" ? "never"
: values.map((value) => JSON.stringify(value)).join(" | "); : values.map((value) => JSON.stringify(value)).join(" | ");
} }
function parseTypeMapYaml(source, filePath) { function parseTypeMapYaml(source: string, filePath: string) {
const communicationTypes = new Set(reservedCommunicationTypes); const document = YAML.parseDocument(source, { prettyErrors: false });
const dataTypes = new Set(reservedDataTypes); if (document.errors.length) {
let section = null; const error = document.errors[0];
let sectionIndent = -1; const line =
error.pos?.[0] === undefined
for (const [index, originalLine] of source.split(/\r?\n/).entries()) { ? 1
const withoutComment = originalLine.replace(/\s+#.*$/, ""); : source.slice(0, error.pos[0]).split("\n").length;
if (!withoutComment.trim()) { throw new Error(`${filePath}:${line}: ${error.message}`);
continue;
} }
if (/^\t/.test(withoutComment)) { const root = document.toJS() as {
throw new Error(`${filePath}:${index + 1}: tabs are not supported in type-maps.yaml indentation`); type_maps?: Record<
string,
{
CommunicationTypes?: Record<string, unknown>;
DataTypes?: Record<string, unknown>;
}
>;
};
const communicationTypes = new Set<string>(RESERVED_COMMUNICATION_TYPES);
const dataTypes = new Set<string>(RESERVED_DATA_TYPES);
for (const [version, map] of Object.entries(root.type_maps ?? {})) {
if (!/^\d+\.\d+$/.test(version))
throw new Error(`${filePath}: unparseable type-map version '${version}'`);
const ids = new Map<number, string>();
for (const [section, target] of [
["CommunicationTypes", communicationTypes],
["DataTypes", dataTypes],
] as const) {
for (const [name, value] of Object.entries(
map[section as "CommunicationTypes" | "DataTypes"] ?? {},
)) {
if (!Number.isInteger(value) || (value as number) < FIRST_USER_TYPE_ID)
throw new Error(
`${filePath}: ${version}.${section}.${name} must use an integer id >= ${FIRST_USER_TYPE_ID}`,
);
const id = value as number;
const previous = ids.get(id);
if (previous && previous !== `${section}.${name}`)
throw new Error(
`${filePath}: duplicate type id ${id} (${previous} and ${section}.${name})`,
);
ids.set(id, `${section}.${name}`);
target.add(name);
} }
const indent = withoutComment.match(/^ */)?.[0].length ?? 0;
const trimmed = withoutComment.trim();
const sectionMatch = trimmed.match(/^(CommunicationTypes|DataTypes):\s*$/);
if (sectionMatch) {
section = sectionMatch[1];
sectionIndent = indent;
continue;
}
if (section && indent <= sectionIndent) {
section = null;
}
if (!section) {
continue;
}
const entryMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*\d+\s*$/);
if (!entryMatch) {
throw new Error(`${filePath}:${index + 1}: expected '${section}' entries as 'Name: numeric_id'`);
}
if (section === "CommunicationTypes") {
communicationTypes.add(entryMatch[1]);
} else {
dataTypes.add(entryMatch[1]);
} }
} }
return { return {
communicationTypes: [...communicationTypes].sort(), communicationTypes: [...communicationTypes].sort(),
dataTypes: [...dataTypes].sort(), dataTypes: [...dataTypes].sort(),
}; };
} }
function generateTypeMapModule(metadata) { function generateTypeMapModule(metadata: {
communicationTypes: string[];
dataTypes: string[];
}) {
const js = `export const communicationTypes = ${JSON.stringify(metadata.communicationTypes, null, 2)};\nexport const dataTypes = ${JSON.stringify(metadata.dataTypes, null, 2)};\n`; const js = `export const communicationTypes = ${JSON.stringify(metadata.communicationTypes, null, 2)};\nexport const dataTypes = ${JSON.stringify(metadata.dataTypes, null, 2)};\n`;
const dts = `export type MTPCommunicationType = ${quoteList(metadata.communicationTypes)};\nexport type MTPDataType = ${quoteList(metadata.dataTypes)};\nexport declare const communicationTypes: readonly MTPCommunicationType[];\nexport declare const dataTypes: readonly MTPDataType[];\n`; const dts = `export type MTPCommunicationType = ${quoteList(metadata.communicationTypes)};\nexport type MTPDataType = ${quoteList(metadata.dataTypes)};\nexport declare const communicationTypes: readonly MTPCommunicationType[];\nexport declare const dataTypes: readonly MTPDataType[];\n`;
return { js, dts }; return { js, dts };
@ -199,7 +173,9 @@ function generateTypeMapModule(metadata) {
async function writeTypeMapModule(outDir, typeMapsPath) { async function writeTypeMapModule(outDir, typeMapsPath) {
const source = await fs.readFile(typeMapsPath, "utf8").catch((error) => { const source = await fs.readFile(typeMapsPath, "utf8").catch((error) => {
throw new Error(`Failed to read type map '${typeMapsPath}': ${error.message}`); throw new Error(
`Failed to read type map '${typeMapsPath}': ${error.message}`,
);
}); });
const metadata = parseTypeMapYaml(source, typeMapsPath); const metadata = parseTypeMapYaml(source, typeMapsPath);
const module = generateTypeMapModule(metadata); const module = generateTypeMapModule(metadata);
@ -222,7 +198,7 @@ async function copyWasmBuildInputs(buildRoot) {
for (const input of inputs) { for (const input of inputs) {
const source = path.join(packageRoot, input); const source = path.join(packageRoot, input);
if (!await pathExists(source)) { if (!(await pathExists(source))) {
continue; continue;
} }
@ -255,7 +231,9 @@ async function runWasmPack({ outDir, typeMapsPath, release, wasmPackArgs }) {
env: { env: {
...process.env, ...process.env,
MTP_TYPE_MAPS: typeMapsPath, MTP_TYPE_MAPS: typeMapsPath,
RUSTFLAGS: [process.env.RUSTFLAGS, "--cfg web_sys_unstable_apis"].filter(Boolean).join(" "), RUSTFLAGS: [process.env.RUSTFLAGS, "--cfg web_sys_unstable_apis"]
.filter(Boolean)
.join(" "),
}, },
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });
@ -270,7 +248,11 @@ async function runWasmPack({ outDir, typeMapsPath, release, wasmPackArgs }) {
}); });
child.on("error", (error) => { child.on("error", (error) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") { if ((error as NodeJS.ErrnoException).code === "ENOENT") {
reject(new Error("Failed to run wasm-pack. Install wasm-pack or enter the project Nix dev shell, then retry.")); reject(
new Error(
"Failed to run wasm-pack. Install wasm-pack or enter the project Nix dev shell, then retry.",
),
);
} else { } else {
reject(error); reject(error);
} }
@ -279,7 +261,11 @@ async function runWasmPack({ outDir, typeMapsPath, release, wasmPackArgs }) {
if (code === 0) { if (code === 0) {
resolve(); resolve();
} else { } else {
reject(new Error(`wasm-pack failed with exit code ${code}.\n${stdout}${stderr}`.trim())); reject(
new Error(
`wasm-pack failed with exit code ${code}.\n${stdout}${stderr}`.trim(),
),
);
} }
}); });
}); });
@ -294,37 +280,60 @@ async function buildIfNeeded(state, force = false) {
} }
state.buildPromise = (async () => { state.buildPromise = (async () => {
const typeMapSource = await writeTypeMapModule(state.outDir, state.typeMapsPath); const typeMapSource = await writeTypeMapModule(
state.outDir,
state.typeMapsPath,
);
const packageInputs = await hashPackageInputs(); const packageInputs = await hashPackageInputs();
const fingerprint = crypto const fingerprint = crypto
.createHash("sha256") .createHash("sha256")
.update(JSON.stringify({ .update(
JSON.stringify({
packageRoot, packageRoot,
packageInputs, packageInputs,
typeMapsPath: state.typeMapsPath, typeMapsPath: state.typeMapsPath,
typeMapSource, typeMapSource,
release: state.release, release: state.release,
wasmPackArgs: state.wasmPackArgs, wasmPackArgs: state.wasmPackArgs,
})) }),
)
.digest("hex"); .digest("hex");
const stampPath = path.join(state.outDir, ".mtp-build.json"); const stampPath = path.join(state.outDir, ".mtp-build.json");
const rawEntryPath = path.join(state.outDir, rawEntryName); const rawEntryPath = path.join(state.outDir, rawEntryName);
const wasmPath = path.join(state.outDir, wasmEntryName); const wasmPath = path.join(state.outDir, wasmEntryName);
let previousFingerprint = null; let previousFingerprint = null;
try { try {
previousFingerprint = JSON.parse(await fs.readFile(stampPath, "utf8")).fingerprint; previousFingerprint = JSON.parse(
await fs.readFile(stampPath, "utf8"),
).fingerprint;
} catch { } catch {
previousFingerprint = null; previousFingerprint = null;
} }
if (!force && previousFingerprint === fingerprint && await pathExists(rawEntryPath) && await pathExists(wasmPath)) { if (
!force &&
previousFingerprint === fingerprint &&
(await pathExists(rawEntryPath)) &&
(await pathExists(wasmPath))
) {
return; return;
} }
console.info("\x1b[1m\x1b[35mmtp\x1b[0m compiling wasm... (this could take a minute)"); console.info(
"\x1b[1m\x1b[35mmtp\x1b[0m compiling wasm... (this could take a minute)",
);
await runWasmPack(state); await runWasmPack(state);
console.log("\x1b[1m\x1b[35mmtp\x1b[0m \x1b[32mcompilation finished.\x1b[0m"); console.log(
await fs.writeFile(stampPath, JSON.stringify({ fingerprint, builtAt: new Date().toISOString() }, null, 2)); "\x1b[1m\x1b[35mmtp\x1b[0m \x1b[32mcompilation finished.\x1b[0m",
);
await fs.writeFile(
stampPath,
JSON.stringify(
{ fingerprint, builtAt: new Date().toISOString() },
null,
2,
),
);
})().finally(() => { })().finally(() => {
state.buildPromise = null; state.buildPromise = null;
}); });
@ -334,7 +343,7 @@ async function buildIfNeeded(state, force = false) {
export function mtp(options: MTPVitePluginOptions): VitePlugin { export function mtp(options: MTPVitePluginOptions): VitePlugin {
const normalized = normalizeOptions(options); const normalized = normalizeOptions(options);
const state = { const state: any = {
outDir: null, outDir: null,
typeMapsPath: null, typeMapsPath: null,
release: true, release: true,
@ -347,11 +356,16 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin {
async config(config, env) { async config(config, env) {
const root = path.resolve(config.root ?? process.cwd()); const root = path.resolve(config.root ?? process.cwd());
state.typeMapsPath = path.resolve(root, normalized.typeMaps); state.typeMapsPath = path.resolve(root, normalized.typeMaps);
state.outDir = path.resolve(root, normalized.outDir ?? path.join("node_modules", ".vite", "mtp")); state.outDir = path.resolve(
root,
normalized.outDir ?? path.join("node_modules", ".vite", "mtp"),
);
state.release = normalized.release ?? env.command === "build"; state.release = normalized.release ?? env.command === "build";
if (!await pathExists(state.typeMapsPath)) { if (!(await pathExists(state.typeMapsPath))) {
throw new Error(`mtp/vite could not find typeMaps file: ${state.typeMapsPath}`); throw new Error(
`mtp/vite could not find typeMaps file: ${state.typeMapsPath}`,
);
} }
await buildIfNeeded(state); await buildIfNeeded(state);
@ -367,21 +381,27 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin {
}; };
}, },
buildStart() { buildStart() {
this.addWatchFile(state.typeMapsPath); (this as any).addWatchFile(state.typeMapsPath);
}, },
async configureServer(server) { async configureServer(server) {
const wasmPath = path.join(state.outDir, wasmEntryName); const wasmPath = path.join(state.outDir, wasmEntryName);
const wasmUrl = devServerPath(server.config.root, wasmPath); const wasmUrl = devServerPath(server.config.root, wasmPath);
if (wasmUrl) { if (wasmUrl) {
server.middlewares.use(async (req, res, next) => { server.middlewares.use(async (req, res, next) => {
if (!req.url || new URL(req.url, "http://localhost").pathname !== wasmUrl) { if (
!req.url ||
new URL(req.url, "http://localhost").pathname !== wasmUrl
) {
next(); next();
return; return;
} }
try { try {
res.setHeader("Content-Type", "application/wasm"); res.setHeader("Content-Type", "application/wasm");
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); res.setHeader(
"Cache-Control",
"no-cache, no-store, must-revalidate",
);
res.end(await fs.readFile(wasmPath)); res.end(await fs.readFile(wasmPath));
} catch (error) { } catch (error) {
next(error); next(error);
@ -408,7 +428,9 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin {
const scheduleRebuild = (changedPath: string) => { const scheduleRebuild = (changedPath: string) => {
const resolved = path.resolve(changedPath); const resolved = path.resolve(changedPath);
const isTypeMap = resolved === state.typeMapsPath; const isTypeMap = resolved === state.typeMapsPath;
const isSource = sourceWatchDirs.some((dir) => resolved.startsWith(`${dir}${path.sep}`)); const isSource = sourceWatchDirs.some((dir) =>
resolved.startsWith(`${dir}${path.sep}`),
);
if (!isTypeMap && !isSource) { if (!isTypeMap && !isSource) {
return; return;
} }
@ -423,9 +445,13 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin {
try { try {
await buildIfNeeded(state, true); await buildIfNeeded(state, true);
server.moduleGraph.invalidateAll(); server.moduleGraph.invalidateAll();
if (server.ws) {
server.ws.send({ type: "full-reload" }); server.ws.send({ type: "full-reload" });
}
} catch (error) { } catch (error) {
server.config.logger.error(error instanceof Error ? error.message : String(error)); server.config.logger.error(
error instanceof Error ? error.message : String(error),
);
} }
})(); })();
}, 200); }, 200);

View file

@ -338,6 +338,48 @@ await describe("E2EE Encrypt/Decrypt", async () => {
assert.deepEqual(aliceAfter2.sendChainKey, bobAfter2.recvChainKey); assert.deepEqual(aliceAfter2.sendChainKey, bobAfter2.recvChainKey);
assert.equal(aliceAfter2.sendCount, 2); assert.equal(aliceAfter2.sendCount, 2);
}); });
await it("decrypts messages delivered out of order exactly once", async () => {
const ss = sdk.crypto.sha256(new Uint8Array([4, 5, 6]));
const { initSessions } = setupSessions(ss);
let { aliceSession, bobSession } = await initSessions();
const sent = [];
for (const label of ["first", "second", "third"]) {
const encrypted = await encryptPayload({
plaintext: sdk.codec.encode("Ping", { Version: label }),
session: aliceSession,
});
sent.push(encrypted.payload);
aliceSession = encrypted.session;
}
const third = await decryptPayload({ payload: sent[2], session: bobSession });
bobSession = third.session;
assert.equal(sdk.codec.decode(third.plaintext).data["Version"], "third");
assert.equal(bobSession.recvCount, 3);
assert.deepEqual(
bobSession.skippedMessageKeys.map(({ messageNumber }) => messageNumber),
[0, 1],
);
const first = await decryptPayload({ payload: sent[0], session: bobSession });
bobSession = first.session;
assert.equal(sdk.codec.decode(first.plaintext).data["Version"], "first");
assert.deepEqual(
bobSession.skippedMessageKeys.map(({ messageNumber }) => messageNumber),
[1],
);
const second = await decryptPayload({ payload: sent[1], session: bobSession });
bobSession = second.session;
assert.equal(sdk.codec.decode(second.plaintext).data["Version"], "second");
assert.equal(bobSession.skippedMessageKeys.length, 0);
await assert.rejects(
decryptPayload({ payload: sent[0], session: bobSession }),
/replay/,
);
});
}); });
await describe("E2EE Public Key Bundle", async () => { await describe("E2EE Public Key Bundle", async () => {

2
transport/Cargo.lock generated
View file

@ -4,4 +4,4 @@ version = 4
[[package]] [[package]]
name = "transport" name = "transport"
version = "0.1.0" version = "0.2.0"

View file

@ -1,11 +1,11 @@
[package] [package]
name = "mtp-transport" name = "mtp-transport"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
mtp-codec = { version = "0.1.0", path = "../codec" } mtp-codec = { version = "0.2.0", path = "../codec" }
mtp-common = { version = "0.1.0", path = "../common" } mtp-common = { version = "0.2.0", path = "../common" }
wtransport = { version = "0.7.1", default-features = false, features = [ wtransport = { version = "0.7.1", default-features = false, features = [
"aws-lc-rs", "aws-lc-rs",
"quinn", "quinn",
@ -14,18 +14,25 @@ wtransport = { version = "0.7.1", default-features = false, features = [
rustls = { version = "0.23.41" } rustls = { version = "0.23.41" }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
rustls-native-certs = "0.8.4" rustls-native-certs = "0.8.4"
log = "0.4"
rcgen = "0.14" rcgen = "0.14"
tracing = "0.1" tracing = "0.1"
async-trait = "0.1"
sha2 = "0.11"
[dev-dependencies] [dev-dependencies]
[[test]] [[test]]
name = "integration" name = "integration"
required-features = ["host"] required-features = ["host", "insecure-tls"]
[features] [features]
# Enables hosting a MTP server # Enables hosting a MTP server
host = [] host = []
pipes = ["mtp-codec/pipes"] pipes = ["mtp-codec/pipes"]
# Compiles the insecure certificate verifier (NoopCertVerifier).
# Even with this feature enabled, the verifier requires the environment
# variable MTP_INSECURE_TLS=1 at runtime. Intended for local development
# only; never enable in release builds.
insecure-tls = []

View file

@ -1,23 +1,27 @@
use std::sync::Arc; use std::sync::Arc;
use mtp_common::CommunicationError; use mtp_common::CommunicationError;
use rustls::{ use rustls::{ClientConfig as RustlsClientConfig, RootCertStore, pki_types::pem::PemObject};
ClientConfig as RustlsClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme, use wtransport::{ClientConfig as WTransportClientConfig, Endpoint};
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
pki_types::{ServerName, UnixTime, pem::PemObject},
};
use wtransport::{ClientConfig, Endpoint};
use crate::{ConnectionHandle, Policy, Receiver, Sender}; use crate::{ConnectionHandle, Policy, Receiver, Sender};
#[derive(Debug)] #[cfg(feature = "insecure-tls")]
struct NoopCertVerifier; mod noop_verifier {
use rustls::{
DigitallySignedStruct, SignatureScheme,
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
pki_types::{CertificateDer, ServerName, UnixTime},
};
impl ServerCertVerifier for NoopCertVerifier { #[derive(Debug)]
pub(super) struct NoopCertVerifier;
impl ServerCertVerifier for NoopCertVerifier {
fn verify_server_cert( fn verify_server_cert(
&self, &self,
_end_entity: &rustls::pki_types::CertificateDer<'_>, _end_entity: &CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>], _intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>, _server_name: &ServerName<'_>,
_ocsp_response: &[u8], _ocsp_response: &[u8],
_now: UnixTime, _now: UnixTime,
@ -28,7 +32,7 @@ impl ServerCertVerifier for NoopCertVerifier {
fn verify_tls12_signature( fn verify_tls12_signature(
&self, &self,
_message: &[u8], _message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>, _cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct, _dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> { ) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion()) Ok(HandshakeSignatureValid::assertion())
@ -37,7 +41,7 @@ impl ServerCertVerifier for NoopCertVerifier {
fn verify_tls13_signature( fn verify_tls13_signature(
&self, &self,
_message: &[u8], _message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>, _cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct, _dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> { ) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion()) Ok(HandshakeSignatureValid::assertion())
@ -57,19 +61,112 @@ impl ServerCertVerifier for NoopCertVerifier {
SignatureScheme::ED25519, SignatureScheme::ED25519,
] ]
} }
}
}
/// TLS and transport settings for a native client connection.
///
/// Certificate verification uses system roots by default. Disabling verification
/// requires an explicit call to [`Self::with_insecure_certificate_verification`]
/// and the `insecure-tls` compile-time feature.
pub struct ClientConfig {
server_cert: Option<Vec<u8>>,
pinned_hash: Option<[u8; 32]>,
insecure_certificate_verification: bool,
policy: Policy,
}
impl ClientConfig {
pub fn new(policy: Policy) -> Self {
Self {
server_cert: None,
pinned_hash: None,
insecure_certificate_verification: false,
policy,
}
}
pub fn with_server_certificate(mut self, cert_pem: Vec<u8>) -> Self {
self.server_cert = Some(cert_pem);
self.insecure_certificate_verification = false;
self.pinned_hash = None;
self
}
/// Pin the connection to a specific SPKI SHA-256 hash.
///
/// The client will only accept server certificates whose DER-encoded Subject
/// Public Key Info matches the given 32-byte hash. This is the recommended
/// approach for trusting self-signed certificates without disabling
/// verification entirely.
pub fn with_pinned_certificate_hash(mut self, hash: [u8; 32]) -> Self {
self.pinned_hash = Some(hash);
self.server_cert = None;
self.insecure_certificate_verification = false;
self
}
/// Disable server certificate verification.
///
/// Requires the `insecure-tls` feature at compile time and the environment
/// variable `MTP_INSECURE_TLS=1` at runtime. Returns an error if either
/// condition is not met.
///
/// Intended only for local development with a self-signed host.
#[cfg(feature = "insecure-tls")]
pub fn with_insecure_certificate_verification(mut self) -> Self {
self.server_cert = None;
self.pinned_hash = None;
self.insecure_certificate_verification = true;
self
}
} }
pub async fn connect( pub async fn connect(
url: &str, url: &str,
server_cert: Option<Vec<u8>>, server_cert: Option<Vec<u8>>,
policy: Policy, policy: Policy,
) -> Result<(Sender, Receiver), CommunicationError> {
let config = match server_cert {
Some(cert_pem) => ClientConfig::new(policy).with_server_certificate(cert_pem),
None => ClientConfig::new(policy),
};
connect_with_config(url, config).await
}
/// Connect using explicit TLS and transport configuration.
pub async fn connect_with_config(
url: &str,
config: ClientConfig,
) -> Result<(Sender, Receiver), CommunicationError> { ) -> Result<(Sender, Receiver), CommunicationError> {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let client_config = if let Some(cert_pem) = server_cert { let client_config = if config.insecure_certificate_verification {
configure_client_with_cert(cert_pem, &policy)? #[cfg(feature = "insecure-tls")]
{
let env_val = std::env::var("MTP_INSECURE_TLS")
.map(|v| v == "1")
.unwrap_or(false);
if !env_val {
return Err(CommunicationError::Other(
"insecure TLS requires MTP_INSECURE_TLS=1 in the environment".into(),
));
}
client_config_insecure(&config.policy)?
}
#[cfg(not(feature = "insecure-tls"))]
{
unreachable!(
"insecure_certificate_verification is only set when \
the insecure-tls feature is enabled"
)
}
} else if let Some(hash) = config.pinned_hash {
crate::pinning::configure_client_pinned_hash(hash, &config.policy)?
} else if let Some(cert_pem) = config.server_cert {
configure_client_with_cert(cert_pem, &config.policy)?
} else { } else {
configure_client_system_roots(&policy)? configure_client_system_roots(&config.policy)?
}; };
let endpoint = Endpoint::client(client_config) let endpoint = Endpoint::client(client_config)
@ -81,7 +178,7 @@ pub async fn connect(
.map_err(|e| CommunicationError::ConnectingError(e.to_string()))?; .map_err(|e| CommunicationError::ConnectingError(e.to_string()))?;
let handle = Arc::new(ConnectionHandle::new()); let handle = Arc::new(ConnectionHandle::new());
let policy = Arc::new(policy); let policy = Arc::new(config.policy);
let sender = Sender::new(connection.clone(), handle.clone(), policy.clone()); let sender = Sender::new(connection.clone(), handle.clone(), policy.clone());
let receiver = Receiver::new(connection, handle, policy); let receiver = Receiver::new(connection, handle, policy);
@ -92,7 +189,7 @@ pub async fn connect(
fn configure_client_with_cert( fn configure_client_with_cert(
server_cert: Vec<u8>, server_cert: Vec<u8>,
policy: &Policy, policy: &Policy,
) -> Result<ClientConfig, CommunicationError> { ) -> Result<WTransportClientConfig, CommunicationError> {
let mut root_store = RootCertStore::empty(); let mut root_store = RootCertStore::empty();
let certs = rustls::pki_types::CertificateDer::pem_slice_iter(&server_cert) let certs = rustls::pki_types::CertificateDer::pem_slice_iter(&server_cert)
@ -108,7 +205,10 @@ fn configure_client_with_cert(
client_config_from_roots(root_store, policy) client_config_from_roots(root_store, policy)
} }
fn client_config_insecure(policy: &Policy) -> Result<ClientConfig, CommunicationError> { #[cfg(feature = "insecure-tls")]
fn client_config_insecure(policy: &Policy) -> Result<WTransportClientConfig, CommunicationError> {
use noop_verifier::NoopCertVerifier;
let mut tls_config = RustlsClientConfig::builder() let mut tls_config = RustlsClientConfig::builder()
.dangerous() .dangerous()
.with_custom_certificate_verifier(Arc::new(NoopCertVerifier)) .with_custom_certificate_verifier(Arc::new(NoopCertVerifier))
@ -116,7 +216,7 @@ fn client_config_insecure(policy: &Policy) -> Result<ClientConfig, Communication
tls_config.alpn_protocols = vec![b"h3".to_vec()]; tls_config.alpn_protocols = vec![b"h3".to_vec()];
Ok(ClientConfig::builder() Ok(WTransportClientConfig::builder()
.with_bind_default() .with_bind_default()
.with_custom_tls(tls_config) .with_custom_tls(tls_config)
.keep_alive_interval(policy.keep_alive_interval) .keep_alive_interval(policy.keep_alive_interval)
@ -125,16 +225,11 @@ fn client_config_insecure(policy: &Policy) -> Result<ClientConfig, Communication
.build()) .build())
} }
fn configure_client_system_roots(policy: &Policy) -> Result<ClientConfig, CommunicationError> { fn configure_client_system_roots(
// Check if insecure mode is enabled via env variable MTP_INSECURE policy: &Policy,
let insecure = std::env::var("MTP_INSECURE").is_ok(); ) -> Result<WTransportClientConfig, CommunicationError> {
if insecure {
// Insecure mode: skip certificate verification entirely
return client_config_insecure(policy);
}
let mut root_store = RootCertStore::empty(); let mut root_store = RootCertStore::empty();
// Load native certs
let certs = rustls_native_certs::load_native_certs().certs; let certs = rustls_native_certs::load_native_certs().certs;
for cert in certs { for cert in certs {
@ -147,14 +242,14 @@ fn configure_client_system_roots(policy: &Policy) -> Result<ClientConfig, Commun
fn client_config_from_roots( fn client_config_from_roots(
root_store: RootCertStore, root_store: RootCertStore,
policy: &Policy, policy: &Policy,
) -> Result<ClientConfig, CommunicationError> { ) -> Result<WTransportClientConfig, CommunicationError> {
let mut tls_config = RustlsClientConfig::builder() let mut tls_config = RustlsClientConfig::builder()
.with_root_certificates(root_store) .with_root_certificates(root_store)
.with_no_client_auth(); .with_no_client_auth();
tls_config.alpn_protocols = vec![b"h3".to_vec()]; tls_config.alpn_protocols = vec![b"h3".to_vec()];
Ok(ClientConfig::builder() Ok(WTransportClientConfig::builder()
.with_bind_default() .with_bind_default()
.with_custom_tls(tls_config) .with_custom_tls(tls_config)
.keep_alive_interval(policy.keep_alive_interval) .keep_alive_interval(policy.keep_alive_interval)

View file

@ -4,16 +4,17 @@ use crate::pipe::PipeReader;
use mtp_codec::CommunicationValue; use mtp_codec::CommunicationValue;
use mtp_common::CommunicationError; use mtp_common::CommunicationError;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::{Mutex, Notify, RwLock, Semaphore, mpsc}; use tokio::sync::{Mutex, Notify, RwLock, Semaphore, mpsc};
use tokio::time::{Duration, sleep, timeout}; use tokio::time::{Duration, sleep, timeout};
use tracing::{debug, info, instrument, trace}; use tracing::{debug, info, instrument, trace, warn};
use wtransport::Connection; use wtransport::Connection;
#[cfg(feature = "pipes")] #[cfg(feature = "pipes")]
#[derive(Debug)] #[derive(Debug)]
pub enum TransportEvent { pub enum TransportEvent<R = wtransport::RecvStream> {
Message(CommunicationValue), Message(CommunicationValue),
Pipe(PipeReader), Pipe(PipeReader<R>),
} }
const APPLICATION_CLOSE_REASON: &str = "mtp-close"; const APPLICATION_CLOSE_REASON: &str = "mtp-close";
@ -28,6 +29,8 @@ pub enum SendMode {
pub struct Policy { pub struct Policy {
pub send_mode: SendMode, pub send_mode: SendMode,
pub max_message_size: u64, pub max_message_size: u64,
/// Receive limit used until the application-level handshake completes.
pub handshake_max_message_size: u64,
pub close_frame_len: u32, pub close_frame_len: u32,
pub application_close_code: u32, pub application_close_code: u32,
pub open_stream_timeout: Duration, pub open_stream_timeout: Duration,
@ -37,8 +40,6 @@ pub struct Policy {
pub keep_alive_interval: Option<Duration>, pub keep_alive_interval: Option<Duration>,
pub max_idle_timeout: Option<Duration>, pub max_idle_timeout: Option<Duration>,
pub force_close_delay: Duration, pub force_close_delay: Duration,
pub max_transient_recv_errors: usize,
pub transient_recv_backoff: Duration,
pub persistent_stream_max_retries: usize, pub persistent_stream_max_retries: usize,
pub persistent_stream_retry_backoff: Duration, pub persistent_stream_retry_backoff: Duration,
pub receiver_queue_capacity: usize, pub receiver_queue_capacity: usize,
@ -50,7 +51,8 @@ impl Default for Policy {
fn default() -> Self { fn default() -> Self {
Self { Self {
send_mode: SendMode::PersistentStream, send_mode: SendMode::PersistentStream,
max_message_size: 1_000_000_000, max_message_size: 16 * 1024 * 1024,
handshake_max_message_size: 64 * 1024,
close_frame_len: u32::MAX, close_frame_len: u32::MAX,
application_close_code: 0, application_close_code: 0,
open_stream_timeout: Duration::from_millis(2_000), open_stream_timeout: Duration::from_millis(2_000),
@ -60,8 +62,6 @@ impl Default for Policy {
keep_alive_interval: Some(Duration::from_secs(3)), keep_alive_interval: Some(Duration::from_secs(3)),
max_idle_timeout: Some(Duration::from_secs(30)), max_idle_timeout: Some(Duration::from_secs(30)),
force_close_delay: Duration::from_millis(300), force_close_delay: Duration::from_millis(300),
max_transient_recv_errors: 20,
transient_recv_backoff: Duration::from_millis(100),
persistent_stream_max_retries: 4, persistent_stream_max_retries: 4,
persistent_stream_retry_backoff: Duration::from_millis(20), persistent_stream_retry_backoff: Duration::from_millis(20),
receiver_queue_capacity: 1000, receiver_queue_capacity: 1000,
@ -82,6 +82,11 @@ impl Policy {
self self
} }
pub fn with_handshake_max_message_size(mut self, max_message_size: u64) -> Self {
self.handshake_max_message_size = max_message_size;
self
}
pub fn with_timeouts( pub fn with_timeouts(
mut self, mut self,
open_stream_timeout: Duration, open_stream_timeout: Duration,
@ -179,15 +184,15 @@ impl Sender {
match timeout(policy.write_timeout, write_result).await { match timeout(policy.write_timeout, write_result).await {
Ok(Ok(())) => Ok(()), Ok(Ok(())) => Ok(()),
Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => { Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => {
log::warn!("[Sender] write failed: peer sent STOP_SENDING (error code {code})"); warn!("[Sender] write failed: peer sent STOP_SENDING (error code {code})");
Err(CommunicationError::StreamClosed) Err(CommunicationError::StreamClosed)
} }
Ok(Err(other)) => { Ok(Err(other)) => {
log::warn!("[Sender] write failed: {other}"); warn!("[Sender] write failed: {other}");
Err(CommunicationError::StreamError) Err(CommunicationError::StreamError)
} }
Err(_) => { Err(_) => {
log::warn!("[Sender] write timed out (len={})", bytes.len()); warn!("[Sender] write timed out (len={})", bytes.len());
Err(CommunicationError::StreamError) Err(CommunicationError::StreamError)
} }
} }
@ -260,6 +265,12 @@ impl Sender {
} }
let err = res.err().unwrap_or(CommunicationError::StreamError); let err = res.err().unwrap_or(CommunicationError::StreamError);
if !matches!(
err,
CommunicationError::StreamError | CommunicationError::StreamClosed
) {
return Err(err);
}
*stream_opt = None; *stream_opt = None;
tries += 1; tries += 1;
if tries > policy.persistent_stream_max_retries { if tries > policy.persistent_stream_max_retries {
@ -283,15 +294,15 @@ impl Sender {
match timeout(policy.write_timeout, stream.finish()).await { match timeout(policy.write_timeout, stream.finish()).await {
Ok(Ok(())) => Ok(()), Ok(Ok(())) => Ok(()),
Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => { Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => {
log::warn!("[Sender] finish failed: peer sent STOP_SENDING (error code {code})"); warn!("[Sender] finish failed: peer sent STOP_SENDING (error code {code})");
Err(CommunicationError::StreamClosed) Err(CommunicationError::StreamClosed)
} }
Ok(Err(other)) => { Ok(Err(other)) => {
log::warn!("[Sender] finish failed: {other}"); warn!("[Sender] finish failed: {other}");
Err(CommunicationError::StreamError) Err(CommunicationError::StreamError)
} }
Err(_) => { Err(_) => {
log::warn!("[Sender] finish timed out"); warn!("[Sender] finish timed out");
Err(CommunicationError::StreamError) Err(CommunicationError::StreamError)
} }
} }
@ -308,30 +319,30 @@ impl Sender {
match timeout(policy.write_timeout, stream.write_all(&len_bytes)).await { match timeout(policy.write_timeout, stream.write_all(&len_bytes)).await {
Ok(Ok(())) => {} Ok(Ok(())) => {}
Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => { Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => {
log::warn!( warn!(
"[Sender] close frame write failed: peer sent STOP_SENDING (error code {code})" "[Sender] close frame write failed: peer sent STOP_SENDING (error code {code})"
); );
} }
Ok(Err(other)) => { Ok(Err(other)) => {
log::warn!("[Sender] close frame write failed: {other}"); warn!("[Sender] close frame write failed: {other}");
} }
Err(_) => { Err(_) => {
log::warn!("[Sender] close frame write timed out"); warn!("[Sender] close frame write timed out");
} }
} }
match timeout(policy.write_timeout, stream.finish()).await { match timeout(policy.write_timeout, stream.finish()).await {
Ok(Ok(())) => {} Ok(Ok(())) => {}
Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => { Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => {
log::warn!( warn!(
"[Sender] close frame finish failed: peer sent STOP_SENDING (error code {code})" "[Sender] close frame finish failed: peer sent STOP_SENDING (error code {code})"
); );
} }
Ok(Err(other)) => { Ok(Err(other)) => {
log::warn!("[Sender] close frame finish failed: {other}"); warn!("[Sender] close frame finish failed: {other}");
} }
Err(_) => { Err(_) => {
log::warn!("[Sender] close frame finish timed out"); warn!("[Sender] close frame finish timed out");
} }
} }
@ -402,17 +413,15 @@ impl Sender {
match timeout(self.policy.write_timeout, stream.finish()).await { match timeout(self.policy.write_timeout, stream.finish()).await {
Ok(Ok(())) => {} Ok(Ok(())) => {}
Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => { Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => {
log::warn!( warn!("[Sender] finish_stream: peer sent STOP_SENDING (error code {code})");
"[Sender] finish_stream: peer sent STOP_SENDING (error code {code})"
);
return Err(CommunicationError::StreamClosed); return Err(CommunicationError::StreamClosed);
} }
Ok(Err(other)) => { Ok(Err(other)) => {
log::warn!("[Sender] finish_stream failed: {other}"); warn!("[Sender] finish_stream failed: {other}");
return Err(CommunicationError::StreamError); return Err(CommunicationError::StreamError);
} }
Err(_) => { Err(_) => {
log::warn!("[Sender] finish_stream timed out"); warn!("[Sender] finish_stream timed out");
return Err(CommunicationError::StreamError); return Err(CommunicationError::StreamError);
} }
} }
@ -478,13 +487,13 @@ impl Sender {
if let Some(mut stream) = stream_guard.lock().await.take() { if let Some(mut stream) = stream_guard.lock().await.take() {
match timeout(policy.write_timeout, stream.finish()).await { match timeout(policy.write_timeout, stream.finish()).await {
Ok(Ok(())) => {} Ok(Ok(())) => {}
Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => log::warn!( Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => warn!(
"[Sender] persistent stream finish failed: peer sent STOP_SENDING (error code {code})" "[Sender] persistent stream finish failed: peer sent STOP_SENDING (error code {code})"
), ),
Ok(Err(e)) => { Ok(Err(e)) => {
log::warn!("[Sender] persistent stream finish failed: {e}") warn!("[Sender] persistent stream finish failed: {e}")
} }
Err(_) => log::warn!("[Sender] persistent stream finish timed out"), Err(_) => warn!("[Sender] persistent stream finish timed out"),
} }
} }
@ -526,11 +535,11 @@ impl Sender {
match timeout(policy.write_timeout, close_write).await { match timeout(policy.write_timeout, close_write).await {
Ok(Ok(())) => {} Ok(Ok(())) => {}
Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => log::warn!( Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => warn!(
"[Sender] close_and_wait failed: peer sent STOP_SENDING (error code {code})" "[Sender] close_and_wait failed: peer sent STOP_SENDING (error code {code})"
), ),
Ok(Err(e)) => log::warn!("[Sender] close_and_wait failed: {e}"), Ok(Err(e)) => warn!("[Sender] close_and_wait failed: {e}"),
Err(_) => log::warn!("[Sender] close_and_wait timed out"), Err(_) => warn!("[Sender] close_and_wait timed out"),
} }
} else { } else {
let _ = Self::send_close_frame(&connection, &policy).await; let _ = Self::send_close_frame(&connection, &policy).await;
@ -585,6 +594,7 @@ struct ReceiverInner {
handle: Arc<ConnectionHandle>, handle: Arc<ConnectionHandle>,
ping_control: Arc<RwLock<PingControl>>, ping_control: Arc<RwLock<PingControl>>,
queue_notify: Arc<Notify>, queue_notify: Arc<Notify>,
max_message_size: Arc<AtomicU64>,
} }
impl Clone for Receiver { impl Clone for Receiver {
@ -611,6 +621,26 @@ struct PingControl {
impl Receiver { impl Receiver {
pub fn new(connection: Connection, handle: Arc<ConnectionHandle>, policy: Arc<Policy>) -> Self { pub fn new(connection: Connection, handle: Arc<ConnectionHandle>, policy: Arc<Policy>) -> Self {
Self::new_with_max_message_size(connection, handle, policy.clone(), policy.max_message_size)
}
pub(crate) fn new_for_handshake(
connection: Connection,
handle: Arc<ConnectionHandle>,
policy: Arc<Policy>,
) -> Self {
let initial_max = policy
.handshake_max_message_size
.min(policy.max_message_size);
Self::new_with_max_message_size(connection, handle, policy, initial_max)
}
fn new_with_max_message_size(
connection: Connection,
handle: Arc<ConnectionHandle>,
policy: Arc<Policy>,
initial_max_message_size: u64,
) -> Self {
#[cfg(feature = "pipes")] #[cfg(feature = "pipes")]
let (msg_tx, msg_rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>( let (msg_tx, msg_rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(
policy.receiver_queue_capacity, policy.receiver_queue_capacity,
@ -629,6 +659,8 @@ impl Receiver {
let accept_ping_control = ping_control.clone(); let accept_ping_control = ping_control.clone();
let queue_notify = Arc::new(Notify::new()); let queue_notify = Arc::new(Notify::new());
let accept_queue_notify = queue_notify.clone(); let accept_queue_notify = queue_notify.clone();
let max_message_size = Arc::new(AtomicU64::new(initial_max_message_size));
let accept_max_message_size = max_message_size.clone();
let stream_limit = Arc::new(Semaphore::new(policy.max_concurrent_stream_tasks.max(1))); let stream_limit = Arc::new(Semaphore::new(policy.max_concurrent_stream_tasks.max(1)));
let accept_stream_limit = stream_limit.clone(); let accept_stream_limit = stream_limit.clone();
debug!( debug!(
@ -695,6 +727,7 @@ impl Receiver {
let stream_handle = conn_handle.clone(); let stream_handle = conn_handle.clone();
let stream_policy = accept_policy.clone(); let stream_policy = accept_policy.clone();
let stream_ping_control = accept_ping_control.clone(); let stream_ping_control = accept_ping_control.clone();
let stream_max_message_size = accept_max_message_size.clone();
tokio::spawn(async move { tokio::spawn(async move {
let _permit = permit; let _permit = permit;
@ -713,7 +746,8 @@ impl Receiver {
break; break;
} }
match Self::read_one_frame(&mut s, &stream_policy).await { let frame_limit = stream_max_message_size.load(Ordering::Relaxed);
match Self::read_one_frame(&mut s, &stream_policy, frame_limit).await {
Ok(ReceivedFrame::Message(msg)) => { Ok(ReceivedFrame::Message(msg)) => {
frame_count += 1; frame_count += 1;
@ -721,8 +755,8 @@ impl Receiver {
{ {
let pipe_request_type = let pipe_request_type =
mtp_codec::CommunicationType::PipeRequest mtp_codec::CommunicationType::PipeRequest
.to_id(&mtp_codec::TypeMap::latest()); .try_to_id(&mtp_codec::TypeMap::latest());
if msg.get_type() == pipe_request_type if Some(msg.get_type()) == pipe_request_type
&& frame_count == 1 && frame_count == 1
{ {
let pipe_id = msg.get_id(); let pipe_id = msg.get_id();
@ -751,17 +785,17 @@ impl Receiver {
} }
let ping_type = mtp_codec::CommunicationType::Ping let ping_type = mtp_codec::CommunicationType::Ping
.to_id(&mtp_codec::TypeMap::latest()); .try_to_id(&mtp_codec::TypeMap::latest());
let pong_type = mtp_codec::CommunicationType::Pong let pong_type = mtp_codec::CommunicationType::Pong
.to_id(&mtp_codec::TypeMap::latest()); .try_to_id(&mtp_codec::TypeMap::latest());
let control = { let control = {
let control = stream_ping_control.read().await; let control = stream_ping_control.read().await;
if msg.get_type() == ping_type { if Some(msg.get_type()) == ping_type {
control control
.pong_sender .pong_sender
.clone() .clone()
.map(|sender| (Some(sender), None)) .map(|sender| (Some(sender), None))
} else if msg.get_type() == pong_type { } else if Some(msg.get_type()) == pong_type {
control control
.pong_observer .pong_observer
.clone() .clone()
@ -781,7 +815,7 @@ impl Receiver {
); );
} }
if let Err(e) = sender.send(&pong).await { if let Err(e) = sender.send(&pong).await {
log::warn!("[Receiver] failed to send Pong: {e}"); warn!("[Receiver] failed to send Pong: {e}");
} }
continue; continue;
} }
@ -882,16 +916,24 @@ impl Receiver {
handle, handle,
ping_control, ping_control,
queue_notify, queue_notify,
max_message_size,
}), }),
} }
} }
/// Change the receive cap for subsequently parsed frames.
pub fn set_max_message_size(&self, max_message_size: u64) {
self.inner
.max_message_size
.store(max_message_size, Ordering::Relaxed);
}
/* Respond to reserved Ping frames without exposing them to application I/O. */ /* Respond to reserved Ping frames without exposing them to application I/O. */
pub fn respond_to_pings(&self, sender: Sender) { pub fn respond_to_pings(&self, sender: Sender) {
if let Ok(mut control) = self.inner.ping_control.try_write() { if let Ok(mut control) = self.inner.ping_control.try_write() {
control.pong_sender = Some(sender); control.pong_sender = Some(sender);
} else { } else {
log::warn!("[Receiver] could not register Ping responder: control lock busy"); warn!("[Receiver] could not register Ping responder: control lock busy");
} }
} }
@ -900,7 +942,7 @@ impl Receiver {
if let Ok(mut control) = self.inner.ping_control.try_write() { if let Ok(mut control) = self.inner.ping_control.try_write() {
control.pong_observer = Some(observer); control.pong_observer = Some(observer);
} else { } else {
log::warn!("[Receiver] could not register Pong observer: control lock busy"); warn!("[Receiver] could not register Pong observer: control lock busy");
} }
} }
@ -908,6 +950,7 @@ impl Receiver {
async fn read_one_frame( async fn read_one_frame(
stream: &mut wtransport::RecvStream, stream: &mut wtransport::RecvStream,
policy: &Policy, policy: &Policy,
max_message_size: u64,
) -> Result<ReceivedFrame, CommunicationError> { ) -> Result<ReceivedFrame, CommunicationError> {
use wtransport::error::{StreamReadError, StreamReadExactError}; use wtransport::error::{StreamReadError, StreamReadExactError};
@ -918,23 +961,23 @@ impl Receiver {
return Ok(ReceivedFrame::Idle); return Ok(ReceivedFrame::Idle);
} }
Ok(Err(StreamReadExactError::FinishedEarly(n))) => { Ok(Err(StreamReadExactError::FinishedEarly(n))) => {
log::warn!( warn!(
"[Receiver] length-prefix read ended early ({n}/4 bytes): stream closed by peer" "[Receiver] length-prefix read ended early ({n}/4 bytes): stream closed by peer"
); );
return Err(CommunicationError::StreamError); return Err(CommunicationError::StreamError);
} }
Ok(Err(StreamReadExactError::Read(StreamReadError::Reset(code)))) => { Ok(Err(StreamReadExactError::Read(StreamReadError::Reset(code)))) => {
log::warn!( warn!(
"[Receiver] length-prefix read failed: peer sent RESET_STREAM (error code {code})" "[Receiver] length-prefix read failed: peer sent RESET_STREAM (error code {code})"
); );
return Err(CommunicationError::StreamError); return Err(CommunicationError::StreamError);
} }
Ok(Err(other)) => { Ok(Err(other)) => {
log::warn!("[Receiver] length-prefix read failed: {other}"); warn!("[Receiver] length-prefix read failed: {other}");
return Err(CommunicationError::StreamError); return Err(CommunicationError::StreamError);
} }
Err(_) => { Err(_) => {
log::warn!("[Receiver] length-prefix read timed out"); warn!("[Receiver] length-prefix read timed out");
return Err(CommunicationError::StreamError); return Err(CommunicationError::StreamError);
} }
} }
@ -945,34 +988,52 @@ impl Receiver {
} }
let len_usize = len as usize; let len_usize = len as usize;
if len as u64 > policy.max_message_size { if len as u64 > max_message_size {
return Err(CommunicationError::MessageTooLarge); return Err(CommunicationError::MessageTooLarge);
} }
let mut buf = vec![0u8; len_usize]; // Grow in bounded chunks instead of trusting the peer's length prefix
match timeout(policy.read_timeout, stream.read_exact(&mut buf)).await { // enough to allocate the complete frame up front.
Ok(Ok(())) => {} let mut buf = Vec::new();
buf.try_reserve(len_usize.min(16 * 1024))
.map_err(|_| CommunicationError::MessageTooLarge)?;
while buf.len() < len_usize {
let chunk_len = (len_usize - buf.len()).min(16 * 1024);
let mut chunk = [0u8; 16 * 1024];
match timeout(
policy.read_timeout,
stream.read_exact(&mut chunk[..chunk_len]),
)
.await
{
Ok(Ok(())) => {
buf.try_reserve(chunk_len)
.map_err(|_| CommunicationError::MessageTooLarge)?;
buf.extend_from_slice(&chunk[..chunk_len]);
}
Ok(Err(StreamReadExactError::FinishedEarly(n))) => { Ok(Err(StreamReadExactError::FinishedEarly(n))) => {
log::warn!( warn!(
"[Receiver] body read ended early ({n}/{len_usize} bytes): stream closed by peer" "[Receiver] body read ended early ({}/{len_usize} bytes): stream closed by peer",
buf.len() + n
); );
return Err(CommunicationError::StreamError); return Err(CommunicationError::StreamError);
} }
Ok(Err(StreamReadExactError::Read(StreamReadError::Reset(code)))) => { Ok(Err(StreamReadExactError::Read(StreamReadError::Reset(code)))) => {
log::warn!( warn!(
"[Receiver] body read failed: peer sent RESET_STREAM (error code {code})" "[Receiver] body read failed: peer sent RESET_STREAM (error code {code})"
); );
return Err(CommunicationError::StreamError); return Err(CommunicationError::StreamError);
} }
Ok(Err(other)) => { Ok(Err(other)) => {
log::warn!("[Receiver] body read failed: {other}"); warn!("[Receiver] body read failed: {other}");
return Err(CommunicationError::StreamError); return Err(CommunicationError::StreamError);
} }
Err(_) => { Err(_) => {
log::warn!("[Receiver] body read timed out (len={len_usize})"); warn!("[Receiver] body read timed out (len={len_usize})");
return Err(CommunicationError::StreamError); return Err(CommunicationError::StreamError);
} }
} }
}
let message = CommunicationValue::from_bytes(&buf) let message = CommunicationValue::from_bytes(&buf)
.map_err(|_| CommunicationError::ParseCommunicationValue)?; .map_err(|_| CommunicationError::ParseCommunicationValue)?;
@ -1155,7 +1216,8 @@ mod tests {
fn test_policy_default_values() { fn test_policy_default_values() {
let p = Policy::default(); let p = Policy::default();
assert_eq!(p.send_mode, SendMode::PersistentStream); assert_eq!(p.send_mode, SendMode::PersistentStream);
assert_eq!(p.max_message_size, 1_000_000_000); assert_eq!(p.max_message_size, 16 * 1024 * 1024);
assert_eq!(p.handshake_max_message_size, 64 * 1024);
assert_eq!(p.close_frame_len, u32::MAX); assert_eq!(p.close_frame_len, u32::MAX);
assert_eq!(p.application_close_code, 0); assert_eq!(p.application_close_code, 0);
assert_eq!(p.open_stream_timeout, Duration::from_millis(2_000)); assert_eq!(p.open_stream_timeout, Duration::from_millis(2_000));
@ -1165,8 +1227,6 @@ mod tests {
assert_eq!(p.keep_alive_interval, Some(Duration::from_secs(3))); assert_eq!(p.keep_alive_interval, Some(Duration::from_secs(3)));
assert_eq!(p.max_idle_timeout, Some(Duration::from_secs(30))); assert_eq!(p.max_idle_timeout, Some(Duration::from_secs(30)));
assert_eq!(p.force_close_delay, Duration::from_millis(300)); assert_eq!(p.force_close_delay, Duration::from_millis(300));
assert_eq!(p.max_transient_recv_errors, 20);
assert_eq!(p.transient_recv_backoff, Duration::from_millis(100));
assert_eq!(p.persistent_stream_max_retries, 4); assert_eq!(p.persistent_stream_max_retries, 4);
assert_eq!(p.persistent_stream_retry_backoff, Duration::from_millis(20)); assert_eq!(p.persistent_stream_retry_backoff, Duration::from_millis(20));
assert_eq!(p.receiver_queue_capacity, 1000); assert_eq!(p.receiver_queue_capacity, 1000);

21
transport/src/framing.rs Normal file
View file

@ -0,0 +1,21 @@
use crate::{Policy, TransportSendStream};
use mtp_codec::CommunicationValue;
use mtp_common::CommunicationError;
/// Writes the canonical length-prefixed MTP frame used by every transport.
pub(crate) async fn write_frame<S: TransportSendStream>(
stream: &mut S,
value: &CommunicationValue,
policy: &Policy,
) -> Result<(), CommunicationError> {
let bytes = value.to_bytes().map_err(|_| CommunicationError::Encode)?;
if bytes.len() as u64 > policy.max_message_size
|| bytes.len() as u64 >= policy.close_frame_len as u64
{
return Err(CommunicationError::MessageTooLarge);
}
stream
.write_all(&(bytes.len() as u32).to_be_bytes())
.await?;
stream.write_all(&bytes).await
}

View file

@ -0,0 +1,403 @@
//! Transport-neutral MTP framing.
//!
//! These types are used by non-wtransport backends. The established
//! [`crate::Sender`] and [`crate::Receiver`] remain source compatible native
//! wrappers while the framing implementation below is shared by adapters.
use crate::{
Policy, TransportConnection, TransportRecvStream, TransportSendStream, framing::write_frame,
};
use mtp_codec::CommunicationValue;
use mtp_common::CommunicationError;
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock, Semaphore, mpsc};
use tokio::time::timeout;
#[cfg(feature = "pipes")]
use crate::pipe::{PipeReader, PipeWriter};
pub struct GenericSender<C: TransportConnection> {
connection: C,
policy: Arc<Policy>,
persistent: Arc<Mutex<Option<C::SendStream>>>,
send_lock: Arc<Mutex<()>>,
}
impl<C: TransportConnection> Clone for GenericSender<C> {
fn clone(&self) -> Self {
Self {
connection: self.connection.clone(),
policy: self.policy.clone(),
persistent: self.persistent.clone(),
send_lock: self.send_lock.clone(),
}
}
}
impl<C: TransportConnection> GenericSender<C> {
pub fn new(connection: C, policy: Arc<Policy>) -> Self {
Self {
connection,
policy,
persistent: Arc::new(Mutex::new(None)),
send_lock: Arc::new(Mutex::new(())),
}
}
async fn open(&self) -> Result<C::SendStream, CommunicationError> {
timeout(self.policy.open_stream_timeout, self.connection.open_uni())
.await
.map_err(|_| CommunicationError::StreamError)?
}
pub async fn send(&self, value: &CommunicationValue) -> Result<(), CommunicationError> {
let _lock = self.send_lock.lock().await;
if self.connection.close_reason().is_some() {
return Err(CommunicationError::StreamClosed);
}
match self.policy.send_mode {
crate::SendMode::SingleStreamPerMessage => {
let mut stream = self.open().await?;
timeout(
self.policy.write_timeout,
write_frame(&mut stream, value, &self.policy),
)
.await
.map_err(|_| CommunicationError::StreamError)??;
timeout(self.policy.write_timeout, stream.finish())
.await
.map_err(|_| CommunicationError::StreamError)?
}
crate::SendMode::PersistentStream => {
let mut stream = self.persistent.lock().await;
let mut attempts = 0;
loop {
if stream.is_none() {
*stream = Some(self.open().await?);
}
let result = timeout(
self.policy.write_timeout,
write_frame(stream.as_mut().unwrap(), value, &self.policy),
)
.await
.map_err(|_| CommunicationError::StreamError)
.and_then(|r| r);
if result.is_ok() {
return result;
}
*stream = None;
attempts += 1;
if attempts > self.policy.persistent_stream_max_retries {
return result;
}
tokio::time::sleep(
self.policy.persistent_stream_retry_backoff * attempts as u32,
)
.await;
}
}
}
}
#[cfg(feature = "pipes")]
pub async fn open_pipe(
&self,
pipe_id: u32,
description: &str,
) -> Result<PipeWriter<C::SendStream>, CommunicationError> {
if self.connection.close_reason().is_some() {
return Err(CommunicationError::StreamClosed);
}
let mut stream = self.open().await?;
let request = CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest)
.with_id(pipe_id)
.add_typed_default(
mtp_codec::DataType::Description,
mtp_codec::DataValue::Str(description.to_string()),
);
timeout(
self.policy.write_timeout,
write_frame(&mut stream, &request, &self.policy),
)
.await
.map_err(|_| CommunicationError::StreamError)??;
Ok(PipeWriter { stream })
}
pub fn close(&self) {
self.connection
.close(self.policy.application_close_code, b"mtp-close");
}
pub fn is_closed(&self) -> bool {
self.connection.close_reason().is_some()
}
pub fn is_open(&self) -> bool {
!self.is_closed()
}
pub fn close_reason(&self) -> Option<CommunicationError> {
self.connection.close_reason()
}
}
pub struct GenericReceiver<C: TransportConnection> {
incoming: Arc<Mutex<mpsc::Receiver<Result<CommunicationValue, CommunicationError>>>>,
#[cfg(feature = "pipes")]
pipes: Arc<Mutex<mpsc::Receiver<PipeReader<C::RecvStream>>>>,
connection: C,
ping_sender: Arc<RwLock<Option<GenericSender<C>>>>,
}
impl<C: TransportConnection> Clone for GenericReceiver<C> {
fn clone(&self) -> Self {
Self {
incoming: self.incoming.clone(),
#[cfg(feature = "pipes")]
pipes: self.pipes.clone(),
connection: self.connection.clone(),
ping_sender: self.ping_sender.clone(),
}
}
}
impl<C: TransportConnection> GenericReceiver<C> {
pub fn new(connection: C, policy: Arc<Policy>) -> Self {
let (tx, rx) = mpsc::channel(policy.receiver_queue_capacity);
#[cfg(feature = "pipes")]
let (pipe_tx, pipe_rx) = mpsc::channel(policy.receiver_queue_capacity);
let ping_sender: Arc<RwLock<Option<GenericSender<C>>>> = Arc::new(RwLock::new(None));
let task_ping_sender = ping_sender.clone();
let task_connection = connection.clone();
let task_policy = policy.clone();
tokio::spawn(async move {
let limit = Arc::new(Semaphore::new(
task_policy.max_concurrent_stream_tasks.max(1),
));
loop {
let stream = match timeout(
task_policy.accept_stream_timeout,
task_connection.accept_uni(),
)
.await
{
Ok(Ok(stream)) => stream,
Ok(Err(error)) => {
let _ = tx.send(Err(error)).await;
break;
}
Err(_) => {
if task_connection.close_reason().is_some() {
let _ = tx.send(Err(CommunicationError::StreamClosed)).await;
break;
} else {
continue;
}
}
};
let tx = tx.clone();
#[cfg(feature = "pipes")]
let pipe_tx = pipe_tx.clone();
let policy = task_policy.clone();
let permit = limit.clone();
let ping_sender = task_ping_sender.clone();
tokio::spawn(async move {
let Ok(_permit) = permit.acquire_owned().await else {
return;
};
let mut stream = stream;
let mut frames = 0usize;
loop {
if policy
.max_frames_per_stream
.is_some_and(|max| frames >= max)
{
let _ = tx.send(Err(CommunicationError::StreamError)).await;
break;
}
let mut len = [0; 4];
match timeout(policy.read_timeout, stream.read_exact(&mut len)).await {
Ok(Ok(())) => {}
Ok(Err(_)) | Err(_) => break,
}
let len = u32::from_be_bytes(len);
if len == policy.close_frame_len {
let _ = tx.send(Err(CommunicationError::StreamClosed)).await;
break;
}
if len as u64 > policy.max_message_size {
let _ = tx.send(Err(CommunicationError::MessageTooLarge)).await;
break;
}
let target_len = len as usize;
let mut body = Vec::new();
if body.try_reserve(target_len.min(16 * 1024)).is_err() {
let _ = tx.send(Err(CommunicationError::MessageTooLarge)).await;
break;
}
while body.len() < target_len {
let chunk_len = (target_len - body.len()).min(16 * 1024);
let mut chunk = [0u8; 16 * 1024];
if !matches!(
timeout(
policy.read_timeout,
stream.read_exact(&mut chunk[..chunk_len]),
)
.await,
Ok(Ok(()))
) || body.try_reserve(chunk_len).is_err()
{
let _ = tx.send(Err(CommunicationError::StreamError)).await;
break;
}
body.extend_from_slice(&chunk[..chunk_len]);
}
if body.len() != target_len {
break;
}
frames += 1;
let message = match CommunicationValue::from_bytes(&body) {
Ok(message) => message,
Err(_) => {
let _ = tx
.send(Err(CommunicationError::ParseCommunicationValue))
.await;
break;
}
};
#[cfg(feature = "pipes")]
{
let pipe_request_type = mtp_codec::CommunicationType::PipeRequest
.try_to_id(&mtp_codec::TypeMap::latest());
if Some(message.get_type()) == pipe_request_type && frames == 1 {
let pipe_id = message.get_id();
let description = message
.get_str(mtp_codec::DataType::Description)
.unwrap_or("")
.to_string();
let pipe_reader = PipeReader {
stream,
description,
pipe_id,
};
if pipe_tx.send(pipe_reader).await.is_err() {
break;
}
return;
}
}
if message.is_type(mtp_codec::CommunicationType::Ping) {
if let Some(sender) = ping_sender.read().await.clone() {
let mut pong =
CommunicationValue::new(mtp_codec::CommunicationType::Pong)
.with_id(message.get_id());
if let Some(timestamp) =
message.get_data_opt(mtp_codec::DataType::Timestamp)
{
pong = pong.add_typed_default(
mtp_codec::DataType::Timestamp,
timestamp.clone(),
);
}
let _ = sender.send(&pong).await;
}
continue;
}
if tx.send(Ok(message)).await.is_err() {
break;
}
}
});
}
});
Self {
incoming: Arc::new(Mutex::new(rx)),
#[cfg(feature = "pipes")]
pipes: Arc::new(Mutex::new(pipe_rx)),
connection,
ping_sender,
}
}
pub async fn respond_to_pings(&self, sender: GenericSender<C>) {
*self.ping_sender.write().await = Some(sender);
}
pub async fn receive(&self) -> Result<CommunicationValue, CommunicationError> {
self.incoming
.lock()
.await
.recv()
.await
.unwrap_or(Err(CommunicationError::StreamClosed))
}
#[cfg(feature = "pipes")]
pub async fn receive_event(
&self,
) -> Result<crate::TransportEvent<C::RecvStream>, CommunicationError> {
let mut incoming = self.incoming.lock().await;
let mut pipes = self.pipes.lock().await;
tokio::select! {
msg = incoming.recv() => {
match msg {
Some(Ok(val)) => Ok(crate::TransportEvent::Message(val)),
Some(Err(e)) => Err(e),
None => Err(self
.connection
.close_reason()
.unwrap_or(CommunicationError::StreamClosed)),
}
}
pipe = pipes.recv() => {
match pipe {
Some(reader) => Ok(crate::TransportEvent::Pipe(reader)),
None => Err(self
.connection
.close_reason()
.unwrap_or(CommunicationError::StreamClosed)),
}
}
}
}
#[cfg(feature = "pipes")]
pub async fn receive_pipe(&self) -> Result<PipeReader<C::RecvStream>, CommunicationError> {
self.pipes
.lock()
.await
.recv()
.await
.ok_or(CommunicationError::StreamClosed)
}
#[cfg(feature = "pipes")]
pub fn try_receive_pipe(
&self,
) -> Result<Option<PipeReader<C::RecvStream>>, CommunicationError> {
match self.pipes.try_lock() {
Ok(mut rx) => match rx.try_recv() {
Ok(reader) => Ok(Some(reader)),
Err(mpsc::error::TryRecvError::Empty) => Ok(None),
Err(mpsc::error::TryRecvError::Disconnected) => {
Err(CommunicationError::StreamClosed)
}
},
Err(_) => Ok(None),
}
}
pub fn is_closed(&self) -> bool {
self.connection.close_reason().is_some()
}
pub fn is_open(&self) -> bool {
!self.is_closed()
}
pub fn close_reason(&self) -> Option<CommunicationError> {
self.connection.close_reason()
}
}

View file

@ -3,24 +3,53 @@ use mtp_common::CommunicationError;
use rustls::pki_types::{PrivateKeyDer, pem::PemObject}; use rustls::pki_types::{PrivateKeyDer, pem::PemObject};
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
use std::sync::Arc; use std::sync::Arc;
use tracing::debug;
use wtransport::{Connection as WTConnection, Endpoint, ServerConfig}; use wtransport::{Connection as WTConnection, Endpoint, ServerConfig};
fn generate_self_signed_cert() -> (Vec<u8>, Vec<u8>) { fn generate_self_signed_cert() -> Result<(Vec<u8>, Vec<u8>), CommunicationError> {
let key_pair = match rcgen::KeyPair::generate() { let key_pair = rcgen::KeyPair::generate().map_err(|e| {
Ok(key_pair) => key_pair, CommunicationError::Other(format!("failed to generate self-signed key pair: {e}"))
Err(e) => panic!("failed to generate self-signed key pair: {e}"), })?;
}; let params = rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()])
let params = match rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()]) { .map_err(|e| {
Ok(params) => params, CommunicationError::Other(format!(
Err(e) => panic!("failed to build self-signed certificate params: {e}"), "failed to build self-signed certificate params: {e}"
}; ))
let cert = match params.self_signed(&key_pair) { })?;
Ok(cert) => cert, let cert = params
Err(e) => panic!("failed to self-sign certificate: {e}"), .self_signed(&key_pair)
}; .map_err(|e| CommunicationError::Other(format!("failed to self-sign certificate: {e}")))?;
let cert_pem = cert.pem(); let cert_pem = cert.pem();
let key_pem = key_pair.serialize_pem(); let key_pem = key_pair.serialize_pem();
(cert_pem.into_bytes(), key_pem.into_bytes()) Ok((cert_pem.into_bytes(), key_pem.into_bytes()))
}
enum HostCredentials {
Pem { cert_pem: Vec<u8>, key_pem: Vec<u8> },
SelfSigned,
}
/// TLS and transport settings for a native host.
pub struct HostConfig {
credentials: HostCredentials,
policy: Policy,
}
impl HostConfig {
pub fn new(cert_pem: Vec<u8>, key_pem: Vec<u8>, policy: Policy) -> Self {
Self {
credentials: HostCredentials::Pem { cert_pem, key_pem },
policy,
}
}
/// Generate a self-signed certificate for local development.
pub fn self_signed(policy: Policy) -> Self {
Self {
credentials: HostCredentials::SelfSigned,
policy,
}
}
} }
pub struct Host { pub struct Host {
@ -61,19 +90,24 @@ pub async fn host(
cert_pem: Vec<u8>, cert_pem: Vec<u8>,
key_pem: Vec<u8>, key_pem: Vec<u8>,
policy: Policy, policy: Policy,
) -> Result<Host, CommunicationError> {
host_with_config(ip, port, HostConfig::new(cert_pem, key_pem, policy)).await
}
/// Start a host using explicit TLS and transport configuration.
pub async fn host_with_config(
ip: IpAddr,
port: u16,
config: HostConfig,
) -> Result<Host, CommunicationError> { ) -> Result<Host, CommunicationError> {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
// When MTP_INSECURE is set, generate a self-signed cert so the host can let (cert_pem, key_pem) = match config.credentials {
// run without externally-provided TLS credentials. HostCredentials::Pem { cert_pem, key_pem } => (cert_pem, key_pem),
let is_insecure = std::env::var("MTP_INSECURE").is_ok(); HostCredentials::SelfSigned => generate_self_signed_cert()?,
let (use_cert_pem, use_key_pem) = if is_insecure {
generate_self_signed_cert()
} else {
(cert_pem, key_pem)
}; };
let server_config = configure_server(ip, port, use_cert_pem, use_key_pem, &policy).await?; let server_config = configure_server(ip, port, cert_pem, key_pem, &config.policy).await?;
let endpoint = Endpoint::server(server_config) let endpoint = Endpoint::server(server_config)
.map_err(|e| CommunicationError::Other(format!("Endpoint creation failed: {}", e)))?; .map_err(|e| CommunicationError::Other(format!("Endpoint creation failed: {}", e)))?;
@ -83,7 +117,7 @@ pub async fn host(
let (incoming_tx, incoming_rx) = tokio::sync::mpsc::channel(16); let (incoming_tx, incoming_rx) = tokio::sync::mpsc::channel(16);
let policy = Arc::new(policy); let policy = Arc::new(config.policy);
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
loop { loop {
@ -92,7 +126,7 @@ pub async fn host(
let request = match incoming_session.await { let request = match incoming_session.await {
Ok(req) => req, Ok(req) => req,
Err(e) => { Err(e) => {
log::debug!("incoming WebTransport session failed: {e}"); debug!("incoming WebTransport session failed: {e}");
continue; continue;
} }
}; };
@ -103,7 +137,7 @@ pub async fn host(
{ {
Ok(conn) => conn, Ok(conn) => conn,
Err(e) => { Err(e) => {
log::debug!("WebTransport request accept failed: {e}"); debug!("WebTransport request accept failed: {e}");
continue; continue;
} }
}; };
@ -128,7 +162,7 @@ async fn handle_connection(
let handle = Arc::new(ConnectionHandle::new()); let handle = Arc::new(ConnectionHandle::new());
let sender = Sender::new(connection.clone(), handle.clone(), policy.clone()); let sender = Sender::new(connection.clone(), handle.clone(), policy.clone());
let receiver = Receiver::new(connection, handle, policy); let receiver = Receiver::new_for_handshake(connection, handle, policy);
let _ = tx.send((sender, receiver)).await; let _ = tx.send((sender, receiver)).await;
} }

View file

@ -1,21 +1,27 @@
pub mod client; pub mod client;
pub mod connection; pub mod connection;
pub mod connection_handle; pub mod connection_handle;
mod framing;
pub mod generic_connection;
pub mod pinning;
pub mod transport_traits;
#[cfg(feature = "pipes")] #[cfg(feature = "pipes")]
pub mod pipe; pub mod pipe;
pub use connection::{Policy, Receiver, SendMode, Sender}; pub use connection::{Policy, Receiver, SendMode, Sender};
pub use generic_connection::{GenericReceiver, GenericSender};
#[cfg(feature = "pipes")] #[cfg(feature = "pipes")]
pub use connection::TransportEvent; pub use connection::TransportEvent;
#[cfg(feature = "pipes")] #[cfg(feature = "pipes")]
pub use pipe::{PipeReader, PipeWriter}; pub use pipe::{PipeReader, PipeWriter};
pub use client::connect; pub use client::{ClientConfig, connect, connect_with_config};
pub use connection_handle::ConnectionHandle; pub use connection_handle::ConnectionHandle;
pub use transport_traits::{TransportConnection, TransportRecvStream, TransportSendStream};
#[cfg(feature = "host")] #[cfg(feature = "host")]
pub mod host; pub mod host;
#[cfg(feature = "host")] #[cfg(feature = "host")]
pub use host::{Host, host}; pub use host::{Host, HostConfig, host, host_with_config};

Some files were not shown because too many files have changed in this diff Show more