commit 9fa3873bd7639c78c6a1b9d31bbee7f386d89346 Author: Christian Nennemann Date: Thu Feb 19 21:58:51 2026 +0100 feat: M1 — Noise transport, Cap'n Proto framing, Ping/Pong Establishes the foundational transport layer for noiseml: - Noise_XX_25519_ChaChaPoly_BLAKE2s handshake (initiator + responder) via `snow`; mutual authentication of static X25519 keys guaranteed before any application data flows. - Length-prefixed frame codec (4-byte LE u32, max 65 535 B per Noise spec) implemented as a Tokio Encoder/Decoder pair. - Cap'n Proto Envelope schema with MsgType enum (Ping, Pong, and future MLS message types defined but not yet dispatched). - Server: TCP listener, one Tokio task per connection, Ping→Pong handler, fresh X25519 keypair logged at startup. - Client: `ping` subcommand — handshake, send Ping, receive Pong, print RTT, exit 0. - Integration tests: bidirectional Ping/Pong with mutual-auth verification; server keypair reuse across sequential connections. - Docker multi-stage build (rust:bookworm → debian:bookworm-slim, non-root) and docker-compose with TCP healthcheck. No MLS group state, no AS/DS, no persistence — out of scope for M1. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bfeeae1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +target/ +.git/ +.gitignore +*.md +docs/ +docker-compose.yml +.dockerignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4e53ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +**/*.rs.bk +.vscode/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9f7d158 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1340 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "capnp" +version = "0.19.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e985a566bdaae9a428a957d12b10c318d41b2afddb54cfbb764878059df636e" +dependencies = [ + "embedded-io", +] + +[[package]] +name = "capnp-futures" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8f3ee810b3890498e51028448ac732cdd5009223897124dd2fac6b085b5d867" +dependencies = [ + "capnp", + "futures", +] + +[[package]] +name = "capnp-rpc" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe57ab22a5e121e6fddaf36e837514aab9ae888bcff2baa6fda5630820dfc501" +dependencies = [ + "capnp", + "capnp-futures", + "futures", +] + +[[package]] +name = "capnpc" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ba30e0f08582d53c2f3710cf4bb65ff562614b1ba86906d7391adffe189ec" +dependencies = [ + "capnp", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "noiseml-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "capnp", + "capnp-rpc", + "clap", + "futures", + "noiseml-core", + "noiseml-proto", + "noiseml-server", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "noiseml-core" +version = "0.1.0" +dependencies = [ + "bytes", + "capnp", + "ed25519-dalek", + "hkdf", + "noiseml-proto", + "rand", + "sha2", + "snow", + "thiserror", + "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", + "dashmap", + "futures", + "noiseml-core", + "noiseml-proto", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "snow" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850948bee068e713b8ab860fe1adc4d109676ab4c3b621fd8147f06b261f2f85" +dependencies = [ + "aes-gcm", + "blake2", + "chacha20poly1305", + "curve25519-dalek", + "rand_core", + "rustc_version", + "sha2", + "subtle", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +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", +] + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5944e89 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,60 @@ +[workspace] +resolver = "2" +members = [ + "crates/noiseml-core", + "crates/noiseml-proto", + "crates/noiseml-server", + "crates/noiseml-client", +] + +# Shared dependency versions — bump here to affect the whole workspace. +[workspace.dependencies] + +# ── Crypto ──────────────────────────────────────────────────────────────────── +openmls = { version = "0.5", default-features = false, features = ["crypto-subtle"] } +openmls_rust_crypto = { version = "0.2" } +openmls_basic_credential = { version = "0.2" } +# ml-kem 0.2 is the current stable release (FIPS 203, ML-KEM-768). +# All three parameter sets (512/768/1024) are compiled in by default — no feature flag needed. +ml-kem = { version = "0.2" } +x25519-dalek = { version = "2", features = ["static_secrets"] } +ed25519-dalek = { version = "2", features = ["rand_core"] } +snow = { version = "0.9", features = ["default-resolver"] } +sha2 = { version = "0.10" } +hkdf = { version = "0.12" } +zeroize = { version = "1", features = ["derive"] } +rand = { version = "0.8" } + +# ── Serialisation + RPC ─────────────────────────────────────────────────────── +capnp = { version = "0.19" } +capnp-rpc = { version = "0.19" } + +# ── Async / networking ──────────────────────────────────────────────────────── +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["codec"] } +futures = { version = "0.3" } + +# ── Server utilities ────────────────────────────────────────────────────────── +dashmap = { version = "5" } +tracing = { version = "0.1" } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# ── Error handling ──────────────────────────────────────────────────────────── +anyhow = { version = "1" } +thiserror = { version = "1" } + +# ── CLI ─────────────────────────────────────────────────────────────────────── +clap = { version = "4", features = ["derive"] } + +# ── Build-time ──────────────────────────────────────────────────────────────── +capnpc = { version = "0.19" } + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +strip = "symbols" + +[profile.dev] +opt-level = 0 +debug = true diff --git a/crates/noiseml-client/Cargo.toml b/crates/noiseml-client/Cargo.toml new file mode 100644 index 0000000..9de38c0 --- /dev/null +++ b/crates/noiseml-client/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "noiseml-client" +version = "0.1.0" +edition = "2021" +description = "CLI client for noiseml." +license = "MIT" + +[[bin]] +name = "noiseml" +path = "src/main.rs" + +[dependencies] +noiseml-core = { path = "../noiseml-core" } +noiseml-proto = { path = "../noiseml-proto" } + +# Serialisation + RPC +capnp = { workspace = true } +capnp-rpc = { workspace = true } + +# Async +tokio = { workspace = true } +tokio-util = { workspace = true } +futures = { workspace = true } + +# Error handling +anyhow = { workspace = true } +thiserror = { workspace = true } + +# Logging +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +# CLI +clap = { workspace = true } + +[dev-dependencies] +# Integration tests spin up both server and client in the same process. +noiseml-server = { path = "../noiseml-server" } diff --git a/crates/noiseml-client/src/main.rs b/crates/noiseml-client/src/main.rs new file mode 100644 index 0000000..4d14570 --- /dev/null +++ b/crates/noiseml-client/src/main.rs @@ -0,0 +1,145 @@ +//! noiseml CLI client. +//! +//! # M1 subcommands +//! +//! | Subcommand | Description | +//! |------------|-----------------------------------------| +//! | `ping` | Send a Ping to the server, print RTT | +//! +//! # Configuration +//! +//! | Env var | CLI flag | Default | +//! |-----------------|--------------|---------------------| +//! | `NOISEML_SERVER`| `--server` | `127.0.0.1:7000` | +//! | `RUST_LOG` | — | `warn` | +//! +//! # Keypair lifecycle +//! +//! A fresh ephemeral X25519 keypair is generated per invocation in M1. +//! M2 introduces persistent identity keys stored locally and registered +//! with the Authentication Service. + +use anyhow::Context; +use clap::{Parser, Subcommand}; +use tokio::net::TcpStream; + +use noiseml_core::{NoiseKeypair, handshake_initiator}; +use noiseml_proto::{MsgType, ParsedEnvelope}; + +// ── 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, + }, +} + +// ── 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, + } +} + +// ── Subcommand implementations ──────────────────────────────────────────────── + +/// Connect to `server`, complete Noise_XX, send a Ping, and print RTT. +/// +/// Exits with status 0 on a valid Pong, non-zero on any error. +async fn cmd_ping(server: &str) -> anyhow::Result<()> { + // Generate a fresh ephemeral keypair for this session. + // M2 will load a persistent identity keypair instead. + 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(fmt_key) + .unwrap_or_else(|| "unknown".into()); + tracing::debug!(server_key = %remote, "handshake complete"); + } + + // Record send time immediately before writing to minimise measurement skew. + 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" + ); + } + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Format the first 4 bytes of a key as hex with a trailing ellipsis. +fn fmt_key(key: &[u8]) -> String { + if key.len() < 4 { + return format!("{key:02x?}"); + } + format!("{:02x}{:02x}{:02x}{:02x}…", key[0], key[1], key[2], key[3]) +} + +/// 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 +} diff --git a/crates/noiseml-client/tests/noise_transport.rs b/crates/noiseml-client/tests/noise_transport.rs new file mode 100644 index 0000000..56bea73 --- /dev/null +++ b/crates/noiseml-client/tests/noise_transport.rs @@ -0,0 +1,209 @@ +//! M1 integration test: Noise_XX handshake + Ping/Pong round-trip. +//! +//! Both the server-side and client-side logic run in the same Tokio runtime +//! using `tokio::spawn`. The test verifies: +//! +//! 1. The Noise_XX handshake completes from both sides. +//! 2. A Ping sent by the client arrives as a Ping on the server side. +//! 3. The server's Pong arrives correctly on the client side. +//! 4. Mutual authentication: each peer's observed remote static key matches the +//! other peer's actual public key (the core security property of XX). + +use std::sync::Arc; + +use tokio::net::TcpListener; + +use noiseml_core::{NoiseKeypair, handshake_initiator, handshake_responder}; +use noiseml_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. +#[tokio::test] +async fn noise_xx_ping_pong_round_trip() { + let server_keypair = Arc::new(NoiseKeypair::generate()); + let client_keypair = NoiseKeypair::generate(); + + // Bind the listener *before* spawning so the port is ready when the client + // calls connect — no sleep or retry needed. + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind test listener"); + let server_addr = listener.local_addr().expect("failed to get local addr"); + + // ── Server task ─────────────────────────────────────────────────────────── + // + // Handles exactly one connection: completes the handshake, asserts that it + // receives a Ping, sends a Pong, then returns the client's observed key. + let server_kp = Arc::clone(&server_keypair); + let server_task = tokio::spawn(async move { + let (stream, _peer) = listener.accept().await.expect("server accept failed"); + + let mut transport = handshake_responder(stream, &server_kp) + .await + .expect("server Noise_XX handshake failed"); + + let env = transport + .recv_envelope() + .await + .expect("server recv_envelope failed"); + + match env.msg_type { + MsgType::Ping => {} + _ => panic!("server expected Ping, received a different message type"), + } + + transport + .send_envelope(&ParsedEnvelope { + msg_type: MsgType::Pong, + group_id: vec![], + sender_id: vec![], + payload: vec![], + timestamp_ms: 0, + }) + .await + .expect("server send_envelope failed"); + + // Return the client's public key as authenticated by the server. + transport + .remote_static_public_key() + .expect("server: no remote static key after completed XX handshake") + .to_vec() + }); + + // ── Client side ─────────────────────────────────────────────────────────── + let stream = tokio::net::TcpStream::connect(server_addr) + .await + .expect("client connect failed"); + + let mut transport = handshake_initiator(stream, &client_keypair) + .await + .expect("client Noise_XX handshake failed"); + + // Capture the server's public key as authenticated by the client. + let server_key_seen_by_client = transport + .remote_static_public_key() + .expect("client: no remote static key after completed XX handshake") + .to_vec(); + + transport + .send_envelope(&ParsedEnvelope { + msg_type: MsgType::Ping, + group_id: vec![], + sender_id: vec![], + payload: vec![], + timestamp_ms: 1_700_000_000_000, + }) + .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"); + + match pong.msg_type { + MsgType::Pong => {} + _ => panic!("client expected Pong, received a different message type"), + } + + // ── Mutual authentication assertions ────────────────────────────────────── + let client_key_seen_by_server = server_task + .await + .expect("server task panicked — see output above"); + + // The server authenticated the client's static public key correctly. + assert_eq!( + client_key_seen_by_server, + client_keypair.public_bytes().to_vec(), + "server's authenticated view of client key does not match client's actual public key" + ); + + // The client authenticated the server's static public key correctly. + assert_eq!( + server_key_seen_by_client, + server_keypair.public_bytes().to_vec(), + "client's authenticated view of server key does not match server's actual public key" + ); +} + +/// A second independent connection on the same server must also succeed, +/// confirming that the server keypair reuse across connections is correct. +#[tokio::test] +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 server_addr = listener.local_addr().expect("local_addr failed"); + + let server_kp = Arc::clone(&server_keypair); + tokio::spawn(async move { + for _ in 0..2_u8 { + let (stream, _) = listener.accept().await.expect("accept failed"); + let kp = Arc::clone(&server_kp); + tokio::spawn(async move { + let mut t = handshake_responder(stream, &kp) + .await + .expect("server handshake failed"); + let env = t.recv_envelope().await.expect("recv failed"); + match env.msg_type { + MsgType::Ping => {} + _ => panic!("expected Ping"), + } + t.send_envelope(&ParsedEnvelope { + msg_type: MsgType::Pong, + group_id: vec![], + sender_id: vec![], + payload: vec![], + timestamp_ms: 0, + }) + .await + .expect("server send failed"); + }); + } + }); + + for _ in 0..2_u8 { + let kp = NoiseKeypair::generate(); + let stream = tokio::net::TcpStream::connect(server_addr) + .await + .expect("connect failed"); + let mut t = handshake_initiator(stream, &kp) + .await + .expect("client handshake failed"); + + t.send_envelope(&ParsedEnvelope { + msg_type: MsgType::Ping, + group_id: vec![], + sender_id: vec![], + payload: vec![], + timestamp_ms: 0, + }) + .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"); + + match pong.msg_type { + MsgType::Pong => {} + _ => panic!("expected Pong"), + } + + // Each client sees the *same* server public key (key reuse across connections). + let seen = t + .remote_static_public_key() + .expect("no remote key") + .to_vec(); + assert_eq!(seen, server_keypair.public_bytes().to_vec()); + } +} diff --git a/crates/noiseml-core/Cargo.toml b/crates/noiseml-core/Cargo.toml new file mode 100644 index 0000000..bd4f39f --- /dev/null +++ b/crates/noiseml-core/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "noiseml-core" +version = "0.1.0" +edition = "2021" +description = "Crypto primitives, Noise_XX transport, MLS state machine, and Cap'n Proto frame codec for noiseml." +license = "MIT" + +[dependencies] +# Crypto +# openmls / openmls_rust_crypto / openmls_basic_credential — added in M2 +# ml-kem — added in M5 (hybrid PQ ciphersuite) +x25519-dalek = { workspace = true } +ed25519-dalek = { workspace = true } +snow = { workspace = true } +sha2 = { workspace = true } +hkdf = { workspace = true } +zeroize = { workspace = true } +rand = { workspace = true } + +# Serialisation +capnp = { workspace = true } +noiseml-proto = { path = "../noiseml-proto" } + +# Async codec +tokio-util = { workspace = true } +bytes = { version = "1" } + +# Error handling +thiserror = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/noiseml-core/src/codec.rs b/crates/noiseml-core/src/codec.rs new file mode 100644 index 0000000..63818f4 --- /dev/null +++ b/crates/noiseml-core/src/codec.rs @@ -0,0 +1,204 @@ +//! Length-prefixed byte frame codec for Tokio's `Framed` adapter. +//! +//! # Wire format +//! +//! ```text +//! ┌──────────────────────────┬──────────────────────────────────────┐ +//! │ length (4 bytes, LE u32)│ payload (length bytes) │ +//! └──────────────────────────┴──────────────────────────────────────┘ +//! ``` +//! +//! Little-endian was chosen over big-endian for consistency with Cap'n Proto's +//! own segment table encoding. Both sides of the connection use the same codec. +//! +//! # Usage +//! +//! This codec is transport-agnostic: during the Noise handshake it frames raw +//! Noise handshake messages; after the handshake it frames Noise-encrypted +//! application data. In both cases the payload is opaque bytes from the +//! codec's perspective. +//! +//! # Frame size limit +//! +//! The Noise protocol specifies a maximum message size of 65 535 bytes. +//! Frames larger than [`NOISE_MAX_MSG`] are rejected as protocol violations. + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use tokio_util::codec::{Decoder, Encoder}; + +use crate::error::CodecError; + +/// Maximum Noise protocol message size in bytes (per RFC / Noise spec §3). +pub const NOISE_MAX_MSG: usize = 65_535; + +/// A stateless codec that prepends / reads a 4-byte little-endian length field. +/// +/// Implements both [`Encoder`] and [`Decoder`] so it can be used with +/// `tokio_util::codec::Framed`. +#[derive(Debug, Clone, Copy, Default)] +pub struct LengthPrefixedCodec; + +impl LengthPrefixedCodec { + pub fn new() -> Self { + Self + } +} + +impl Encoder for LengthPrefixedCodec { + type Error = CodecError; + + /// Prepend a 4-byte LE length field and append the payload to `dst`. + /// + /// # Errors + /// + /// Returns [`CodecError::FrameTooLarge`] if `item.len() > NOISE_MAX_MSG`. + /// Returns [`CodecError::Io`] if the underlying write fails (propagated + /// by `tokio-util` from the TCP stream). + fn encode(&mut self, item: Bytes, dst: &mut BytesMut) -> Result<(), Self::Error> { + let len = item.len(); + if len > NOISE_MAX_MSG { + return Err(CodecError::FrameTooLarge { + len, + max: NOISE_MAX_MSG, + }); + } + // Reserve exactly the space needed: 4 bytes header + payload. + dst.reserve(4 + len); + dst.put_u32_le(len as u32); + dst.extend_from_slice(&item); + Ok(()) + } +} + +impl Decoder for LengthPrefixedCodec { + type Item = BytesMut; + type Error = CodecError; + + /// Read a length-prefixed frame from `src`. + /// + /// Returns `Ok(None)` when more bytes are needed (standard Decoder contract). + /// Returns `Ok(Some(frame))` when a complete frame is available. + /// + /// # Errors + /// + /// Returns [`CodecError::FrameTooLarge`] if the length field exceeds + /// [`NOISE_MAX_MSG`]. This is treated as an unrecoverable protocol + /// violation — callers should close the connection. + fn decode(&mut self, src: &mut BytesMut) -> Result, Self::Error> { + // Need at least the 4-byte length header. + if src.len() < 4 { + src.reserve(4_usize.saturating_sub(src.len())); + return Ok(None); + } + + // 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; + + if frame_len > NOISE_MAX_MSG { + return Err(CodecError::FrameTooLarge { + len: frame_len, + max: NOISE_MAX_MSG, + }); + } + + let total = 4 + frame_len; + if src.len() < total { + // Tell Tokio how many additional bytes we need to avoid O(n) polling. + src.reserve(total - src.len()); + return Ok(None); + } + + // Consume the 4-byte length header, then split the payload. + src.advance(4); + Ok(Some(src.split_to(frame_len))) + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn encode_then_decode(payload: &[u8]) -> BytesMut { + let mut codec = LengthPrefixedCodec::new(); + let mut buf = BytesMut::new(); + codec + .encode(Bytes::copy_from_slice(payload), &mut buf) + .expect("encode failed"); + let decoded = codec.decode(&mut buf).expect("decode error"); + decoded.expect("expected a complete frame") + } + + #[test] + fn round_trip_empty_payload() { + let result = encode_then_decode(&[]); + assert!(result.is_empty()); + } + + #[test] + fn round_trip_small_payload() { + let payload = b"hello noiseml"; + let result = encode_then_decode(payload); + assert_eq!(&result[..], payload); + } + + #[test] + fn round_trip_max_size_payload() { + let payload = vec![0xAB_u8; NOISE_MAX_MSG]; + let result = encode_then_decode(&payload); + assert_eq!(&result[..], &payload[..]); + } + + #[test] + fn oversized_encode_returns_error() { + let mut codec = LengthPrefixedCodec::new(); + let mut buf = BytesMut::new(); + let oversized = Bytes::from(vec![0u8; NOISE_MAX_MSG + 1]); + let err = codec.encode(oversized, &mut buf).unwrap_err(); + assert!(matches!(err, CodecError::FrameTooLarge { .. })); + } + + #[test] + fn oversized_length_field_decode_returns_error() { + let mut codec = LengthPrefixedCodec::new(); + let mut buf = BytesMut::new(); + // Encode a fake length field that exceeds NOISE_MAX_MSG. + buf.put_u32_le((NOISE_MAX_MSG + 1) as u32); + let err = codec.decode(&mut buf).unwrap_err(); + assert!(matches!(err, CodecError::FrameTooLarge { .. })); + } + + #[test] + fn partial_payload_returns_none() { + let mut codec = LengthPrefixedCodec::new(); + let mut buf = BytesMut::new(); + // Length header says 10 bytes but we only provide 5. + buf.put_u32_le(10); + buf.extend_from_slice(&[0u8; 5]); + let result = codec.decode(&mut buf).expect("decode error"); + assert!(result.is_none()); + } + + #[test] + fn partial_header_returns_none() { + let mut codec = LengthPrefixedCodec::new(); + // Only 2 bytes of the 4-byte header are available. + let mut buf = BytesMut::from(&[0x00_u8, 0x01][..]); + let result = codec.decode(&mut buf).expect("decode error"); + assert!(result.is_none()); + } + + #[test] + fn length_field_is_little_endian() { + let payload = b"le-check"; + let mut codec = LengthPrefixedCodec::new(); + let mut buf = BytesMut::new(); + codec + .encode(Bytes::from_static(payload), &mut buf) + .expect("encode failed"); + // First 4 bytes are the LE length: 8 in LE is [0x08, 0x00, 0x00, 0x00]. + assert_eq!(&buf[..4], &[8, 0, 0, 0]); + } +} diff --git a/crates/noiseml-core/src/error.rs b/crates/noiseml-core/src/error.rs new file mode 100644 index 0000000..cfecac1 --- /dev/null +++ b/crates/noiseml-core/src/error.rs @@ -0,0 +1,71 @@ +//! Error types for `noiseml-core`. +//! +//! Two separate error types are used to preserve type-level separation of concerns: +//! +//! - [`CodecError`] — errors from the length-prefixed frame codec (I/O and framing only). +//! `tokio-util` requires the codec error implement `From`. +//! +//! - [`CoreError`] — errors from the Noise handshake and transport layer. + +use thiserror::Error; + +/// Maximum plaintext bytes per Noise transport frame. +/// +/// Noise limits each message to 65 535 bytes. ChaCha20-Poly1305 consumes +/// 16 bytes for the authentication tag, leaving 65 519 bytes for plaintext. +pub const MAX_PLAINTEXT_LEN: usize = 65_519; + +// ── Codec errors ────────────────────────────────────────────────────────────── + +/// Errors produced by [`LengthPrefixedCodec`](crate::LengthPrefixedCodec). +#[derive(Debug, Error)] +pub enum CodecError { + /// The underlying TCP stream returned an I/O error. + /// + /// This variant satisfies the `tokio-util` requirement that codec error + /// types implement `From`. + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + /// A frame length field exceeded the Noise protocol maximum (65 535 bytes). + /// + /// This is treated as a protocol violation and the connection should be + /// closed rather than retried. + #[error("frame length {len} exceeds maximum {max} bytes")] + FrameTooLarge { len: usize, max: usize }, +} + +// ── Core errors ─────────────────────────────────────────────────────────────── + +/// Errors produced by the Noise handshake and [`NoiseTransport`](crate::NoiseTransport). +#[derive(Debug, Error)] +pub enum CoreError { + /// The `snow` Noise protocol engine returned an error. + /// + /// This covers DH failures, decryption failures, state machine violations, + /// and pattern parse errors. + #[error("Noise protocol error: {0}")] + Noise(#[from] snow::Error), + + /// The frame codec reported an I/O or framing error. + #[error("frame codec error: {0}")] + Codec(#[from] CodecError), + + /// Cap'n Proto serialisation or deserialisation failed. + #[error("Cap'n Proto error: {0}")] + Capnp(#[from] capnp::Error), + + /// The remote peer closed the connection before the handshake completed. + #[error("peer closed connection during Noise handshake")] + HandshakeIncomplete, + + /// The remote peer closed the connection during normal operation. + #[error("peer closed connection")] + ConnectionClosed, + + /// The caller attempted to send a plaintext larger than the Noise maximum. + /// + /// The limit is [`MAX_PLAINTEXT_LEN`] bytes per frame. + #[error("plaintext {size} B exceeds Noise frame limit of {MAX_PLAINTEXT_LEN} B")] + MessageTooLarge { size: usize }, +} diff --git a/crates/noiseml-core/src/keypair.rs b/crates/noiseml-core/src/keypair.rs new file mode 100644 index 0000000..4078e2a --- /dev/null +++ b/crates/noiseml-core/src/keypair.rs @@ -0,0 +1,119 @@ +//! Static X25519 keypair for the Noise_XX handshake. +//! +//! # Security properties +//! +//! - The private key is stored as [`x25519_dalek::StaticSecret`], which +//! implements [`ZeroizeOnDrop`](zeroize::ZeroizeOnDrop) — the key material +//! is overwritten with zeros when the `StaticSecret` is dropped. +//! +//! - [`NoiseKeypair::private_bytes`] returns a [`Zeroizing`](zeroize::Zeroizing) +//! wrapper so the caller's copy of the raw bytes is also cleared on drop. +//! Pass it directly to `snow::Builder::local_private_key` and let it fall +//! out of scope immediately after. +//! +//! - The public key is not secret and may be freely cloned or logged. +//! +//! # Persistence +//! +//! `NoiseKeypair` does not implement `Serialize` intentionally. Key persistence +//! to disk is handled at the application layer (M6) with appropriate file +//! permission checks and, optionally, passphrase-based encryption. + +use rand::rngs::OsRng; +use x25519_dalek::{PublicKey, StaticSecret}; +use zeroize::Zeroizing; + +/// A static X25519 keypair used for Noise_XX mutual authentication. +/// +/// Generate once per node identity and reuse across connections. +/// The private scalar is zeroized when this value is dropped. +pub struct NoiseKeypair { + /// Private scalar — zeroized on drop via `x25519_dalek`'s `ZeroizeOnDrop` impl. + private: StaticSecret, + /// Corresponding public key — derived from `private` at construction time. + public: PublicKey, +} + +impl NoiseKeypair { + /// Generate a fresh keypair from the OS CSPRNG. + /// + /// This calls `getrandom` on Linux (via `OsRng`) and is suitable for + /// generating long-lived static identity keys. + pub fn generate() -> Self { + let private = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&private); + Self { private, public } + } + + /// Return the raw private key bytes in a [`Zeroizing`] wrapper. + /// + /// The returned wrapper clears the 32-byte copy when dropped. + /// Use it immediately to initialise a `snow::Builder` and let it drop: + /// + /// ```rust,ignore + /// let private = keypair.private_bytes(); + /// let session = snow::Builder::new(params) + /// .local_private_key(&private[..]) + /// .build_initiator()?; + /// // `private` is zeroized here. + /// ``` + pub fn private_bytes(&self) -> Zeroizing<[u8; 32]> { + Zeroizing::new(self.private.to_bytes()) + } + + /// Return the public key bytes. + /// + /// Safe to log or transmit — this is not secret material. + pub fn public_bytes(&self) -> [u8; 32] { + self.public.to_bytes() + } +} + +// Prevent accidental `{:?}` printing of the private key. +impl std::fmt::Debug for NoiseKeypair { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Show only the first 4 bytes of the public key as a sanity identifier. + // No external crate needed; the private key is never printed. + let pub_bytes = self.public_bytes(); + write!( + f, + "NoiseKeypair {{ public: {:02x}{:02x}{:02x}{:02x}…, private: [redacted] }}", + pub_bytes[0], pub_bytes[1], pub_bytes[2], pub_bytes[3], + ) + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_public_key_matches_private() { + let kp = NoiseKeypair::generate(); + // Re-derive the public key from the private bytes and confirm they match. + let private_bytes = kp.private_bytes(); + let secret = StaticSecret::from(*private_bytes); + let rederived = PublicKey::from(&secret); + assert_eq!(rederived.to_bytes(), kp.public_bytes()); + } + + #[test] + fn two_keypairs_differ() { + let a = NoiseKeypair::generate(); + let b = NoiseKeypair::generate(); + assert_ne!(a.public_bytes(), b.public_bytes()); + } + + #[test] + fn private_bytes_is_zeroizing() { + // Verify that Zeroizing<[u8;32]> does not expose the key via Debug. + let kp = NoiseKeypair::generate(); + 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"); + } +} diff --git a/crates/noiseml-core/src/lib.rs b/crates/noiseml-core/src/lib.rs new file mode 100644 index 0000000..93027ce --- /dev/null +++ b/crates/noiseml-core/src/lib.rs @@ -0,0 +1,28 @@ +//! Core cryptographic primitives, Noise_XX transport, and frame codec for noiseml. +//! +//! # Module layout +//! +//! | Module | Responsibility | +//! |------------|----------------------------------------------------------| +//! | `error` | [`CoreError`] and [`CodecError`] types | +//! | `keypair` | [`NoiseKeypair`] — static X25519 key, zeroize-on-drop | +//! | `codec` | [`LengthPrefixedCodec`] — Tokio Encoder + Decoder | +//! | `noise` | [`handshake_initiator`], [`handshake_responder`], [`NoiseTransport`] | +//! +//! # What is NOT in this crate (M1) +//! +//! - MLS group state machine — added in M2/M3 (`openmls` integration) +//! - Hybrid PQ KEM — added in M5 +//! - Ed25519 identity keypair — added in M2 (needed for MLS credentials) + +mod codec; +mod error; +mod keypair; +mod noise; + +// ── Public API ──────────────────────────────────────────────────────────────── + +pub use codec::{LengthPrefixedCodec, NOISE_MAX_MSG}; +pub use error::{CodecError, CoreError, MAX_PLAINTEXT_LEN}; +pub use keypair::NoiseKeypair; +pub use noise::{handshake_initiator, handshake_responder, NoiseTransport}; diff --git a/crates/noiseml-core/src/noise.rs b/crates/noiseml-core/src/noise.rs new file mode 100644 index 0000000..e86db78 --- /dev/null +++ b/crates/noiseml-core/src/noise.rs @@ -0,0 +1,325 @@ +//! Noise_XX handshake and encrypted transport. +//! +//! # Protocol +//! +//! Pattern: `Noise_XX_25519_ChaChaPoly_BLAKE2s` +//! +//! ```text +//! XX handshake (3 messages): +//! -> e (initiator sends ephemeral public key) +//! <- e, ee, s, es (responder replies; mutual DH + responder static) +//! -> s, se (initiator sends static key; final DH) +//! ``` +//! +//! After the handshake both peers have authenticated each other's static X25519 +//! keys and negotiated a symmetric session with ChaCha20-Poly1305. +//! +//! # Framing +//! +//! All messages — handshake and application — are carried in length-prefixed +//! frames (see [`LengthPrefixedCodec`](crate::LengthPrefixedCodec)). +//! +//! In the handshake phase the frame payload is the raw Noise handshake bytes +//! produced by `snow`. In the transport phase the frame payload is a +//! Noise-encrypted Cap'n Proto message. +//! +//! # Post-quantum gap (ADR-006) +//! +//! The Noise transport uses classical X25519. PQ-Noise is not yet standardised +//! in `snow`. MLS application data is PQ-protected from M5 onward. The residual +//! risk (metadata exposure via handshake harvest) is accepted for M1–M5. + +use bytes::Bytes; +use futures::{SinkExt, StreamExt}; +use tokio::net::TcpStream; +use tokio_util::codec::Framed; + +use crate::{ + codec::{LengthPrefixedCodec, NOISE_MAX_MSG}, + error::{CoreError, MAX_PLAINTEXT_LEN}, + keypair::NoiseKeypair, +}; +use noiseml_proto::{parse_envelope, build_envelope, ParsedEnvelope}; + +/// Noise parameters used throughout noiseml. +/// +/// `Noise_XX_25519_ChaChaPoly_BLAKE2s` — both parties authenticate each +/// other's static X25519 keys; ChaCha20-Poly1305 for AEAD; BLAKE2s as PRF. +const NOISE_PARAMS: &str = "Noise_XX_25519_ChaChaPoly_BLAKE2s"; + +/// ChaCha20-Poly1305 authentication tag overhead per Noise message. +const NOISE_TAG_LEN: usize = 16; + +// ── Public type ─────────────────────────────────────────────────────────────── + +/// An authenticated, encrypted Noise transport session. +/// +/// Obtained by completing a [`handshake_initiator`] or [`handshake_responder`] +/// call. All subsequent I/O is through [`send_frame`](Self::send_frame) and +/// [`recv_frame`](Self::recv_frame), or the higher-level envelope helpers. +/// +/// # Thread safety +/// +/// `NoiseTransport` is `Send` but not `Clone` or `Sync`. Use one instance per +/// Tokio task; use message passing to share data across tasks. +pub struct NoiseTransport { + /// The TCP stream wrapped in the length-prefix codec. + framed: Framed, + /// The Noise session in transport mode — encrypts and decrypts frames. + session: snow::TransportState, + /// Remote peer's static X25519 public key, captured from the HandshakeState + /// before `into_transport_mode()` consumes it. + /// + /// Stored here explicitly rather than via `TransportState::get_remote_static()` + /// because snow does not guarantee the method survives the mode transition. + remote_static: Option>, +} + +impl NoiseTransport { + // ── Transport-layer I/O ─────────────────────────────────────────────────── + + /// Encrypt `plaintext` and send it as a single length-prefixed frame. + /// + /// # Errors + /// + /// - [`CoreError::MessageTooLarge`] if `plaintext` exceeds + /// [`MAX_PLAINTEXT_LEN`] bytes. + /// - [`CoreError::Noise`] if the Noise session fails to encrypt. + /// - [`CoreError::Codec`] if the underlying TCP write fails. + pub async fn send_frame(&mut self, plaintext: &[u8]) -> Result<(), CoreError> { + if plaintext.len() > MAX_PLAINTEXT_LEN { + return Err(CoreError::MessageTooLarge { + size: plaintext.len(), + }); + } + + // Allocate exactly the right amount: plaintext + AEAD tag. + let mut ciphertext = vec![0u8; plaintext.len() + NOISE_TAG_LEN]; + let len = self + .session + .write_message(plaintext, &mut ciphertext) + .map_err(CoreError::Noise)?; + + self.framed + .send(Bytes::copy_from_slice(&ciphertext[..len])) + .await + .map_err(CoreError::Codec)?; + + Ok(()) + } + + /// Receive the next length-prefixed frame and decrypt it. + /// + /// Awaits until a complete frame arrives on the TCP stream. + /// + /// # Errors + /// + /// - [`CoreError::ConnectionClosed`] if the peer closed the connection. + /// - [`CoreError::Noise`] if decryption or authentication fails. + /// - [`CoreError::Codec`] if the underlying TCP read or framing fails. + pub async fn recv_frame(&mut self) -> Result, CoreError> { + let ciphertext = self + .framed + .next() + .await + .ok_or(CoreError::ConnectionClosed)? + .map_err(CoreError::Codec)?; + + // Plaintext is always shorter than ciphertext (AEAD tag is stripped). + let mut plaintext = vec![0u8; ciphertext.len()]; + let len = self + .session + .read_message(&ciphertext, &mut plaintext) + .map_err(CoreError::Noise)?; + + plaintext.truncate(len); + Ok(plaintext) + } + + // ── Envelope-level I/O ──────────────────────────────────────────────────── + + /// 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. + 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 + } + + /// Receive a frame, decrypt it, and deserialise it as a [`ParsedEnvelope`]. + /// + /// This is the primary application-level receive method. + pub async fn recv_envelope(&mut self) -> Result { + let bytes = self.recv_frame().await?; + parse_envelope(&bytes).map_err(CoreError::Capnp) + } + + // ── Session metadata ────────────────────────────────────────────────────── + + /// Return the remote peer's static X25519 public key (32 bytes), as + /// authenticated during the Noise_XX handshake. + /// + /// Returns `None` only in the impossible case where the XX handshake + /// completed without exchanging static keys (a snow implementation bug). + /// In practice this is always `Some` after a successful handshake. + pub fn remote_static_public_key(&self) -> Option<&[u8]> { + self.remote_static.as_deref() + } +} + +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]) + }); + f.debug_struct("NoiseTransport") + .field("remote_static", &remote) + .finish_non_exhaustive() + } +} + +// ── Handshake functions ─────────────────────────────────────────────────────── + +/// Complete a Noise_XX handshake as the **initiator** over `stream`. +/// +/// The initiator sends the first handshake message. After the three-message +/// exchange completes, the function returns an authenticated [`NoiseTransport`] +/// ready for application data. +/// +/// # Errors +/// +/// - [`CoreError::HandshakeIncomplete`] if the peer closes the connection mid-handshake. +/// - [`CoreError::Noise`] if any Noise operation fails (pattern mismatch, bad DH, etc.). +/// - [`CoreError::Codec`] if any TCP I/O fails during the handshake. +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", + ); + + // The private key bytes are held in a Zeroizing wrapper and cleared after + // snow clones them internally during build_initiator(). + let private = keypair.private_bytes(); + let mut session = snow::Builder::new(params) + .local_private_key(&private[..]) + .build_initiator() + .map_err(CoreError::Noise)?; + drop(private); // zeroize our copy; snow holds its own internal copy + + let mut framed = Framed::new(stream, LengthPrefixedCodec::new()); + let mut buf = vec![0u8; NOISE_MAX_MSG]; + + // ── Message 1: -> e ────────────────────────────────────────────────────── + let len = session + .write_message(&[], &mut buf) + .map_err(CoreError::Noise)?; + framed + .send(Bytes::copy_from_slice(&buf[..len])) + .await + .map_err(CoreError::Codec)?; + + // ── Message 2: <- e, ee, s, es ─────────────────────────────────────────── + let msg2 = recv_handshake_frame(&mut framed).await?; + session + .read_message(&msg2, &mut buf) + .map_err(CoreError::Noise)?; + + // ── Message 3: -> s, se ────────────────────────────────────────────────── + let len = session + .write_message(&[], &mut buf) + .map_err(CoreError::Noise)?; + framed + .send(Bytes::copy_from_slice(&buf[..len])) + .await + .map_err(CoreError::Codec)?; + + // Zeroize the scratch buffer — it contained plaintext key material during + // the handshake (ephemeral key bytes in message 2 payload). + zeroize::Zeroize::zeroize(&mut buf); + + // Capture the remote static key from HandshakeState before consuming it. + let remote_static = session.get_remote_static().map(|k| k.to_vec()); + let transport_session = session.into_transport_mode().map_err(CoreError::Noise)?; + Ok(NoiseTransport { + framed, + session: transport_session, + remote_static, + }) +} + +/// Complete a Noise_XX handshake as the **responder** over `stream`. +/// +/// The responder waits for the initiator's first message. After the +/// three-message exchange completes, the function returns an authenticated +/// [`NoiseTransport`] ready for application data. +/// +/// # Errors +/// +/// Same as [`handshake_initiator`]. +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 private = keypair.private_bytes(); + let mut session = snow::Builder::new(params) + .local_private_key(&private[..]) + .build_responder() + .map_err(CoreError::Noise)?; + drop(private); + + let mut framed = Framed::new(stream, LengthPrefixedCodec::new()); + let mut buf = vec![0u8; NOISE_MAX_MSG]; + + // ── Message 1: <- e ────────────────────────────────────────────────────── + let msg1 = recv_handshake_frame(&mut framed).await?; + session + .read_message(&msg1, &mut buf) + .map_err(CoreError::Noise)?; + + // ── Message 2: -> e, ee, s, es ─────────────────────────────────────────── + let len = session + .write_message(&[], &mut buf) + .map_err(CoreError::Noise)?; + framed + .send(Bytes::copy_from_slice(&buf[..len])) + .await + .map_err(CoreError::Codec)?; + + // ── Message 3: <- s, se ────────────────────────────────────────────────── + let msg3 = recv_handshake_frame(&mut framed).await?; + session + .read_message(&msg3, &mut buf) + .map_err(CoreError::Noise)?; + + zeroize::Zeroize::zeroize(&mut buf); + + // Capture the remote static key from HandshakeState before consuming it. + let remote_static = session.get_remote_static().map(|k| k.to_vec()); + let transport_session = session.into_transport_mode().map_err(CoreError::Noise)?; + Ok(NoiseTransport { + framed, + session: transport_session, + remote_static, + }) +} + +// ── Private helpers ─────────────────────────────────────────────────────────── + +/// Read one handshake frame from `framed`, mapping stream closure to +/// [`CoreError::HandshakeIncomplete`]. +async fn recv_handshake_frame( + framed: &mut Framed, +) -> Result { + framed + .next() + .await + .ok_or(CoreError::HandshakeIncomplete)? + .map_err(CoreError::Codec) +} diff --git a/crates/noiseml-proto/Cargo.toml b/crates/noiseml-proto/Cargo.toml new file mode 100644 index 0000000..cc4da19 --- /dev/null +++ b/crates/noiseml-proto/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "noiseml-proto" +version = "0.1.0" +edition = "2021" +description = "Cap'n Proto schemas, generated types, and serialisation helpers for noiseml. No crypto, no I/O." +license = "MIT" + +# build.rs invokes capnpc to generate Rust source from .capnp schemas. +build = "build.rs" + +[dependencies] +capnp = { workspace = true } + +[build-dependencies] +capnpc = { workspace = true } diff --git a/crates/noiseml-proto/build.rs b/crates/noiseml-proto/build.rs new file mode 100644 index 0000000..ad366b1 --- /dev/null +++ b/crates/noiseml-proto/build.rs @@ -0,0 +1,45 @@ +//! Build script for noiseml-proto. +//! +//! Invokes the `capnp` compiler to generate Rust types from `.capnp` schemas +//! located in the workspace-root `schemas/` directory. +//! +//! # Prerequisites +//! +//! The `capnp` CLI must be installed and on `PATH`. +//! +//! Debian/Ubuntu: apt-get install capnproto +//! macOS: brew install capnp +//! Docker: see docker/Dockerfile + +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"), + ); + + // Workspace root is two levels above this crate (noiseml/crates/noiseml-proto). + let workspace_root = manifest_dir + .join("../..") + .canonicalize() + .expect("could not canonicalize workspace root path"); + + let schemas_dir = workspace_root.join("schemas"); + + // Re-run this build script whenever any schema file changes. + println!( + "cargo:rerun-if-changed={}", + schemas_dir.join("envelope.capnp").display() + ); + + capnpc::CompilerCommand::new() + // Treat `schemas/` as the include root so that inter-schema imports + // (e.g. `using import "/auth.capnp"`) resolve correctly in later milestones. + .src_prefix(&schemas_dir) + .file(schemas_dir.join("envelope.capnp")) + .run() + .expect( + "Cap'n Proto schema compilation failed. \ + Is `capnp` installed? (apt-get install capnproto / brew install capnp)", + ); +} diff --git a/crates/noiseml-proto/src/lib.rs b/crates/noiseml-proto/src/lib.rs new file mode 100644 index 0000000..33097cc --- /dev/null +++ b/crates/noiseml-proto/src/lib.rs @@ -0,0 +1,197 @@ +//! Cap'n Proto schemas, generated types, and serialisation helpers for noiseml. +//! +//! # Design constraints +//! +//! This crate is intentionally restricted: +//! - **No crypto** — key material never enters this crate. +//! - **No I/O** — callers own transport; this crate only converts bytes ↔ types. +//! - **No async** — pure synchronous data-layer code. +//! +//! # Generated code +//! +//! `build.rs` invokes `capnpc` at compile time and writes generated Rust source +//! into `$OUT_DIR`. The `include!` macros below splice that code in as a module. +//! +//! # Canonical serialisation (M2+) +//! +//! `build_envelope` uses standard Cap'n Proto wire format. Canonical serialisation +//! (deterministic byte representation for cryptographic signing of KeyPackages and +//! Commits) is added in M2 once the Authentication Service is introduced. + +// ── Generated types ─────────────────────────────────────────────────────────── + +/// Cap'n Proto generated types for `schemas/envelope.capnp`. +/// +/// Do not edit this module by hand — it is entirely machine-generated. +pub mod envelope_capnp { + include!(concat!(env!("OUT_DIR"), "/envelope_capnp.rs")); +} + +// ── Re-exports ──────────────────────────────────────────────────────────────── + +/// The message-type discriminant from the `Envelope` schema. +/// +/// Re-exported here so callers can `use noiseml_proto::MsgType` without +/// spelling out the full generated module path. +pub use envelope_capnp::envelope::MsgType; + +// ── Owned envelope type ─────────────────────────────────────────────────────── + +/// An owned, decoded `Envelope` with no Cap'n Proto reader lifetimes. +/// +/// All byte fields are eagerly copied out of the Cap'n Proto reader so that +/// this type is `Send + 'static` and can cross async task boundaries freely. +/// +/// # Invariants +/// +/// - `group_id` and `sender_id` are either empty (for control messages such as +/// `Ping`/`Pong`) or exactly 32 bytes (SHA-256 digest). +/// - `payload` is empty for `Ping` and `Pong`; non-empty for all MLS variants. +#[derive(Debug, Clone)] +pub struct ParsedEnvelope { + pub msg_type: MsgType, + /// SHA-256 of the group name, or empty for point-to-point control messages. + pub group_id: Vec, + /// SHA-256 of the sender's Ed25519 identity public key, or empty. + pub sender_id: Vec, + /// Opaque payload — interpretation is determined by `msg_type`. + pub payload: Vec, + /// Unix timestamp in milliseconds. + pub timestamp_ms: u64, +} + +// ── Serialisation helpers ───────────────────────────────────────────────────── + +/// Serialise a [`ParsedEnvelope`] to unpacked Cap'n Proto wire bytes. +/// +/// 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). +/// +/// # Errors +/// +/// Returns [`capnp::Error`] if the underlying allocator fails (out of memory). +/// This is not expected under normal operation. +pub fn build_envelope(env: &ParsedEnvelope) -> Result, capnp::Error> { + use capnp::message; + + let mut message = message::Builder::new_default(); + { + let mut root = message.init_root::(); + root.set_msg_type(env.msg_type); + root.set_group_id(&env.group_id); + root.set_sender_id(&env.sender_id); + root.set_payload(&env.payload); + root.set_timestamp_ms(env.timestamp_ms); + } + to_bytes(&message) +} + +/// Deserialise unpacked Cap'n Proto wire bytes into a [`ParsedEnvelope`]. +/// +/// All data is copied out of the Cap'n Proto reader before returning, so the +/// input slice is not retained. +/// +/// # Errors +/// +/// - [`capnp::Error`] if the bytes are not valid Cap'n Proto wire format. +/// - [`capnp::Error`] if `msgType` contains a discriminant not present in the +/// current schema (forward-compatibility guard). +pub fn parse_envelope(bytes: &[u8]) -> Result { + let reader = from_bytes(bytes)?; + let root = reader.get_root::()?; + + let msg_type = root.get_msg_type().map_err(|nis| { + capnp::Error::failed(format!( + "Envelope.msgType contains unknown discriminant: {nis}" + )) + })?; + + Ok(ParsedEnvelope { + msg_type, + group_id: root.get_group_id()?.to_vec(), + sender_id: root.get_sender_id()?.to_vec(), + payload: root.get_payload()?.to_vec(), + timestamp_ms: root.get_timestamp_ms(), + }) +} + +// ── Low-level byte ↔ message conversions ────────────────────────────────────── + +/// 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. +pub fn to_bytes( + msg: &capnp::message::Builder, +) -> Result, capnp::Error> { + let mut buf = Vec::new(); + capnp::serialize::write_message(&mut buf, msg)?; + Ok(buf) +} + +/// Deserialise unpacked wire bytes into a message with owned segments. +/// +/// Uses `ReaderOptions::new()` (default limits: 64 MiB, 512 nesting levels). +/// Callers that receive data from untrusted peers should consider tightening +/// the traversal limit via `ReaderOptions::traversal_limit_in_words`. +pub fn from_bytes( + bytes: &[u8], +) -> Result, capnp::Error> { + let mut cursor = std::io::Cursor::new(bytes); + capnp::serialize::read_message(&mut cursor, capnp::message::ReaderOptions::new()) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// Round-trip a Ping envelope through build → parse and verify all fields. + #[test] + fn ping_round_trip() { + let original = ParsedEnvelope { + msg_type: MsgType::Ping, + group_id: vec![], + sender_id: vec![0xAB; 32], + payload: vec![], + timestamp_ms: 1_700_000_000_000, + }; + + let bytes = build_envelope(&original).expect("build_envelope failed"); + let parsed = parse_envelope(&bytes).expect("parse_envelope failed"); + + assert!(matches!(parsed.msg_type, MsgType::Ping)); + assert_eq!(parsed.group_id, original.group_id); + assert_eq!(parsed.sender_id, original.sender_id); + assert_eq!(parsed.payload, original.payload); + assert_eq!(parsed.timestamp_ms, original.timestamp_ms); + } + + /// Round-trip a Pong envelope. + #[test] + fn pong_round_trip() { + let original = ParsedEnvelope { + msg_type: MsgType::Pong, + group_id: vec![], + sender_id: vec![0xCD; 32], + payload: vec![], + timestamp_ms: 1_700_000_001_000, + }; + + let bytes = build_envelope(&original).expect("build_envelope failed"); + let parsed = parse_envelope(&bytes).expect("parse_envelope failed"); + + assert!(matches!(parsed.msg_type, MsgType::Pong)); + assert_eq!(parsed.sender_id, original.sender_id); + assert_eq!(parsed.timestamp_ms, original.timestamp_ms); + } + + /// Corrupted bytes must produce an error, not a panic. + #[test] + fn corrupted_bytes_error() { + let result = parse_envelope(&[0xFF, 0xFF, 0xFF, 0xFF]); + assert!(result.is_err(), "expected error for corrupted input"); + } +} diff --git a/crates/noiseml-server/Cargo.toml b/crates/noiseml-server/Cargo.toml new file mode 100644 index 0000000..e1a510a --- /dev/null +++ b/crates/noiseml-server/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "noiseml-server" +version = "0.1.0" +edition = "2021" +description = "Delivery Service and Authentication Service for noiseml." +license = "MIT" + +[[bin]] +name = "noiseml-server" +path = "src/main.rs" + +[dependencies] +noiseml-core = { path = "../noiseml-core" } +noiseml-proto = { path = "../noiseml-proto" } + +# Serialisation + RPC +capnp = { workspace = true } +capnp-rpc = { workspace = true } + +# Async +tokio = { workspace = true } +tokio-util = { workspace = true } +futures = { workspace = true } + +# Server utilities +dashmap = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +# Error handling +anyhow = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/noiseml-server/src/main.rs b/crates/noiseml-server/src/main.rs new file mode 100644 index 0000000..0d0627f --- /dev/null +++ b/crates/noiseml-server/src/main.rs @@ -0,0 +1,180 @@ +//! noiseml-server — Delivery Service + Authentication Service binary. +//! +//! # M1 scope +//! +//! Accepts Noise_XX connections over TCP and replies to `Ping` frames with +//! `Pong`. The AS and DS RPC interfaces (Cap'n Proto RPC) are added in M2+. +//! +//! # Configuration +//! +//! | Env var | CLI flag | Default | +//! |------------------|-------------|-----------------| +//! | `NOISEML_LISTEN` | `--listen` | `0.0.0.0:7000` | +//! | `RUST_LOG` | — | `info` | +//! +//! # Keypair lifecycle +//! +//! A fresh static X25519 keypair is generated at startup. The public key is +//! logged so clients can optionally pin it. M6 replaces this with persistent +//! key loading from SQLite. + +use std::sync::Arc; + +use anyhow::Context; +use clap::Parser; +use tokio::net::{TcpListener, TcpStream}; +use tracing::Instrument; + +use noiseml_core::{CodecError, CoreError, NoiseKeypair, handshake_responder}; +use noiseml_proto::{MsgType, ParsedEnvelope}; + +// ── CLI ─────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Parser)] +#[command( + name = "noiseml-server", + about = "noiseml Delivery Service + Authentication Service", + version +)] +struct Args { + /// TCP address to listen on. + #[arg(long, default_value = "0.0.0.0:7000", env = "NOISEML_LISTEN")] + listen: 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("info")), + ) + .init(); + + let args = Args::parse(); + + // Generate a fresh static keypair for this server instance. + // M6 will replace this with persistent key loading from SQLite. + let keypair = Arc::new(NoiseKeypair::generate()); + + { + let pub_bytes = keypair.public_bytes(); + tracing::info!( + listen = %args.listen, + public_key = %fmt_key(&pub_bytes), + "noiseml-server starting — key is ephemeral in M1 (not persisted)" + ); + } + + let listener = TcpListener::bind(&args.listen) + .await + .with_context(|| format!("failed to bind to {}", args.listen))?; + + tracing::info!(listen = %args.listen, "accepting connections"); + + loop { + let (stream, peer_addr) = listener.accept().await.context("accept failed")?; + let keypair = Arc::clone(&keypair); + + tokio::spawn( + async move { + match handle_connection(stream, keypair).await { + Ok(()) => tracing::debug!("connection closed cleanly"), + Err(e) => tracing::warn!(error = %e, "connection error"), + } + } + .instrument(tracing::info_span!("conn", peer = %peer_addr)), + ); + } +} + +// ── Per-connection handler ──────────────────────────────────────────────────── + +/// Drive a single client connection through handshake and M1 message loop. +/// +/// Returns `Ok(())` on any clean or expected disconnection. +/// Returns `Err` only for unexpected Noise or decryption failures. +async fn handle_connection( + stream: TcpStream, + keypair: Arc, +) -> Result<(), CoreError> { + let mut transport = handshake_responder(stream, &keypair).await?; + + { + let remote = transport + .remote_static_public_key() + .map(fmt_key) + .unwrap_or_else(|| "unknown".into()); + tracing::info!(remote_key = %remote, "Noise_XX handshake complete"); + } + + loop { + let env = match transport.recv_envelope().await { + Ok(env) => env, + + // Clean EOF: the peer closed the connection gracefully. + Err(CoreError::ConnectionClosed) => { + tracing::debug!("peer disconnected"); + return Ok(()); + } + + // Unclean TCP close (RST / unexpected EOF): treat as normal disconnect. + Err(CoreError::Codec(CodecError::Io(ref e))) + if matches!( + e.kind(), + std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::UnexpectedEof + | std::io::ErrorKind::BrokenPipe + ) => + { + tracing::debug!(io_kind = %e.kind(), "peer disconnected (unclean)"); + return Ok(()); + } + + Err(e) => return Err(e), + }; + + match env.msg_type { + MsgType::Ping => { + tracing::debug!("ping → pong"); + transport + .send_envelope(&ParsedEnvelope { + msg_type: MsgType::Pong, + group_id: vec![], + sender_id: vec![], + payload: vec![], + timestamp_ms: current_timestamp_ms(), + }) + .await?; + } + + // All other message types are silently ignored in M1. + // M2 adds AS/DS RPC dispatch here. + _ => { + tracing::warn!("unexpected message type in M1 — ignoring"); + } + } + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Format the first 4 bytes of a key as hex with a trailing ellipsis. +fn fmt_key(key: &[u8]) -> String { + if key.len() < 4 { + return format!("{key:02x?}"); + } + format!("{:02x}{:02x}{:02x}{:02x}…", key[0], key[1], key[2], key[3]) +} + +/// Return the current Unix timestamp in milliseconds. +/// +/// Falls back to 0 if the system clock predates the Unix epoch (pathological). +fn current_timestamp_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..96bfbc1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + server: + build: + context: . + dockerfile: docker/Dockerfile + ports: + - "7000:7000" + environment: + RUST_LOG: "info" + NOISEML_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: + test: ["CMD", "bash", "-c", "echo '' > /dev/tcp/localhost/7000"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + restart: unless-stopped diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..04ecf1b --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,71 @@ +# ── Stage 1: Builder ────────────────────────────────────────────────────────── +# +# Uses the official Rust image on Debian Bookworm. +# capnproto is installed here because build.rs invokes `capnp` at compile time. +FROM rust:bookworm AS builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends capnproto \ + && rm -rf /var/lib/apt/lists/* + +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 + +# 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 + +# 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 + +# 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 + +RUN cargo build --release --bin noiseml-server + +# ── Stage 2: Runtime ────────────────────────────────────────────────────────── +# +# Minimal Debian Bookworm image — no Rust toolchain, no capnp compiler. +FROM debian:bookworm-slim AS runtime + +# ca-certificates is included so future HTTPS calls (e.g. from M6 key sync) +# work without further changes to this stage. +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 + +EXPOSE 7000 + +ENV RUST_LOG=info \ + NOISEML_LISTEN=0.0.0.0:7000 + +# Run as a non-root user. +USER nobody + +CMD ["noiseml-server"] diff --git a/master-prompt.md b/master-prompt.md new file mode 100644 index 0000000..de3c109 --- /dev/null +++ b/master-prompt.md @@ -0,0 +1,329 @@ +# noiseml — 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. + +This is not a prototype. Every milestone produces production-ready, tested, deployable code. + +--- + +## Non-Negotiable Engineering Standards + +- **Production-ready only.** No stubs, mocks, `todo!()`, `unimplemented!()`, or placeholder logic in deliverables. If something is out of scope for the current milestone, it is explicitly omitted with a documented reason, not silently stubbed. +- **YAGNI / KISS / DRY.** Do not add features, abstractions, or generics that are not required by the current milestone. Favour clarity over cleverness. +- **Spec-first.** Document the design (ADR or inline doc comment) before implementing it. Every public API must have a doc comment explaining what it does, its invariants, and any error conditions. +- **Security-by-design.** Secrets use `zeroize`. No secret material in logs. No `unwrap()` on cryptographic operations — all errors are typed and propagated. Constant-time comparisons where required. +- **Containerised.** The server runs in Docker. `docker-compose.yml` is always kept up to date. +- **Dependency hygiene.** Pin major versions. Prefer the `dalek` ecosystem for classical crypto, `snow` for Noise, `openmls` for MLS, `ml-kem` for post-quantum, `capnp`/`capnp-rpc` for serialisation and RPC. Do not introduce new dependencies without justification. +- **Review before presenting.** Before presenting any code, review it for: missing error handling, security gaps, incomplete implementations, and deviation from these standards. Fix all issues found before output. + +--- + +## Git Standards + +- GPG-signed commits only. +- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `test:`, `refactor:`. +- Feature branches per milestone: `feat/m1-noise-transport`, `feat/m2-keypackage-as`, etc. +- No `Co-authored-by` trailers. +- Commit messages describe *why*, not just *what*. + +--- + +## Architecture + +### Workspace Layout + +``` +noiseml/ +├── 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 +├── schemas/ # .capnp schema files (canonical source of truth) +│ ├── envelope.capnp +│ ├── auth.capnp +│ └── delivery.capnp +├── docker/ +│ └── Dockerfile +├── docker-compose.yml +└── docs/ + └── architecture.md +``` + +### Crate Responsibilities + +**noiseml-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** +- 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** +- 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** +- Tokio TCP connection to server +- Noise handshake, then Cap'n Proto RPC client stub +- CLI interface (clap) +- Drives noiseml-core for all crypto operations +- Displays received messages to stdout + +### Transport Stack + +``` +TCP connection +└── Noise_XX handshake (snow) + └── Authenticated encrypted channel (ChaCha20-Poly1305) + └── [u32 frame_len][Cap'n Proto encoded message] + └── Cap'n Proto RPC (capnp-rpc, M2+) +``` + +Both sides hold static X25519 keypairs for the Noise handshake and Ed25519 keypairs for MLS identity. After Noise_XX, mutual authentication is complete. All subsequent frames are Noise-encrypted. Cap'n Proto RPC runs inside the encrypted channel — it has no knowledge of the transport security. + +### Cap'n Proto Schemas + +```capnp +# schemas/envelope.capnp +@0xDEADBEEFCAFEBABE; # unique file ID (generate with: capnp id) + +struct Envelope { + msgType @0 :MsgType; + groupId @1 :Data; # 32 bytes, SHA-256 of group name + senderId @2 :Data; # 32 bytes, SHA-256 of sender identity key + payload @3 :Data; # opaque: MLS blob or control payload + timestampMs @4 :UInt64; # unix milliseconds + + enum MsgType { + ping @0; + pong @1; + keyPackageUpload @2; + keyPackageFetch @3; + keyPackageResponse @4; + mlsWelcome @5; + mlsCommit @6; + mlsApplication @7; + error @8; + } +} + +# schemas/auth.capnp +@0xAAAABBBBCCCCDDDD; + +interface AuthenticationService { + # Upload a KeyPackage for later retrieval by peers adding this client to a group. + # identityKey: Ed25519 public key bytes (32 bytes) + # package: openmls-serialised KeyPackage blob + # Returns the SHA-256 fingerprint of the package on success. + uploadKeyPackage @0 (identityKey :Data, package :Data) -> (fingerprint :Data); + + # Fetch one KeyPackage for a given identity key. + # Consuming: the server removes the returned KeyPackage (one-time use, MLS spec). + # Returns empty Data if no KeyPackage is available for this identity. + fetchKeyPackage @1 (identityKey :Data) -> (package :Data); +} + +# schemas/delivery.capnp +@0x1111222233334444; + +interface DeliveryService { + # Fan out an MLS message to all current members of a group. + # groupId: 32-byte group identifier + # message: serialised MLSMessage blob + # Returns count of recipients the message was queued for. + fanOut @0 (groupId :Data, message :Data) -> (recipientCount :UInt32); + + # Subscribe to incoming messages for a group. + # memberId: 32-byte identity key fingerprint of the subscribing client. + # Returns a capability stream; server pushes MLS blobs as they arrive. + subscribe @1 (groupId :Data, memberId :Data) -> (stream :MessageStream); +} + +interface MessageStream { + # Pull the next available message for this subscriber. + # Blocks (promise does not resolve) until a message is available. + # sequenceNo is monotonically increasing per group, used for gap detection. + next @0 () -> (message :Data, sequenceNo :UInt64); +} +``` + +### MLS Design + +- Ciphersuite: `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` as baseline (M1–M4), replaced with hybrid PQ ciphersuite at M5. +- DS is MLS-unaware: routes `MLSMessage` blobs by `group_id`. Does not inspect epoch or content. +- AS stores `KeyPackage` blobs indexed by `(identity_key_fingerprint, package_id)`. One KeyPackage consumed per member-add operation (MLS requirement: KeyPackages are single-use). +- Welcome messages routed by the DS to the target client using `sender_id` → `target_id` mapping in the Envelope. +- Cap'n Proto canonical form used when serialising any structure that is subsequently signed (MLS Commit signatures, AS KeyPackage fingerprints). + +### Post-Quantum (M5) + +Hybrid KEM construction: +``` +SharedSecret = HKDF-SHA256( + ikm = X25519_ss || ML-KEM-768_ss, + info = "noiseml-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`. + +--- + +## Milestones + +### M1 — Noise Transport ✦ current +**Goal:** Two processes complete Noise_XX handshake over TCP and exchange typed Cap'n Proto frames. + +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 +- Integration test: server and client in same test binary using `tokio::spawn` +- `docker-compose.yml` running the server + +### M2 — Authentication Service + KeyPackage Exchange +**Goal:** Clients register identity and publish/fetch MLS KeyPackages via Cap'n Proto RPC. + +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 +- Test: Alice uploads KeyPackage, Bob fetches it, fingerprints match + +### M3 — MLS Group Create + Welcome +**Goal:** Alice creates a group and adds Bob via MLS Welcome. Both hold valid epoch 1 state. + +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 +- 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()` +- 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 +- All M3/M4 tests pass unchanged with new ciphersuite +- Criterion benchmarks: key generation, encap/decap, group-add latency (10/100/1000 members) + +### M6 — Persistence + Production Docker +**Goal:** Server survives restart. Full containerised deployment. + +Deliverables: +- `noiseml-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) + +--- + +## Dependencies (pinned majors) + +```toml +# Crypto +openmls = "0.6" +openmls_rust_crypto = "0.6" +ml-kem = "0.3" +x25519-dalek = "2" +ed25519-dalek = "2" +snow = "0.9" +chacha20poly1305 = "0.10" +sha2 = "0.10" +hkdf = "0.12" +zeroize = { version = "1", features = ["derive"] } +rand = "0.8" + +# Serialisation + RPC +capnp = "0.19" +capnp-rpc = "0.19" + +# Build-time only +capnpc = "0.19" # build-dependency in noiseml-proto + +# Async / networking +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["codec"] } + +# Server utilities +dashmap = "5" +sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio"] } # M6+ +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" +thiserror = "1" + +# CLI +clap = { version = "4", features = ["derive"] } +``` + +--- + +## Key Design Decisions (ADR Summary) + +**ADR-001: Noise_XX for transport authentication** +Both parties hold static keys registered with the AS. XX pattern provides mutual authentication and identity hiding for the initiator. No TLS dependency, no certificate infrastructure. + +**ADR-002: Cap'n Proto replaces MessagePack** +Cap'n Proto provides: zero-copy reads, schema-enforced types, canonical serialisation for cryptographic signing, and a built-in async RPC system (capnp-rpc) that eliminates hand-rolled message dispatch. The build-time codegen overhead is accepted as worthwhile. + +**ADR-003: Cap'n Proto RPC runs inside the Noise tunnel** +Cap'n Proto RPC has no transport security of its own. It operates over the byte stream produced by the Noise session. Separation of concerns: Noise owns authentication and confidentiality, Cap'n Proto owns framing and dispatch. + +**ADR-004: DS is MLS-unaware** +The Delivery Service routes opaque `MLSMessage` blobs by `group_id`. It never decrypts or inspects MLS content. This is the correct MLS architecture (RFC 9420 §4) and is a natural Audit-Core integration point: the DS log is an append-only sequence of authenticated blobs. + +**ADR-005: Single-use KeyPackages** +MLS requires that each KeyPackage be used at most once (to preserve forward secrecy of the initial key exchange). The AS consumes a KeyPackage on fetch. Clients should pre-upload multiple KeyPackages. The AS warns when a client's supply runs low (M2+). + +**ADR-006: PQ gap in Noise transport is accepted** +The MLS content layer is PQ-protected from M5. The Noise transport (X25519) remains classical — PQ-Noise (draft-noise-pq) is not yet supported by `snow`. Harvest-now-decrypt-later against the handshake metadata is an accepted residual risk for M1–M5. No long-lived content secrets transit the Noise handshake, so the practical impact is limited to identity/timing metadata. + +--- + +## 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: + +1. Confirm the current milestone and task. +2. State any design decisions being made (ADR format if significant). +3. Produce complete, production-ready code for the task. +4. Review the code internally for gaps before presenting. +5. State what the next logical task is. + +When asking for code, always specify: +- Which crate(s) are affected. +- Whether this is a new file or modification to existing. +- Any constraints or context the assistant may not have (e.g. existing types already defined). + +--- + +*noiseml — 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 new file mode 100644 index 0000000..7fdbb4e --- /dev/null +++ b/schemas/envelope.capnp @@ -0,0 +1,52 @@ +# envelope.capnp — top-level wire message for all noiseml traffic. +# +# Every frame exchanged over the Noise channel is serialised as an Envelope. +# The Delivery Service routes by (groupId, msgType) without inspecting payload. +# +# Field sizing rationale: +# groupId / senderId : 32 bytes — SHA-256 digest +# payload : opaque — MLS blob or control data; size bounded by +# the Noise transport max message size (65535 B) +# timestampMs : UInt64 — unix epoch milliseconds; sufficient until year 292M +# +# ID generated with: capnp id +@0xe4a7f2c8b1d63509; + +struct Envelope { + # Message type discriminant — determines how payload is interpreted. + msgType @0 :MsgType; + + # 32-byte SHA-256 digest of the group name. + # The Delivery Service uses this as its routing key. + # Zero-filled for point-to-point control messages (ping, keyPackageUpload, etc.). + groupId @1 :Data; + + # 32-byte SHA-256 digest of the sender's Ed25519 identity public key. + senderId @2 :Data; + + # Opaque payload. Interpretation is determined by msgType: + # ping / pong — empty + # keyPackageUpload — openmls-serialised KeyPackage blob + # keyPackageFetch — target identity key (32 bytes) + # keyPackageResponse — openmls-serialised KeyPackage blob (or empty if none) + # mlsWelcome — MLSMessage blob (Welcome variant) + # mlsCommit — MLSMessage blob (PublicMessage / Commit variant) + # mlsApplication — MLSMessage blob (PrivateMessage / Application variant) + # error — UTF-8 error description + payload @3 :Data; + + # Unix timestamp in milliseconds at the time of send. + timestampMs @4 :UInt64; + + enum MsgType { + ping @0; + pong @1; + keyPackageUpload @2; + keyPackageFetch @3; + keyPackageResponse @4; + mlsWelcome @5; + mlsCommit @6; + mlsApplication @7; + error @8; + } +}