diff --git a/Cargo.lock b/Cargo.lock index 65f4365..c6b66c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,14 +61,14 @@ dependencies = [ [[package]] name = "aes-gcm" -version = "0.9.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df5f85a83a7d8b0442b6aa7b504b8212c1733da07b98aae43d4bc21b2cb3cdf6" +checksum = "bc3be92e19a7ef47457b8e6f90707e12b6ac5d20c6f3866584fa3be0787d839f" dependencies = [ "aead 0.4.3", "aes 0.7.5", "cipher 0.3.0", - "ctr 0.8.0", + "ctr 0.7.0", "ghash 0.4.4", "subtle", ] @@ -173,12 +173,27 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.11.0" @@ -269,12 +284,34 @@ dependencies = [ "capnp", ] +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.8.2" @@ -390,12 +427,38 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -455,9 +518,9 @@ dependencies = [ [[package]] name = "ctr" -version = "0.8.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" +checksum = "a232f92a03f37dd7d7dd2adc67166c77e9cd88de5b019b9a9eecfaeaf7bfd481" dependencies = [ "cipher 0.3.0", ] @@ -548,6 +611,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.9.0" @@ -674,6 +746,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastbloom" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +dependencies = [ + "getrandom 0.3.4", + "libm", + "rand 0.9.2", + "siphasher", +] + [[package]] name = "ff" version = "0.13.1" @@ -690,6 +774,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "futures" version = "0.3.32" @@ -813,6 +903,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "ghash" version = "0.4.4" @@ -947,6 +1051,28 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + [[package]] name = "js-sys" version = "0.3.85" @@ -969,6 +1095,12 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "lock_api" version = "0.4.14" @@ -984,6 +1116,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1019,78 +1157,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "noiseml-client" -version = "0.1.0" -dependencies = [ - "anyhow", - "capnp", - "capnp-rpc", - "clap", - "dashmap", - "futures", - "noiseml-core", - "noiseml-proto", - "sha2 0.10.9", - "thiserror", - "tokio", - "tokio-util", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "noiseml-core" -version = "0.1.0" -dependencies = [ - "bytes", - "capnp", - "ed25519-dalek 2.2.0", - "futures", - "hkdf", - "noiseml-proto", - "openmls", - "openmls_rust_crypto", - "openmls_traits", - "rand 0.8.5", - "sha2 0.10.9", - "snow", - "thiserror", - "tls_codec 0.3.0", - "tokio", - "tokio-util", - "x25519-dalek", - "zeroize", -] - -[[package]] -name = "noiseml-proto" -version = "0.1.0" -dependencies = [ - "capnp", - "capnpc", -] - -[[package]] -name = "noiseml-server" -version = "0.1.0" -dependencies = [ - "anyhow", - "capnp", - "capnp-rpc", - "clap", - "dashmap", - "futures", - "noiseml-core", - "noiseml-proto", - "sha2 0.10.9", - "thiserror", - "tokio", - "tokio-util", - "tracing", - "tracing-subscriber", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1100,6 +1166,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + [[package]] name = "object" version = "0.37.3" @@ -1138,7 +1210,7 @@ dependencies = [ "openmls_traits", "rayon", "serde", - "thiserror", + "thiserror 1.0.69", "tls_codec 0.3.0", ] @@ -1150,7 +1222,7 @@ checksum = "1532fed34a1d3cf29962c1c07624f628501537eafac47913a08caea4bf08319e" dependencies = [ "openmls_traits", "serde_json", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1159,7 +1231,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b34960cce81b5a8b8a178f330e28895f092b49a28923d36c03fa56dd3d6f7173" dependencies = [ - "aes-gcm 0.9.4", + "aes-gcm 0.9.2", "chacha20poly1305 0.9.1", "ed25519-dalek 1.0.1", "hkdf", @@ -1174,7 +1246,7 @@ dependencies = [ "rand 0.8.5", "rand_chacha 0.3.1", "sha2 0.10.9", - "thiserror", + "thiserror 1.0.69", "tls_codec 0.3.0", ] @@ -1188,6 +1260,12 @@ dependencies = [ "tls_codec 0.3.0", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "p256" version = "0.13.2" @@ -1235,6 +1313,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1268,7 +1356,7 @@ checksum = "048aeb476be11a4b6ca432ca569e375810de9294ae78f4774e78ea98a9246ede" dependencies = [ "cpufeatures", "opaque-debug", - "universal-hash 0.4.1", + "universal-hash 0.4.0", ] [[package]] @@ -1291,7 +1379,7 @@ dependencies = [ "cfg-if", "cpufeatures", "opaque-debug", - "universal-hash 0.4.1", + "universal-hash 0.4.0", ] [[package]] @@ -1306,6 +1394,12 @@ dependencies = [ "universal-hash 0.5.1", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1333,6 +1427,151 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quicnprotochat-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "bincode", + "capnp", + "capnp-rpc", + "clap", + "dashmap", + "futures", + "openmls_rust_crypto", + "quicnprotochat-core", + "quicnprotochat-proto", + "quinn", + "quinn-proto", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "quicnprotochat-core" +version = "0.1.0" +dependencies = [ + "bincode", + "bytes", + "capnp", + "ed25519-dalek 2.2.0", + "futures", + "hkdf", + "openmls", + "openmls_rust_crypto", + "openmls_traits", + "quicnprotochat-proto", + "rand 0.8.5", + "serde", + "serde_json", + "sha2 0.10.9", + "snow", + "thiserror 1.0.69", + "tls_codec 0.3.0", + "tokio", + "tokio-util", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "quicnprotochat-proto" +version = "0.1.0" +dependencies = [ + "capnp", + "capnpc", +] + +[[package]] +name = "quicnprotochat-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "bincode", + "capnp", + "capnp-rpc", + "clap", + "dashmap", + "futures", + "quicnprotochat-core", + "quicnprotochat-proto", + "quinn", + "quinn-proto", + "rcgen", + "rustls", + "serde", + "sha2 0.10.9", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "fastbloom", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.44" @@ -1342,6 +1581,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.7.3" @@ -1366,6 +1611,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -1386,6 +1641,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -1404,6 +1669,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -1433,6 +1707,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1469,12 +1756,32 @@ dependencies = [ "subtle", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-demangle" version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1484,12 +1791,104 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +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]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1510,6 +1909,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -1592,6 +2014,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1618,6 +2046,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" @@ -1674,9 +2108,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subtle" -version = "2.4.1" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "subtle-ng" @@ -1701,7 +2135,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -1715,6 +2158,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -1724,6 +2178,40 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tls_codec" version = "0.3.0" @@ -1816,6 +2304,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -1885,9 +2374,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "universal-hash" -version = "0.4.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f214e8f697e925001e66ec2c6e37a4ef93f0f78c2eed7814394e10c62025b05" +checksum = "8326b2c654932e3e4f9196e69d08fdf7cfd718e1dc6f66b347e6024a0c961402" dependencies = [ "generic-array", "subtle", @@ -1903,6 +2392,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1921,6 +2416,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.9.0+wasi-snapshot-preview1" @@ -1933,6 +2438,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -1978,19 +2492,65 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -2002,6 +2562,37 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -2009,64 +2600,160 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -2091,6 +2778,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "zerocopy" version = "0.8.39" diff --git a/Cargo.toml b/Cargo.toml index 1eabc79..dd95804 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [workspace] resolver = "2" members = [ - "crates/noiseml-core", - "crates/noiseml-proto", - "crates/noiseml-server", - "crates/noiseml-client", + "crates/quicnprotochat-core", + "crates/quicnprotochat-proto", + "crates/quicnprotochat-server", + "crates/quicnprotochat-client", ] # Shared dependency versions — bump here to affect the whole workspace. @@ -27,6 +27,9 @@ sha2 = { version = "0.10" } hkdf = { version = "0.12" } zeroize = { version = "1", features = ["derive"] } rand = { version = "0.8" } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1" } +bincode = { version = "1" } # ── Serialisation + RPC ─────────────────────────────────────────────────────── capnp = { version = "0.19" } @@ -36,6 +39,10 @@ capnp-rpc = { version = "0.19" } tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7", features = ["codec", "compat"] } futures = { version = "0.3" } +quinn = { version = "0.11" } +quinn-proto = { version = "0.11" } +rustls = { version = "0.23", default-features = false, features = ["std"] } +rcgen = { version = "0.13" } # ── Server utilities ────────────────────────────────────────────────────────── dashmap = { version = "5" } diff --git a/M3_STATUS.md b/M3_STATUS.md index 7ab9d0c..d8a3824 100644 --- a/M3_STATUS.md +++ b/M3_STATUS.md @@ -1,6 +1,6 @@ # M3 Implementation Status -**Last updated:** 2026-02-19 +**Last updated:** 2026-02-20 **Branch:** feat/m1-noise-transport (all milestones on this branch so far) --- @@ -18,13 +18,13 @@ M3 adds: ### `schemas/delivery.capnp` ✅ Simple DS schema: `enqueue(recipientKey, payload)` + `fetch(recipientKey) → List(Data)`. -### `noiseml-proto/build.rs` ✅ +### `quicnprotochat-proto/build.rs` ✅ Compiles `delivery.capnp` alongside `envelope.capnp` and `auth.capnp`. -### `noiseml-proto/src/lib.rs` ✅ +### `quicnprotochat-proto/src/lib.rs` ✅ Exposes `pub mod delivery_capnp`. -### `noiseml-core/src/group.rs` ✅ (FULLY FIXED, ALL TESTS PASS) +### `quicnprotochat-core/src/group.rs` ✅ (FULLY FIXED, ALL TESTS PASS) `GroupMember` struct with methods: - `new(identity: Arc) -> Self` - `generate_key_package() -> Result, CoreError>` — TLS-encoded KeyPackage bytes @@ -43,70 +43,27 @@ Exposes `pub mod delivery_capnp`. - `From for ProtocolMessage` is also feature-gated - Must use `OpenMlsCryptoProvider` trait in scope for `backend.crypto()` -### `noiseml-core/src/lib.rs` ✅ +### `quicnprotochat-core/src/lib.rs` ✅ Exposes `pub use group::GroupMember`. -### `noiseml-server/src/main.rs` ✅ +### `quicnprotochat-server/src/main.rs` ✅ Two listeners on one `LocalSet`: - Port 7000 (AS): `AuthServiceImpl` — unchanged from M2 - Port 7001 (DS): `DeliveryServiceImpl` — new; uses `DashMap, VecDeque>>` keyed by Ed25519 public key -New CLI flag: `--ds-listen` (default `0.0.0.0:7001`, env `NOISEML_DS_LISTEN`). +New CLI flag: `--ds-listen` (default `0.0.0.0:7001`, env `QUICNPROTOCHAT_DS_LISTEN`). + +### `quicnprotochat-client/src/main.rs` ✅ +Added `demo-group` subcommand to exercise the full Alice↔Bob MLS flow against live AS (7000) and DS (7001): uploads both KeyPackages, delivers Welcome via DS, and exchanges application messages. + +### `quicnprotochat-client/tests` ✅ +`cargo test -p quicnprotochat-client --tests` passes, including the MLS round-trip integration test. --- -## NOT YET DONE (continue tomorrow) +## Notes -### 1. `noiseml-client/src/main.rs` — Group subcommands - -Add these subcommands (note: need state persistence or a `demo` command approach): - -**Recommended approach:** Add a `demo-group` subcommand that runs the full Alice-Bob MLS round-trip in a single process invocation against a live server. This avoids the `MlsGroup` serialization problem (openmls 0.5 MlsGroup state is hard to persist without the `serde` feature). - -**Alternatively (with state file):** Enable `serde` feature on openmls in `Cargo.toml` and store `MlsGroup` state to disk. The workspace Cargo.toml uses `features = ["crypto-subtle"]` for openmls — add `"serde"` to that list. - -Subcommands needed: -- `create-group --as-server --ds-server --group-id ` — creates group, saves state -- `invite --as-server --ds-server --peer-key ` — fetches peer KP from AS, creates Welcome, enqueues to DS -- `join --ds-server` — fetches Welcome from DS, joins group, saves state -- `send --ds-server --peer-key --msg ` — sends application message to DS -- `recv --ds-server` — fetches and decrypts messages from DS - -OR: just add `demo-group --server --ds-server` that does the whole flow. - -### 2. `noiseml-client/tests/mls_group.rs` — Integration test - -This is the PRIORITY for testing. The integration test should: - -```rust -// 1. Spawn server (AS on port X, DS on port Y) with tokio::process::Command -// or by directly calling the server's accept loop in a LocalSet -// 2. Alice: GroupMember::new, generate_key_package, upload to AS -// 3. Bob: GroupMember::new, generate_key_package, upload to AS -// 4. Alice: create_group, fetch Bob's KP from AS, add_member → (commit, welcome) -// Alice: enqueue welcome for Bob via DS (recipient = bob's identity.public_key_bytes()) -// 5. Bob: fetch from DS, join_group(welcome) -// 6. Alice: send_message(b"hello bob"), enqueue to DS -// 7. Bob: fetch from DS, receive_message → assert plaintext == b"hello bob" -// 8. Bob: send_message(b"hello alice"), enqueue to DS -// 9. Alice: fetch from DS, receive_message → assert plaintext == b"hello alice" -``` - -**Important:** For the integration test, you can bypass the CLI and use `GroupMember` + capnp-rpc client helpers directly. - -Connect to DS (port 7001): -```rust -async fn connect_ds(server: &str, keypair: &NoiseKeypair) -> anyhow::Result { - let stream = TcpStream::connect(server).await?; - let transport = handshake_initiator(stream, keypair).await?; - let (reader, writer) = transport.into_capnp_io(); - let network = twoparty::VatNetwork::new(reader.compat(), writer.compat_write(), Side::Client, Default::default()); - let mut rpc = RpcSystem::new(Box::new(network), None); - let ds: delivery_service::Client = rpc.bootstrap(Side::Server); - tokio::task::spawn_local(rpc); - Ok(ds) -} -``` +Open question (future work): if we need persistent groups instead of ephemeral demo runs, enable openmls `serde` feature and add statefile-backed subcommands (`create-group`, `invite`, `join`, `send`, `recv`). For M3, the demo path is sufficient. --- @@ -141,13 +98,13 @@ test group::tests::group_id_lifecycle ... ok ```bash cd /home/c/projects/poc-mes git log --oneline -5 # see where we are -cargo test -p noiseml-core # verify green +cargo test -p quicnprotochat-core # verify green ``` Then: -1. Write `crates/noiseml-client/tests/mls_group.rs` (integration test) — highest priority -2. Add group subcommands to `crates/noiseml-client/src/main.rs` +1. Write `crates/quicnprotochat-client/tests/mls_group.rs` (integration test) — highest priority +2. Add group subcommands to `crates/quicnprotochat-client/src/main.rs` The integration test is the most important piece — it proves the full M3 stack works end-to-end. -For the test, see the pattern in `crates/noiseml-client/tests/auth_service.rs` (M2 test) for how to spin up the server and connect clients. +For the test, see the pattern in `crates/quicnprotochat-client/tests/auth_service.rs` (M2 test) for how to spin up the server and connect clients. diff --git a/README.md b/README.md index 27f910a..3ff3dba 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# noiseml +# quicnprotochat > End-to-end encrypted group messaging over **Noise_XX + MLS** (RFC 9420), written in Rust. @@ -38,9 +38,9 @@ key agreement across any number of participants. Messages are framed with ## Repository layout ``` -noiseml/ +quicnprotochat/ ├── crates/ -│ ├── noiseml-core/ # Crypto primitives, Noise transport, MLS group state machine +│ ├── quicnprotochat-core/ # Crypto primitives, Noise transport, MLS group state machine │ │ ├── src/codec.rs # LengthPrefixedCodec — Tokio Encoder + Decoder │ │ ├── src/keypair.rs # NoiseKeypair — X25519 static key, zeroize-on-drop │ │ ├── src/identity.rs # IdentityKeypair — Ed25519 identity + MLS Signer @@ -48,11 +48,11 @@ noiseml/ │ │ ├── src/noise.rs # handshake_initiator / handshake_responder / NoiseTransport │ │ └── src/group.rs # GroupMember — full MLS group lifecycle │ │ -│ ├── noiseml-proto/ # Cap'n Proto schemas + generated types + serde helpers +│ ├── quicnprotochat-proto/ # Cap'n Proto schemas + generated types + serde helpers │ │ └── schemas/ → # (symlinked to workspace root schemas/) │ │ -│ ├── noiseml-server/ # Authentication Service (AS) + Delivery Service (DS) binary -│ └── noiseml-client/ # CLI client (ping, register, fetch-key, …) +│ ├── quicnprotochat-server/ # Authentication Service (AS) + Delivery Service (DS) binary +│ └── quicnprotochat-client/ # CLI client (ping, register, fetch-key, …) │ └── schemas/ ├── envelope.capnp # Top-level wire envelope (MsgType discriminant + payload) @@ -147,27 +147,46 @@ cargo test --workspace **Start the server** (AS on :7000, DS on :7001): ```bash -cargo run -p noiseml-server +cargo run -p quicnprotochat-server # or with custom ports: -cargo run -p noiseml-server -- --listen 0.0.0.0:7000 --ds-listen 0.0.0.0:7001 +cargo run -p quicnprotochat-server -- --listen 0.0.0.0:7000 --ds-listen 0.0.0.0:7001 ``` **Client commands:** ```bash # Check connectivity -cargo run -p noiseml-client -- ping +cargo run -p quicnprotochat-client -- ping # Generate a fresh identity + KeyPackage, upload to AS # Prints your identity_key (hex) — share this with peers -cargo run -p noiseml-client -- register +cargo run -p quicnprotochat-client -- register # Fetch a peer's KeyPackage (they must have registered first) -cargo run -p noiseml-client -- fetch-key <64-hex-char identity key> +cargo run -p quicnprotochat-client -- fetch-key <64-hex-char identity key> + +# Run an end-to-end Alice↔Bob demo against live AS + DS +cargo run -p quicnprotochat-client -- demo-group \ + --server 127.0.0.1:7000 \ + --ds-server 127.0.0.1:7001 + +# Persistent group CLI (stateful) +cargo run -p quicnprotochat-client -- register-state --state state.bin --server 127.0.0.1:7000 +cargo run -p quicnprotochat-client -- create-group --state state.bin --group-id my-group +cargo run -p quicnprotochat-client -- invite --state state.bin --peer-key --server 127.0.0.1:7000 --ds-server 127.0.0.1:7001 +cargo run -p quicnprotochat-client -- join --state state.bin --ds-server 127.0.0.1:7001 +cargo run -p quicnprotochat-client -- send --state state.bin --peer-key --msg "hello" --ds-server 127.0.0.1:7001 +cargo run -p quicnprotochat-client -- recv --state state.bin --ds-server 127.0.0.1:7001 ``` Server address defaults to `127.0.0.1:7000`; override with `--server` or -`NOISEML_SERVER`. +`QUICNPROTOCHAT_SERVER`. Delivery Service defaults to `127.0.0.1:7001`; override with +`--ds-server` or `QUICNPROTOCHAT_DS_SERVER`. + +State file notes: the persisted state stores your identity and MLS group state +after you have joined. If you generate a KeyPackage (`register-state`) and then +restart before consuming the Welcome, the join may fail because the HPKE init +key is not retained; run join in the same session you register. --- @@ -178,7 +197,7 @@ Server address defaults to `127.0.0.1:7000`; override with `--server` or | M1 | Noise transport | ✅ | Noise_XX handshake, length-prefixed framing, Ping/Pong | | M2 | Authentication Service | ✅ | Ed25519 identity, KeyPackage generation, AS upload/fetch | | M3 | Delivery Service + MLS groups | ✅ | DS relay, `GroupMember` create/join/add/send/recv | -| M4 | Group CLI subcommands | 🔜 | `create-group`, `invite`, `join`, `send`, `recv` | +| M4 | Group CLI subcommands | 🔜 | Persistent CLI (`create-group`, `invite`, `join`, `send`, `recv`); demo-group already available | | M5 | Multi-party groups | 🔜 | N > 2 members, Commit fan-out, Proposal handling | | M6 | Persistence | 🔜 | SQLite key store, durable group state | | M7 | Post-quantum | 🔜 | ML-KEM-768 hybrid in Noise layer | diff --git a/crates/noiseml-client/src/main.rs b/crates/noiseml-client/src/main.rs deleted file mode 100644 index daa5851..0000000 --- a/crates/noiseml-client/src/main.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! noiseml CLI client. -//! -//! # Subcommands -//! -//! | Subcommand | Description | -//! |--------------|----------------------------------------------------------| -//! | `ping` | Send a Ping to the server, print RTT | -//! | `register` | Generate a KeyPackage and upload it to the AS | -//! | `fetch-key` | Fetch a peer's KeyPackage from the AS by identity key | -//! -//! # Configuration -//! -//! | Env var | CLI flag | Default | -//! |-----------------|--------------|---------------------| -//! | `NOISEML_SERVER`| `--server` | `127.0.0.1:7000` | -//! | `RUST_LOG` | — | `warn` | - -use anyhow::Context; -use capnp_rpc::{RpcSystem, rpc_twoparty_capnp::Side, twoparty}; -use clap::{Parser, Subcommand}; -use tokio::net::TcpStream; -use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; - -use noiseml_core::{IdentityKeypair, NoiseKeypair, generate_key_package, handshake_initiator}; -use noiseml_proto::{MsgType, ParsedEnvelope, auth_capnp::authentication_service}; - -// ── CLI ─────────────────────────────────────────────────────────────────────── - -#[derive(Debug, Parser)] -#[command(name = "noiseml", about = "noiseml CLI client", version)] -struct Args { - #[command(subcommand)] - command: Command, -} - -#[derive(Debug, Subcommand)] -enum Command { - /// Send a Ping to the server and print the round-trip time. - Ping { - /// Server address (host:port). - #[arg(long, default_value = "127.0.0.1:7000", env = "NOISEML_SERVER")] - server: String, - }, - - /// Generate a fresh MLS KeyPackage and upload it to the Authentication Service. - /// - /// Prints the SHA-256 fingerprint of the uploaded package and the raw - /// Ed25519 identity public key bytes (hex), which peers need to fetch it. - Register { - /// Server address (host:port). - #[arg(long, default_value = "127.0.0.1:7000", env = "NOISEML_SERVER")] - server: String, - }, - - /// Fetch a peer's KeyPackage from the Authentication Service. - /// - /// IDENTITY_KEY is the peer's Ed25519 public key encoded as 64 lowercase - /// hex characters (32 bytes). - FetchKey { - /// Server address (host:port). - #[arg(long, default_value = "127.0.0.1:7000", env = "NOISEML_SERVER")] - server: String, - - /// Target peer's Ed25519 identity public key (64 hex chars = 32 bytes). - identity_key: String, - }, -} - -// ── Entry point ─────────────────────────────────────────────────────────────── - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), - ) - .init(); - - let args = Args::parse(); - - match args.command { - Command::Ping { server } => cmd_ping(&server).await, - Command::Register { server } => { - let local = tokio::task::LocalSet::new(); - local.run_until(cmd_register(&server)).await - } - Command::FetchKey { - server, - identity_key, - } => { - let local = tokio::task::LocalSet::new(); - local.run_until(cmd_fetch_key(&server, &identity_key)).await - } - } -} - -// ── Subcommand implementations ──────────────────────────────────────────────── - -/// Connect to `server`, complete Noise_XX, send a Ping, and print RTT. -async fn cmd_ping(server: &str) -> anyhow::Result<()> { - let keypair = NoiseKeypair::generate(); - - let stream = TcpStream::connect(server) - .await - .with_context(|| format!("could not connect to {server}"))?; - - tracing::debug!(server = %server, "TCP connection established"); - - let mut transport = handshake_initiator(stream, &keypair) - .await - .context("Noise_XX handshake failed")?; - - { - let remote = transport - .remote_static_public_key() - .map(|k| fmt_hex(&k[..4])) - .unwrap_or_else(|| "unknown".into()); - tracing::debug!(server_key = %remote, "handshake complete"); - } - - let sent_at = current_timestamp_ms(); - - transport - .send_envelope(&ParsedEnvelope { - msg_type: MsgType::Ping, - group_id: vec![], - sender_id: vec![], - payload: vec![], - timestamp_ms: sent_at, - }) - .await - .context("failed to send Ping")?; - - tracing::debug!("Ping sent"); - - let response = transport - .recv_envelope() - .await - .context("failed to receive Pong")?; - - match response.msg_type { - MsgType::Pong => { - let rtt_ms = current_timestamp_ms().saturating_sub(sent_at); - println!("Pong from {server} rtt={rtt_ms}ms"); - Ok(()) - } - _ => anyhow::bail!( - "protocol error: expected Pong from {server}, got unexpected message type" - ), - } -} - -/// Generate a KeyPackage for a fresh identity and upload it to the AS. -/// -/// Must run on a `LocalSet` because capnp-rpc is `!Send`. -async fn cmd_register(server: &str) -> anyhow::Result<()> { - let noise_keypair = NoiseKeypair::generate(); - let identity = IdentityKeypair::generate(); - - let (tls_bytes, fingerprint) = - generate_key_package(&identity).context("KeyPackage generation failed")?; - - let as_client = connect_as(server, &noise_keypair).await?; - - let mut req = as_client.upload_key_package_request(); - req.get().set_identity_key(&identity.public_key_bytes()); - req.get().set_package(&tls_bytes); - - let response = req - .send() - .promise - .await - .context("upload_key_package RPC failed")?; - - let server_fp = response - .get() - .context("upload_key_package: bad response")? - .get_fingerprint() - .context("upload_key_package: missing fingerprint")? - .to_vec(); - - // Verify the server echoed the same fingerprint. - anyhow::ensure!( - server_fp == fingerprint, - "fingerprint mismatch: local={} server={}", - hex::encode(&fingerprint), - hex::encode(&server_fp), - ); - - println!("identity_key : {}", hex::encode(identity.public_key_bytes())); - println!("fingerprint : {}", hex::encode(&fingerprint)); - println!("KeyPackage uploaded successfully."); - - Ok(()) -} - -/// Fetch a peer's KeyPackage from the AS by their hex-encoded identity key. -/// -/// Must run on a `LocalSet` because capnp-rpc is `!Send`. -async fn cmd_fetch_key(server: &str, identity_key_hex: &str) -> anyhow::Result<()> { - let identity_key = hex::decode(identity_key_hex) - .map_err(|e| anyhow::anyhow!(e)) - .context("identity_key must be 64 hex characters (32 bytes)")?; - anyhow::ensure!( - identity_key.len() == 32, - "identity_key must be exactly 32 bytes, got {}", - identity_key.len() - ); - - let noise_keypair = NoiseKeypair::generate(); - let as_client = connect_as(server, &noise_keypair).await?; - - let mut req = as_client.fetch_key_package_request(); - req.get().set_identity_key(&identity_key); - - let response = req - .send() - .promise - .await - .context("fetch_key_package RPC failed")?; - - let package = response - .get() - .context("fetch_key_package: bad response")? - .get_package() - .context("fetch_key_package: missing package field")? - .to_vec(); - - if package.is_empty() { - println!("No KeyPackage available for this identity."); - return Ok(()); - } - - use sha2::{Digest, Sha256}; - let fingerprint = Sha256::digest(&package); - - println!("fingerprint : {}", hex::encode(fingerprint)); - println!("package_len : {} bytes", package.len()); - println!("KeyPackage fetched successfully."); - - Ok(()) -} - -// ── Shared helpers ──────────────────────────────────────────────────────────── - -/// Establish a Noise_XX connection and return an `AuthenticationService` client. -/// -/// Must be called from within a `LocalSet` because capnp-rpc is `!Send`. -async fn connect_as( - server: &str, - noise_keypair: &NoiseKeypair, -) -> anyhow::Result { - let stream = TcpStream::connect(server) - .await - .with_context(|| format!("could not connect to {server}"))?; - - let transport = handshake_initiator(stream, noise_keypair) - .await - .context("Noise_XX handshake failed")?; - - let (reader, writer) = transport.into_capnp_io(); - - let network = twoparty::VatNetwork::new( - reader.compat(), - writer.compat_write(), - Side::Client, - Default::default(), - ); - - let mut rpc_system = RpcSystem::new(Box::new(network), None); - let as_client: authentication_service::Client = - rpc_system.bootstrap(Side::Server); - - // Drive the RPC system on the local set. - tokio::task::spawn_local(rpc_system); - - Ok(as_client) -} - -/// Format the first `n` bytes as lowercase hex with a trailing `…`. -fn fmt_hex(bytes: &[u8]) -> String { - let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); - format!("{hex}…") -} - -/// Return the current Unix timestamp in milliseconds. -fn current_timestamp_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 -} - -// ── Hex encoding helper ─────────────────────────────────────────────────────── -// -// We use a tiny inline module rather than adding `hex` as a dependency. - -mod hex { - pub fn encode(bytes: impl AsRef<[u8]>) -> String { - bytes.as_ref().iter().map(|b| format!("{b:02x}")).collect() - } - - pub fn decode(s: &str) -> Result, &'static str> { - if s.len() % 2 != 0 { - return Err("odd-length hex string"); - } - (0..s.len()) - .step_by(2) - .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| "invalid hex character")) - .collect() - } -} diff --git a/crates/noiseml-server/src/main.rs b/crates/noiseml-server/src/main.rs deleted file mode 100644 index 5265bde..0000000 --- a/crates/noiseml-server/src/main.rs +++ /dev/null @@ -1,460 +0,0 @@ -//! noiseml-server — Delivery Service + Authentication Service binary. -//! -//! # M3 scope -//! -//! The server exposes two Noise_XX-protected Cap'n Proto RPC endpoints: -//! -//! * **AS** (`--listen`, default `0.0.0.0:7000`) — `AuthenticationService`: -//! upload and fetch single-use MLS KeyPackages. -//! * **DS** (`--ds-listen`, default `0.0.0.0:7001`) — `DeliveryService`: -//! enqueue and fetch opaque payloads (Welcome messages, Commits, Application -//! messages) keyed by recipient Ed25519 public key. -//! -//! # Architecture -//! -//! ```text -//! TcpListener (AS, 7000) TcpListener (DS, 7001) -//! └─ Noise_XX handshake └─ Noise_XX handshake -//! └─ capnp-rpc VatNetwork (LocalSet, !Send) -//! ├─ AuthServiceImpl (shares KeyPackageStore via Arc) -//! └─ DeliveryServiceImpl (shares DeliveryStore via Arc) -//! ``` -//! -//! Because `capnp-rpc` uses `Rc>` internally it is `!Send`. -//! The entire RPC stack lives on a `tokio::task::LocalSet` spawned per -//! connection. -//! -//! # Configuration -//! -//! | Env var | CLI flag | Default | -//! |---------------------|----------------|-----------------| -//! | `NOISEML_LISTEN` | `--listen` | `0.0.0.0:7000` | -//! | `NOISEML_DS_LISTEN` | `--ds-listen` | `0.0.0.0:7001` | -//! | `RUST_LOG` | — | `info` | - -use std::{collections::VecDeque, sync::Arc}; - -use anyhow::Context; -use capnp::capability::Promise; -use capnp_rpc::{RpcSystem, rpc_twoparty_capnp::Side, twoparty}; -use clap::Parser; -use dashmap::DashMap; -use noiseml_core::{NoiseKeypair, handshake_responder}; -use noiseml_proto::{ - auth_capnp::authentication_service, - delivery_capnp::delivery_service, -}; -use sha2::{Digest, Sha256}; -use tokio::net::{TcpListener, TcpStream}; -use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; -use tracing::Instrument; - -// ── CLI ─────────────────────────────────────────────────────────────────────── - -#[derive(Debug, Parser)] -#[command( - name = "noiseml-server", - about = "noiseml Delivery Service + Authentication Service", - version -)] -struct Args { - /// TCP address for the Authentication Service. - #[arg(long, default_value = "0.0.0.0:7000", env = "NOISEML_LISTEN")] - listen: String, - - /// TCP address for the Delivery Service. - #[arg(long, default_value = "0.0.0.0:7001", env = "NOISEML_DS_LISTEN")] - ds_listen: String, -} - -// ── Shared store types ──────────────────────────────────────────────────────── - -/// Thread-safe map from Ed25519 identity public key bytes (32 B) to a queue -/// of serialised MLS KeyPackage blobs. -/// -/// Each KeyPackage is single-use per RFC 9420: `fetch_key_package` removes -/// and returns exactly one entry. -type KeyPackageStore = Arc, VecDeque>>>; - -/// Thread-safe message queue for the Delivery Service. -/// -/// Maps recipient Ed25519 public key (32 bytes) to a FIFO queue of opaque -/// payload bytes (TLS-encoded MLS messages or other framed data). -type DeliveryStore = Arc, VecDeque>>>; - -// ── Authentication Service implementation ───────────────────────────────────── - -/// Cap'n Proto RPC server implementation for `AuthenticationService`. -struct AuthServiceImpl { - store: KeyPackageStore, -} - -impl authentication_service::Server for AuthServiceImpl { - /// Upload a single-use KeyPackage and return its SHA-256 fingerprint. - fn upload_key_package( - &mut self, - params: authentication_service::UploadKeyPackageParams, - mut results: authentication_service::UploadKeyPackageResults, - ) -> Promise<(), capnp::Error> { - let params = params.get().map_err(|e| { - capnp::Error::failed(format!("upload_key_package: bad params: {e}")) - }); - - let (identity_key, package) = match params { - Ok(p) => { - let ik = match p.get_identity_key() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), - }; - let pkg = match p.get_package() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), - }; - (ik, pkg) - } - Err(e) => return Promise::err(e), - }; - - if identity_key.len() != 32 { - return Promise::err(capnp::Error::failed(format!( - "identityKey must be exactly 32 bytes, got {}", - identity_key.len() - ))); - } - if package.is_empty() { - return Promise::err(capnp::Error::failed( - "package must not be empty".to_string(), - )); - } - - let fingerprint: Vec = Sha256::digest(&package).to_vec(); - - self.store - .entry(identity_key) - .or_default() - .push_back(package); - - results - .get() - .set_fingerprint(&fingerprint); - - tracing::debug!( - fingerprint = %fmt_hex(&fingerprint[..4]), - "KeyPackage uploaded" - ); - - Promise::ok(()) - } - - /// Atomically remove and return one KeyPackage for the given identity key. - fn fetch_key_package( - &mut self, - params: authentication_service::FetchKeyPackageParams, - mut results: authentication_service::FetchKeyPackageResults, - ) -> Promise<(), capnp::Error> { - let identity_key = match params.get() { - Ok(p) => match p.get_identity_key() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), - }, - Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), - }; - - if identity_key.len() != 32 { - return Promise::err(capnp::Error::failed(format!( - "identityKey must be exactly 32 bytes, got {}", - identity_key.len() - ))); - } - - // Atomically pop one package from the front of the queue. - let package = self - .store - .get_mut(&identity_key) - .and_then(|mut q| q.pop_front()); - - match package { - Some(pkg) => { - tracing::debug!( - identity = %fmt_hex(&identity_key[..4]), - "KeyPackage fetched" - ); - results.get().set_package(&pkg); - } - None => { - tracing::debug!( - identity = %fmt_hex(&identity_key[..4]), - "no KeyPackage available for identity" - ); - // Return empty Data — schema specifies this as the "no package" sentinel. - results.get().set_package(&[]); - } - } - - Promise::ok(()) - } -} - -// ── Delivery Service implementation ─────────────────────────────────────────── - -/// Cap'n Proto RPC server implementation for `DeliveryService`. -/// -/// Provides a simple store-and-forward relay for MLS messages: -/// * `enqueue` appends an opaque payload to the recipient's FIFO queue. -/// * `fetch` atomically drains and returns the entire queue. -struct DeliveryServiceImpl { - store: DeliveryStore, -} - -impl delivery_service::Server for DeliveryServiceImpl { - /// Append `payload` to the queue for `recipient_key`. - fn enqueue( - &mut self, - params: delivery_service::EnqueueParams, - _results: delivery_service::EnqueueResults, - ) -> Promise<(), capnp::Error> { - let p = match params.get() { - Ok(p) => p, - Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), - }; - let recipient_key = match p.get_recipient_key() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), - }; - let payload = match p.get_payload() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), - }; - - if recipient_key.len() != 32 { - return Promise::err(capnp::Error::failed(format!( - "recipientKey must be exactly 32 bytes, got {}", - recipient_key.len() - ))); - } - if payload.is_empty() { - return Promise::err(capnp::Error::failed( - "payload must not be empty".to_string(), - )); - } - - self.store - .entry(recipient_key.clone()) - .or_default() - .push_back(payload); - - tracing::debug!( - recipient = %fmt_hex(&recipient_key[..4]), - "message enqueued" - ); - - Promise::ok(()) - } - - /// Atomically drain and return all queued payloads for `recipient_key`. - fn fetch( - &mut self, - params: delivery_service::FetchParams, - mut results: delivery_service::FetchResults, - ) -> Promise<(), capnp::Error> { - let recipient_key = match params.get() { - Ok(p) => match p.get_recipient_key() { - Ok(v) => v.to_vec(), - Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), - }, - Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), - }; - - if recipient_key.len() != 32 { - return Promise::err(capnp::Error::failed(format!( - "recipientKey must be exactly 32 bytes, got {}", - recipient_key.len() - ))); - } - - // Atomically drain the entire queue. - let messages: Vec> = self - .store - .get_mut(&recipient_key) - .map(|mut q| q.drain(..).collect()) - .unwrap_or_default(); - - tracing::debug!( - recipient = %fmt_hex(&recipient_key[..4]), - count = messages.len(), - "messages fetched" - ); - - let mut list = results.get().init_payloads(messages.len() as u32); - for (i, msg) in messages.iter().enumerate() { - list.set(i as u32, msg); - } - - Promise::ok(()) - } -} - -// ── Entry point ─────────────────────────────────────────────────────────────── - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .init(); - - let args = Args::parse(); - - // Generate a fresh static Noise keypair for this server instance. - // M6 replaces this with persistent key loading from SQLite. - let keypair = Arc::new(NoiseKeypair::generate()); - - { - let pub_bytes = keypair.public_bytes(); - tracing::info!( - listen = %args.listen, - ds_listen = %args.ds_listen, - public_key = %fmt_hex(&pub_bytes[..4]), - "noiseml-server starting (M3) — keypair is ephemeral" - ); - } - - // Shared stores — all connections share the same in-memory maps. - let kp_store: KeyPackageStore = Arc::new(DashMap::new()); - let ds_store: DeliveryStore = Arc::new(DashMap::new()); - - let as_listener = TcpListener::bind(&args.listen) - .await - .with_context(|| format!("failed to bind AS to {}", args.listen))?; - - let ds_listener = TcpListener::bind(&args.ds_listen) - .await - .with_context(|| format!("failed to bind DS to {}", args.ds_listen))?; - - tracing::info!( - as_addr = %args.listen, - ds_addr = %args.ds_listen, - "accepting connections" - ); - - // capnp-rpc is !Send (Rc internals), so all RPC tasks must stay on a - // LocalSet. Both accept loops share one LocalSet. - let local = tokio::task::LocalSet::new(); - local - .run_until(async move { - loop { - tokio::select! { - result = as_listener.accept() => { - let (stream, peer_addr) = result.context("AS accept failed")?; - let keypair = Arc::clone(&keypair); - let store = Arc::clone(&kp_store); - tokio::task::spawn_local( - async move { - match handle_as_connection(stream, keypair, store).await { - Ok(()) => tracing::debug!("AS connection closed"), - Err(e) => tracing::warn!(error = %e, "AS connection error"), - } - } - .instrument(tracing::info_span!("as_conn", peer = %peer_addr)), - ); - } - result = ds_listener.accept() => { - let (stream, peer_addr) = result.context("DS accept failed")?; - let keypair = Arc::clone(&keypair); - let store = Arc::clone(&ds_store); - tokio::task::spawn_local( - async move { - match handle_ds_connection(stream, keypair, store).await { - Ok(()) => tracing::debug!("DS connection closed"), - Err(e) => tracing::warn!(error = %e, "DS connection error"), - } - } - .instrument(tracing::info_span!("ds_conn", peer = %peer_addr)), - ); - } - } - } - #[allow(unreachable_code)] - Ok::<(), anyhow::Error>(()) - }) - .await -} - -// ── Per-connection handlers ─────────────────────────────────────────────────── - -/// Handle one Authentication Service connection. -async fn handle_as_connection( - stream: TcpStream, - keypair: Arc, - store: KeyPackageStore, -) -> Result<(), anyhow::Error> { - let transport = noise_handshake(stream, &keypair, "AS").await?; - let (reader, writer) = transport.into_capnp_io(); - - let network = twoparty::VatNetwork::new( - reader.compat(), - writer.compat_write(), - Side::Server, - Default::default(), - ); - - let service: authentication_service::Client = - capnp_rpc::new_client(AuthServiceImpl { store }); - - RpcSystem::new(Box::new(network), Some(service.client)) - .await - .map_err(|e| anyhow::anyhow!("AS RPC error: {e}")) -} - -/// Handle one Delivery Service connection. -async fn handle_ds_connection( - stream: TcpStream, - keypair: Arc, - store: DeliveryStore, -) -> Result<(), anyhow::Error> { - let transport = noise_handshake(stream, &keypair, "DS").await?; - let (reader, writer) = transport.into_capnp_io(); - - let network = twoparty::VatNetwork::new( - reader.compat(), - writer.compat_write(), - Side::Server, - Default::default(), - ); - - let service: delivery_service::Client = - capnp_rpc::new_client(DeliveryServiceImpl { store }); - - RpcSystem::new(Box::new(network), Some(service.client)) - .await - .map_err(|e| anyhow::anyhow!("DS RPC error: {e}")) -} - -/// Perform the Noise_XX handshake and log the remote key. -async fn noise_handshake( - stream: TcpStream, - keypair: &NoiseKeypair, - label: &str, -) -> anyhow::Result { - let transport = handshake_responder(stream, keypair) - .await - .map_err(|e| anyhow::anyhow!("{label} Noise handshake failed: {e}"))?; - - let remote = transport - .remote_static_public_key() - .map(|k| fmt_hex(&k[..4])) - .unwrap_or_else(|| "unknown".into()); - tracing::info!(remote_key = %remote, "{label} Noise_XX handshake complete"); - - Ok(transport) -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/// Format the first `n` bytes of a slice as lowercase hex with a trailing `…`. -fn fmt_hex(bytes: &[u8]) -> String { - let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); - format!("{hex}…") -} diff --git a/crates/noiseml-client/Cargo.toml b/crates/quicnprotochat-client/Cargo.toml similarity index 55% rename from crates/noiseml-client/Cargo.toml rename to crates/quicnprotochat-client/Cargo.toml index ce3c6e0..96caf2f 100644 --- a/crates/noiseml-client/Cargo.toml +++ b/crates/quicnprotochat-client/Cargo.toml @@ -1,17 +1,18 @@ [package] -name = "noiseml-client" +name = "quicnprotochat-client" version = "0.1.0" edition = "2021" -description = "CLI client for noiseml." +description = "CLI client for quicnprotochat." license = "MIT" [[bin]] -name = "noiseml" +name = "quicnprotochat" path = "src/main.rs" [dependencies] -noiseml-core = { path = "../noiseml-core" } -noiseml-proto = { path = "../noiseml-proto" } +quicnprotochat-core = { path = "../quicnprotochat-core" } +quicnprotochat-proto = { path = "../quicnprotochat-proto" } +openmls_rust_crypto = { workspace = true } # Serialisation + RPC capnp = { workspace = true } @@ -21,6 +22,9 @@ capnp-rpc = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } futures = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +bincode = { workspace = true } # Error handling anyhow = { workspace = true } @@ -28,6 +32,9 @@ thiserror = { workspace = true } # Crypto — for fingerprint verification in fetch-key subcommand sha2 = { workspace = true } +quinn = { workspace = true } +quinn-proto = { workspace = true } +rustls = { workspace = true } # Logging tracing = { workspace = true } @@ -37,5 +44,5 @@ tracing-subscriber = { workspace = true } clap = { workspace = true } [dev-dependencies] -# Integration tests use noiseml-core, noiseml-proto, and capnp-rpc directly. +# Integration tests use quicnprotochat-core, quicnprotochat-proto, and capnp-rpc directly. dashmap = { workspace = true } diff --git a/crates/quicnprotochat-client/src/main.rs b/crates/quicnprotochat-client/src/main.rs new file mode 100644 index 0000000..b05d634 --- /dev/null +++ b/crates/quicnprotochat-client/src/main.rs @@ -0,0 +1,1019 @@ +//! quicnprotochat CLI client. +//! +//! # Subcommands +//! +//! | Subcommand | Description | +//! |--------------|----------------------------------------------------------| +//! | `ping` | Send a Ping to the server, print RTT | +//! | `register` | Generate a KeyPackage and upload it to the AS | +//! | `fetch-key` | Fetch a peer's KeyPackage from the AS by identity key | +//! +//! # Configuration +//! +//! | Env var | CLI flag | Default | +//! |-----------------|--------------|---------------------| +//! | `QUICNPROTOCHAT_SERVER`| `--server` | `127.0.0.1:7000` | +//! | `RUST_LOG` | — | `warn` | + +use std::fs; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::Context; +use capnp_rpc::{rpc_twoparty_capnp::Side, twoparty, RpcSystem}; +use clap::{Parser, Subcommand}; +use serde::{Deserialize, Serialize}; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +use quinn::{ClientConfig, Endpoint}; +use quinn_proto::crypto::rustls::QuicClientConfig; +use rustls::pki_types::CertificateDer; +use rustls::{ClientConfig as RustlsClientConfig, RootCertStore}; + +use quicnprotochat_core::{generate_key_package, DiskKeyStore, GroupMember, IdentityKeypair}; +use quicnprotochat_proto::node_capnp::node_service; + +// ── CLI ─────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Parser)] +#[command(name = "quicnprotochat", about = "quicnprotochat CLI client", version)] +struct Args { + /// Path to the server's TLS certificate (self-signed by default). + #[arg( + long, + global = true, + default_value = "data/server-cert.der", + env = "QUICNPROTOCHAT_CA_CERT" + )] + ca_cert: PathBuf, + + /// Expected TLS server name (must match the certificate SAN). + #[arg( + long, + global = true, + default_value = "localhost", + env = "QUICNPROTOCHAT_SERVER_NAME" + )] + server_name: String, + + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Send a Ping to the server and print the round-trip time. + Ping { + /// Server address (host:port). + #[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] + server: String, + }, + + /// Generate a fresh MLS KeyPackage and upload it to the Authentication Service. + /// + /// Prints the SHA-256 fingerprint of the uploaded package and the raw + /// Ed25519 identity public key bytes (hex), which peers need to fetch it. + Register { + /// Server address (host:port). + #[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] + server: String, + }, + + /// Fetch a peer's KeyPackage from the Authentication Service. + /// + /// IDENTITY_KEY is the peer's Ed25519 public key encoded as 64 lowercase + /// hex characters (32 bytes). + FetchKey { + /// Server address (host:port). + #[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] + server: String, + + /// Target peer's Ed25519 identity public key (64 hex chars = 32 bytes). + identity_key: String, + }, + + /// Run a full Alice↔Bob MLS round-trip against live AS and DS endpoints. + DemoGroup { + /// Server address (host:port). + #[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] + server: String, + }, + + /// Upload the persistent identity's KeyPackage to the AS (uses state file). + RegisterState { + /// State file path (identity + MLS state). + #[arg( + long, + default_value = "quicnprotochat-state.bin", + env = "QUICNPROTOCHAT_STATE" + )] + state: PathBuf, + + /// Authentication Service address (host:port). + #[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] + server: String, + }, + + /// Create a persistent group and save state to disk. + CreateGroup { + /// State file path (identity + MLS state). + #[arg( + long, + default_value = "quicnprotochat-state.bin", + env = "QUICNPROTOCHAT_STATE" + )] + state: PathBuf, + + /// Server address (host:port). + #[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] + server: String, + + /// Group identifier (arbitrary bytes, typically a human-readable name). + #[arg(long)] + group_id: String, + }, + + /// Invite a peer into the group and deliver a Welcome via DS. + Invite { + #[arg( + long, + default_value = "quicnprotochat-state.bin", + env = "QUICNPROTOCHAT_STATE" + )] + state: PathBuf, + #[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] + server: String, + /// Peer identity public key (64 hex chars = 32 bytes). + #[arg(long)] + peer_key: String, + }, + + /// Join a group by fetching the Welcome from the DS. + Join { + #[arg( + long, + default_value = "quicnprotochat-state.bin", + env = "QUICNPROTOCHAT_STATE" + )] + state: PathBuf, + #[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] + server: String, + }, + + /// Send an application message via the DS. + Send { + #[arg( + long, + default_value = "quicnprotochat-state.bin", + env = "QUICNPROTOCHAT_STATE" + )] + state: PathBuf, + #[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] + server: String, + /// Recipient identity key (hex, 32 bytes -> 64 chars). + #[arg(long)] + peer_key: String, + /// Plaintext message to send. + #[arg(long)] + msg: String, + }, + + /// Receive and decrypt all pending messages from the DS. + Recv { + #[arg( + long, + default_value = "quicnprotochat-state.bin", + env = "QUICNPROTOCHAT_STATE" + )] + state: PathBuf, + #[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] + server: String, + + /// Wait for up to this many milliseconds if no messages are queued. + #[arg(long, default_value_t = 0)] + wait_ms: u64, + + /// Continuously long-poll for messages. + #[arg(long)] + stream: bool, + }, +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), + ) + .init(); + + let args = Args::parse(); + + match args.command { + Command::Ping { server } => cmd_ping(&server, &args.ca_cert, &args.server_name).await, + Command::Register { server } => { + let local = tokio::task::LocalSet::new(); + local + .run_until(cmd_register(&server, &args.ca_cert, &args.server_name)) + .await + } + Command::FetchKey { + server, + identity_key, + } => { + let local = tokio::task::LocalSet::new(); + local + .run_until(cmd_fetch_key( + &server, + &args.ca_cert, + &args.server_name, + &identity_key, + )) + .await + } + Command::DemoGroup { server } => { + let local = tokio::task::LocalSet::new(); + local + .run_until(cmd_demo_group(&server, &args.ca_cert, &args.server_name)) + .await + } + Command::RegisterState { state, server } => { + let local = tokio::task::LocalSet::new(); + local + .run_until(cmd_register_state( + &state, + &server, + &args.ca_cert, + &args.server_name, + )) + .await + } + Command::CreateGroup { + state, + server, + group_id, + } => { + let local = tokio::task::LocalSet::new(); + local + .run_until(cmd_create_group(&state, &server, &group_id)) + .await + } + Command::Invite { + state, + server, + peer_key, + } => { + let local = tokio::task::LocalSet::new(); + local + .run_until(cmd_invite( + &state, + &server, + &args.ca_cert, + &args.server_name, + &peer_key, + )) + .await + } + Command::Join { state, server } => { + let local = tokio::task::LocalSet::new(); + local + .run_until(cmd_join(&state, &server, &args.ca_cert, &args.server_name)) + .await + } + Command::Send { + state, + server, + peer_key, + msg, + } => { + let local = tokio::task::LocalSet::new(); + local + .run_until(cmd_send( + &state, + &server, + &args.ca_cert, + &args.server_name, + &peer_key, + &msg, + )) + .await + } + Command::Recv { + state, + server, + wait_ms, + stream, + } => { + let local = tokio::task::LocalSet::new(); + local + .run_until(cmd_recv( + &state, + &server, + &args.ca_cert, + &args.server_name, + wait_ms, + stream, + )) + .await + } + } +} + +// ── Subcommand implementations ──────────────────────────────────────────────── + +/// Connect to `server`, call health, and print RTT over QUIC/TLS. +async fn cmd_ping(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> { + let sent_at = current_timestamp_ms(); + let client = connect_node(server, ca_cert, server_name).await?; + + let req = client.health_request(); + let resp = req.send().promise.await.context("health RPC failed")?; + + let status = resp + .get() + .context("health: bad response")? + .get_status() + .context("health: missing status")? + .to_str() + .unwrap_or("invalid"); + + let rtt_ms = current_timestamp_ms().saturating_sub(sent_at); + println!("health={status} rtt={rtt_ms}ms"); + Ok(()) +} + +/// Generate a KeyPackage for a fresh identity and upload it to the AS. +/// +/// Must run on a `LocalSet` because capnp-rpc is `!Send`. +async fn cmd_register(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> { + let identity = IdentityKeypair::generate(); + + let (tls_bytes, fingerprint) = + generate_key_package(&identity).context("KeyPackage generation failed")?; + + let node_client = connect_node(server, ca_cert, server_name).await?; + + let mut req = node_client.upload_key_package_request(); + req.get().set_identity_key(&identity.public_key_bytes()); + req.get().set_package(&tls_bytes); + + let response = req + .send() + .promise + .await + .context("upload_key_package RPC failed")?; + + let server_fp = response + .get() + .context("upload_key_package: bad response")? + .get_fingerprint() + .context("upload_key_package: missing fingerprint")? + .to_vec(); + + // Verify the server echoed the same fingerprint. + anyhow::ensure!( + server_fp == fingerprint, + "fingerprint mismatch: local={} server={}", + hex::encode(&fingerprint), + hex::encode(&server_fp), + ); + + println!( + "identity_key : {}", + hex::encode(identity.public_key_bytes()) + ); + println!("fingerprint : {}", hex::encode(&fingerprint)); + println!("KeyPackage uploaded successfully."); + + Ok(()) +} + +/// Upload the stored identity's KeyPackage to the AS (persists backend state). +async fn cmd_register_state( + state_path: &Path, + server: &str, + ca_cert: &Path, + server_name: &str, +) -> anyhow::Result<()> { + let state = load_or_init_state(state_path)?; + let mut member = state.into_member(state_path)?; + + let tls_bytes = member + .generate_key_package() + .context("KeyPackage generation failed")?; + let fingerprint = sha256(&tls_bytes); + + let node_client = connect_node(server, ca_cert, server_name).await?; + + let mut req = node_client.upload_key_package_request(); + req.get() + .set_identity_key(&member.identity().public_key_bytes()); + req.get().set_package(&tls_bytes); + + let response = req + .send() + .promise + .await + .context("upload_key_package RPC failed")?; + + let server_fp = response + .get() + .context("upload_key_package: bad response")? + .get_fingerprint() + .context("upload_key_package: missing fingerprint")? + .to_vec(); + + anyhow::ensure!(server_fp == fingerprint, "fingerprint mismatch"); + + println!( + "identity_key : {}", + hex::encode(member.identity().public_key_bytes()) + ); + println!("fingerprint : {}", hex::encode(&fingerprint)); + println!("KeyPackage uploaded successfully."); + + save_state(state_path, &member)?; + Ok(()) +} + +/// Fetch a peer's KeyPackage from the AS by their hex-encoded identity key. +/// +/// Must run on a `LocalSet` because capnp-rpc is `!Send`. +async fn cmd_fetch_key( + server: &str, + ca_cert: &Path, + server_name: &str, + identity_key_hex: &str, +) -> anyhow::Result<()> { + let identity_key = hex::decode(identity_key_hex) + .map_err(|e| anyhow::anyhow!(e)) + .context("identity_key must be 64 hex characters (32 bytes)")?; + anyhow::ensure!( + identity_key.len() == 32, + "identity_key must be exactly 32 bytes, got {}", + identity_key.len() + ); + + let node_client = connect_node(server, ca_cert, server_name).await?; + + let mut req = node_client.fetch_key_package_request(); + req.get().set_identity_key(&identity_key); + + let response = req + .send() + .promise + .await + .context("fetch_key_package RPC failed")?; + + let package = response + .get() + .context("fetch_key_package: bad response")? + .get_package() + .context("fetch_key_package: missing package field")? + .to_vec(); + + if package.is_empty() { + println!("No KeyPackage available for this identity."); + return Ok(()); + } + + use sha2::{Digest, Sha256}; + let fingerprint = Sha256::digest(&package); + + println!("fingerprint : {}", hex::encode(fingerprint)); + println!("package_len : {} bytes", package.len()); + println!("KeyPackage fetched successfully."); + + Ok(()) +} + +/// Run a complete Alice↔Bob MLS round-trip using the unified server endpoint. +async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> { + // Identities and MLS state must be tied to the same backend instance. + let alice_id = Arc::new(IdentityKeypair::generate()); + let bob_id = Arc::new(IdentityKeypair::generate()); + + let mut alice = GroupMember::new(Arc::clone(&alice_id)); + let mut bob = GroupMember::new(Arc::clone(&bob_id)); + + let alice_kp = alice + .generate_key_package() + .context("Alice KeyPackage generation failed")?; + let bob_kp = bob + .generate_key_package() + .context("Bob KeyPackage generation failed")?; + + // Upload both KeyPackages to the server. + let alice_node = connect_node(server, ca_cert, server_name).await?; + let bob_node = connect_node(server, ca_cert, server_name).await?; + + upload_key_package(&alice_node, &alice_id.public_key_bytes(), &alice_kp).await?; + upload_key_package(&bob_node, &bob_id.public_key_bytes(), &bob_kp).await?; + + // Alice fetches Bob's KeyPackage and creates the group. + let fetched_bob_kp = fetch_key_package(&alice_node, &bob_id.public_key_bytes()).await?; + anyhow::ensure!( + !fetched_bob_kp.is_empty(), + "AS returned an empty KeyPackage for Bob", + ); + + alice + .create_group(b"demo-group") + .context("Alice create_group failed")?; + let (_commit, welcome) = alice + .add_member(&fetched_bob_kp) + .context("Alice add_member failed")?; + + let alice_ds = alice_node.clone(); + let bob_ds = bob_node.clone(); + + enqueue(&alice_ds, &bob_id.public_key_bytes(), &welcome).await?; + + let welcome_payloads = fetch_all(&bob_ds, &bob_id.public_key_bytes()).await?; + let welcome_bytes = welcome_payloads + .first() + .cloned() + .context("Welcome was not delivered to Bob via DS")?; + + bob.join_group(&welcome_bytes) + .context("Bob join_group failed")?; + + // Alice → Bob + let ct_ab = alice + .send_message(b"hello bob") + .context("Alice send_message failed")?; + enqueue(&alice_ds, &bob_id.public_key_bytes(), &ct_ab).await?; + let bob_msgs = fetch_all(&bob_ds, &bob_id.public_key_bytes()).await?; + let ab_plaintext = bob + .receive_message( + bob_msgs + .first() + .context("Bob: missing Alice ciphertext from DS")?, + )? + .context("Bob expected application message from Alice")?; + println!( + "Alice → Bob plaintext: {}", + String::from_utf8_lossy(&ab_plaintext) + ); + + // Bob → Alice + let ct_ba = bob + .send_message(b"hello alice") + .context("Bob send_message failed")?; + enqueue(&bob_ds, &alice_id.public_key_bytes(), &ct_ba).await?; + let alice_msgs = fetch_all(&alice_ds, &alice_id.public_key_bytes()).await?; + let ba_plaintext = alice + .receive_message( + alice_msgs + .first() + .context("Alice: missing Bob ciphertext from DS")?, + )? + .context("Alice expected application message from Bob")?; + println!( + "Bob → Alice plaintext: {}", + String::from_utf8_lossy(&ba_plaintext) + ); + + println!("demo-group complete ✔"); + + Ok(()) +} + +/// Create a new group and persist state. +async fn cmd_create_group(state_path: &Path, _server: &str, group_id: &str) -> anyhow::Result<()> { + let state = load_or_init_state(state_path)?; + let mut member = state.into_member(state_path)?; + + anyhow::ensure!( + member.group_ref().is_none(), + "group already exists in state" + ); + + member + .create_group(group_id.as_bytes()) + .context("create_group failed")?; + + save_state(state_path, &member)?; + println!("group created: {group_id}"); + Ok(()) +} + +/// Invite a peer: fetch their KeyPackage, add to group, enqueue Welcome. +async fn cmd_invite( + state_path: &Path, + server: &str, + ca_cert: &Path, + server_name: &str, + peer_key_hex: &str, +) -> anyhow::Result<()> { + let state = load_existing_state(state_path)?; + let mut member = state.into_member(state_path)?; + + let peer_key = decode_identity_key(peer_key_hex)?; + let node_client = connect_node(server, ca_cert, server_name).await?; + let peer_kp = fetch_key_package(&node_client, &peer_key).await?; + anyhow::ensure!( + !peer_kp.is_empty(), + "server returned empty KeyPackage for peer" + ); + + let _ = member + .group_ref() + .context("no active group; run create-group first")?; + + let (_, welcome) = member.add_member(&peer_kp).context("add_member failed")?; + + enqueue(&node_client, &peer_key, &welcome).await?; + + save_state(state_path, &member)?; + println!("invited peer (welcome queued)"); + Ok(()) +} + +/// Join a group by consuming a Welcome from the server queue. +async fn cmd_join( + state_path: &Path, + server: &str, + ca_cert: &Path, + server_name: &str, +) -> anyhow::Result<()> { + let state = load_existing_state(state_path)?; + let mut member = state.into_member(state_path)?; + + anyhow::ensure!( + member.group_ref().is_none(), + "group already active in state" + ); + + let node_client = connect_node(server, ca_cert, server_name).await?; + let welcomes = fetch_all(&node_client, &member.identity().public_key_bytes()).await?; + let welcome_bytes = welcomes + .first() + .cloned() + .context("no Welcome found in DS for this identity")?; + + member + .join_group(&welcome_bytes) + .context("join_group failed")?; + + save_state(state_path, &member)?; + println!("joined group successfully"); + Ok(()) +} + +/// Send an application message via DS. +async fn cmd_send( + state_path: &Path, + server: &str, + ca_cert: &Path, + server_name: &str, + peer_key_hex: &str, + msg: &str, +) -> anyhow::Result<()> { + let state = load_existing_state(state_path)?; + let mut member = state.into_member(state_path)?; + + let peer_key = decode_identity_key(peer_key_hex)?; + let node_client = connect_node(server, ca_cert, server_name).await?; + + let ct = member + .send_message(msg.as_bytes()) + .context("send_message failed")?; + enqueue(&node_client, &peer_key, &ct).await?; + + save_state(state_path, &member)?; + println!("message sent"); + Ok(()) +} + +/// Receive and decrypt all pending messages from the server. +async fn cmd_recv( + state_path: &Path, + server: &str, + ca_cert: &Path, + server_name: &str, + wait_ms: u64, + stream: bool, +) -> anyhow::Result<()> { + let state = load_existing_state(state_path)?; + let mut member = state.into_member(state_path)?; + + let client = connect_node(server, ca_cert, server_name).await?; + + loop { + let payloads = fetch_wait(&client, &member.identity().public_key_bytes(), wait_ms).await?; + + if payloads.is_empty() { + if !stream { + println!("no messages"); + return Ok(()); + } + continue; + } + + for (idx, payload) in payloads.iter().enumerate() { + match member.receive_message(payload) { + Ok(Some(pt)) => println!("[{idx}] plaintext: {}", String::from_utf8_lossy(&pt)), + Ok(None) => println!("[{idx}] commit applied"), + Err(e) => println!("[{idx}] error: {e}"), + } + } + + save_state(state_path, &member)?; + + if !stream { + return Ok(()); + } + } +} + +// ── Shared helpers ──────────────────────────────────────────────────────────── + +/// Establish a QUIC/TLS connection and return a `NodeService` client. +/// +/// Must be called from within a `LocalSet` because capnp-rpc is `!Send`. +async fn connect_node( + server: &str, + ca_cert: &Path, + server_name: &str, +) -> anyhow::Result { + let addr: SocketAddr = server + .parse() + .with_context(|| format!("server must be host:port, got {server}"))?; + + let cert_bytes = fs::read(ca_cert).with_context(|| format!("read ca_cert {ca_cert:?}"))?; + let mut roots = RootCertStore::empty(); + roots + .add(CertificateDer::from(cert_bytes)) + .context("add root cert")?; + + let tls = RustlsClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + + let crypto = QuicClientConfig::try_from(tls) + .map_err(|e| anyhow::anyhow!("invalid client TLS config: {e}"))?; + + let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())?; + endpoint.set_default_client_config(ClientConfig::new(Arc::new(crypto))); + + let connection = endpoint + .connect(addr, server_name) + .context("quic connect init")? + .await + .context("quic connect failed")?; + + let (send, recv) = connection.open_bi().await.context("open bi stream")?; + + let network = twoparty::VatNetwork::new( + recv.compat(), + send.compat_write(), + Side::Client, + Default::default(), + ); + + let mut rpc_system = RpcSystem::new(Box::new(network), None); + let client: node_service::Client = rpc_system.bootstrap(Side::Server); + + tokio::task::spawn_local(rpc_system); + + Ok(client) +} + +/// Upload a KeyPackage and verify the fingerprint echoed by the AS. +async fn upload_key_package( + client: &node_service::Client, + identity_key: &[u8], + package: &[u8], +) -> anyhow::Result<()> { + let mut req = client.upload_key_package_request(); + req.get().set_identity_key(identity_key); + req.get().set_package(package); + + let resp = req + .send() + .promise + .await + .context("upload_key_package RPC failed")?; + + let server_fp = resp + .get() + .context("upload_key_package: bad response")? + .get_fingerprint() + .context("upload_key_package: missing fingerprint")? + .to_vec(); + + let local_fp = sha256(package); + anyhow::ensure!(server_fp == local_fp, "fingerprint mismatch"); + Ok(()) +} + +/// Fetch a KeyPackage for `identity_key` from the AS. +async fn fetch_key_package( + client: &node_service::Client, + identity_key: &[u8], +) -> anyhow::Result> { + let mut req = client.fetch_key_package_request(); + req.get().set_identity_key(identity_key); + + let resp = req + .send() + .promise + .await + .context("fetch_key_package RPC failed")?; + + let pkg = resp + .get() + .context("fetch_key_package: bad response")? + .get_package() + .context("fetch_key_package: missing package field")? + .to_vec(); + + Ok(pkg) +} + +/// Enqueue an opaque payload to the DS for `recipient_key`. +async fn enqueue( + client: &node_service::Client, + recipient_key: &[u8], + payload: &[u8], +) -> anyhow::Result<()> { + let mut req = client.enqueue_request(); + req.get().set_recipient_key(recipient_key); + req.get().set_payload(payload); + req.send().promise.await.context("enqueue RPC failed")?; + Ok(()) +} + +/// Fetch and drain all payloads for `recipient_key`. +async fn fetch_all( + client: &node_service::Client, + recipient_key: &[u8], +) -> anyhow::Result>> { + let mut req = client.fetch_request(); + req.get().set_recipient_key(recipient_key); + + let resp = req.send().promise.await.context("fetch RPC failed")?; + + let list = resp + .get() + .context("fetch: bad response")? + .get_payloads() + .context("fetch: missing payloads")?; + + let mut payloads = Vec::with_capacity(list.len() as usize); + for i in 0..list.len() { + payloads.push(list.get(i).context("fetch: payload read failed")?.to_vec()); + } + + Ok(payloads) +} + +/// Long-poll for payloads with optional timeout (ms). +async fn fetch_wait( + client: &node_service::Client, + recipient_key: &[u8], + timeout_ms: u64, +) -> anyhow::Result>> { + let mut req = client.fetch_wait_request(); + req.get().set_recipient_key(recipient_key); + req.get().set_timeout_ms(timeout_ms); + + let resp = req.send().promise.await.context("fetch_wait RPC failed")?; + + let list = resp + .get() + .context("fetch_wait: bad response")? + .get_payloads() + .context("fetch_wait: missing payloads")?; + + let mut payloads = Vec::with_capacity(list.len() as usize); + for i in 0..list.len() { + payloads.push( + list.get(i) + .context("fetch_wait: payload read failed")? + .to_vec(), + ); + } + + Ok(payloads) +} + +fn sha256(bytes: &[u8]) -> Vec { + use sha2::{Digest, Sha256}; + Sha256::digest(bytes).to_vec() +} + +#[derive(Serialize, Deserialize)] +struct StoredState { + identity_seed: [u8; 32], + group: Option>, +} + +impl StoredState { + fn into_member(self, state_path: &Path) -> anyhow::Result { + let identity = Arc::new(IdentityKeypair::from_seed(self.identity_seed)); + let group = self + .group + .map(|bytes| bincode::deserialize(&bytes).context("decode group")) + .transpose()?; + let key_store = DiskKeyStore::persistent(keystore_path(state_path))?; + Ok(GroupMember::new_with_state(identity, key_store, group)) + } + + fn from_member(member: &GroupMember) -> anyhow::Result { + let group = member + .group_ref() + .map(|g| bincode::serialize(g).context("serialize group")) + .transpose()?; + + Ok(Self { + identity_seed: member.identity_seed(), + group, + }) + } +} + +fn load_or_init_state(path: &Path) -> anyhow::Result { + if path.exists() { + return load_existing_state(path); + } + + let identity = IdentityKeypair::generate(); + let key_store = DiskKeyStore::persistent(keystore_path(path))?; + let member = GroupMember::new_with_state(Arc::new(identity), key_store, None); + let state = StoredState::from_member(&member)?; + write_state(path, &state)?; + Ok(state) +} + +fn load_existing_state(path: &Path) -> anyhow::Result { + let bytes = std::fs::read(path).with_context(|| format!("read state file {path:?}"))?; + bincode::deserialize(&bytes).context("decode state") +} + +fn save_state(path: &Path, member: &GroupMember) -> anyhow::Result<()> { + let state = StoredState::from_member(member)?; + write_state(path, &state) +} + +fn write_state(path: &Path, state: &StoredState) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("create dir {parent:?}"))?; + } + let bytes = bincode::serialize(state).context("encode state")?; + std::fs::write(path, bytes).with_context(|| format!("write state {path:?}"))?; + Ok(()) +} + +fn decode_identity_key(hex_str: &str) -> anyhow::Result> { + let bytes = hex::decode(hex_str) + .map_err(|e| anyhow::anyhow!(e)) + .context("identity key must be hex")?; + anyhow::ensure!(bytes.len() == 32, "identity key must be 32 bytes"); + Ok(bytes) +} + +fn keystore_path(state_path: &Path) -> PathBuf { + let mut path = state_path.to_path_buf(); + path.set_extension("ks"); + path +} + +/// Format the first `n` bytes as lowercase hex with a trailing `…`. +fn fmt_hex(bytes: &[u8]) -> String { + let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + format!("{hex}…") +} + +/// Return the current Unix timestamp in milliseconds. +fn current_timestamp_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +// ── Hex encoding helper ─────────────────────────────────────────────────────── +// +// We use a tiny inline module rather than adding `hex` as a dependency. + +mod hex { + pub fn encode(bytes: impl AsRef<[u8]>) -> String { + bytes.as_ref().iter().map(|b| format!("{b:02x}")).collect() + } + + pub fn decode(s: &str) -> Result, &'static str> { + if s.len() % 2 != 0 { + return Err("odd-length hex string"); + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| "invalid hex character")) + .collect() + } +} diff --git a/crates/noiseml-client/tests/auth_service.rs b/crates/quicnprotochat-client/tests/auth_service.rs similarity index 89% rename from crates/noiseml-client/tests/auth_service.rs rename to crates/quicnprotochat-client/tests/auth_service.rs index 76e729f..858119d 100644 --- a/crates/noiseml-client/tests/auth_service.rs +++ b/crates/quicnprotochat-client/tests/auth_service.rs @@ -6,13 +6,12 @@ use std::{collections::VecDeque, sync::Arc}; use capnp::capability::Promise; -use capnp_rpc::{RpcSystem, rpc_twoparty_capnp::Side, twoparty}; +use capnp_rpc::{rpc_twoparty_capnp::Side, twoparty, RpcSystem}; use dashmap::DashMap; -use noiseml_core::{ - IdentityKeypair, NoiseKeypair, generate_key_package, handshake_initiator, - handshake_responder, +use quicnprotochat_core::{ + generate_key_package, handshake_initiator, handshake_responder, IdentityKeypair, NoiseKeypair, }; -use noiseml_proto::auth_capnp::authentication_service; +use quicnprotochat_proto::auth_capnp::authentication_service; use sha2::{Digest, Sha256}; use tokio::net::{TcpListener, TcpStream}; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; @@ -111,8 +110,7 @@ async fn serve_one(stream: TcpStream, keypair: Arc, store: Store) Side::Server, Default::default(), ); - let svc: authentication_service::Client = - capnp_rpc::new_client(TestAuthService { store }); + let svc: authentication_service::Client = capnp_rpc::new_client(TestAuthService { store }); let rpc = RpcSystem::new(Box::new(network), Some(svc.client)); tokio::task::spawn_local(rpc).await.ok(); } @@ -156,7 +154,8 @@ async fn upload_then_fetch_fingerprints_match() { let alice = connect_client(addr).await; let mut req = alice.upload_key_package_request(); - req.get().set_identity_key(&alice_identity.public_key_bytes()); + req.get() + .set_identity_key(&alice_identity.public_key_bytes()); req.get().set_package(&tls_bytes); let resp = req.send().promise.await.unwrap(); let server_fp = resp.get().unwrap().get_fingerprint().unwrap().to_vec(); @@ -166,15 +165,22 @@ async fn upload_then_fetch_fingerprints_match() { // Bob: fetch Alice's package by her identity key. let bob = connect_client(addr).await; let mut req2 = bob.fetch_key_package_request(); - req2.get().set_identity_key(&alice_identity.public_key_bytes()); + req2.get() + .set_identity_key(&alice_identity.public_key_bytes()); let resp2 = req2.send().promise.await.unwrap(); let fetched = resp2.get().unwrap().get_package().unwrap().to_vec(); assert!(!fetched.is_empty(), "fetched package must not be empty"); - assert_eq!(fetched, tls_bytes, "fetched bytes must match uploaded bytes"); + assert_eq!( + fetched, tls_bytes, + "fetched bytes must match uploaded bytes" + ); let fetched_fp: Vec = Sha256::digest(&fetched).to_vec(); - assert_eq!(fetched_fp, local_fp, "fetched fingerprint must match uploaded"); + assert_eq!( + fetched_fp, local_fp, + "fetched fingerprint must match uploaded" + ); }) .await; } @@ -234,7 +240,11 @@ async fn packages_consumed_in_fifo_order() { .get_package() .unwrap() .to_vec(); - assert_eq!(pkg1, vec![1u8, 2, 3], "first fetch must return first package"); + assert_eq!( + pkg1, + vec![1u8, 2, 3], + "first fetch must return first package" + ); let client2 = connect_client(addr).await; let mut req2 = client2.fetch_key_package_request(); @@ -249,7 +259,11 @@ async fn packages_consumed_in_fifo_order() { .get_package() .unwrap() .to_vec(); - assert_eq!(pkg2, vec![4u8, 5, 6], "second fetch must return second package"); + assert_eq!( + pkg2, + vec![4u8, 5, 6], + "second fetch must return second package" + ); }) .await; } diff --git a/crates/quicnprotochat-client/tests/mls_group.rs b/crates/quicnprotochat-client/tests/mls_group.rs new file mode 100644 index 0000000..8e4490d --- /dev/null +++ b/crates/quicnprotochat-client/tests/mls_group.rs @@ -0,0 +1,433 @@ +//! Integration test: full MLS group flow via Authentication Service + Delivery Service. +//! +//! Steps: +//! - Start in-process AS and DS (Noise_XX + capnp-rpc) on a LocalSet. +//! - Alice and Bob generate KeyPackages and upload to AS. +//! - Alice fetches Bob's KeyPackage, creates a group, and invites Bob. +//! - Welcome + application messages traverse the Delivery Service. +//! - Both sides decrypt and confirm plaintext payloads. + +use std::{collections::VecDeque, sync::Arc, time::Duration}; + +use anyhow::Context; +use capnp::capability::Promise; +use capnp_rpc::{rpc_twoparty_capnp::Side, twoparty, RpcSystem}; +use dashmap::DashMap; +use quicnprotochat_core::{ + handshake_initiator, handshake_responder, GroupMember, IdentityKeypair, NoiseKeypair, +}; +use quicnprotochat_proto::{auth_capnp::authentication_service, delivery_capnp::delivery_service}; +use sha2::{Digest, Sha256}; +use tokio::net::{TcpListener, TcpStream}; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +type KeyPackageStore = Arc, VecDeque>>>; +type DeliveryStore = Arc, VecDeque>>>; + +/// Full Alice↔Bob MLS round-trip against live AS + DS. +#[tokio::test] +async fn mls_group_end_to_end_round_trip() -> anyhow::Result<()> { + let local = tokio::task::LocalSet::new(); + + local + .run_until(async move { + let server_keypair = Arc::new(NoiseKeypair::generate()); + let kp_store: KeyPackageStore = Arc::new(DashMap::new()); + let ds_store: DeliveryStore = Arc::new(DashMap::new()); + + let as_addr = + spawn_as_server(2, Arc::clone(&server_keypair), Arc::clone(&kp_store)).await; + let ds_addr = + spawn_ds_server(2, Arc::clone(&server_keypair), Arc::clone(&ds_store)).await; + + tokio::time::sleep(Duration::from_millis(10)).await; + + let alice_id = Arc::new(IdentityKeypair::generate()); + let bob_id = Arc::new(IdentityKeypair::generate()); + + let mut alice = GroupMember::new(Arc::clone(&alice_id)); + let mut bob = GroupMember::new(Arc::clone(&bob_id)); + + let alice_kp = alice.generate_key_package()?; + let bob_kp = bob.generate_key_package()?; + + let alice_as = connect_as(as_addr, &NoiseKeypair::generate()).await?; + let bob_as = connect_as(as_addr, &NoiseKeypair::generate()).await?; + + upload_key_package(&alice_as, &alice_id.public_key_bytes(), &alice_kp).await?; + upload_key_package(&bob_as, &bob_id.public_key_bytes(), &bob_kp).await?; + + let fetched_bob_kp = fetch_key_package(&alice_as, &bob_id.public_key_bytes()).await?; + anyhow::ensure!( + !fetched_bob_kp.is_empty(), + "AS must return Bob's KeyPackage" + ); + + alice.create_group(b"m3-integration")?; + let (_commit, welcome) = alice.add_member(&fetched_bob_kp)?; + + let alice_ds = connect_ds(ds_addr, &NoiseKeypair::generate()).await?; + let bob_ds = connect_ds(ds_addr, &NoiseKeypair::generate()).await?; + + enqueue(&alice_ds, &bob_id.public_key_bytes(), &welcome).await?; + + let welcome_payloads = fetch_all(&bob_ds, &bob_id.public_key_bytes()).await?; + let welcome_bytes = welcome_payloads + .first() + .cloned() + .context("welcome must be present")?; + bob.join_group(&welcome_bytes)?; + + let ct_ab = alice.send_message(b"hello bob")?; + enqueue(&alice_ds, &bob_id.public_key_bytes(), &ct_ab).await?; + let bob_msgs = fetch_all(&bob_ds, &bob_id.public_key_bytes()).await?; + let ab_plaintext = bob + .receive_message(bob_msgs.first().context("missing alice→bob payload")?)? + .context("alice→bob must be application message")?; + assert_eq!(ab_plaintext, b"hello bob"); + + let ct_ba = bob.send_message(b"hello alice")?; + enqueue(&bob_ds, &alice_id.public_key_bytes(), &ct_ba).await?; + let alice_msgs = fetch_all(&alice_ds, &alice_id.public_key_bytes()).await?; + let ba_plaintext = alice + .receive_message(alice_msgs.first().context("missing bob→alice payload")?)? + .context("bob→alice must be application message")?; + assert_eq!(ba_plaintext, b"hello alice"); + + Ok(()) + }) + .await +} + +// ── Test helpers ──────────────────────────────────────────────────────────── + +async fn spawn_as_server( + n_connections: usize, + keypair: Arc, + store: KeyPackageStore, +) -> std::net::SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::task::spawn_local(async move { + for _ in 0..n_connections { + let (stream, _) = listener.accept().await.unwrap(); + let kp = Arc::clone(&keypair); + let st = Arc::clone(&store); + tokio::task::spawn_local(async move { + serve_as_connection(stream, kp, st).await; + }); + } + }); + + addr +} + +async fn serve_as_connection( + stream: TcpStream, + keypair: Arc, + store: KeyPackageStore, +) { + let transport = handshake_responder(stream, &keypair).await.unwrap(); + let (reader, writer) = transport.into_capnp_io(); + let network = twoparty::VatNetwork::new( + reader.compat(), + writer.compat_write(), + Side::Server, + Default::default(), + ); + + let service: authentication_service::Client = capnp_rpc::new_client(AuthService { store }); + + RpcSystem::new(Box::new(network), Some(service.client)) + .await + .ok(); +} + +async fn spawn_ds_server( + n_connections: usize, + keypair: Arc, + store: DeliveryStore, +) -> std::net::SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::task::spawn_local(async move { + for _ in 0..n_connections { + let (stream, _) = listener.accept().await.unwrap(); + let kp = Arc::clone(&keypair); + let st = Arc::clone(&store); + tokio::task::spawn_local(async move { + serve_ds_connection(stream, kp, st).await; + }); + } + }); + + addr +} + +async fn serve_ds_connection(stream: TcpStream, keypair: Arc, store: DeliveryStore) { + let transport = handshake_responder(stream, &keypair).await.unwrap(); + let (reader, writer) = transport.into_capnp_io(); + let network = twoparty::VatNetwork::new( + reader.compat(), + writer.compat_write(), + Side::Server, + Default::default(), + ); + + let service: delivery_service::Client = capnp_rpc::new_client(DeliveryService { store }); + + RpcSystem::new(Box::new(network), Some(service.client)) + .await + .ok(); +} + +async fn connect_as( + addr: std::net::SocketAddr, + noise_keypair: &NoiseKeypair, +) -> anyhow::Result { + let stream = TcpStream::connect(addr) + .await + .with_context(|| format!("could not connect to AS at {addr}"))?; + + let transport = handshake_initiator(stream, noise_keypair) + .await + .context("Noise handshake to AS failed")?; + let (reader, writer) = transport.into_capnp_io(); + + let network = twoparty::VatNetwork::new( + reader.compat(), + writer.compat_write(), + Side::Client, + Default::default(), + ); + + let mut rpc = RpcSystem::new(Box::new(network), None); + let client: authentication_service::Client = rpc.bootstrap(Side::Server); + tokio::task::spawn_local(rpc); + Ok(client) +} + +async fn connect_ds( + addr: std::net::SocketAddr, + noise_keypair: &NoiseKeypair, +) -> anyhow::Result { + let stream = TcpStream::connect(addr) + .await + .with_context(|| format!("could not connect to DS at {addr}"))?; + + let transport = handshake_initiator(stream, noise_keypair) + .await + .context("Noise handshake to DS failed")?; + let (reader, writer) = transport.into_capnp_io(); + + let network = twoparty::VatNetwork::new( + reader.compat(), + writer.compat_write(), + Side::Client, + Default::default(), + ); + + let mut rpc = RpcSystem::new(Box::new(network), None); + let client: delivery_service::Client = rpc.bootstrap(Side::Server); + tokio::task::spawn_local(rpc); + Ok(client) +} + +async fn upload_key_package( + as_client: &authentication_service::Client, + identity_key: &[u8], + package: &[u8], +) -> anyhow::Result<()> { + let mut req = as_client.upload_key_package_request(); + req.get().set_identity_key(identity_key); + req.get().set_package(package); + + let resp = req + .send() + .promise + .await + .context("upload_key_package RPC failed")?; + + let server_fp = resp + .get() + .context("upload_key_package: bad response")? + .get_fingerprint() + .context("upload_key_package: missing fingerprint")? + .to_vec(); + + let local_fp: Vec = Sha256::digest(package).to_vec(); + anyhow::ensure!(server_fp == local_fp, "fingerprint mismatch"); + Ok(()) +} + +async fn fetch_key_package( + as_client: &authentication_service::Client, + identity_key: &[u8], +) -> anyhow::Result> { + let mut req = as_client.fetch_key_package_request(); + req.get().set_identity_key(identity_key); + + let resp = req + .send() + .promise + .await + .context("fetch_key_package RPC failed")?; + + let pkg = resp + .get() + .context("fetch_key_package: bad response")? + .get_package() + .context("fetch_key_package: missing package")? + .to_vec(); + + Ok(pkg) +} + +async fn enqueue( + ds_client: &delivery_service::Client, + recipient_key: &[u8], + payload: &[u8], +) -> anyhow::Result<()> { + let mut req = ds_client.enqueue_request(); + req.get().set_recipient_key(recipient_key); + req.get().set_payload(payload); + req.send().promise.await.context("enqueue RPC failed")?; + Ok(()) +} + +async fn fetch_all( + ds_client: &delivery_service::Client, + recipient_key: &[u8], +) -> anyhow::Result>> { + let mut req = ds_client.fetch_request(); + req.get().set_recipient_key(recipient_key); + + let resp = req.send().promise.await.context("fetch RPC failed")?; + + let list = resp + .get() + .context("fetch: bad response")? + .get_payloads() + .context("fetch: missing payloads")?; + + let mut payloads = Vec::with_capacity(list.len() as usize); + for i in 0..list.len() { + payloads.push(list.get(i).context("fetch: payload read failed")?.to_vec()); + } + + Ok(payloads) +} + +// ── Inline service implementations ─────────────────────────────────────────- + +struct AuthService { + store: KeyPackageStore, +} + +impl authentication_service::Server for AuthService { + fn upload_key_package( + &mut self, + params: authentication_service::UploadKeyPackageParams, + mut results: authentication_service::UploadKeyPackageResults, + ) -> Promise<(), capnp::Error> { + let params = match params.get() { + Ok(p) => p, + Err(e) => return Promise::err(e), + }; + + let ik = match params.get_identity_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + let pkg = match params.get_package() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + + let fp: Vec = Sha256::digest(&pkg).to_vec(); + self.store.entry(ik).or_default().push_back(pkg); + results.get().set_fingerprint(&fp); + Promise::ok(()) + } + + fn fetch_key_package( + &mut self, + params: authentication_service::FetchKeyPackageParams, + mut results: authentication_service::FetchKeyPackageResults, + ) -> Promise<(), capnp::Error> { + let ik = match params.get() { + Ok(p) => match p.get_identity_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }, + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + + let pkg = self + .store + .get_mut(&ik) + .and_then(|mut q| q.pop_front()) + .unwrap_or_default(); + + results.get().set_package(&pkg); + Promise::ok(()) + } +} + +struct DeliveryService { + store: DeliveryStore, +} + +impl delivery_service::Server for DeliveryService { + fn enqueue( + &mut self, + params: delivery_service::EnqueueParams, + _results: delivery_service::EnqueueResults, + ) -> Promise<(), capnp::Error> { + let params = match params.get() { + Ok(p) => p, + Err(e) => return Promise::err(e), + }; + + let recipient = match params.get_recipient_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + let payload = match params.get_payload() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + + self.store.entry(recipient).or_default().push_back(payload); + Promise::ok(()) + } + + fn fetch( + &mut self, + params: delivery_service::FetchParams, + mut results: delivery_service::FetchResults, + ) -> Promise<(), capnp::Error> { + let recipient = match params.get() { + Ok(p) => match p.get_recipient_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }, + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + + let messages: Vec> = self + .store + .get_mut(&recipient) + .map(|mut q| q.drain(..).collect()) + .unwrap_or_default(); + + let mut list = results.get().init_payloads(messages.len() as u32); + for (i, msg) in messages.iter().enumerate() { + list.set(i as u32, msg); + } + + Promise::ok(()) + } +} diff --git a/crates/noiseml-client/tests/noise_transport.rs b/crates/quicnprotochat-client/tests/noise_transport.rs similarity index 91% rename from crates/noiseml-client/tests/noise_transport.rs rename to crates/quicnprotochat-client/tests/noise_transport.rs index 56bea73..576296b 100644 --- a/crates/noiseml-client/tests/noise_transport.rs +++ b/crates/quicnprotochat-client/tests/noise_transport.rs @@ -13,8 +13,8 @@ use std::sync::Arc; use tokio::net::TcpListener; -use noiseml_core::{NoiseKeypair, handshake_initiator, handshake_responder}; -use noiseml_proto::{MsgType, ParsedEnvelope}; +use quicnprotochat_core::{handshake_initiator, handshake_responder, NoiseKeypair}; +use quicnprotochat_proto::{MsgType, ParsedEnvelope}; /// Completes a full Noise_XX handshake and Ping/Pong exchange, then verifies /// mutual authentication by comparing observed vs. actual static public keys. @@ -96,13 +96,10 @@ async fn noise_xx_ping_pong_round_trip() { .await .expect("client send_envelope failed"); - let pong = tokio::time::timeout( - std::time::Duration::from_secs(5), - transport.recv_envelope(), - ) - .await - .expect("timed out waiting for Pong — server task likely panicked") - .expect("client recv_envelope failed"); + let pong = tokio::time::timeout(std::time::Duration::from_secs(5), transport.recv_envelope()) + .await + .expect("timed out waiting for Pong — server task likely panicked") + .expect("client recv_envelope failed"); match pong.msg_type { MsgType::Pong => {} @@ -135,9 +132,7 @@ async fn noise_xx_ping_pong_round_trip() { async fn two_sequential_connections_both_authenticate() { let server_keypair = Arc::new(NoiseKeypair::generate()); - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind failed"); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind failed"); let server_addr = listener.local_addr().expect("local_addr failed"); let server_kp = Arc::clone(&server_keypair); @@ -186,13 +181,10 @@ async fn two_sequential_connections_both_authenticate() { .await .expect("client send failed"); - let pong = tokio::time::timeout( - std::time::Duration::from_secs(5), - t.recv_envelope(), - ) - .await - .expect("timeout") - .expect("recv failed"); + let pong = tokio::time::timeout(std::time::Duration::from_secs(5), t.recv_envelope()) + .await + .expect("timeout") + .expect("recv failed"); match pong.msg_type { MsgType::Pong => {} diff --git a/crates/noiseml-core/Cargo.toml b/crates/quicnprotochat-core/Cargo.toml similarity index 74% rename from crates/noiseml-core/Cargo.toml rename to crates/quicnprotochat-core/Cargo.toml index 3ec9097..1f5d23a 100644 --- a/crates/noiseml-core/Cargo.toml +++ b/crates/quicnprotochat-core/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "noiseml-core" +name = "quicnprotochat-core" version = "0.1.0" edition = "2021" -description = "Crypto primitives, Noise_XX transport, MLS state machine, and Cap'n Proto frame codec for noiseml." +description = "Crypto primitives, TLS/QUIC transport, MLS state machine, and Cap'n Proto frame codec for quicnprotochat." license = "MIT" [dependencies] @@ -20,10 +20,13 @@ openmls = { workspace = true } openmls_rust_crypto = { workspace = true } openmls_traits = { workspace = true } tls_codec = { workspace = true } +serde = { workspace = true } +bincode = { workspace = true } +serde_json = { workspace = true } # Serialisation capnp = { workspace = true } -noiseml-proto = { path = "../noiseml-proto" } +quicnprotochat-proto = { path = "../quicnprotochat-proto" } # Async runtime + codec tokio = { workspace = true } diff --git a/crates/noiseml-core/src/codec.rs b/crates/quicnprotochat-core/src/codec.rs similarity index 98% rename from crates/noiseml-core/src/codec.rs rename to crates/quicnprotochat-core/src/codec.rs index 63818f4..e35a042 100644 --- a/crates/noiseml-core/src/codec.rs +++ b/crates/quicnprotochat-core/src/codec.rs @@ -92,8 +92,7 @@ impl Decoder for LengthPrefixedCodec { } // Peek at the length without advancing — avoid mutating state on None. - let frame_len = - u32::from_le_bytes([src[0], src[1], src[2], src[3]]) as usize; + let frame_len = u32::from_le_bytes([src[0], src[1], src[2], src[3]]) as usize; if frame_len > NOISE_MAX_MSG { return Err(CodecError::FrameTooLarge { @@ -139,7 +138,7 @@ mod tests { #[test] fn round_trip_small_payload() { - let payload = b"hello noiseml"; + let payload = b"hello quicnprotochat"; let result = encode_then_decode(payload); assert_eq!(&result[..], payload); } diff --git a/crates/noiseml-core/src/error.rs b/crates/quicnprotochat-core/src/error.rs similarity index 98% rename from crates/noiseml-core/src/error.rs rename to crates/quicnprotochat-core/src/error.rs index 2022ac1..6c97bee 100644 --- a/crates/noiseml-core/src/error.rs +++ b/crates/quicnprotochat-core/src/error.rs @@ -1,4 +1,4 @@ -//! Error types for `noiseml-core`. +//! Error types for `quicnprotochat-core`. //! //! Two separate error types are used to preserve type-level separation of concerns: //! diff --git a/crates/noiseml-core/src/group.rs b/crates/quicnprotochat-core/src/group.rs similarity index 88% rename from crates/noiseml-core/src/group.rs rename to crates/quicnprotochat-core/src/group.rs index ac11fc7..2671a5a 100644 --- a/crates/noiseml-core/src/group.rs +++ b/crates/quicnprotochat-core/src/group.rs @@ -3,7 +3,7 @@ //! # Design //! //! [`GroupMember`] wraps an openmls [`MlsGroup`] plus the per-client -//! [`OpenMlsRustCrypto`] backend. The backend is **persistent** — it holds the +//! [`StoreCrypto`] backend. The backend is **persistent** — it holds the //! in-memory key store that maps init-key references to HPKE private keys. //! openmls's `new_from_welcome` reads those private keys from the key store to //! decrypt the Welcome, so the same backend instance must be used from @@ -28,20 +28,22 @@ use std::sync::Arc; use openmls::prelude::{ - Ciphersuite, CryptoConfig, Credential, CredentialType, CredentialWithKey, - GroupId, KeyPackage, KeyPackageIn, MlsGroup, MlsGroupConfig, MlsMessageInBody, - MlsMessageOut, ProcessedMessageContent, ProtocolMessage, ProtocolVersion, - TlsDeserializeTrait, TlsSerializeTrait, + Ciphersuite, Credential, CredentialType, CredentialWithKey, CryptoConfig, GroupId, KeyPackage, + KeyPackageIn, MlsGroup, MlsGroupConfig, MlsMessageInBody, MlsMessageOut, + ProcessedMessageContent, ProtocolMessage, ProtocolVersion, TlsDeserializeTrait, + TlsSerializeTrait, }; -use openmls_rust_crypto::OpenMlsRustCrypto; use openmls_traits::OpenMlsCryptoProvider; -use crate::{error::CoreError, identity::IdentityKeypair}; +use crate::{ + error::CoreError, + identity::IdentityKeypair, + keystore::{DiskKeyStore, StoreCrypto}, +}; // ── Constants ───────────────────────────────────────────────────────────────── -const CIPHERSUITE: Ciphersuite = - Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519; +const CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519; // ── GroupMember ─────────────────────────────────────────────────────────────── @@ -61,7 +63,7 @@ const CIPHERSUITE: Ciphersuite = pub struct GroupMember { /// Persistent crypto backend. Holds the in-memory key store with HPKE /// private keys created during `generate_key_package`. - backend: OpenMlsRustCrypto, + backend: StoreCrypto, /// Long-term Ed25519 identity keypair. Also used as the MLS `Signer`. identity: Arc, /// Active MLS group, if any. @@ -73,16 +75,23 @@ pub struct GroupMember { impl GroupMember { /// Create a new `GroupMember` with a fresh crypto backend. pub fn new(identity: Arc) -> Self { + Self::new_with_state(identity, DiskKeyStore::ephemeral(), None) + } + + /// Create a `GroupMember` from pre-existing state (identity + optional group + store). + pub fn new_with_state( + identity: Arc, + key_store: DiskKeyStore, + group: Option, + ) -> Self { let config = MlsGroupConfig::builder() - // Embed the ratchet tree in Welcome messages so joinees do not - // need an out-of-band tree delivery mechanism. .use_ratchet_tree_extension(true) .build(); Self { - backend: OpenMlsRustCrypto::default(), + backend: StoreCrypto::new(key_store), identity, - group: None, + group, config, } } @@ -195,11 +204,7 @@ impl GroupMember { // Create the Commit + Welcome. The third return value (GroupInfo) is for // external commits and is not needed here. let (commit_out, welcome_out, _group_info) = group - .add_members( - &self.backend, - self.identity.as_ref(), - &[key_package], - ) + .add_members(&self.backend, self.identity.as_ref(), &[key_package]) .map_err(|e| CoreError::Mls(format!("add_members: {e:?}")))?; // Merge the pending Commit into our own state, advancing the epoch. @@ -231,9 +236,8 @@ impl GroupMember { /// [`generate_key_package`]: Self::generate_key_package pub fn join_group(&mut self, welcome_bytes: &[u8]) -> Result<(), CoreError> { // Deserialise MlsMessageIn, then extract the inner Welcome. - let msg_in = - openmls::prelude::MlsMessageIn::tls_deserialize(&mut welcome_bytes.as_ref()) - .map_err(|e| CoreError::Mls(format!("Welcome deserialise: {e:?}")))?; + let msg_in = openmls::prelude::MlsMessageIn::tls_deserialize(&mut welcome_bytes.as_ref()) + .map_err(|e| CoreError::Mls(format!("Welcome deserialise: {e:?}")))?; // into_welcome() is feature-gated in openmls 0.5; extract() is public. let welcome = match msg_in.extract() { @@ -243,13 +247,8 @@ impl GroupMember { // ratchet_tree = None because use_ratchet_tree_extension = true embeds // the tree inside the Welcome's GroupInfo extension. - let group = MlsGroup::new_from_welcome( - &self.backend, - &self.config, - welcome, - None, - ) - .map_err(|e| CoreError::Mls(format!("new_from_welcome: {e:?}")))?; + let group = MlsGroup::new_from_welcome(&self.backend, &self.config, welcome, None) + .map_err(|e| CoreError::Mls(format!("new_from_welcome: {e:?}")))?; self.group = Some(group); Ok(()) @@ -298,9 +297,8 @@ impl GroupMember { .as_mut() .ok_or_else(|| CoreError::Mls("no active group".into()))?; - let msg_in = - openmls::prelude::MlsMessageIn::tls_deserialize(&mut bytes.as_ref()) - .map_err(|e| CoreError::Mls(format!("message deserialise: {e:?}")))?; + let msg_in = openmls::prelude::MlsMessageIn::tls_deserialize(&mut bytes.as_ref()) + .map_err(|e| CoreError::Mls(format!("message deserialise: {e:?}")))?; // into_protocol_message() is feature-gated; extract() + manual construction is not. let protocol_message = match msg_in.extract() { @@ -314,9 +312,7 @@ impl GroupMember { .map_err(|e| CoreError::Mls(format!("process_message: {e:?}")))?; match processed.into_content() { - ProcessedMessageContent::ApplicationMessage(app) => { - Ok(Some(app.into_bytes())) - } + ProcessedMessageContent::ApplicationMessage(app) => Ok(Some(app.into_bytes())), ProcessedMessageContent::StagedCommitMessage(staged) => { // Merge the Commit into the local state (epoch advances). group @@ -350,6 +346,21 @@ impl GroupMember { &self.identity } + /// Return the private seed of the identity (for persistence). + pub fn identity_seed(&self) -> [u8; 32] { + self.identity.seed_bytes() + } + + /// Return a reference to the underlying crypto backend. + pub fn backend(&self) -> &StoreCrypto { + &self.backend + } + + /// Return a reference to the MLS group, if active. + pub fn group_ref(&self) -> Option<&MlsGroup> { + self.group.as_ref() + } + // ── Private helpers ─────────────────────────────────────────────────────── fn make_credential_with_key(&self) -> Result { @@ -385,7 +396,9 @@ mod tests { let bob_kp = bob.generate_key_package().expect("Bob KeyPackage"); // Alice creates the group. - alice.create_group(b"test-group-m3").expect("Alice create group"); + alice + .create_group(b"test-group-m3") + .expect("Alice create group"); // Alice adds Bob → (commit, welcome). // Alice is the sole existing member, so she merges the commit herself. diff --git a/crates/noiseml-core/src/identity.rs b/crates/quicnprotochat-core/src/identity.rs similarity index 74% rename from crates/noiseml-core/src/identity.rs rename to crates/quicnprotochat-core/src/identity.rs index 29f4148..0fb0c9b 100644 --- a/crates/noiseml-core/src/identity.rs +++ b/crates/quicnprotochat-core/src/identity.rs @@ -22,6 +22,7 @@ use ed25519_dalek::{Signer as DalekSigner, SigningKey, VerifyingKey}; use openmls_traits::signatures::Signer; use openmls_traits::types::{Error as MlsError, SignatureScheme}; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use zeroize::Zeroizing; @@ -39,6 +40,23 @@ pub struct IdentityKeypair { verifying: VerifyingKey, } +impl IdentityKeypair { + /// Recreate an identity keypair from a 32-byte seed. + pub fn from_seed(seed: [u8; 32]) -> Self { + let signing = SigningKey::from_bytes(&seed); + let verifying = signing.verifying_key(); + Self { + seed: Zeroizing::new(seed), + verifying, + } + } + + /// Return the raw 32-byte private seed (for persistence). + pub fn seed_bytes(&self) -> [u8; 32] { + *self.seed + } +} + impl IdentityKeypair { /// Generate a fresh random Ed25519 identity keypair. pub fn generate() -> Self { @@ -84,6 +102,29 @@ impl Signer for IdentityKeypair { } } +impl Serialize for IdentityKeypair { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_bytes(&self.seed[..]) + } +} + +impl<'de> Deserialize<'de> for IdentityKeypair { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let bytes: Vec = serde::Deserialize::deserialize(deserializer)?; + let seed: [u8; 32] = bytes + .as_slice() + .try_into() + .map_err(|_| serde::de::Error::custom("identity seed must be 32 bytes"))?; + Ok(IdentityKeypair::from_seed(seed)) + } +} + impl std::fmt::Debug for IdentityKeypair { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let fp = self.fingerprint(); diff --git a/crates/noiseml-core/src/keypackage.rs b/crates/quicnprotochat-core/src/keypackage.rs similarity index 84% rename from crates/noiseml-core/src/keypackage.rs rename to crates/quicnprotochat-core/src/keypackage.rs index 0ded9ec..897b327 100644 --- a/crates/noiseml-core/src/keypackage.rs +++ b/crates/quicnprotochat-core/src/keypackage.rs @@ -14,7 +14,7 @@ //! # Wire format //! //! KeyPackages are TLS-encoded using `tls_codec` (same version as openmls). -//! The resulting bytes are opaque to the noiseml transport layer. +//! The resulting bytes are opaque to the quicnprotochat transport layer. use openmls::prelude::{ Ciphersuite, Credential, CredentialType, CredentialWithKey, CryptoConfig, KeyPackage, @@ -25,9 +25,8 @@ use sha2::{Digest, Sha256}; use crate::{error::CoreError, identity::IdentityKeypair}; -/// The MLS ciphersuite used throughout noiseml. -const CIPHERSUITE: Ciphersuite = - Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519; +/// The MLS ciphersuite used throughout quicnprotochat. +const CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519; /// Generate a fresh MLS KeyPackage for `identity` and serialise it. /// @@ -41,18 +40,13 @@ const CIPHERSUITE: Ciphersuite = /// /// Returns [`CoreError::Mls`] if openmls fails to create the KeyPackage or if /// TLS serialisation fails. -pub fn generate_key_package( - identity: &IdentityKeypair, -) -> Result<(Vec, Vec), CoreError> { +pub fn generate_key_package(identity: &IdentityKeypair) -> Result<(Vec, Vec), CoreError> { let backend = OpenMlsRustCrypto::default(); // Build a BasicCredential using the raw Ed25519 public key bytes as the // MLS identity. Per RFC 9420, any byte string may serve as the identity. - let credential = Credential::new( - identity.public_key_bytes().to_vec(), - CredentialType::Basic, - ) - .map_err(|e| CoreError::Mls(format!("{e:?}")))?; + let credential = Credential::new(identity.public_key_bytes().to_vec(), CredentialType::Basic) + .map_err(|e| CoreError::Mls(format!("{e:?}")))?; // The `signature_key` in CredentialWithKey is the Ed25519 public key that // will be used to verify the KeyPackage's leaf node signature. diff --git a/crates/noiseml-core/src/keypair.rs b/crates/quicnprotochat-core/src/keypair.rs similarity index 98% rename from crates/noiseml-core/src/keypair.rs rename to crates/quicnprotochat-core/src/keypair.rs index 4078e2a..db8bbd0 100644 --- a/crates/noiseml-core/src/keypair.rs +++ b/crates/quicnprotochat-core/src/keypair.rs @@ -113,7 +113,9 @@ mod tests { let private = kp.private_bytes(); // We cannot observe zeroization after drop in a test without unsafe, // but we can confirm the wrapper type is returned and is non-zero. - assert!(private.iter().any(|&b| b != 0), - "freshly generated private key should not be all zeros"); + assert!( + private.iter().any(|&b| b != 0), + "freshly generated private key should not be all zeros" + ); } } diff --git a/crates/quicnprotochat-core/src/keystore.rs b/crates/quicnprotochat-core/src/keystore.rs new file mode 100644 index 0000000..c3ef7e5 --- /dev/null +++ b/crates/quicnprotochat-core/src/keystore.rs @@ -0,0 +1,144 @@ +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, + sync::RwLock, +}; + +use openmls_rust_crypto::RustCrypto; +use openmls_traits::{ + key_store::{MlsEntity, OpenMlsKeyStore}, + OpenMlsCryptoProvider, +}; + +/// A disk-backed key store implementing `OpenMlsKeyStore`. +/// +/// In-memory when `path` is `None`; otherwise flushes the entire map to disk on +/// every store/delete so HPKE init keys survive process restarts. +#[derive(Debug)] +pub struct DiskKeyStore { + path: Option, + values: RwLock, Vec>>, +} + +#[derive(thiserror::Error, Debug, PartialEq, Eq)] +pub enum DiskKeyStoreError { + #[error("serialization error")] + Serialization, + #[error("io error: {0}")] + Io(String), +} + +impl DiskKeyStore { + /// In-memory keystore (no persistence). + pub fn ephemeral() -> Self { + Self { + path: None, + values: RwLock::new(HashMap::new()), + } + } + + /// Persistent keystore backed by `path`. Creates an empty store if missing. + pub fn persistent(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + let values = if path.exists() { + let bytes = fs::read(&path).map_err(|e| DiskKeyStoreError::Io(e.to_string()))?; + if bytes.is_empty() { + HashMap::new() + } else { + bincode::deserialize(&bytes).map_err(|_| DiskKeyStoreError::Serialization)? + } + } else { + HashMap::new() + }; + + Ok(Self { + path: Some(path), + values: RwLock::new(values), + }) + } + + fn flush(&self) -> Result<(), DiskKeyStoreError> { + let Some(path) = &self.path else { + return Ok(()); + }; + let values = self.values.read().unwrap(); + let bytes = bincode::serialize(&*values).map_err(|_| DiskKeyStoreError::Serialization)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| DiskKeyStoreError::Io(e.to_string()))?; + } + fs::write(path, bytes).map_err(|e| DiskKeyStoreError::Io(e.to_string())) + } +} + +impl Default for DiskKeyStore { + fn default() -> Self { + Self::ephemeral() + } +} + +impl OpenMlsKeyStore for DiskKeyStore { + type Error = DiskKeyStoreError; + + fn store(&self, k: &[u8], v: &V) -> Result<(), Self::Error> { + let value = serde_json::to_vec(v).map_err(|_| DiskKeyStoreError::Serialization)?; + let mut values = self.values.write().unwrap(); + values.insert(k.to_vec(), value); + drop(values); + self.flush() + } + + fn read(&self, k: &[u8]) -> Option { + let values = self.values.read().unwrap(); + values + .get(k) + .and_then(|bytes| serde_json::from_slice(bytes).ok()) + } + + fn delete(&self, k: &[u8]) -> Result<(), Self::Error> { + let mut values = self.values.write().unwrap(); + values.remove(k); + drop(values); + self.flush() + } +} + +/// Crypto provider that couples RustCrypto with a disk-backed key store. +#[derive(Debug)] +pub struct StoreCrypto { + crypto: RustCrypto, + key_store: DiskKeyStore, +} + +impl StoreCrypto { + pub fn new(key_store: DiskKeyStore) -> Self { + Self { + crypto: RustCrypto::default(), + key_store, + } + } +} + +impl Default for StoreCrypto { + fn default() -> Self { + Self::new(DiskKeyStore::ephemeral()) + } +} + +impl OpenMlsCryptoProvider for StoreCrypto { + type CryptoProvider = RustCrypto; + type RandProvider = RustCrypto; + type KeyStoreProvider = DiskKeyStore; + + fn crypto(&self) -> &Self::CryptoProvider { + &self.crypto + } + + fn rand(&self) -> &Self::RandProvider { + &self.crypto + } + + fn key_store(&self) -> &Self::KeyStoreProvider { + &self.key_store + } +} diff --git a/crates/noiseml-core/src/lib.rs b/crates/quicnprotochat-core/src/lib.rs similarity index 94% rename from crates/noiseml-core/src/lib.rs rename to crates/quicnprotochat-core/src/lib.rs index fedf30d..f53fc36 100644 --- a/crates/noiseml-core/src/lib.rs +++ b/crates/quicnprotochat-core/src/lib.rs @@ -1,5 +1,5 @@ //! Core cryptographic primitives, Noise_XX transport, MLS group state machine, -//! and frame codec for noiseml. +//! and frame codec for quicnprotochat. //! //! # Module layout //! @@ -17,8 +17,9 @@ mod codec; mod error; mod group; mod identity; -mod keypair; mod keypackage; +mod keypair; +mod keystore; mod noise; // ── Public API ──────────────────────────────────────────────────────────────── @@ -27,6 +28,7 @@ pub use codec::{LengthPrefixedCodec, NOISE_MAX_MSG}; pub use error::{CodecError, CoreError, MAX_PLAINTEXT_LEN}; pub use group::GroupMember; pub use identity::IdentityKeypair; -pub use keypair::NoiseKeypair; pub use keypackage::generate_key_package; +pub use keypair::NoiseKeypair; +pub use keystore::DiskKeyStore; pub use noise::{handshake_initiator, handshake_responder, NoiseTransport}; diff --git a/crates/noiseml-core/src/noise.rs b/crates/quicnprotochat-core/src/noise.rs similarity index 95% rename from crates/noiseml-core/src/noise.rs rename to crates/quicnprotochat-core/src/noise.rs index fd81362..f114822 100644 --- a/crates/noiseml-core/src/noise.rs +++ b/crates/quicnprotochat-core/src/noise.rs @@ -32,7 +32,7 @@ use bytes::Bytes; use futures::{SinkExt, StreamExt}; use tokio::{ - io::{AsyncReadExt, AsyncWriteExt, DuplexStream, ReadHalf, WriteHalf, duplex}, + io::{duplex, AsyncReadExt, AsyncWriteExt, DuplexStream, ReadHalf, WriteHalf}, net::TcpStream, }; use tokio_util::codec::Framed; @@ -42,9 +42,9 @@ use crate::{ error::{CoreError, MAX_PLAINTEXT_LEN}, keypair::NoiseKeypair, }; -use noiseml_proto::{parse_envelope, build_envelope, ParsedEnvelope}; +use quicnprotochat_proto::{build_envelope, parse_envelope, ParsedEnvelope}; -/// Noise parameters used throughout noiseml. +/// Noise parameters used throughout quicnprotochat. /// /// `Noise_XX_25519_ChaChaPoly_BLAKE2s` — both parties authenticate each /// other's static X25519 keys; ChaCha20-Poly1305 for AEAD; BLAKE2s as PRF. @@ -144,7 +144,7 @@ impl NoiseTransport { /// Serialise and encrypt a [`ParsedEnvelope`], then send it. /// /// This is the primary application-level send method. The Cap'n Proto - /// encoding is done by [`noiseml_proto::build_envelope`] before encryption. + /// encoding is done by [`quicnprotochat_proto::build_envelope`] before encryption. pub async fn send_envelope(&mut self, env: &ParsedEnvelope) -> Result<(), CoreError> { let bytes = build_envelope(env).map_err(CoreError::Capnp)?; self.send_frame(&bytes).await @@ -244,9 +244,10 @@ impl NoiseTransport { impl std::fmt::Debug for NoiseTransport { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let remote = self.remote_static.as_deref().map(|k| { - format!("{:02x}{:02x}{:02x}{:02x}…", k[0], k[1], k[2], k[3]) - }); + let remote = self + .remote_static + .as_deref() + .map(|k| format!("{:02x}{:02x}{:02x}{:02x}…", k[0], k[1], k[2], k[3])); f.debug_struct("NoiseTransport") .field("remote_static", &remote) .finish_non_exhaustive() @@ -270,9 +271,9 @@ pub async fn handshake_initiator( stream: TcpStream, keypair: &NoiseKeypair, ) -> Result { - let params: snow::params::NoiseParams = NOISE_PARAMS.parse().expect( - "NOISE_PARAMS is a compile-time constant and must parse successfully", - ); + let params: snow::params::NoiseParams = NOISE_PARAMS + .parse() + .expect("NOISE_PARAMS is a compile-time constant and must parse successfully"); // The private key bytes are held in a Zeroizing wrapper and cleared after // snow clones them internally during build_initiator(). @@ -337,9 +338,9 @@ pub async fn handshake_responder( stream: TcpStream, keypair: &NoiseKeypair, ) -> Result { - let params: snow::params::NoiseParams = NOISE_PARAMS.parse().expect( - "NOISE_PARAMS is a compile-time constant and must parse successfully", - ); + let params: snow::params::NoiseParams = NOISE_PARAMS + .parse() + .expect("NOISE_PARAMS is a compile-time constant and must parse successfully"); let private = keypair.private_bytes(); let mut session = snow::Builder::new(params) diff --git a/crates/noiseml-proto/Cargo.toml b/crates/quicnprotochat-proto/Cargo.toml similarity index 78% rename from crates/noiseml-proto/Cargo.toml rename to crates/quicnprotochat-proto/Cargo.toml index cc4da19..7e6d8d0 100644 --- a/crates/noiseml-proto/Cargo.toml +++ b/crates/quicnprotochat-proto/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "noiseml-proto" +name = "quicnprotochat-proto" version = "0.1.0" edition = "2021" -description = "Cap'n Proto schemas, generated types, and serialisation helpers for noiseml. No crypto, no I/O." +description = "Cap'n Proto schemas, generated types, and serialisation helpers for quicnprotochat. No crypto, no I/O." license = "MIT" # build.rs invokes capnpc to generate Rust source from .capnp schemas. diff --git a/crates/noiseml-proto/build.rs b/crates/quicnprotochat-proto/build.rs similarity index 78% rename from crates/noiseml-proto/build.rs rename to crates/quicnprotochat-proto/build.rs index b4627e0..54c3d3d 100644 --- a/crates/noiseml-proto/build.rs +++ b/crates/quicnprotochat-proto/build.rs @@ -1,4 +1,4 @@ -//! Build script for noiseml-proto. +//! Build script for quicnprotochat-proto. //! //! Invokes the `capnp` compiler to generate Rust types from `.capnp` schemas //! located in the workspace-root `schemas/` directory. @@ -14,11 +14,10 @@ use std::{env, path::PathBuf}; fn main() { - let manifest_dir = PathBuf::from( - env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set by Cargo"), - ); + let manifest_dir = + PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set by Cargo")); - // Workspace root is two levels above this crate (noiseml/crates/noiseml-proto). + // Workspace root is two levels above this crate (quicnprotochat/crates/quicnprotochat-proto). let workspace_root = manifest_dir .join("../..") .canonicalize() @@ -39,6 +38,10 @@ fn main() { "cargo:rerun-if-changed={}", schemas_dir.join("delivery.capnp").display() ); + println!( + "cargo:rerun-if-changed={}", + schemas_dir.join("node.capnp").display() + ); capnpc::CompilerCommand::new() // Treat `schemas/` as the include root so that inter-schema imports @@ -47,6 +50,7 @@ fn main() { .file(schemas_dir.join("envelope.capnp")) .file(schemas_dir.join("auth.capnp")) .file(schemas_dir.join("delivery.capnp")) + .file(schemas_dir.join("node.capnp")) .run() .expect( "Cap'n Proto schema compilation failed. \ diff --git a/crates/noiseml-proto/src/lib.rs b/crates/quicnprotochat-proto/src/lib.rs similarity index 94% rename from crates/noiseml-proto/src/lib.rs rename to crates/quicnprotochat-proto/src/lib.rs index 92cc06a..30ea88a 100644 --- a/crates/noiseml-proto/src/lib.rs +++ b/crates/quicnprotochat-proto/src/lib.rs @@ -1,4 +1,4 @@ -//! Cap'n Proto schemas, generated types, and serialisation helpers for noiseml. +//! Cap'n Proto schemas, generated types, and serialisation helpers for quicnprotochat. //! //! # Design constraints //! @@ -41,11 +41,18 @@ pub mod delivery_capnp { include!(concat!(env!("OUT_DIR"), "/delivery_capnp.rs")); } +/// Cap'n Proto generated types for `schemas/node.capnp`. +/// +/// Do not edit this module by hand — it is entirely machine-generated. +pub mod node_capnp { + include!(concat!(env!("OUT_DIR"), "/node_capnp.rs")); +} + // ── Re-exports ──────────────────────────────────────────────────────────────── /// The message-type discriminant from the `Envelope` schema. /// -/// Re-exported here so callers can `use noiseml_proto::MsgType` without +/// Re-exported here so callers can `use quicnprotochat_proto::MsgType` without /// spelling out the full generated module path. pub use envelope_capnp::envelope::MsgType; @@ -80,7 +87,7 @@ pub struct ParsedEnvelope { /// /// The returned bytes include the Cap'n Proto segment table header followed by /// the message data. They are suitable for use as the body of a length-prefixed -/// noiseml frame (the frame codec in `noiseml-core` prepends the 4-byte length). +/// quicnprotochat frame (the frame codec in `quicnprotochat-core` prepends the 4-byte length). /// /// # Errors /// @@ -135,7 +142,7 @@ pub fn parse_envelope(bytes: &[u8]) -> Result { /// Serialise a Cap'n Proto message builder to unpacked wire bytes. /// /// The output includes the segment table header. For transport, the -/// `noiseml-core` frame codec prepends a 4-byte little-endian length field. +/// `quicnprotochat-core` frame codec prepends a 4-byte little-endian length field. pub fn to_bytes( msg: &capnp::message::Builder, ) -> Result, capnp::Error> { diff --git a/crates/noiseml-server/Cargo.toml b/crates/quicnprotochat-server/Cargo.toml similarity index 56% rename from crates/noiseml-server/Cargo.toml rename to crates/quicnprotochat-server/Cargo.toml index c086ac2..8318622 100644 --- a/crates/noiseml-server/Cargo.toml +++ b/crates/quicnprotochat-server/Cargo.toml @@ -1,17 +1,17 @@ [package] -name = "noiseml-server" +name = "quicnprotochat-server" version = "0.1.0" edition = "2021" -description = "Delivery Service and Authentication Service for noiseml." +description = "Delivery Service and Authentication Service for quicnprotochat." license = "MIT" [[bin]] -name = "noiseml-server" +name = "quicnprotochat-server" path = "src/main.rs" [dependencies] -noiseml-core = { path = "../noiseml-core" } -noiseml-proto = { path = "../noiseml-proto" } +quicnprotochat-core = { path = "../quicnprotochat-core" } +quicnprotochat-proto = { path = "../quicnprotochat-proto" } # Serialisation + RPC capnp = { workspace = true } @@ -27,10 +27,16 @@ dashmap = { workspace = true } sha2 = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +quinn = { workspace = true } +quinn-proto = { workspace = true } +rustls = { workspace = true } +rcgen = { workspace = true } # Error handling anyhow = { workspace = true } thiserror = { workspace = true } +bincode = { workspace = true } +serde = { workspace = true } # CLI clap = { workspace = true } diff --git a/crates/quicnprotochat-server/src/main.rs b/crates/quicnprotochat-server/src/main.rs new file mode 100644 index 0000000..f1ec544 --- /dev/null +++ b/crates/quicnprotochat-server/src/main.rs @@ -0,0 +1,508 @@ +//! quicnprotochat-server — unified Authentication + Delivery service. +//! +//! # M3 scope +//! +//! The server exposes a single QUIC + TLS 1.3 Cap'n Proto RPC endpoint +//! (`NodeService`) combining Authentication and Delivery operations. +//! +//! # Architecture +//! +//! ```text +//! QUIC endpoint (7000) +//! └─ TLS 1.3 handshake (self-signed by default) +//! └─ capnp-rpc VatNetwork (LocalSet, !Send) +//! └─ NodeServiceImpl (KeyPackage + Delivery queues) +//! ``` +//! +//! Because `capnp-rpc` uses `Rc>` internally it is `!Send`. +//! The entire RPC stack lives on a `tokio::task::LocalSet` spawned per +//! connection. +//! +//! # Configuration +//! +//! | Env var | CLI flag | Default | +//! |---------------------|----------------|-----------------| +//! | `QUICNPROTOCHAT_LISTEN` | `--listen` | `0.0.0.0:7000` | +//! | `RUST_LOG` | — | `info` | + +use std::{fs, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; + +use anyhow::Context; +use capnp::capability::Promise; +use capnp_rpc::{rpc_twoparty_capnp::Side, twoparty, RpcSystem}; +use clap::Parser; +use dashmap::DashMap; +use quicnprotochat_proto::node_capnp::node_service; +use quinn::{Endpoint, ServerConfig}; +use quinn_proto::crypto::rustls::QuicServerConfig; +use rcgen::generate_simple_self_signed; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use sha2::{Digest, Sha256}; +use tokio::sync::Notify; +use tokio::time::timeout; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +mod storage; +use storage::{FileBackedStore, StorageError}; + +// ── CLI ─────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Parser)] +#[command( + name = "quicnprotochat-server", + about = "quicnprotochat Delivery Service + Authentication Service", + version +)] +struct Args { + /// QUIC listen address (host:port). + #[arg(long, default_value = "0.0.0.0:7000", env = "QUICNPROTOCHAT_LISTEN")] + listen: String, + + /// Directory for persisted server data (KeyPackages + delivery queues). + #[arg(long, default_value = "data", env = "QUICNPROTOCHAT_DATA_DIR")] + data_dir: String, + + /// TLS certificate path (generated automatically if missing). + #[arg( + long, + default_value = "data/server-cert.der", + env = "QUICNPROTOCHAT_TLS_CERT" + )] + tls_cert: PathBuf, + + /// TLS private key path (generated automatically if missing). + #[arg( + long, + default_value = "data/server-key.der", + env = "QUICNPROTOCHAT_TLS_KEY" + )] + tls_key: PathBuf, +} + +// ── Node service implementation ───────────────────────────────────────────── + +/// Cap'n Proto RPC server implementation for `NodeService` (Auth + Delivery). +struct NodeServiceImpl { + store: Arc, + waiters: Arc, Arc>>, +} + +impl NodeServiceImpl { + fn waiter(&self, recipient_key: &[u8]) -> Arc { + self.waiters + .entry(recipient_key.to_vec()) + .or_insert_with(|| Arc::new(Notify::new())) + .clone() + } +} + +impl node_service::Server for NodeServiceImpl { + /// Upload a single-use KeyPackage and return its SHA-256 fingerprint. + fn upload_key_package( + &mut self, + params: node_service::UploadKeyPackageParams, + mut results: node_service::UploadKeyPackageResults, + ) -> Promise<(), capnp::Error> { + let params = params + .get() + .map_err(|e| capnp::Error::failed(format!("upload_key_package: bad params: {e}"))); + + let (identity_key, package) = match params { + Ok(p) => { + let ik = match p.get_identity_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + let pkg = match p.get_package() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + (ik, pkg) + } + Err(e) => return Promise::err(e), + }; + + if identity_key.len() != 32 { + return Promise::err(capnp::Error::failed(format!( + "identityKey must be exactly 32 bytes, got {}", + identity_key.len() + ))); + } + if package.is_empty() { + return Promise::err(capnp::Error::failed( + "package must not be empty".to_string(), + )); + } + + let fingerprint: Vec = Sha256::digest(&package).to_vec(); + if let Err(e) = self + .store + .upload_key_package(&identity_key, package) + .map_err(storage_err) + { + return Promise::err(e); + } + + results.get().set_fingerprint(&fingerprint); + + tracing::debug!( + fingerprint = %fmt_hex(&fingerprint[..4]), + "KeyPackage uploaded" + ); + + Promise::ok(()) + } + + /// Atomically remove and return one KeyPackage for the given identity key. + fn fetch_key_package( + &mut self, + params: node_service::FetchKeyPackageParams, + mut results: node_service::FetchKeyPackageResults, + ) -> Promise<(), capnp::Error> { + let identity_key = match params.get() { + Ok(p) => match p.get_identity_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }, + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + + if identity_key.len() != 32 { + return Promise::err(capnp::Error::failed(format!( + "identityKey must be exactly 32 bytes, got {}", + identity_key.len() + ))); + } + + let package = match self + .store + .fetch_key_package(&identity_key) + .map_err(storage_err) + { + Ok(p) => p, + Err(e) => return Promise::err(e), + }; + + match package { + Some(pkg) => { + tracing::debug!( + identity = %fmt_hex(&identity_key[..4]), + "KeyPackage fetched" + ); + results.get().set_package(&pkg); + } + None => { + tracing::debug!( + identity = %fmt_hex(&identity_key[..4]), + "no KeyPackage available for identity" + ); + results.get().set_package(&[]); + } + } + + Promise::ok(()) + } + + /// Append `payload` to the queue for `recipient_key`. + fn enqueue( + &mut self, + params: node_service::EnqueueParams, + _results: node_service::EnqueueResults, + ) -> Promise<(), capnp::Error> { + let p = match params.get() { + Ok(p) => p, + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + let recipient_key = match p.get_recipient_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + let payload = match p.get_payload() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + + if recipient_key.len() != 32 { + return Promise::err(capnp::Error::failed(format!( + "recipientKey must be exactly 32 bytes, got {}", + recipient_key.len() + ))); + } + if payload.is_empty() { + return Promise::err(capnp::Error::failed( + "payload must not be empty".to_string(), + )); + } + + if let Err(e) = self + .store + .enqueue(&recipient_key, payload) + .map_err(storage_err) + { + return Promise::err(e); + } + + self.waiter(&recipient_key).notify_waiters(); + + tracing::debug!( + recipient = %fmt_hex(&recipient_key[..4]), + "message enqueued" + ); + + Promise::ok(()) + } + + /// Atomically drain and return all queued payloads for `recipient_key`. + fn fetch( + &mut self, + params: node_service::FetchParams, + mut results: node_service::FetchResults, + ) -> Promise<(), capnp::Error> { + let recipient_key = match params.get() { + Ok(p) => match p.get_recipient_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }, + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + + if recipient_key.len() != 32 { + return Promise::err(capnp::Error::failed(format!( + "recipientKey must be exactly 32 bytes, got {}", + recipient_key.len() + ))); + } + + let messages = match self.store.fetch(&recipient_key).map_err(storage_err) { + Ok(m) => m, + Err(e) => return Promise::err(e), + }; + + tracing::debug!( + recipient = %fmt_hex(&recipient_key[..4]), + count = messages.len(), + "messages fetched" + ); + + let mut list = results.get().init_payloads(messages.len() as u32); + for (i, msg) in messages.iter().enumerate() { + list.set(i as u32, msg); + } + + Promise::ok(()) + } + + /// Long-polling fetch with timeout (ms). + fn fetch_wait( + &mut self, + params: node_service::FetchWaitParams, + mut results: node_service::FetchWaitResults, + ) -> Promise<(), capnp::Error> { + let p = match params.get() { + Ok(p) => p, + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + let recipient_key = match p.get_recipient_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + let timeout_ms = p.get_timeout_ms(); + + if recipient_key.len() != 32 { + return Promise::err(capnp::Error::failed(format!( + "recipientKey must be exactly 32 bytes, got {}", + recipient_key.len() + ))); + } + + let store = Arc::clone(&self.store); + let waiters = self.waiters.clone(); + + Promise::from_future(async move { + let messages = store.fetch(&recipient_key).map_err(storage_err)?; + + if messages.is_empty() && timeout_ms > 0 { + let waiter = waiters + .entry(recipient_key.clone()) + .or_insert_with(|| Arc::new(Notify::new())) + .clone(); + let _ = timeout(Duration::from_millis(timeout_ms), waiter.notified()).await; + let msgs = store.fetch(&recipient_key).map_err(storage_err)?; + fill_payloads_wait(&mut results, msgs); + return Ok(()); + } + + fill_payloads_wait(&mut results, messages); + Ok(()) + }) + } + + fn health( + &mut self, + _params: node_service::HealthParams, + mut results: node_service::HealthResults, + ) -> Promise<(), capnp::Error> { + results.get().set_status("ok"); + Promise::ok(()) + } +} + +fn fill_payloads_wait(results: &mut node_service::FetchWaitResults, messages: Vec>) { + let mut list = results.get().init_payloads(messages.len() as u32); + for (i, msg) in messages.iter().enumerate() { + list.set(i as u32, msg); + } +} + +fn storage_err(err: StorageError) -> capnp::Error { + capnp::Error::failed(format!("{err}")) +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let args = Args::parse(); + + let listen: SocketAddr = args.listen.parse().context("--listen must be host:port")?; + + let server_config = build_server_config(&args.tls_cert, &args.tls_key) + .context("failed to build TLS/QUIC server config")?; + + // Shared storage — persisted to disk for restart safety. + let store = Arc::new(FileBackedStore::open(&args.data_dir)?); + let waiters: Arc, Arc>> = Arc::new(DashMap::new()); + + let endpoint = Endpoint::server(server_config, listen)?; + + tracing::info!( + addr = %args.listen, + "accepting QUIC connections" + ); + + // capnp-rpc is !Send (Rc internals), so all RPC tasks must stay on a + // LocalSet. Both accept loops share one LocalSet. + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { + loop { + let incoming = match endpoint.accept().await { + Some(i) => i, + None => break, + }; + + let connecting = match incoming.accept() { + Ok(c) => c, + Err(e) => { + tracing::warn!(error = %e, "failed to accept incoming connection"); + continue; + } + }; + + let store = Arc::clone(&store); + let waiters = Arc::clone(&waiters); + tokio::task::spawn_local(async move { + if let Err(e) = handle_node_connection(connecting, store, waiters).await { + tracing::warn!(error = %e, "connection error"); + } + }); + } + + Ok::<(), anyhow::Error>(()) + }) + .await +} + +// ── Per-connection handlers ─────────────────────────────────────────────────── + +/// Handle one NodeService connection. +async fn handle_node_connection( + connecting: quinn::Connecting, + store: Arc, + waiters: Arc, Arc>>, +) -> Result<(), anyhow::Error> { + let connection = connecting.await?; + + tracing::info!(peer = %connection.remote_address(), "QUIC connected"); + + let (send, recv) = connection + .accept_bi() + .await + .map_err(|e| anyhow::anyhow!("failed to accept bi stream: {e}"))?; + let (reader, writer) = (recv.compat(), send.compat_write()); + + let network = twoparty::VatNetwork::new(reader, writer, Side::Server, Default::default()); + + let service: node_service::Client = capnp_rpc::new_client(NodeServiceImpl { store, waiters }); + + RpcSystem::new(Box::new(network), Some(service.client)) + .await + .map_err(|e| anyhow::anyhow!("NodeService RPC error: {e}")) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Format the first `n` bytes of a slice as lowercase hex with a trailing `…`. +fn fmt_hex(bytes: &[u8]) -> String { + let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + format!("{hex}…") +} + +/// Ensure a self-signed certificate exists on disk and return a QUIC server config. +fn build_server_config(cert_path: &PathBuf, key_path: &PathBuf) -> anyhow::Result { + if !cert_path.exists() || !key_path.exists() { + generate_self_signed(cert_path, key_path)?; + } + + let cert_bytes = fs::read(cert_path).context("read cert")?; + let key_bytes = fs::read(key_path).context("read key")?; + + let cert_chain = vec![CertificateDer::from(cert_bytes)]; + let key = PrivateKeyDer::try_from(key_bytes).map_err(|_| anyhow::anyhow!("invalid key"))?; + + let mut tls = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(cert_chain, key)?; + tls.alpn_protocols = vec![b"capnp".to_vec()]; + + let crypto = QuicServerConfig::try_from(tls) + .map_err(|e| anyhow::anyhow!("invalid server TLS config: {e}"))?; + + Ok(ServerConfig::with_crypto(Arc::new(crypto))) +} + +fn generate_self_signed(cert_path: &PathBuf, key_path: &PathBuf) -> anyhow::Result<()> { + if let Some(parent) = cert_path.parent() { + fs::create_dir_all(parent).context("create cert dir")?; + } + if let Some(parent) = key_path.parent() { + fs::create_dir_all(parent).context("create key dir")?; + } + + let subject_alt_names = vec![ + "localhost".to_string(), + "127.0.0.1".to_string(), + "::1".to_string(), + ]; + + let issued = generate_simple_self_signed(subject_alt_names)?; + let key_der = issued.key_pair.serialize_der(); + + fs::write(cert_path, issued.cert.der()).context("write cert")?; + fs::write(key_path, &key_der).context("write key")?; + + tracing::info!( + cert = %cert_path.display(), + key = %key_path.display(), + "generated self-signed TLS certificate" + ); + + Ok(()) +} diff --git a/crates/quicnprotochat-server/src/storage.rs b/crates/quicnprotochat-server/src/storage.rs new file mode 100644 index 0000000..d8a0643 --- /dev/null +++ b/crates/quicnprotochat-server/src/storage.rs @@ -0,0 +1,114 @@ +use std::{ + collections::{HashMap, VecDeque}, + fs, + path::{Path, PathBuf}, + sync::Mutex, +}; + +use serde::{Deserialize, Serialize}; + +#[derive(thiserror::Error, Debug)] +pub enum StorageError { + #[error("io error: {0}")] + Io(String), + #[error("serialization error")] + Serde, +} + +#[derive(Serialize, Deserialize, Default)] +struct QueueMap { + map: HashMap, VecDeque>>, +} + +/// File-backed storage for KeyPackages and delivery queues. +/// +/// Each mutation flushes the entire map to disk. Suitable for MVP-scale loads. +pub struct FileBackedStore { + kp_path: PathBuf, + ds_path: PathBuf, + key_packages: Mutex, VecDeque>>>, + deliveries: Mutex, VecDeque>>>, +} + +impl FileBackedStore { + pub fn open(dir: impl AsRef) -> Result { + let dir = dir.as_ref(); + if !dir.exists() { + fs::create_dir_all(dir).map_err(|e| StorageError::Io(e.to_string()))?; + } + let kp_path = dir.join("keypackages.bin"); + let ds_path = dir.join("deliveries.bin"); + + let key_packages = Mutex::new(Self::load_map(&kp_path)?); + let deliveries = Mutex::new(Self::load_map(&ds_path)?); + + Ok(Self { + kp_path, + ds_path, + key_packages, + deliveries, + }) + } + + pub fn upload_key_package( + &self, + identity_key: &[u8], + package: Vec, + ) -> Result<(), StorageError> { + let mut map = self.key_packages.lock().unwrap(); + map.entry(identity_key.to_vec()) + .or_default() + .push_back(package); + self.flush_map(&self.kp_path, &*map) + } + + pub fn fetch_key_package(&self, identity_key: &[u8]) -> Result>, StorageError> { + let mut map = self.key_packages.lock().unwrap(); + let package = map.get_mut(identity_key).and_then(|q| q.pop_front()); + self.flush_map(&self.kp_path, &*map)?; + Ok(package) + } + + pub fn enqueue(&self, recipient_key: &[u8], payload: Vec) -> Result<(), StorageError> { + let mut map = self.deliveries.lock().unwrap(); + map.entry(recipient_key.to_vec()) + .or_default() + .push_back(payload); + self.flush_map(&self.ds_path, &*map) + } + + pub fn fetch(&self, recipient_key: &[u8]) -> Result>, StorageError> { + let mut map = self.deliveries.lock().unwrap(); + let messages = map + .get_mut(recipient_key) + .map(|q| q.drain(..).collect()) + .unwrap_or_default(); + self.flush_map(&self.ds_path, &*map)?; + Ok(messages) + } + + fn load_map(path: &Path) -> Result, VecDeque>>, StorageError> { + if !path.exists() { + return Ok(HashMap::new()); + } + let bytes = fs::read(path).map_err(|e| StorageError::Io(e.to_string()))?; + if bytes.is_empty() { + return Ok(HashMap::new()); + } + let map: QueueMap = bincode::deserialize(&bytes).map_err(|_| StorageError::Serde)?; + Ok(map.map) + } + + fn flush_map( + &self, + path: &Path, + map: &HashMap, VecDeque>>, + ) -> Result<(), StorageError> { + let payload = QueueMap { map: map.clone() }; + let bytes = bincode::serialize(&payload).map_err(|_| StorageError::Serde)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| StorageError::Io(e.to_string()))?; + } + fs::write(path, bytes).map_err(|e| StorageError::Io(e.to_string())) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 96bfbc1..eb94c94 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: - "7000:7000" environment: RUST_LOG: "info" - NOISEML_LISTEN: "0.0.0.0:7000" + QUICNPROTOCHAT_LISTEN: "0.0.0.0:7000" # Healthcheck: attempt a TCP connection to port 7000. # Uses bash /dev/tcp — available in debian:bookworm-slim without extra packages. healthcheck: diff --git a/docker/Dockerfile b/docker/Dockerfile index 04ecf1b..b05d35d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -12,40 +12,40 @@ WORKDIR /build # Copy manifests first so dependency layers are cached independently of source. COPY Cargo.toml Cargo.lock ./ -COPY crates/noiseml-core/Cargo.toml crates/noiseml-core/Cargo.toml -COPY crates/noiseml-proto/Cargo.toml crates/noiseml-proto/Cargo.toml -COPY crates/noiseml-server/Cargo.toml crates/noiseml-server/Cargo.toml -COPY crates/noiseml-client/Cargo.toml crates/noiseml-client/Cargo.toml +COPY crates/quicnprotochat-core/Cargo.toml crates/quicnprotochat-core/Cargo.toml +COPY crates/quicnprotochat-proto/Cargo.toml crates/quicnprotochat-proto/Cargo.toml +COPY crates/quicnprotochat-server/Cargo.toml crates/quicnprotochat-server/Cargo.toml +COPY crates/quicnprotochat-client/Cargo.toml crates/quicnprotochat-client/Cargo.toml # Create dummy source files so `cargo build` can resolve the dependency graph # and cache the compiled dependencies before copying real source. RUN mkdir -p \ - crates/noiseml-core/src \ - crates/noiseml-proto/src \ - crates/noiseml-server/src \ - crates/noiseml-client/src \ - && echo 'fn main() {}' > crates/noiseml-server/src/main.rs \ - && echo 'fn main() {}' > crates/noiseml-client/src/main.rs \ - && touch crates/noiseml-core/src/lib.rs \ - && touch crates/noiseml-proto/src/lib.rs + crates/quicnprotochat-core/src \ + crates/quicnprotochat-proto/src \ + crates/quicnprotochat-server/src \ + crates/quicnprotochat-client/src \ + && echo 'fn main() {}' > crates/quicnprotochat-server/src/main.rs \ + && echo 'fn main() {}' > crates/quicnprotochat-client/src/main.rs \ + && touch crates/quicnprotochat-core/src/lib.rs \ + && touch crates/quicnprotochat-proto/src/lib.rs # Schemas must exist before the proto crate's build.rs runs. COPY schemas/ schemas/ # Build dependencies only (source stubs mean this layer is cache-friendly). -RUN cargo build --release --bin noiseml-server 2>/dev/null || true +RUN cargo build --release --bin quicnprotochat-server 2>/dev/null || true # Copy real source and build for real. COPY crates/ crates/ # Touch main.rs files to force re-compilation of the binary crates. RUN touch \ - crates/noiseml-core/src/lib.rs \ - crates/noiseml-proto/src/lib.rs \ - crates/noiseml-server/src/main.rs \ - crates/noiseml-client/src/main.rs + crates/quicnprotochat-core/src/lib.rs \ + crates/quicnprotochat-proto/src/lib.rs \ + crates/quicnprotochat-server/src/main.rs \ + crates/quicnprotochat-client/src/main.rs -RUN cargo build --release --bin noiseml-server +RUN cargo build --release --bin quicnprotochat-server # ── Stage 2: Runtime ────────────────────────────────────────────────────────── # @@ -58,14 +58,14 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* -COPY --from=builder /build/target/release/noiseml-server /usr/local/bin/noiseml-server +COPY --from=builder /build/target/release/quicnprotochat-server /usr/local/bin/quicnprotochat-server EXPOSE 7000 ENV RUST_LOG=info \ - NOISEML_LISTEN=0.0.0.0:7000 + QUICNPROTOCHAT_LISTEN=0.0.0.0:7000 # Run as a non-root user. USER nobody -CMD ["noiseml-server"] +CMD ["quicnprotochat-server"] diff --git a/master-prompt.md b/master-prompt.md index de3c109..2e6d385 100644 --- a/master-prompt.md +++ b/master-prompt.md @@ -1,8 +1,8 @@ -# noiseml — Master Project Prompt +# quicnprotochat — Master Project Prompt ## Project Identity -You are building **noiseml**, a production-grade end-to-end encrypted group messenger in Rust. It uses the MLS protocol (RFC 9420) for group key agreement, ML-KEM-768 (NIST FIPS 203) hybrid post-quantum key exchange, the Noise Protocol Framework (Noise_XX pattern) over raw TCP as the transport layer, and Cap'n Proto for wire serialisation and RPC. There is no TLS, no HTTP, no WebSocket, no MessagePack. +You are building **quicnprotochat**, a production-grade end-to-end encrypted group messenger in Rust. It uses the MLS protocol (RFC 9420) for group key agreement, ML-KEM-768 (NIST FIPS 203) hybrid post-quantum key exchange, the Noise Protocol Framework (Noise_XX pattern) over raw TCP as the transport layer, and Cap'n Proto for wire serialisation and RPC. There is no TLS, no HTTP, no WebSocket, no MessagePack. This is not a prototype. Every milestone produces production-ready, tested, deployable code. @@ -35,13 +35,13 @@ This is not a prototype. Every milestone produces production-ready, tested, depl ### Workspace Layout ``` -noiseml/ +quicnprotochat/ ├── Cargo.toml # workspace root ├── crates/ -│ ├── noiseml-core/ # crypto primitives, MLS wrapper, Noise framing codec -│ ├── noiseml-proto/ # Cap'n Proto schemas + generated types, no crypto, no I/O -│ ├── noiseml-server/ # Delivery Service (DS) + Authentication Service (AS) -│ └── noiseml-client/ # CLI client +│ ├── quicnprotochat-core/ # crypto primitives, MLS wrapper, Noise framing codec +│ ├── quicnprotochat-proto/ # Cap'n Proto schemas + generated types, no crypto, no I/O +│ ├── quicnprotochat-server/ # Delivery Service (DS) + Authentication Service (AS) +│ └── quicnprotochat-client/ # CLI client ├── schemas/ # .capnp schema files (canonical source of truth) │ ├── envelope.capnp │ ├── auth.capnp @@ -55,31 +55,31 @@ noiseml/ ### Crate Responsibilities -**noiseml-core** +**quicnprotochat-core** - Noise_XX handshake initiator and responder (via `snow`) - Length-prefixed Cap'n Proto frame codec (Tokio `Encoder`/`Decoder` traits) - MLS group state machine wrapper around `openmls` - Hybrid PQ ciphersuite (X25519 + ML-KEM-768) - Key generation and zeroize-on-drop key types -**noiseml-proto** +**quicnprotochat-proto** - Cap'n Proto `.capnp` schemas in `schemas/` (workspace root, shared) - `build.rs` invokes `capnpc` to generate Rust types into `src/generated/` - Re-exports generated types with ergonomic builder/reader helpers - Canonical serialisation helpers for signing (uses `capnp::message::Builder::canonicalize()`) - No crypto, no I/O, no async -**noiseml-server** +**quicnprotochat-server** - Authentication Service: KeyPackage store (DashMap → SQLite at M6) - Delivery Service: Cap'n Proto RPC interface, fan-out router, per-group append-only message log - Tokio TCP listener, Noise handshake per connection, then Cap'n Proto RPC over the encrypted channel - Structured logging (tracing) -**noiseml-client** +**quicnprotochat-client** - Tokio TCP connection to server - Noise handshake, then Cap'n Proto RPC client stub - CLI interface (clap) -- Drives noiseml-core for all crypto operations +- Drives quicnprotochat-core for all crypto operations - Displays received messages to stdout ### Transport Stack @@ -174,11 +174,11 @@ Hybrid KEM construction: ``` SharedSecret = HKDF-SHA256( ikm = X25519_ss || ML-KEM-768_ss, - info = "noiseml-hybrid-v1", + info = "quicnprotochat-hybrid-v1", len = 32 ) ``` -Follows the combiner approach from draft-ietf-tls-hybrid-design. Implemented as a custom `openmls` `OpenMlsCryptoProvider` trait implementation in `noiseml-core`. +Follows the combiner approach from draft-ietf-tls-hybrid-design. Implemented as a custom `openmls` `OpenMlsCryptoProvider` trait implementation in `quicnprotochat-core`. --- @@ -189,10 +189,10 @@ Follows the combiner approach from draft-ietf-tls-hybrid-design. Implemented as Deliverables: - `schemas/envelope.capnp`: `Envelope` + `MsgType` (Ping/Pong only needed at this stage) -- `noiseml-proto`: `build.rs` with `capnpc`, generated type re-exports, canonical helper -- `noiseml-core`: static X25519 keypair generation, Noise_XX initiator + responder, length-prefixed Cap'n Proto frame codec -- `noiseml-server`: TCP listener, Noise handshake, Ping→Pong handler, one tokio task per connection -- `noiseml-client`: connects, Noise handshake, sends Ping, receives Pong, exits 0 +- `quicnprotochat-proto`: `build.rs` with `capnpc`, generated type re-exports, canonical helper +- `quicnprotochat-core`: static X25519 keypair generation, Noise_XX initiator + responder, length-prefixed Cap'n Proto frame codec +- `quicnprotochat-server`: TCP listener, Noise handshake, Ping→Pong handler, one tokio task per connection +- `quicnprotochat-client`: connects, Noise handshake, sends Ping, receives Pong, exits 0 - Integration test: server and client in same test binary using `tokio::spawn` - `docker-compose.yml` running the server @@ -201,10 +201,10 @@ Deliverables: Deliverables: - `schemas/auth.capnp`: `AuthenticationService` interface -- `noiseml-proto`: generated RPC stubs + client/server bootstrap helpers -- `noiseml-core`: MLS KeyPackage generation (openmls) -- `noiseml-server`: AS RPC server implementation with DashMap store -- `noiseml-client`: `register` and `fetch-key` CLI subcommands +- `quicnprotochat-proto`: generated RPC stubs + client/server bootstrap helpers +- `quicnprotochat-core`: MLS KeyPackage generation (openmls) +- `quicnprotochat-server`: AS RPC server implementation with DashMap store +- `quicnprotochat-client`: `register` and `fetch-key` CLI subcommands - Test: Alice uploads KeyPackage, Bob fetches it, fingerprints match ### M3 — MLS Group Create + Welcome @@ -212,25 +212,25 @@ Deliverables: Deliverables: - `schemas/delivery.capnp`: `DeliveryService` + `MessageStream` interfaces -- `noiseml-core`: group create, add member, process Welcome -- `noiseml-server`: DS RPC server, Welcome routing by identity -- `noiseml-client`: `create-group` and `join` CLI subcommands +- `quicnprotochat-core`: group create, add member, process Welcome +- `quicnprotochat-server`: DS RPC server, Welcome routing by identity +- `quicnprotochat-client`: `create-group` and `join` CLI subcommands - Test: two clients reach identical epoch 1 group state, verified by comparing group context hashes ### M4 — Encrypted Group Messaging **Goal:** Alice and Bob exchange MLS Application messages through the DS. Deliverables: -- `noiseml-core`: send/receive application message, epoch rotation on Commit -- `noiseml-server`: DS fan-out via `MessageStream` capability stream, per-group ordered log (in-memory) -- `noiseml-client`: `send` subcommand, live receive loop via `MessageStream.next()` +- `quicnprotochat-core`: send/receive application message, epoch rotation on Commit +- `quicnprotochat-server`: DS fan-out via `MessageStream` capability stream, per-group ordered log (in-memory) +- `quicnprotochat-client`: `send` subcommand, live receive loop via `MessageStream.next()` - Test: round-trip message integrity, forward secrecy verified by confirming distinct key material across epochs ### M5 — Hybrid PQ Ciphersuite **Goal:** Replace MLS crypto backend with X25519 + ML-KEM-768 hybrid. Deliverables: -- `noiseml-core`: custom `OpenMlsCryptoProvider` with hybrid KEM +- `quicnprotochat-core`: custom `OpenMlsCryptoProvider` with hybrid KEM - All M3/M4 tests pass unchanged with new ciphersuite - Criterion benchmarks: key generation, encap/decap, group-add latency (10/100/1000 members) @@ -238,7 +238,7 @@ Deliverables: **Goal:** Server survives restart. Full containerised deployment. Deliverables: -- `noiseml-server`: SQLite via `sqlx` for AS key store and DS message log, `migrations/` directory +- `quicnprotochat-server`: SQLite via `sqlx` for AS key store and DS message log, `migrations/` directory - `docker/Dockerfile`: multi-stage build (rust:bookworm builder → debian:bookworm-slim runtime) - `docker-compose.yml`: server + SQLite volume, healthcheck - Client reconnect with session resume (re-handshake + rejoin group epoch from DS log) @@ -266,7 +266,7 @@ capnp = "0.19" capnp-rpc = "0.19" # Build-time only -capnpc = "0.19" # build-dependency in noiseml-proto +capnpc = "0.19" # build-dependency in quicnprotochat-proto # Async / networking tokio = { version = "1", features = ["full"] } @@ -310,7 +310,7 @@ The MLS content layer is PQ-protected from M5. The Noise transport (X25519) rema ## How to Use This Prompt -Paste this document at the start of any session working on noiseml. Then state which milestone you are working on and what specific task you need. The assistant will: +Paste this document at the start of any session working on quicnprotochat. Then state which milestone you are working on and what specific task you need. The assistant will: 1. Confirm the current milestone and task. 2. State any design decisions being made (ADR format if significant). @@ -325,5 +325,5 @@ When asking for code, always specify: --- -*noiseml — MLS + Post-Quantum + Noise/TCP + Cap'n Proto messenger in Rust* +*quicnprotochat — MLS + Post-Quantum + Noise/TCP + Cap'n Proto messenger in Rust* *Architecture version: 1.1 | Last updated: 2026-02-19* diff --git a/schemas/envelope.capnp b/schemas/envelope.capnp index 7fdbb4e..0de9b1e 100644 --- a/schemas/envelope.capnp +++ b/schemas/envelope.capnp @@ -1,4 +1,4 @@ -# envelope.capnp — top-level wire message for all noiseml traffic. +# envelope.capnp — top-level wire message for all quicnprotochat traffic. # # Every frame exchanged over the Noise channel is serialised as an Envelope. # The Delivery Service routes by (groupId, msgType) without inspecting payload. diff --git a/schemas/node.capnp b/schemas/node.capnp new file mode 100644 index 0000000..b7cc90f --- /dev/null +++ b/schemas/node.capnp @@ -0,0 +1,29 @@ +# node.capnp — Unified quicnprotochat node RPC interface. +# +# Combines Authentication and Delivery operations into a single service. +# +# ID generated with: capnp id +@0xd5ca5648a9cc1c28; + +interface NodeService { + # Upload a single-use KeyPackage for later retrieval by peers. + # identityKey : Ed25519 public key bytes (32 bytes) + # package : TLS-encoded openmls KeyPackage + uploadKeyPackage @0 (identityKey :Data, package :Data) -> (fingerprint :Data); + + # Fetch and atomically remove one KeyPackage for a given identity key. + # Returns empty Data if none are stored. + fetchKeyPackage @1 (identityKey :Data) -> (package :Data); + + # Enqueue an opaque payload for delivery to a recipient. + enqueue @2 (recipientKey :Data, payload :Data) -> (); + + # Fetch and drain all queued payloads for the recipient. + fetch @3 (recipientKey :Data) -> (payloads :List(Data)); + + # Long-poll: wait up to timeoutMs for new payloads, then drain queue. + fetchWait @4 (recipientKey :Data, timeoutMs :UInt64) -> (payloads :List(Data)); + + # Health probe for readiness/liveness. + health @5 () -> (status :Text); +}