From 9a0b02a01275344ed45b259bc9497766ff7ad504 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Thu, 19 Feb 2026 23:39:49 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20M2=20+=20M3=20=E2=80=94=20AuthService,?= =?UTF-8?q?=20MLS=20group=20lifecycle,=20Delivery=20Service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M2: - schemas/auth.capnp: AuthenticationService (upload/fetch KeyPackage) - noiseml-core: IdentityKeypair (Ed25519), generate_key_package, NoiseTransport with send_envelope/recv_envelope, Noise_XX handshake (initiator + responder) - noiseml-proto: auth_capnp module, ParsedEnvelope helpers - noiseml-server: AuthServiceImpl backed by DashMap queue (single-use KPs) - noiseml-client: register + fetch-key subcommands, ping over Noise_XX - tests: auth_service integration test (upload → fetch round-trip) M3: - schemas/delivery.capnp: DeliveryService (enqueue/fetch opaque payloads) - noiseml-core/group.rs: GroupMember — MLS group lifecycle create_group, add_member (→ Commit+Welcome), join_group, send_message, receive_message; uses openmls 0.5 public API (extract() not into_welcome, KeyPackageIn::validate() not From) - noiseml-server: DeliveryServiceImpl on port 7001 alongside AS on 7000 - noiseml-proto: delivery_capnp module TODO (see M3_STATUS.md): - noiseml-client: group subcommands (create-group, invite, join, send, recv) - noiseml-client/tests/mls_group.rs: full MLS round-trip integration test --- Cargo.lock | 890 +++++++++++++++++++- Cargo.toml | 9 +- M3_STATUS.md | 153 ++++ crates/noiseml-client/Cargo.toml | 7 +- crates/noiseml-client/src/main.rs | 226 ++++- crates/noiseml-client/tests/auth_service.rs | 255 ++++++ crates/noiseml-core/Cargo.toml | 14 +- crates/noiseml-core/src/error.rs | 6 + crates/noiseml-core/src/group.rs | 428 ++++++++++ crates/noiseml-core/src/identity.rs | 97 +++ crates/noiseml-core/src/keypackage.rs | 86 ++ crates/noiseml-core/src/lib.rs | 30 +- crates/noiseml-core/src/noise.rs | 76 +- crates/noiseml-proto/build.rs | 12 +- crates/noiseml-proto/src/lib.rs | 14 + crates/noiseml-server/Cargo.toml | 4 + crates/noiseml-server/src/main.rs | 500 ++++++++--- schemas/auth.capnp | 31 + schemas/delivery.capnp | 35 + 19 files changed, 2664 insertions(+), 209 deletions(-) create mode 100644 M3_STATUS.md create mode 100644 crates/noiseml-client/tests/auth_service.rs create mode 100644 crates/noiseml-core/src/group.rs create mode 100644 crates/noiseml-core/src/identity.rs create mode 100644 crates/noiseml-core/src/keypackage.rs create mode 100644 schemas/auth.capnp create mode 100644 schemas/delivery.capnp diff --git a/Cargo.lock b/Cargo.lock index 9f7d158..65f4365 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,30 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877" +dependencies = [ + "generic-array", +] + [[package]] name = "aead" version = "0.5.2" @@ -12,6 +36,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" +dependencies = [ + "cfg-if", + "cipher 0.3.0", + "cpufeatures", + "opaque-debug", +] + [[package]] name = "aes" version = "0.8.4" @@ -19,21 +55,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures", ] +[[package]] +name = "aes-gcm" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df5f85a83a7d8b0442b6aa7b504b8212c1733da07b98aae43d4bc21b2cb3cdf6" +dependencies = [ + "aead 0.4.3", + "aes 0.7.5", + "cipher 0.3.0", + "ctr 0.8.0", + "ghash 0.4.4", + "subtle", +] + [[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", + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", "subtle", ] @@ -102,6 +152,27 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64ct" version = "1.8.3" @@ -120,7 +191,16 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", ] [[package]] @@ -132,6 +212,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -183,6 +275,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c80e5460aa66fe3b91d40bcbdab953a597b60053e34d684ac6903f863b680a6" +dependencies = [ + "cfg-if", + "cipher 0.3.0", + "cpufeatures", + "zeroize", +] + [[package]] name = "chacha20" version = "0.9.1" @@ -190,23 +294,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures", ] +[[package]] +name = "chacha20poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18446b09be63d457bbec447509e85f662f32952b035ce892290396bc0b0cff5" +dependencies = [ + "aead 0.4.3", + "chacha20 0.8.2", + "cipher 0.3.0", + "poly1305 0.7.2", + "zeroize", +] + [[package]] name = "chacha20poly1305" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ - "aead", - "chacha20", - "cipher", - "poly1305", + "aead 0.5.2", + "chacha20 0.9.1", + "cipher 0.4.4", + "poly1305 0.8.0", "zeroize", ] +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + [[package]] name = "cipher" version = "0.4.4" @@ -279,6 +405,43 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -286,17 +449,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" +dependencies = [ + "cipher 0.3.0", +] + [[package]] name = "ctr" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "curve25519-dalek" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.5.1", + "subtle", + "zeroize", ] [[package]] @@ -308,7 +493,7 @@ dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -326,6 +511,19 @@ dependencies = [ "syn", ] +[[package]] +name = "curve25519-dalek-ng" +version = "4.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c359b7249347e46fb28804470d071c921156ad62b3eef5d34e2ba867533dec8" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.6.4", + "subtle-ng", + "zeroize", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -346,20 +544,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", + "const-oid", "crypto-common", "subtle", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki", +] + +[[package]] +name = "ed25519" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7" +dependencies = [ + "signature 1.6.4", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -367,7 +599,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ "pkcs8", - "signature", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-dalek" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c762bae6dcaf24c4c84667b8579785430908723d5c889f469d76a41d59cc7a9d" +dependencies = [ + "curve25519-dalek 3.2.0", + "ed25519 1.5.3", + "rand 0.7.3", + "serde", + "sha2 0.9.9", + "zeroize", ] [[package]] @@ -376,11 +622,38 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", - "ed25519", - "rand_core", + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "rand_core 0.6.4", "serde", - "sha2", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", "subtle", "zeroize", ] @@ -401,6 +674,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -503,6 +786,18 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", ] [[package]] @@ -512,8 +807,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1583cc1656d7839fd3732b80cf4f38850336cdb9b8ded1cd399ca62958de3c99" +dependencies = [ + "opaque-debug", + "polyval 0.5.3", ] [[package]] @@ -523,7 +830,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ "opaque-debug", - "polyval", + "polyval 0.6.2", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", ] [[package]] @@ -553,7 +877,53 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hpke-rs" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d78d066f8d487fa69d5c3f92f98c11e2540796d213016d107fe86eabf9f26b" +dependencies = [ + "hpke-rs-crypto", + "log", + "serde", + "serde_json", + "tls_codec 0.4.2", + "zeroize", +] + +[[package]] +name = "hpke-rs-crypto" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79df748353d9cee46d565f591d0039973a6554f8ef026b2647ab1ef2b64b91df" +dependencies = [ + "getrandom 0.2.17", + "rand 0.8.5", + "serde", + "serde_json", + "tls_codec 0.4.2", +] + +[[package]] +name = "hpke-rs-rust-crypto" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d6fcfe6949aedbacad5aedb2f8ef9f054a142510e8f4f7355a4ccb3f5bd01f" +dependencies = [ + "aes-gcm 0.10.3", + "chacha20poly1305 0.10.1", + "getrandom 0.2.17", + "hkdf", + "hpke-rs-crypto", + "p256", + "p384", + "rand 0.8.5", + "rand_chacha 0.3.1", + "sha2 0.10.9", + "x25519-dalek-ng", ] [[package]] @@ -571,6 +941,22 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -613,6 +999,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + [[package]] name = "mio" version = "1.1.1" @@ -620,7 +1015,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -632,10 +1027,11 @@ dependencies = [ "capnp", "capnp-rpc", "clap", + "dashmap", "futures", "noiseml-core", "noiseml-proto", - "noiseml-server", + "sha2 0.10.9", "thiserror", "tokio", "tokio-util", @@ -649,13 +1045,18 @@ version = "0.1.0" dependencies = [ "bytes", "capnp", - "ed25519-dalek", + "ed25519-dalek 2.2.0", + "futures", "hkdf", "noiseml-proto", - "rand", - "sha2", + "openmls", + "openmls_rust_crypto", + "openmls_traits", + "rand 0.8.5", + "sha2 0.10.9", "snow", "thiserror", + "tls_codec 0.3.0", "tokio", "tokio-util", "x25519-dalek", @@ -677,10 +1078,12 @@ dependencies = [ "anyhow", "capnp", "capnp-rpc", + "clap", "dashmap", "futures", "noiseml-core", "noiseml-proto", + "sha2 0.10.9", "thiserror", "tokio", "tokio-util", @@ -697,6 +1100,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -715,6 +1127,91 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openmls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95d4a03899704c17e7b280676a3301216faccf9b19eb860968f84a492aef944b" +dependencies = [ + "backtrace", + "log", + "openmls_traits", + "rayon", + "serde", + "thiserror", + "tls_codec 0.3.0", +] + +[[package]] +name = "openmls_memory_keystore" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1532fed34a1d3cf29962c1c07624f628501537eafac47913a08caea4bf08319e" +dependencies = [ + "openmls_traits", + "serde_json", + "thiserror", +] + +[[package]] +name = "openmls_rust_crypto" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34960cce81b5a8b8a178f330e28895f092b49a28923d36c03fa56dd3d6f7173" +dependencies = [ + "aes-gcm 0.9.4", + "chacha20poly1305 0.9.1", + "ed25519-dalek 1.0.1", + "hkdf", + "hmac", + "hpke-rs", + "hpke-rs-crypto", + "hpke-rs-rust-crypto", + "openmls_memory_keystore", + "openmls_traits", + "p256", + "rand 0.7.3", + "rand 0.8.5", + "rand_chacha 0.3.1", + "sha2 0.10.9", + "thiserror", + "tls_codec 0.3.0", +] + +[[package]] +name = "openmls_traits" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a839131d13dfa50e6eef6f11f7718195c3194ac9efd81ebf2cb72e554c4e12f2" +dependencies = [ + "serde", + "tls_codec 0.3.0", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -738,6 +1235,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -754,6 +1260,17 @@ dependencies = [ "spki", ] +[[package]] +name = "poly1305" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048aeb476be11a4b6ca432ca569e375810de9294ae78f4774e78ea98a9246ede" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash 0.4.1", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -762,7 +1279,19 @@ checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8419d2b623c7c0896ff2d5d96e2cb4ede590fed28fcc34934f4c33c036e620a1" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash 0.4.1", ] [[package]] @@ -774,7 +1303,7 @@ dependencies = [ "cfg-if", "cpufeatures", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", ] [[package]] @@ -786,6 +1315,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -804,6 +1342,19 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", +] + [[package]] name = "rand" version = "0.8.5" @@ -811,8 +1362,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", ] [[package]] @@ -822,7 +1383,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", ] [[package]] @@ -831,7 +1401,36 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", ] [[package]] @@ -860,6 +1459,22 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -869,12 +1484,32 @@ dependencies = [ "semver", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.27" @@ -911,6 +1546,32 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + [[package]] name = "sha2" version = "0.10.9" @@ -919,7 +1580,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", ] [[package]] @@ -941,13 +1602,20 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" + [[package]] name = "signature" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "rand_core", + "digest 0.10.7", + "rand_core 0.6.4", ] [[package]] @@ -968,13 +1636,13 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "850948bee068e713b8ab860fe1adc4d109676ab4c3b621fd8147f06b261f2f85" dependencies = [ - "aes-gcm", + "aes-gcm 0.10.3", "blake2", - "chacha20poly1305", - "curve25519-dalek", - "rand_core", + "chacha20poly1305 0.10.1", + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", "rustc_version", - "sha2", + "sha2 0.10.9", "subtle", ] @@ -1006,9 +1674,15 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subtle" -version = "2.6.1" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + +[[package]] +name = "subtle-ng" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" [[package]] name = "syn" @@ -1050,6 +1724,50 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tls_codec" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1e621cbf57f36f5b51ebf366b57ba153be7fed133182a9513e443ecdf506e" +dependencies = [ + "serde", + "tls_codec_derive 0.3.0", + "zeroize", +] + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "serde", + "tls_codec_derive 0.4.2", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3226440488120aabe7e7cc80292634a68e541c407d97b66eceaae787454dae25" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio" version = "1.49.0" @@ -1086,6 +1804,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -1164,6 +1883,16 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "universal-hash" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f214e8f697e925001e66ec2c6e37a4ef93f0f78c2eed7814394e10c62025b05" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -1192,12 +1921,63 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1293,12 +2073,24 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ - "curve25519-dalek", - "rand_core", + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", "serde", "zeroize", ] +[[package]] +name = "x25519-dalek-ng" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf7074de8999662970c3c4c8f7f30925028dd8f4ca31ad4c055efa9cdf2ec326" +dependencies = [ + "curve25519-dalek-ng", + "rand 0.8.5", + "rand_core 0.6.4", + "zeroize", +] + [[package]] name = "zerocopy" version = "0.8.39" @@ -1338,3 +2130,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 5944e89..1eabc79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,10 @@ members = [ # ── Crypto ──────────────────────────────────────────────────────────────────── openmls = { version = "0.5", default-features = false, features = ["crypto-subtle"] } openmls_rust_crypto = { version = "0.2" } -openmls_basic_credential = { version = "0.2" } +openmls_traits = { version = "0.2" } +# tls_codec must match the version used by openmls 0.5 (which uses 0.3) to avoid +# duplicate Serialize trait versions in the dependency graph. +tls_codec = { version = "0.3", features = ["derive"] } # 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" } @@ -31,7 +34,7 @@ capnp-rpc = { version = "0.19" } # ── Async / networking ──────────────────────────────────────────────────────── tokio = { version = "1", features = ["full"] } -tokio-util = { version = "0.7", features = ["codec"] } +tokio-util = { version = "0.7", features = ["codec", "compat"] } futures = { version = "0.3" } # ── Server utilities ────────────────────────────────────────────────────────── @@ -44,7 +47,7 @@ anyhow = { version = "1" } thiserror = { version = "1" } # ── CLI ─────────────────────────────────────────────────────────────────────── -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } # ── Build-time ──────────────────────────────────────────────────────────────── capnpc = { version = "0.19" } diff --git a/M3_STATUS.md b/M3_STATUS.md new file mode 100644 index 0000000..7ab9d0c --- /dev/null +++ b/M3_STATUS.md @@ -0,0 +1,153 @@ +# M3 Implementation Status + +**Last updated:** 2026-02-19 +**Branch:** feat/m1-noise-transport (all milestones on this branch so far) + +--- + +## What is M3? + +M3 adds: +1. **Delivery Service (DS)** — store-and-forward relay for MLS messages (Cap'n Proto RPC on port 7001) +2. **MLS Group Lifecycle** — `GroupMember` struct: create group, add member (Welcome), join group, send/receive encrypted application messages + +--- + +## Completed in M3 + +### `schemas/delivery.capnp` ✅ +Simple DS schema: `enqueue(recipientKey, payload)` + `fetch(recipientKey) → List(Data)`. + +### `noiseml-proto/build.rs` ✅ +Compiles `delivery.capnp` alongside `envelope.capnp` and `auth.capnp`. + +### `noiseml-proto/src/lib.rs` ✅ +Exposes `pub mod delivery_capnp`. + +### `noiseml-core/src/group.rs` ✅ (FULLY FIXED, ALL TESTS PASS) +`GroupMember` struct with methods: +- `new(identity: Arc) -> Self` +- `generate_key_package() -> Result, CoreError>` — TLS-encoded KeyPackage bytes +- `create_group(group_id: &[u8]) -> Result<(), CoreError>` +- `add_member(key_package_bytes: &[u8]) -> Result<(commit_bytes, welcome_bytes), CoreError>` +- `join_group(welcome_bytes: &[u8]) -> Result<(), CoreError>` +- `send_message(plaintext: &[u8]) -> Result, CoreError>` — returns TLS-encoded PrivateMessage +- `receive_message(bytes: &[u8]) -> Result>, CoreError>` — returns plaintext or None for Commit +- `group_id() -> Option>` +- `identity() -> &IdentityKeypair` + +**openmls 0.5 API gotchas resolved:** +- `KeyPackage` only has `TlsSerialize`, not `TlsDeserialize` → use `KeyPackageIn::tls_deserialize(...)?.validate(backend.crypto(), ProtocolVersion::Mls10)?` +- `MlsMessageIn::into_welcome()` is `#[cfg(any(feature = "test-utils", test))]` → use `match msg_in.extract() { MlsMessageInBody::Welcome(w) => w, ... }` +- `MlsMessageIn::into_protocol_message()` is similarly feature-gated → use `match msg_in.extract() { MlsMessageInBody::PrivateMessage(m) => ProtocolMessage::PrivateMessage(m), MlsMessageInBody::PublicMessage(m) => ProtocolMessage::PublicMessage(m), ... }` +- `From for ProtocolMessage` is also feature-gated +- Must use `OpenMlsCryptoProvider` trait in scope for `backend.crypto()` + +### `noiseml-core/src/lib.rs` ✅ +Exposes `pub use group::GroupMember`. + +### `noiseml-server/src/main.rs` ✅ +Two listeners on one `LocalSet`: +- Port 7000 (AS): `AuthServiceImpl` — unchanged from M2 +- Port 7001 (DS): `DeliveryServiceImpl` — new; uses `DashMap, VecDeque>>` keyed by Ed25519 public key + +New CLI flag: `--ds-listen` (default `0.0.0.0:7001`, env `NOISEML_DS_LISTEN`). + +--- + +## NOT YET DONE (continue tomorrow) + +### 1. `noiseml-client/src/main.rs` — Group subcommands + +Add these subcommands (note: need state persistence or a `demo` command approach): + +**Recommended approach:** Add a `demo-group` subcommand that runs the full Alice-Bob MLS round-trip in a single process invocation against a live server. This avoids the `MlsGroup` serialization problem (openmls 0.5 MlsGroup state is hard to persist without the `serde` feature). + +**Alternatively (with state file):** Enable `serde` feature on openmls in `Cargo.toml` and store `MlsGroup` state to disk. The workspace Cargo.toml uses `features = ["crypto-subtle"]` for openmls — add `"serde"` to that list. + +Subcommands needed: +- `create-group --as-server --ds-server --group-id ` — creates group, saves state +- `invite --as-server --ds-server --peer-key ` — fetches peer KP from AS, creates Welcome, enqueues to DS +- `join --ds-server` — fetches Welcome from DS, joins group, saves state +- `send --ds-server --peer-key --msg ` — sends application message to DS +- `recv --ds-server` — fetches and decrypts messages from DS + +OR: just add `demo-group --server --ds-server` that does the whole flow. + +### 2. `noiseml-client/tests/mls_group.rs` — Integration test + +This is the PRIORITY for testing. The integration test should: + +```rust +// 1. Spawn server (AS on port X, DS on port Y) with tokio::process::Command +// or by directly calling the server's accept loop in a LocalSet +// 2. Alice: GroupMember::new, generate_key_package, upload to AS +// 3. Bob: GroupMember::new, generate_key_package, upload to AS +// 4. Alice: create_group, fetch Bob's KP from AS, add_member → (commit, welcome) +// Alice: enqueue welcome for Bob via DS (recipient = bob's identity.public_key_bytes()) +// 5. Bob: fetch from DS, join_group(welcome) +// 6. Alice: send_message(b"hello bob"), enqueue to DS +// 7. Bob: fetch from DS, receive_message → assert plaintext == b"hello bob" +// 8. Bob: send_message(b"hello alice"), enqueue to DS +// 9. Alice: fetch from DS, receive_message → assert plaintext == b"hello alice" +``` + +**Important:** For the integration test, you can bypass the CLI and use `GroupMember` + capnp-rpc client helpers directly. + +Connect to DS (port 7001): +```rust +async fn connect_ds(server: &str, keypair: &NoiseKeypair) -> anyhow::Result { + let stream = TcpStream::connect(server).await?; + let transport = handshake_initiator(stream, keypair).await?; + let (reader, writer) = transport.into_capnp_io(); + let network = twoparty::VatNetwork::new(reader.compat(), writer.compat_write(), Side::Client, Default::default()); + let mut rpc = RpcSystem::new(Box::new(network), None); + let ds: delivery_service::Client = rpc.bootstrap(Side::Server); + tokio::task::spawn_local(rpc); + Ok(ds) +} +``` + +--- + +## Key Design Decisions + +### DS Port (7001) vs same port +The server uses **two separate listeners** (7000 for AS, 7001 for DS) because capnp-rpc supports only one bootstrap capability per connection. No new schema was needed. + +### GroupMember lifecycle (CRITICAL) +The `OpenMlsRustCrypto` backend holds the HPKE init private key **in memory**. The **same `GroupMember` instance** must be used from `generate_key_package()` through `join_group()`. Do NOT create a new GroupMember between these calls. + +### KeyPackage wire format +`GroupMember::generate_key_package()` returns raw TLS-encoded `KeyPackage` bytes (NOT wrapped in `MlsMessageOut`). This is the same format as the standalone `generate_key_package()` function used in M2 tests. The AS stores these raw bytes. + +When adding a member, `add_member()` deserializes via `KeyPackageIn::tls_deserialize(...)?.validate(...)`. + +--- + +## Test Results (all passing) + +``` +test codec::tests::* ... ok (5 tests) +test keypair::tests::* ... ok (3 tests) +test group::tests::two_party_mls_round_trip ... ok +test group::tests::group_id_lifecycle ... ok +``` + +--- + +## How to continue tomorrow + +```bash +cd /home/c/projects/poc-mes +git log --oneline -5 # see where we are +cargo test -p noiseml-core # verify green +``` + +Then: +1. Write `crates/noiseml-client/tests/mls_group.rs` (integration test) — highest priority +2. Add group subcommands to `crates/noiseml-client/src/main.rs` + +The integration test is the most important piece — it proves the full M3 stack works end-to-end. + +For the test, see the pattern in `crates/noiseml-client/tests/auth_service.rs` (M2 test) for how to spin up the server and connect clients. diff --git a/crates/noiseml-client/Cargo.toml b/crates/noiseml-client/Cargo.toml index 9de38c0..ce3c6e0 100644 --- a/crates/noiseml-client/Cargo.toml +++ b/crates/noiseml-client/Cargo.toml @@ -26,6 +26,9 @@ futures = { workspace = true } anyhow = { workspace = true } thiserror = { workspace = true } +# Crypto — for fingerprint verification in fetch-key subcommand +sha2 = { workspace = true } + # Logging tracing = { workspace = true } tracing-subscriber = { workspace = true } @@ -34,5 +37,5 @@ tracing-subscriber = { workspace = true } clap = { workspace = true } [dev-dependencies] -# Integration tests spin up both server and client in the same process. -noiseml-server = { path = "../noiseml-server" } +# Integration tests use noiseml-core, noiseml-proto, and capnp-rpc directly. +dashmap = { workspace = true } diff --git a/crates/noiseml-client/src/main.rs b/crates/noiseml-client/src/main.rs index 4d14570..daa5851 100644 --- a/crates/noiseml-client/src/main.rs +++ b/crates/noiseml-client/src/main.rs @@ -1,10 +1,12 @@ //! noiseml CLI client. //! -//! # M1 subcommands +//! # Subcommands //! -//! | Subcommand | Description | -//! |------------|-----------------------------------------| -//! | `ping` | Send a Ping to the server, print RTT | +//! | Subcommand | Description | +//! |--------------|----------------------------------------------------------| +//! | `ping` | Send a Ping to the server, print RTT | +//! | `register` | Generate a KeyPackage and upload it to the AS | +//! | `fetch-key` | Fetch a peer's KeyPackage from the AS by identity key | //! //! # Configuration //! @@ -12,19 +14,15 @@ //! |-----------------|--------------|---------------------| //! | `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 capnp_rpc::{RpcSystem, rpc_twoparty_capnp::Side, twoparty}; use clap::{Parser, Subcommand}; use tokio::net::TcpStream; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; -use noiseml_core::{NoiseKeypair, handshake_initiator}; -use noiseml_proto::{MsgType, ParsedEnvelope}; +use noiseml_core::{IdentityKeypair, NoiseKeypair, generate_key_package, handshake_initiator}; +use noiseml_proto::{MsgType, ParsedEnvelope, auth_capnp::authentication_service}; // ── CLI ─────────────────────────────────────────────────────────────────────── @@ -43,6 +41,29 @@ enum Command { #[arg(long, default_value = "127.0.0.1:7000", env = "NOISEML_SERVER")] server: String, }, + + /// Generate a fresh MLS KeyPackage and upload it to the Authentication Service. + /// + /// Prints the SHA-256 fingerprint of the uploaded package and the raw + /// Ed25519 identity public key bytes (hex), which peers need to fetch it. + Register { + /// Server address (host:port). + #[arg(long, default_value = "127.0.0.1:7000", env = "NOISEML_SERVER")] + server: String, + }, + + /// Fetch a peer's KeyPackage from the Authentication Service. + /// + /// IDENTITY_KEY is the peer's Ed25519 public key encoded as 64 lowercase + /// hex characters (32 bytes). + FetchKey { + /// Server address (host:port). + #[arg(long, default_value = "127.0.0.1:7000", env = "NOISEML_SERVER")] + server: String, + + /// Target peer's Ed25519 identity public key (64 hex chars = 32 bytes). + identity_key: String, + }, } // ── Entry point ─────────────────────────────────────────────────────────────── @@ -60,17 +81,24 @@ async fn main() -> anyhow::Result<()> { match args.command { Command::Ping { server } => cmd_ping(&server).await, + Command::Register { server } => { + let local = tokio::task::LocalSet::new(); + local.run_until(cmd_register(&server)).await + } + Command::FetchKey { + server, + identity_key, + } => { + let local = tokio::task::LocalSet::new(); + local.run_until(cmd_fetch_key(&server, &identity_key)).await + } } } // ── Subcommand implementations ──────────────────────────────────────────────── /// Connect to `server`, complete Noise_XX, send a Ping, and print RTT. -/// -/// 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) @@ -86,12 +114,11 @@ async fn cmd_ping(server: &str) -> anyhow::Result<()> { { let remote = transport .remote_static_public_key() - .map(fmt_key) + .map(|k| fmt_hex(&k[..4])) .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 @@ -118,22 +145,143 @@ async fn cmd_ping(server: &str) -> anyhow::Result<()> { println!("Pong from {server} rtt={rtt_ms}ms"); Ok(()) } - _ => { - anyhow::bail!( - "protocol error: expected Pong from {server}, got unexpected message type" - ); - } + _ => anyhow::bail!( + "protocol error: expected Pong from {server}, got unexpected message type" + ), } } -// ── Helpers ─────────────────────────────────────────────────────────────────── +/// Generate a KeyPackage for a fresh identity and upload it to the AS. +/// +/// Must run on a `LocalSet` because capnp-rpc is `!Send`. +async fn cmd_register(server: &str) -> anyhow::Result<()> { + let noise_keypair = NoiseKeypair::generate(); + let identity = IdentityKeypair::generate(); -/// 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?}"); + let (tls_bytes, fingerprint) = + generate_key_package(&identity).context("KeyPackage generation failed")?; + + let as_client = connect_as(server, &noise_keypair).await?; + + let mut req = as_client.upload_key_package_request(); + req.get().set_identity_key(&identity.public_key_bytes()); + req.get().set_package(&tls_bytes); + + let response = req + .send() + .promise + .await + .context("upload_key_package RPC failed")?; + + let server_fp = response + .get() + .context("upload_key_package: bad response")? + .get_fingerprint() + .context("upload_key_package: missing fingerprint")? + .to_vec(); + + // Verify the server echoed the same fingerprint. + anyhow::ensure!( + server_fp == fingerprint, + "fingerprint mismatch: local={} server={}", + hex::encode(&fingerprint), + hex::encode(&server_fp), + ); + + println!("identity_key : {}", hex::encode(identity.public_key_bytes())); + println!("fingerprint : {}", hex::encode(&fingerprint)); + println!("KeyPackage uploaded successfully."); + + Ok(()) +} + +/// Fetch a peer's KeyPackage from the AS by their hex-encoded identity key. +/// +/// Must run on a `LocalSet` because capnp-rpc is `!Send`. +async fn cmd_fetch_key(server: &str, identity_key_hex: &str) -> anyhow::Result<()> { + let identity_key = hex::decode(identity_key_hex) + .map_err(|e| anyhow::anyhow!(e)) + .context("identity_key must be 64 hex characters (32 bytes)")?; + anyhow::ensure!( + identity_key.len() == 32, + "identity_key must be exactly 32 bytes, got {}", + identity_key.len() + ); + + let noise_keypair = NoiseKeypair::generate(); + let as_client = connect_as(server, &noise_keypair).await?; + + let mut req = as_client.fetch_key_package_request(); + req.get().set_identity_key(&identity_key); + + let response = req + .send() + .promise + .await + .context("fetch_key_package RPC failed")?; + + let package = response + .get() + .context("fetch_key_package: bad response")? + .get_package() + .context("fetch_key_package: missing package field")? + .to_vec(); + + if package.is_empty() { + println!("No KeyPackage available for this identity."); + return Ok(()); } - format!("{:02x}{:02x}{:02x}{:02x}…", key[0], key[1], key[2], key[3]) + + use sha2::{Digest, Sha256}; + let fingerprint = Sha256::digest(&package); + + println!("fingerprint : {}", hex::encode(fingerprint)); + println!("package_len : {} bytes", package.len()); + println!("KeyPackage fetched successfully."); + + Ok(()) +} + +// ── Shared helpers ──────────────────────────────────────────────────────────── + +/// Establish a Noise_XX connection and return an `AuthenticationService` client. +/// +/// Must be called from within a `LocalSet` because capnp-rpc is `!Send`. +async fn connect_as( + server: &str, + noise_keypair: &NoiseKeypair, +) -> anyhow::Result { + let stream = TcpStream::connect(server) + .await + .with_context(|| format!("could not connect to {server}"))?; + + let transport = handshake_initiator(stream, noise_keypair) + .await + .context("Noise_XX handshake failed")?; + + let (reader, writer) = transport.into_capnp_io(); + + let network = twoparty::VatNetwork::new( + reader.compat(), + writer.compat_write(), + Side::Client, + Default::default(), + ); + + let mut rpc_system = RpcSystem::new(Box::new(network), None); + let as_client: authentication_service::Client = + rpc_system.bootstrap(Side::Server); + + // Drive the RPC system on the local set. + tokio::task::spawn_local(rpc_system); + + Ok(as_client) +} + +/// Format the first `n` bytes as lowercase hex with a trailing `…`. +fn fmt_hex(bytes: &[u8]) -> String { + let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + format!("{hex}…") } /// Return the current Unix timestamp in milliseconds. @@ -143,3 +291,23 @@ fn current_timestamp_ms() -> u64 { .unwrap_or_default() .as_millis() as u64 } + +// ── Hex encoding helper ─────────────────────────────────────────────────────── +// +// We use a tiny inline module rather than adding `hex` as a dependency. + +mod hex { + pub fn encode(bytes: impl AsRef<[u8]>) -> String { + bytes.as_ref().iter().map(|b| format!("{b:02x}")).collect() + } + + pub fn decode(s: &str) -> Result, &'static str> { + if s.len() % 2 != 0 { + return Err("odd-length hex string"); + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| "invalid hex character")) + .collect() + } +} diff --git a/crates/noiseml-client/tests/auth_service.rs b/crates/noiseml-client/tests/auth_service.rs new file mode 100644 index 0000000..76e729f --- /dev/null +++ b/crates/noiseml-client/tests/auth_service.rs @@ -0,0 +1,255 @@ +//! Integration test: M2 Authentication Service — KeyPackage upload + fetch. +//! +//! All tests run inside a single `tokio::task::LocalSet` so that `spawn_local` +//! can be used for capnp-rpc tasks (which are `!Send` due to internal `Rc` use). + +use std::{collections::VecDeque, sync::Arc}; + +use capnp::capability::Promise; +use capnp_rpc::{RpcSystem, rpc_twoparty_capnp::Side, twoparty}; +use dashmap::DashMap; +use noiseml_core::{ + IdentityKeypair, NoiseKeypair, generate_key_package, handshake_initiator, + handshake_responder, +}; +use noiseml_proto::auth_capnp::authentication_service; +use sha2::{Digest, Sha256}; +use tokio::net::{TcpListener, TcpStream}; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +// ── Types ───────────────────────────────────────────────────────────────────── + +type Store = Arc, VecDeque>>>; + +// ── Inline AS server implementation ────────────────────────────────────────── + +struct TestAuthService { + store: Store, +} + +impl authentication_service::Server for TestAuthService { + fn upload_key_package( + &mut self, + params: authentication_service::UploadKeyPackageParams, + mut results: authentication_service::UploadKeyPackageResults, + ) -> Promise<(), capnp::Error> { + let p = match params.get() { + Ok(v) => v, + Err(e) => return Promise::err(e), + }; + let ik = match p.get_identity_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + let pkg = match p.get_package() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + let fp: Vec = Sha256::digest(&pkg).to_vec(); + self.store.entry(ik).or_default().push_back(pkg); + results.get().set_fingerprint(&fp); + Promise::ok(()) + } + + fn fetch_key_package( + &mut self, + params: authentication_service::FetchKeyPackageParams, + mut results: authentication_service::FetchKeyPackageResults, + ) -> Promise<(), capnp::Error> { + let ik = match params.get() { + Ok(p) => match p.get_identity_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }, + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + let pkg = self + .store + .get_mut(&ik) + .and_then(|mut q| q.pop_front()) + .unwrap_or_default(); + results.get().set_package(&pkg); + Promise::ok(()) + } +} + +// ── Test helpers ────────────────────────────────────────────────────────────── + +/// Spawn a server that accepts `n_connections` and returns the bound address. +/// +/// Must be called from within a `LocalSet` context so that the internal +/// `spawn_local` calls are associated with the correct LocalSet. +async fn spawn_server( + n_connections: usize, + keypair: Arc, + store: Store, +) -> std::net::SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::task::spawn_local(async move { + for _ in 0..n_connections { + let (stream, _) = listener.accept().await.unwrap(); + let kp = Arc::clone(&keypair); + let st = Arc::clone(&store); + tokio::task::spawn_local(async move { + serve_one(stream, kp, st).await; + }); + } + }); + + addr +} + +/// Handle a single Noise + capnp-rpc server connection. +async fn serve_one(stream: TcpStream, keypair: Arc, store: Store) { + let transport = handshake_responder(stream, &keypair).await.unwrap(); + let (reader, writer) = transport.into_capnp_io(); + let network = twoparty::VatNetwork::new( + reader.compat(), + writer.compat_write(), + Side::Server, + Default::default(), + ); + let svc: authentication_service::Client = + capnp_rpc::new_client(TestAuthService { store }); + let rpc = RpcSystem::new(Box::new(network), Some(svc.client)); + tokio::task::spawn_local(rpc).await.ok(); +} + +/// Connect and return a client stub. Must run inside a LocalSet. +async fn connect_client(addr: std::net::SocketAddr) -> authentication_service::Client { + let kp = NoiseKeypair::generate(); + let stream = TcpStream::connect(addr).await.unwrap(); + let transport = handshake_initiator(stream, &kp).await.unwrap(); + let (reader, writer) = transport.into_capnp_io(); + let network = twoparty::VatNetwork::new( + reader.compat(), + writer.compat_write(), + Side::Client, + Default::default(), + ); + let mut rpc = RpcSystem::new(Box::new(network), None); + let client: authentication_service::Client = rpc.bootstrap(Side::Server); + tokio::task::spawn_local(rpc); + client +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +/// Alice uploads a KeyPackage; Bob fetches it. Fingerprints must match. +#[tokio::test] +async fn upload_then_fetch_fingerprints_match() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { + let store: Store = Arc::new(DashMap::new()); + let server_kp = Arc::new(NoiseKeypair::generate()); + + // Server accepts 2 connections: one for Alice (upload), one for Bob (fetch). + let addr = spawn_server(2, Arc::clone(&server_kp), Arc::clone(&store)).await; + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + // Alice: generate KeyPackage and upload it. + let alice_identity = IdentityKeypair::generate(); + let (tls_bytes, local_fp) = generate_key_package(&alice_identity).unwrap(); + + let alice = connect_client(addr).await; + let mut req = alice.upload_key_package_request(); + req.get().set_identity_key(&alice_identity.public_key_bytes()); + req.get().set_package(&tls_bytes); + let resp = req.send().promise.await.unwrap(); + let server_fp = resp.get().unwrap().get_fingerprint().unwrap().to_vec(); + + assert_eq!(local_fp, server_fp, "server fingerprint must match local"); + + // Bob: fetch Alice's package by her identity key. + let bob = connect_client(addr).await; + let mut req2 = bob.fetch_key_package_request(); + req2.get().set_identity_key(&alice_identity.public_key_bytes()); + let resp2 = req2.send().promise.await.unwrap(); + let fetched = resp2.get().unwrap().get_package().unwrap().to_vec(); + + assert!(!fetched.is_empty(), "fetched package must not be empty"); + assert_eq!(fetched, tls_bytes, "fetched bytes must match uploaded bytes"); + + let fetched_fp: Vec = Sha256::digest(&fetched).to_vec(); + assert_eq!(fetched_fp, local_fp, "fetched fingerprint must match uploaded"); + }) + .await; +} + +/// Fetching a non-existent key returns empty bytes. +#[tokio::test] +async fn fetch_nonexistent_key_returns_empty() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { + let store: Store = Arc::new(DashMap::new()); + let server_kp = Arc::new(NoiseKeypair::generate()); + let addr = spawn_server(1, server_kp, store).await; + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + let client = connect_client(addr).await; + let mut req = client.fetch_key_package_request(); + req.get().set_identity_key(&[0xAAu8; 32]); + let resp = req.send().promise.await.unwrap(); + let pkg = resp.get().unwrap().get_package().unwrap().to_vec(); + + assert!(pkg.is_empty(), "unknown identity must return empty package"); + }) + .await; +} + +/// Uploading two packages and fetching twice returns them in FIFO order. +#[tokio::test] +async fn packages_consumed_in_fifo_order() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { + let store: Store = Arc::new(DashMap::new()); + + // Pre-populate the store directly. + let key = vec![0x01u8; 32]; + store + .entry(key.clone()) + .or_default() + .extend([vec![1u8, 2, 3], vec![4u8, 5, 6]]); + + let server_kp = Arc::new(NoiseKeypair::generate()); + // Server accepts 2 connections for the 2 fetches. + let addr = spawn_server(2, server_kp, Arc::clone(&store)).await; + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + let client1 = connect_client(addr).await; + let mut req1 = client1.fetch_key_package_request(); + req1.get().set_identity_key(&key); + let pkg1 = req1 + .send() + .promise + .await + .unwrap() + .get() + .unwrap() + .get_package() + .unwrap() + .to_vec(); + assert_eq!(pkg1, vec![1u8, 2, 3], "first fetch must return first package"); + + let client2 = connect_client(addr).await; + let mut req2 = client2.fetch_key_package_request(); + req2.get().set_identity_key(&key); + let pkg2 = req2 + .send() + .promise + .await + .unwrap() + .get() + .unwrap() + .get_package() + .unwrap() + .to_vec(); + assert_eq!(pkg2, vec![4u8, 5, 6], "second fetch must return second package"); + }) + .await; +} diff --git a/crates/noiseml-core/Cargo.toml b/crates/noiseml-core/Cargo.toml index bd4f39f..3ec9097 100644 --- a/crates/noiseml-core/Cargo.toml +++ b/crates/noiseml-core/Cargo.toml @@ -6,9 +6,7 @@ description = "Crypto primitives, Noise_XX transport, MLS state machine, and Cap license = "MIT" [dependencies] -# Crypto -# openmls / openmls_rust_crypto / openmls_basic_credential — added in M2 -# ml-kem — added in M5 (hybrid PQ ciphersuite) +# Crypto — classical x25519-dalek = { workspace = true } ed25519-dalek = { workspace = true } snow = { workspace = true } @@ -17,12 +15,20 @@ hkdf = { workspace = true } zeroize = { workspace = true } rand = { workspace = true } +# Crypto — MLS (M2); ml-kem added in M5 +openmls = { workspace = true } +openmls_rust_crypto = { workspace = true } +openmls_traits = { workspace = true } +tls_codec = { workspace = true } + # Serialisation capnp = { workspace = true } noiseml-proto = { path = "../noiseml-proto" } -# Async codec +# Async runtime + codec +tokio = { workspace = true } tokio-util = { workspace = true } +futures = { workspace = true } bytes = { version = "1" } # Error handling diff --git a/crates/noiseml-core/src/error.rs b/crates/noiseml-core/src/error.rs index cfecac1..2022ac1 100644 --- a/crates/noiseml-core/src/error.rs +++ b/crates/noiseml-core/src/error.rs @@ -68,4 +68,10 @@ pub enum CoreError { /// 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 }, + + /// An MLS operation failed. + /// + /// The inner string is the debug representation of the openmls error. + #[error("MLS error: {0}")] + Mls(String), } diff --git a/crates/noiseml-core/src/group.rs b/crates/noiseml-core/src/group.rs new file mode 100644 index 0000000..ac11fc7 --- /dev/null +++ b/crates/noiseml-core/src/group.rs @@ -0,0 +1,428 @@ +//! MLS group state machine. +//! +//! # Design +//! +//! [`GroupMember`] wraps an openmls [`MlsGroup`] plus the per-client +//! [`OpenMlsRustCrypto`] backend. The backend is **persistent** — it holds the +//! in-memory key store that maps init-key references to HPKE private keys. +//! openmls's `new_from_welcome` reads those private keys from the key store to +//! decrypt the Welcome, so the same backend instance must be used from +//! `generate_key_package` through `join_group`. +//! +//! # Wire format +//! +//! All MLS messages are serialised/deserialised using TLS presentation language +//! encoding (`tls_codec`). The resulting byte vectors are what the transport +//! layer (and the Delivery Service) sees. +//! +//! # MLS ciphersuite +//! +//! `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` — same as M2. +//! +//! # Ratchet tree +//! +//! `use_ratchet_tree_extension = true` so that the ratchet tree is embedded +//! in Welcome messages. `new_from_welcome` is called with `ratchet_tree = None`; +//! openmls extracts the tree from the Welcome's `GroupInfo` extension. + +use std::sync::Arc; + +use openmls::prelude::{ + Ciphersuite, CryptoConfig, Credential, CredentialType, CredentialWithKey, + GroupId, KeyPackage, KeyPackageIn, MlsGroup, MlsGroupConfig, MlsMessageInBody, + MlsMessageOut, ProcessedMessageContent, ProtocolMessage, ProtocolVersion, + TlsDeserializeTrait, TlsSerializeTrait, +}; +use openmls_rust_crypto::OpenMlsRustCrypto; +use openmls_traits::OpenMlsCryptoProvider; + +use crate::{error::CoreError, identity::IdentityKeypair}; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const CIPHERSUITE: Ciphersuite = + Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519; + +// ── GroupMember ─────────────────────────────────────────────────────────────── + +/// Per-client MLS state: identity keypair, crypto backend, and optional group. +/// +/// # Lifecycle +/// +/// ```text +/// GroupMember::new(identity) +/// ├─ generate_key_package() → upload to AS +/// ├─ create_group(group_id) → become sole member +/// │ └─ add_member(kp) → invite a peer; returns (commit, welcome) +/// └─ join_group(welcome) → join after receiving a Welcome +/// ├─ send_message(msg) → encrypt application data +/// └─ receive_message(b) → decrypt; returns Some(plaintext) or None +/// ``` +pub struct GroupMember { + /// Persistent crypto backend. Holds the in-memory key store with HPKE + /// private keys created during `generate_key_package`. + backend: OpenMlsRustCrypto, + /// Long-term Ed25519 identity keypair. Also used as the MLS `Signer`. + identity: Arc, + /// Active MLS group, if any. + group: Option, + /// Shared group configuration (wire format, ratchet tree extension, etc.). + config: MlsGroupConfig, +} + +impl GroupMember { + /// Create a new `GroupMember` with a fresh crypto backend. + pub fn new(identity: Arc) -> Self { + let config = MlsGroupConfig::builder() + // Embed the ratchet tree in Welcome messages so joinees do not + // need an out-of-band tree delivery mechanism. + .use_ratchet_tree_extension(true) + .build(); + + Self { + backend: OpenMlsRustCrypto::default(), + identity, + group: None, + config, + } + } + + // ── KeyPackage ──────────────────────────────────────────────────────────── + + /// Generate a fresh single-use MLS KeyPackage. + /// + /// The HPKE init private key is stored in `self.backend`'s key store. + /// **The same `GroupMember` instance must later call `join_group`** so + /// that `new_from_welcome` can retrieve the private key. + /// + /// # Returns + /// + /// TLS-encoded KeyPackage bytes, ready for upload to the Authentication + /// Service. + /// + /// # Errors + /// + /// Returns [`CoreError::Mls`] if openmls fails to create the KeyPackage. + pub fn generate_key_package(&mut self) -> Result, CoreError> { + let credential_with_key = self.make_credential_with_key()?; + + let key_package = KeyPackage::builder() + .build( + CryptoConfig::with_default_version(CIPHERSUITE), + &self.backend, + self.identity.as_ref(), + credential_with_key, + ) + .map_err(|e| CoreError::Mls(format!("{e:?}")))?; + + key_package + .tls_serialize_detached() + .map_err(|e| CoreError::Mls(format!("{e:?}"))) + } + + // ── Group creation ──────────────────────────────────────────────────────── + + /// Create a new MLS group with `group_id` as the group identifier. + /// + /// The caller becomes the sole member (epoch 0). Use `add_member` to + /// invite additional members. + /// + /// `group_id` can be any non-empty byte string; SHA-256 of a human-readable + /// name is a good choice. + /// + /// # Errors + /// + /// Returns [`CoreError::Mls`] if the group already exists or openmls fails. + pub fn create_group(&mut self, group_id: &[u8]) -> Result<(), CoreError> { + let credential_with_key = self.make_credential_with_key()?; + let mls_id = GroupId::from_slice(group_id); + + let group = MlsGroup::new_with_group_id( + &self.backend, + self.identity.as_ref(), + &self.config, + mls_id, + credential_with_key, + ) + .map_err(|e| CoreError::Mls(format!("{e:?}")))?; + + self.group = Some(group); + Ok(()) + } + + // ── Membership ──────────────────────────────────────────────────────────── + + /// Add a new member by their TLS-encoded KeyPackage bytes. + /// + /// Produces a Commit (to update existing members' state) and a Welcome + /// (to bootstrap the new member). The caller is responsible for + /// distributing these: + /// + /// - Send `commit_bytes` to all **existing** group members via the DS. + /// (In the 2-party case where the creator is the only member, this can + /// be discarded — the creator applies it locally via this method.) + /// - Send `welcome_bytes` to the **new** member via the DS. + /// + /// This method also merges the pending Commit into the local group state + /// (advancing the epoch), so the caller is immediately ready to encrypt. + /// + /// # Returns + /// + /// `(commit_bytes, welcome_bytes)` — both TLS-encoded MLS messages. + /// + /// # Errors + /// + /// Returns [`CoreError::Mls`] if the KeyPackage is malformed, no active + /// group exists, or openmls fails. + pub fn add_member( + &mut self, + key_package_bytes: &[u8], + ) -> Result<(Vec, Vec), CoreError> { + let group = self + .group + .as_mut() + .ok_or_else(|| CoreError::Mls("no active group".into()))?; + + // Deserialise and validate the peer's KeyPackage. KeyPackage only derives + // TlsSerialize; KeyPackageIn derives TlsDeserialize and provides validate() + // which verifies the signature and returns a trusted KeyPackage. + let key_package: KeyPackage = + KeyPackageIn::tls_deserialize(&mut key_package_bytes.as_ref()) + .map_err(|e| CoreError::Mls(format!("KeyPackage deserialise: {e:?}")))? + .validate(self.backend.crypto(), ProtocolVersion::Mls10) + .map_err(|e| CoreError::Mls(format!("KeyPackage validate: {e:?}")))?; + + // Create the Commit + Welcome. The third return value (GroupInfo) is for + // external commits and is not needed here. + let (commit_out, welcome_out, _group_info) = group + .add_members( + &self.backend, + self.identity.as_ref(), + &[key_package], + ) + .map_err(|e| CoreError::Mls(format!("add_members: {e:?}")))?; + + // Merge the pending Commit into our own state, advancing the epoch. + group + .merge_pending_commit(&self.backend) + .map_err(|e| CoreError::Mls(format!("merge_pending_commit: {e:?}")))?; + + let commit_bytes = commit_out + .to_bytes() + .map_err(|e| CoreError::Mls(format!("commit serialise: {e:?}")))?; + let welcome_bytes = welcome_out + .to_bytes() + .map_err(|e| CoreError::Mls(format!("welcome serialise: {e:?}")))?; + + Ok((commit_bytes, welcome_bytes)) + } + + /// Join an existing MLS group from a TLS-encoded Welcome message. + /// + /// The caller must have previously called [`generate_key_package`] on + /// **this same instance** so that the HPKE init private key is in the + /// backend's key store. + /// + /// # Errors + /// + /// Returns [`CoreError::Mls`] if the Welcome does not match any known + /// KeyPackage, or openmls validation fails. + /// + /// [`generate_key_package`]: Self::generate_key_package + pub fn join_group(&mut self, welcome_bytes: &[u8]) -> Result<(), CoreError> { + // Deserialise MlsMessageIn, then extract the inner Welcome. + let msg_in = + openmls::prelude::MlsMessageIn::tls_deserialize(&mut welcome_bytes.as_ref()) + .map_err(|e| CoreError::Mls(format!("Welcome deserialise: {e:?}")))?; + + // into_welcome() is feature-gated in openmls 0.5; extract() is public. + let welcome = match msg_in.extract() { + MlsMessageInBody::Welcome(w) => w, + _ => return Err(CoreError::Mls("expected a Welcome message".into())), + }; + + // ratchet_tree = None because use_ratchet_tree_extension = true embeds + // the tree inside the Welcome's GroupInfo extension. + let group = MlsGroup::new_from_welcome( + &self.backend, + &self.config, + welcome, + None, + ) + .map_err(|e| CoreError::Mls(format!("new_from_welcome: {e:?}")))?; + + self.group = Some(group); + Ok(()) + } + + // ── Application messages ────────────────────────────────────────────────── + + /// Encrypt `plaintext` as an MLS Application message. + /// + /// # Returns + /// + /// TLS-encoded `MlsMessageOut` bytes (PrivateMessage variant). + /// + /// # Errors + /// + /// Returns [`CoreError::Mls`] if there is no active group or encryption fails. + pub fn send_message(&mut self, plaintext: &[u8]) -> Result, CoreError> { + let group = self + .group + .as_mut() + .ok_or_else(|| CoreError::Mls("no active group".into()))?; + + let mls_msg: MlsMessageOut = group + .create_message(&self.backend, self.identity.as_ref(), plaintext) + .map_err(|e| CoreError::Mls(format!("create_message: {e:?}")))?; + + mls_msg + .to_bytes() + .map_err(|e| CoreError::Mls(format!("message serialise: {e:?}"))) + } + + /// Process an incoming TLS-encoded MLS message. + /// + /// # Returns + /// + /// - `Ok(Some(plaintext))` for Application messages. + /// - `Ok(None)` for Commit messages (group state is updated internally). + /// + /// # Errors + /// + /// Returns [`CoreError::Mls`] if the message is malformed, fails + /// authentication, or the group state is inconsistent. + pub fn receive_message(&mut self, bytes: &[u8]) -> Result>, CoreError> { + let group = self + .group + .as_mut() + .ok_or_else(|| CoreError::Mls("no active group".into()))?; + + let msg_in = + openmls::prelude::MlsMessageIn::tls_deserialize(&mut bytes.as_ref()) + .map_err(|e| CoreError::Mls(format!("message deserialise: {e:?}")))?; + + // into_protocol_message() is feature-gated; extract() + manual construction is not. + let protocol_message = match msg_in.extract() { + MlsMessageInBody::PrivateMessage(m) => ProtocolMessage::PrivateMessage(m), + MlsMessageInBody::PublicMessage(m) => ProtocolMessage::PublicMessage(m), + _ => return Err(CoreError::Mls("not a protocol message".into())), + }; + + let processed = group + .process_message(&self.backend, protocol_message) + .map_err(|e| CoreError::Mls(format!("process_message: {e:?}")))?; + + match processed.into_content() { + ProcessedMessageContent::ApplicationMessage(app) => { + Ok(Some(app.into_bytes())) + } + ProcessedMessageContent::StagedCommitMessage(staged) => { + // Merge the Commit into the local state (epoch advances). + group + .merge_staged_commit(&self.backend, *staged) + .map_err(|e| CoreError::Mls(format!("merge_staged_commit: {e:?}")))?; + Ok(None) + } + // Proposals are stored for a later Commit; nothing to return yet. + ProcessedMessageContent::ProposalMessage(proposal) => { + group.store_pending_proposal(*proposal); + Ok(None) + } + ProcessedMessageContent::ExternalJoinProposalMessage(proposal) => { + group.store_pending_proposal(*proposal); + Ok(None) + } + } + } + + // ── Accessors ───────────────────────────────────────────────────────────── + + /// Return the MLS group ID bytes, or `None` if no group is active. + pub fn group_id(&self) -> Option> { + self.group + .as_ref() + .map(|g| g.group_id().as_slice().to_vec()) + } + + /// Return a reference to the identity keypair. + pub fn identity(&self) -> &IdentityKeypair { + &self.identity + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + fn make_credential_with_key(&self) -> Result { + let credential = Credential::new( + self.identity.public_key_bytes().to_vec(), + CredentialType::Basic, + ) + .map_err(|e| CoreError::Mls(format!("{e:?}")))?; + + Ok(CredentialWithKey { + credential, + signature_key: self.identity.public_key_bytes().to_vec().into(), + }) + } +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// Full two-party MLS round-trip: create group → add member → exchange messages. + #[test] + fn two_party_mls_round_trip() { + let alice_id = Arc::new(IdentityKeypair::generate()); + let bob_id = Arc::new(IdentityKeypair::generate()); + + let mut alice = GroupMember::new(Arc::clone(&alice_id)); + let mut bob = GroupMember::new(Arc::clone(&bob_id)); + + // Bob generates a KeyPackage (stored in bob's backend key store). + let bob_kp = bob.generate_key_package().expect("Bob KeyPackage"); + + // Alice creates the group. + alice.create_group(b"test-group-m3").expect("Alice create group"); + + // Alice adds Bob → (commit, welcome). + // Alice is the sole existing member, so she merges the commit herself. + let (_, welcome) = alice.add_member(&bob_kp).expect("Alice add Bob"); + + // Bob joins via the Welcome. His backend holds the matching init key. + bob.join_group(&welcome).expect("Bob join group"); + + // Alice → Bob: application message. + let ct_a = alice.send_message(b"hello bob").expect("Alice send"); + let pt_b = bob + .receive_message(&ct_a) + .expect("Bob recv") + .expect("should be application message"); + assert_eq!(pt_b, b"hello bob"); + + // Bob → Alice: reply. + let ct_b = bob.send_message(b"hello alice").expect("Bob send"); + let pt_a = alice + .receive_message(&ct_b) + .expect("Alice recv") + .expect("should be application message"); + assert_eq!(pt_a, b"hello alice"); + } + + /// `group_id()` returns None before create_group, Some afterwards. + #[test] + fn group_id_lifecycle() { + let id = Arc::new(IdentityKeypair::generate()); + let mut member = GroupMember::new(id); + + assert!(member.group_id().is_none(), "no group before create"); + member.create_group(b"gid").unwrap(); + assert_eq!( + member.group_id().unwrap(), + b"gid".as_slice(), + "group_id must match what was passed" + ); + } +} diff --git a/crates/noiseml-core/src/identity.rs b/crates/noiseml-core/src/identity.rs new file mode 100644 index 0000000..29f4148 --- /dev/null +++ b/crates/noiseml-core/src/identity.rs @@ -0,0 +1,97 @@ +//! Ed25519 identity keypair for MLS credentials and AS registration. +//! +//! # Relationship to the Noise keypair +//! +//! The X25519 [`NoiseKeypair`](crate::NoiseKeypair) is the transport-layer +//! static key used in the Noise_XX handshake. The Ed25519 [`IdentityKeypair`] +//! is the long-term identity key embedded in MLS `BasicCredential`s. The two +//! keys serve different roles and must not be confused. +//! +//! # Zeroize +//! +//! The 32-byte private seed is stored as `Zeroizing<[u8; 32]>`, which zeroes +//! the bytes on drop. `[u8; 32]` is `Copy + Default` and satisfies zeroize's +//! `DefaultIsZeroes` constraint, avoiding a conflict with ed25519-dalek's +//! `SigningKey` zeroize impl. +//! +//! # Fingerprint +//! +//! A 32-byte SHA-256 digest of the raw public key bytes is used as a compact, +//! collision-resistant identifier for logging. + +use ed25519_dalek::{Signer as DalekSigner, SigningKey, VerifyingKey}; +use openmls_traits::signatures::Signer; +use openmls_traits::types::{Error as MlsError, SignatureScheme}; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +/// An Ed25519 identity keypair. +/// +/// Created with [`IdentityKeypair::generate`]. The private signing key seed +/// is zeroed when this struct is dropped. +pub struct IdentityKeypair { + /// Raw 32-byte private seed — zeroized on drop. + /// + /// Stored as bytes rather than `SigningKey` to satisfy zeroize's + /// `DefaultIsZeroes` bound on `Zeroizing`. + seed: Zeroizing<[u8; 32]>, + /// Corresponding 32-byte public verifying key. + verifying: VerifyingKey, +} + +impl IdentityKeypair { + /// Generate a fresh random Ed25519 identity keypair. + pub fn generate() -> Self { + use rand::rngs::OsRng; + let signing = SigningKey::generate(&mut OsRng); + let verifying = signing.verifying_key(); + let seed = Zeroizing::new(signing.to_bytes()); + Self { seed, verifying } + } + + /// Return the raw 32-byte Ed25519 public key. + /// + /// This is the byte array used as `identityKey` in `auth.capnp` calls. + pub fn public_key_bytes(&self) -> [u8; 32] { + self.verifying.to_bytes() + } + + /// Return the SHA-256 fingerprint of the public key (32 bytes). + pub fn fingerprint(&self) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(self.verifying.to_bytes()); + hasher.finalize().into() + } + + /// Reconstruct the `SigningKey` from the stored seed bytes. + fn signing_key(&self) -> SigningKey { + SigningKey::from_bytes(&self.seed) + } +} + +/// Implement the openmls `Signer` trait so `IdentityKeypair` can be passed +/// directly to `KeyPackage::builder().build(...)` without needing the external +/// `openmls_basic_credential` crate. +impl Signer for IdentityKeypair { + fn sign(&self, payload: &[u8]) -> Result, MlsError> { + let sk = self.signing_key(); + let sig: ed25519_dalek::Signature = sk.sign(payload); + Ok(sig.to_bytes().to_vec()) + } + + fn signature_scheme(&self) -> SignatureScheme { + SignatureScheme::ED25519 + } +} + +impl std::fmt::Debug for IdentityKeypair { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let fp = self.fingerprint(); + f.debug_struct("IdentityKeypair") + .field( + "fingerprint", + &format!("{:02x}{:02x}{:02x}{:02x}…", fp[0], fp[1], fp[2], fp[3]), + ) + .finish_non_exhaustive() + } +} diff --git a/crates/noiseml-core/src/keypackage.rs b/crates/noiseml-core/src/keypackage.rs new file mode 100644 index 0000000..0ded9ec --- /dev/null +++ b/crates/noiseml-core/src/keypackage.rs @@ -0,0 +1,86 @@ +//! MLS KeyPackage generation and TLS serialisation. +//! +//! # Ciphersuite +//! +//! `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` (ciphersuite ID `0x0001`). +//! This is the RECOMMENDED ciphersuite from RFC 9420 §17.1. +//! +//! # Single-use semantics +//! +//! Per RFC 9420 §10.1, each KeyPackage MUST be used at most once. The +//! Authentication Service enforces this by atomically removing a package on +//! fetch. +//! +//! # Wire format +//! +//! KeyPackages are TLS-encoded using `tls_codec` (same version as openmls). +//! The resulting bytes are opaque to the noiseml transport layer. + +use openmls::prelude::{ + Ciphersuite, Credential, CredentialType, CredentialWithKey, CryptoConfig, KeyPackage, + TlsSerializeTrait, +}; +use openmls_rust_crypto::OpenMlsRustCrypto; +use sha2::{Digest, Sha256}; + +use crate::{error::CoreError, identity::IdentityKeypair}; + +/// The MLS ciphersuite used throughout noiseml. +const CIPHERSUITE: Ciphersuite = + Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519; + +/// Generate a fresh MLS KeyPackage for `identity` and serialise it. +/// +/// # Returns +/// +/// `(tls_bytes, sha256_fingerprint)` where: +/// - `tls_bytes` is the TLS-encoded KeyPackage blob, suitable for uploading. +/// - `sha256_fingerprint` is the SHA-256 digest of `tls_bytes` for tamper detection. +/// +/// # Errors +/// +/// Returns [`CoreError::Mls`] if openmls fails to create the KeyPackage or if +/// TLS serialisation fails. +pub fn generate_key_package( + identity: &IdentityKeypair, +) -> Result<(Vec, Vec), CoreError> { + let backend = OpenMlsRustCrypto::default(); + + // Build a BasicCredential using the raw Ed25519 public key bytes as the + // MLS identity. Per RFC 9420, any byte string may serve as the identity. + let credential = Credential::new( + identity.public_key_bytes().to_vec(), + CredentialType::Basic, + ) + .map_err(|e| CoreError::Mls(format!("{e:?}")))?; + + // The `signature_key` in CredentialWithKey is the Ed25519 public key that + // will be used to verify the KeyPackage's leaf node signature. + // `SignaturePublicKey` implements `From>`. + let credential_with_key = CredentialWithKey { + credential, + signature_key: identity.public_key_bytes().to_vec().into(), + }; + + // `IdentityKeypair` implements `openmls_traits::signatures::Signer` + // so it can be passed directly to the builder. + let key_package = KeyPackage::builder() + .build( + CryptoConfig::with_default_version(CIPHERSUITE), + &backend, + identity, + credential_with_key, + ) + .map_err(|e| CoreError::Mls(format!("{e:?}")))?; + + // TLS-encode the KeyPackage using the trait from the openmls prelude. + // This uses tls_codec 0.3 (the same version openmls uses internally), + // avoiding a duplicate-trait conflict with tls_codec 0.4. + let tls_bytes = key_package + .tls_serialize_detached() + .map_err(|e| CoreError::Mls(format!("{e:?}")))?; + + let fingerprint: Vec = Sha256::digest(&tls_bytes).to_vec(); + + Ok((tls_bytes, fingerprint)) +} diff --git a/crates/noiseml-core/src/lib.rs b/crates/noiseml-core/src/lib.rs index 93027ce..fedf30d 100644 --- a/crates/noiseml-core/src/lib.rs +++ b/crates/noiseml-core/src/lib.rs @@ -1,28 +1,32 @@ -//! Core cryptographic primitives, Noise_XX transport, and frame codec for noiseml. +//! Core cryptographic primitives, Noise_XX transport, MLS group state machine, +//! 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) +//! | 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`] | +//! | `identity` | [`IdentityKeypair`] — Ed25519 identity key for MLS credentials | +//! | `keypackage` | [`generate_key_package`] — standalone KeyPackage generation | +//! | `group` | [`GroupMember`] — MLS group lifecycle (create/join/send/recv) | mod codec; mod error; +mod group; +mod identity; mod keypair; +mod keypackage; mod noise; // ── Public API ──────────────────────────────────────────────────────────────── pub use codec::{LengthPrefixedCodec, NOISE_MAX_MSG}; pub use error::{CodecError, CoreError, MAX_PLAINTEXT_LEN}; +pub use group::GroupMember; +pub use identity::IdentityKeypair; pub use keypair::NoiseKeypair; +pub use keypackage::generate_key_package; pub use noise::{handshake_initiator, handshake_responder, NoiseTransport}; diff --git a/crates/noiseml-core/src/noise.rs b/crates/noiseml-core/src/noise.rs index e86db78..fd81362 100644 --- a/crates/noiseml-core/src/noise.rs +++ b/crates/noiseml-core/src/noise.rs @@ -31,7 +31,10 @@ use bytes::Bytes; use futures::{SinkExt, StreamExt}; -use tokio::net::TcpStream; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt, DuplexStream, ReadHalf, WriteHalf, duplex}, + net::TcpStream, +}; use tokio_util::codec::Framed; use crate::{ @@ -155,6 +158,77 @@ impl NoiseTransport { parse_envelope(&bytes).map_err(CoreError::Capnp) } + // ── capnp-rpc bridge ───────────────────────────────────────────────────── + + /// Consume the transport and return a byte-stream pair suitable for + /// `capnp-rpc`'s `twoparty::VatNetwork`. + /// + /// # Why this exists + /// + /// `capnp-rpc` expects `AsyncRead + AsyncWrite` byte streams, but + /// `NoiseTransport` is message-based (each call to `send_frame` / + /// `recv_frame` encrypts/decrypts one Noise message). This method bridges + /// the two models by: + /// + /// 1. Creating a `tokio::io::duplex` pipe (an in-process byte channel). + /// 2. Spawning a background task that shuttles bytes between the pipe and + /// the Noise framed transport using `tokio::select!`. + /// + /// The returned `(ReadHalf, WriteHalf)` are the **application** ends of the + /// pipe; `capnp-rpc` reads from `ReadHalf` and writes to `WriteHalf`. The + /// bridge task owns the **transport** end and the `NoiseTransport`. + /// + /// # Framing + /// + /// Each Noise frame carries at most [`MAX_PLAINTEXT_LEN`] bytes of + /// plaintext. The bridge uses that as the read buffer size so that one + /// frame is never split across multiple pipe writes. + /// + /// # Lifetime + /// + /// The bridge task runs until either side of the pipe closes. When the + /// capnp-rpc system drops the pipe halves, the bridge exits cleanly. + pub fn into_capnp_io(mut self) -> (ReadHalf, WriteHalf) { + // Choose a pipe capacity large enough for one max-size Noise frame. + let (app_stream, mut transport_stream) = duplex(MAX_PLAINTEXT_LEN); + + tokio::spawn(async move { + let mut buf = vec![0u8; MAX_PLAINTEXT_LEN]; + + loop { + tokio::select! { + // Noise → app: receive an encrypted frame and write decrypted + // plaintext into the pipe. + noise_result = self.recv_frame() => { + match noise_result { + Ok(plaintext) => { + if transport_stream.write_all(&plaintext).await.is_err() { + break; // app side closed + } + } + Err(_) => break, // peer closed or Noise error + } + } + + // app → Noise: read bytes from the pipe and send as an + // encrypted Noise frame. + read_result = transport_stream.read(&mut buf) => { + match read_result { + Ok(0) | Err(_) => break, // app side closed + Ok(n) => { + if self.send_frame(&buf[..n]).await.is_err() { + break; // peer closed or Noise error + } + } + } + } + } + } + }); + + tokio::io::split(app_stream) + } + // ── Session metadata ────────────────────────────────────────────────────── /// Return the remote peer's static X25519 public key (32 bytes), as diff --git a/crates/noiseml-proto/build.rs b/crates/noiseml-proto/build.rs index ad366b1..b4627e0 100644 --- a/crates/noiseml-proto/build.rs +++ b/crates/noiseml-proto/build.rs @@ -31,12 +31,22 @@ fn main() { "cargo:rerun-if-changed={}", schemas_dir.join("envelope.capnp").display() ); + println!( + "cargo:rerun-if-changed={}", + schemas_dir.join("auth.capnp").display() + ); + println!( + "cargo:rerun-if-changed={}", + schemas_dir.join("delivery.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. + // resolve correctly. .src_prefix(&schemas_dir) .file(schemas_dir.join("envelope.capnp")) + .file(schemas_dir.join("auth.capnp")) + .file(schemas_dir.join("delivery.capnp")) .run() .expect( "Cap'n Proto schema compilation failed. \ diff --git a/crates/noiseml-proto/src/lib.rs b/crates/noiseml-proto/src/lib.rs index 33097cc..92cc06a 100644 --- a/crates/noiseml-proto/src/lib.rs +++ b/crates/noiseml-proto/src/lib.rs @@ -27,6 +27,20 @@ pub mod envelope_capnp { include!(concat!(env!("OUT_DIR"), "/envelope_capnp.rs")); } +/// Cap'n Proto generated types for `schemas/auth.capnp`. +/// +/// Do not edit this module by hand — it is entirely machine-generated. +pub mod auth_capnp { + include!(concat!(env!("OUT_DIR"), "/auth_capnp.rs")); +} + +/// Cap'n Proto generated types for `schemas/delivery.capnp`. +/// +/// Do not edit this module by hand — it is entirely machine-generated. +pub mod delivery_capnp { + include!(concat!(env!("OUT_DIR"), "/delivery_capnp.rs")); +} + // ── Re-exports ──────────────────────────────────────────────────────────────── /// The message-type discriminant from the `Envelope` schema. diff --git a/crates/noiseml-server/Cargo.toml b/crates/noiseml-server/Cargo.toml index e1a510a..c086ac2 100644 --- a/crates/noiseml-server/Cargo.toml +++ b/crates/noiseml-server/Cargo.toml @@ -24,9 +24,13 @@ futures = { workspace = true } # Server utilities dashmap = { workspace = true } +sha2 = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } # Error handling anyhow = { workspace = true } thiserror = { workspace = true } + +# CLI +clap = { workspace = true } diff --git a/crates/noiseml-server/src/main.rs b/crates/noiseml-server/src/main.rs index 0d0627f..5265bde 100644 --- a/crates/noiseml-server/src/main.rs +++ b/crates/noiseml-server/src/main.rs @@ -1,33 +1,54 @@ //! noiseml-server — Delivery Service + Authentication Service binary. //! -//! # M1 scope +//! # M3 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+. +//! The server exposes two Noise_XX-protected Cap'n Proto RPC endpoints: +//! +//! * **AS** (`--listen`, default `0.0.0.0:7000`) — `AuthenticationService`: +//! upload and fetch single-use MLS KeyPackages. +//! * **DS** (`--ds-listen`, default `0.0.0.0:7001`) — `DeliveryService`: +//! enqueue and fetch opaque payloads (Welcome messages, Commits, Application +//! messages) keyed by recipient Ed25519 public key. +//! +//! # Architecture +//! +//! ```text +//! TcpListener (AS, 7000) TcpListener (DS, 7001) +//! └─ Noise_XX handshake └─ Noise_XX handshake +//! └─ capnp-rpc VatNetwork (LocalSet, !Send) +//! ├─ AuthServiceImpl (shares KeyPackageStore via Arc) +//! └─ DeliveryServiceImpl (shares DeliveryStore via Arc) +//! ``` +//! +//! Because `capnp-rpc` uses `Rc>` internally it is `!Send`. +//! The entire RPC stack lives on a `tokio::task::LocalSet` spawned per +//! connection. //! //! # Configuration //! -//! | Env var | CLI flag | Default | -//! |------------------|-------------|-----------------| -//! | `NOISEML_LISTEN` | `--listen` | `0.0.0.0:7000` | -//! | `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. +//! | Env var | CLI flag | Default | +//! |---------------------|----------------|-----------------| +//! | `NOISEML_LISTEN` | `--listen` | `0.0.0.0:7000` | +//! | `NOISEML_DS_LISTEN` | `--ds-listen` | `0.0.0.0:7001` | +//! | `RUST_LOG` | — | `info` | -use std::sync::Arc; +use std::{collections::VecDeque, sync::Arc}; use anyhow::Context; +use capnp::capability::Promise; +use capnp_rpc::{RpcSystem, rpc_twoparty_capnp::Side, twoparty}; use clap::Parser; +use dashmap::DashMap; +use noiseml_core::{NoiseKeypair, handshake_responder}; +use noiseml_proto::{ + auth_capnp::authentication_service, + delivery_capnp::delivery_service, +}; +use sha2::{Digest, Sha256}; use tokio::net::{TcpListener, TcpStream}; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use tracing::Instrument; -use noiseml_core::{CodecError, CoreError, NoiseKeypair, handshake_responder}; -use noiseml_proto::{MsgType, ParsedEnvelope}; - // ── CLI ─────────────────────────────────────────────────────────────────────── #[derive(Debug, Parser)] @@ -37,9 +58,240 @@ use noiseml_proto::{MsgType, ParsedEnvelope}; version )] struct Args { - /// TCP address to listen on. + /// TCP address for the Authentication Service. #[arg(long, default_value = "0.0.0.0:7000", env = "NOISEML_LISTEN")] listen: String, + + /// TCP address for the Delivery Service. + #[arg(long, default_value = "0.0.0.0:7001", env = "NOISEML_DS_LISTEN")] + ds_listen: String, +} + +// ── Shared store types ──────────────────────────────────────────────────────── + +/// Thread-safe map from Ed25519 identity public key bytes (32 B) to a queue +/// of serialised MLS KeyPackage blobs. +/// +/// Each KeyPackage is single-use per RFC 9420: `fetch_key_package` removes +/// and returns exactly one entry. +type KeyPackageStore = Arc, VecDeque>>>; + +/// Thread-safe message queue for the Delivery Service. +/// +/// Maps recipient Ed25519 public key (32 bytes) to a FIFO queue of opaque +/// payload bytes (TLS-encoded MLS messages or other framed data). +type DeliveryStore = Arc, VecDeque>>>; + +// ── Authentication Service implementation ───────────────────────────────────── + +/// Cap'n Proto RPC server implementation for `AuthenticationService`. +struct AuthServiceImpl { + store: KeyPackageStore, +} + +impl authentication_service::Server for AuthServiceImpl { + /// Upload a single-use KeyPackage and return its SHA-256 fingerprint. + fn upload_key_package( + &mut self, + params: authentication_service::UploadKeyPackageParams, + mut results: authentication_service::UploadKeyPackageResults, + ) -> Promise<(), capnp::Error> { + let params = params.get().map_err(|e| { + capnp::Error::failed(format!("upload_key_package: bad params: {e}")) + }); + + let (identity_key, package) = match params { + Ok(p) => { + let ik = match p.get_identity_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + let pkg = match p.get_package() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + (ik, pkg) + } + Err(e) => return Promise::err(e), + }; + + if identity_key.len() != 32 { + return Promise::err(capnp::Error::failed(format!( + "identityKey must be exactly 32 bytes, got {}", + identity_key.len() + ))); + } + if package.is_empty() { + return Promise::err(capnp::Error::failed( + "package must not be empty".to_string(), + )); + } + + let fingerprint: Vec = Sha256::digest(&package).to_vec(); + + self.store + .entry(identity_key) + .or_default() + .push_back(package); + + results + .get() + .set_fingerprint(&fingerprint); + + tracing::debug!( + fingerprint = %fmt_hex(&fingerprint[..4]), + "KeyPackage uploaded" + ); + + Promise::ok(()) + } + + /// Atomically remove and return one KeyPackage for the given identity key. + fn fetch_key_package( + &mut self, + params: authentication_service::FetchKeyPackageParams, + mut results: authentication_service::FetchKeyPackageResults, + ) -> Promise<(), capnp::Error> { + let identity_key = match params.get() { + Ok(p) => match p.get_identity_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }, + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + + if identity_key.len() != 32 { + return Promise::err(capnp::Error::failed(format!( + "identityKey must be exactly 32 bytes, got {}", + identity_key.len() + ))); + } + + // Atomically pop one package from the front of the queue. + let package = self + .store + .get_mut(&identity_key) + .and_then(|mut q| q.pop_front()); + + match package { + Some(pkg) => { + tracing::debug!( + identity = %fmt_hex(&identity_key[..4]), + "KeyPackage fetched" + ); + results.get().set_package(&pkg); + } + None => { + tracing::debug!( + identity = %fmt_hex(&identity_key[..4]), + "no KeyPackage available for identity" + ); + // Return empty Data — schema specifies this as the "no package" sentinel. + results.get().set_package(&[]); + } + } + + Promise::ok(()) + } +} + +// ── Delivery Service implementation ─────────────────────────────────────────── + +/// Cap'n Proto RPC server implementation for `DeliveryService`. +/// +/// Provides a simple store-and-forward relay for MLS messages: +/// * `enqueue` appends an opaque payload to the recipient's FIFO queue. +/// * `fetch` atomically drains and returns the entire queue. +struct DeliveryServiceImpl { + store: DeliveryStore, +} + +impl delivery_service::Server for DeliveryServiceImpl { + /// Append `payload` to the queue for `recipient_key`. + fn enqueue( + &mut self, + params: delivery_service::EnqueueParams, + _results: delivery_service::EnqueueResults, + ) -> Promise<(), capnp::Error> { + let p = match params.get() { + Ok(p) => p, + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + let recipient_key = match p.get_recipient_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + let payload = match p.get_payload() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + + if recipient_key.len() != 32 { + return Promise::err(capnp::Error::failed(format!( + "recipientKey must be exactly 32 bytes, got {}", + recipient_key.len() + ))); + } + if payload.is_empty() { + return Promise::err(capnp::Error::failed( + "payload must not be empty".to_string(), + )); + } + + self.store + .entry(recipient_key.clone()) + .or_default() + .push_back(payload); + + tracing::debug!( + recipient = %fmt_hex(&recipient_key[..4]), + "message enqueued" + ); + + Promise::ok(()) + } + + /// Atomically drain and return all queued payloads for `recipient_key`. + fn fetch( + &mut self, + params: delivery_service::FetchParams, + mut results: delivery_service::FetchResults, + ) -> Promise<(), capnp::Error> { + let recipient_key = match params.get() { + Ok(p) => match p.get_recipient_key() { + Ok(v) => v.to_vec(), + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }, + Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))), + }; + + if recipient_key.len() != 32 { + return Promise::err(capnp::Error::failed(format!( + "recipientKey must be exactly 32 bytes, got {}", + recipient_key.len() + ))); + } + + // Atomically drain the entire queue. + let messages: Vec> = self + .store + .get_mut(&recipient_key) + .map(|mut q| q.drain(..).collect()) + .unwrap_or_default(); + + tracing::debug!( + recipient = %fmt_hex(&recipient_key[..4]), + count = messages.len(), + "messages fetched" + ); + + let mut list = results.get().init_payloads(messages.len() as u32); + for (i, msg) in messages.iter().enumerate() { + list.set(i as u32, msg); + } + + Promise::ok(()) + } } // ── Entry point ─────────────────────────────────────────────────────────────── @@ -55,126 +307,154 @@ async fn main() -> anyhow::Result<()> { let args = Args::parse(); - // Generate a fresh static keypair for this server instance. - // M6 will replace this with persistent key loading from SQLite. + // Generate a fresh static Noise keypair for this server instance. + // M6 replaces this with persistent key loading from SQLite. let keypair = Arc::new(NoiseKeypair::generate()); { let pub_bytes = keypair.public_bytes(); tracing::info!( - listen = %args.listen, - public_key = %fmt_key(&pub_bytes), - "noiseml-server starting — key is ephemeral in M1 (not persisted)" + listen = %args.listen, + ds_listen = %args.ds_listen, + public_key = %fmt_hex(&pub_bytes[..4]), + "noiseml-server starting (M3) — keypair is ephemeral" ); } - let listener = TcpListener::bind(&args.listen) + // Shared stores — all connections share the same in-memory maps. + let kp_store: KeyPackageStore = Arc::new(DashMap::new()); + let ds_store: DeliveryStore = Arc::new(DashMap::new()); + + let as_listener = TcpListener::bind(&args.listen) .await - .with_context(|| format!("failed to bind to {}", args.listen))?; + .with_context(|| format!("failed to bind AS to {}", args.listen))?; - tracing::info!(listen = %args.listen, "accepting connections"); + let ds_listener = TcpListener::bind(&args.ds_listen) + .await + .with_context(|| format!("failed to bind DS to {}", args.ds_listen))?; - loop { - let (stream, peer_addr) = listener.accept().await.context("accept failed")?; - let keypair = Arc::clone(&keypair); + tracing::info!( + as_addr = %args.listen, + ds_addr = %args.ds_listen, + "accepting connections" + ); - tokio::spawn( - async move { - match handle_connection(stream, keypair).await { - Ok(()) => tracing::debug!("connection closed cleanly"), - Err(e) => tracing::warn!(error = %e, "connection error"), + // capnp-rpc is !Send (Rc internals), so all RPC tasks must stay on a + // LocalSet. Both accept loops share one LocalSet. + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { + loop { + tokio::select! { + result = as_listener.accept() => { + let (stream, peer_addr) = result.context("AS accept failed")?; + let keypair = Arc::clone(&keypair); + let store = Arc::clone(&kp_store); + tokio::task::spawn_local( + async move { + match handle_as_connection(stream, keypair, store).await { + Ok(()) => tracing::debug!("AS connection closed"), + Err(e) => tracing::warn!(error = %e, "AS connection error"), + } + } + .instrument(tracing::info_span!("as_conn", peer = %peer_addr)), + ); + } + result = ds_listener.accept() => { + let (stream, peer_addr) = result.context("DS accept failed")?; + let keypair = Arc::clone(&keypair); + let store = Arc::clone(&ds_store); + tokio::task::spawn_local( + async move { + match handle_ds_connection(stream, keypair, store).await { + Ok(()) => tracing::debug!("DS connection closed"), + Err(e) => tracing::warn!(error = %e, "DS connection error"), + } + } + .instrument(tracing::info_span!("ds_conn", peer = %peer_addr)), + ); + } } } - .instrument(tracing::info_span!("conn", peer = %peer_addr)), - ); - } + #[allow(unreachable_code)] + Ok::<(), anyhow::Error>(()) + }) + .await } -// ── Per-connection handler ──────────────────────────────────────────────────── +// ── Per-connection handlers ─────────────────────────────────────────────────── -/// 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( +/// Handle one Authentication Service connection. +async fn handle_as_connection( stream: TcpStream, keypair: Arc, -) -> Result<(), CoreError> { - let mut transport = handshake_responder(stream, &keypair).await?; + store: KeyPackageStore, +) -> Result<(), anyhow::Error> { + let transport = noise_handshake(stream, &keypair, "AS").await?; + let (reader, writer) = transport.into_capnp_io(); - { - let remote = transport - .remote_static_public_key() - .map(fmt_key) - .unwrap_or_else(|| "unknown".into()); - tracing::info!(remote_key = %remote, "Noise_XX handshake complete"); - } + let network = twoparty::VatNetwork::new( + reader.compat(), + writer.compat_write(), + Side::Server, + Default::default(), + ); - loop { - let env = match transport.recv_envelope().await { - Ok(env) => env, + let service: authentication_service::Client = + capnp_rpc::new_client(AuthServiceImpl { store }); - // Clean EOF: the peer closed the connection gracefully. - Err(CoreError::ConnectionClosed) => { - tracing::debug!("peer disconnected"); - return Ok(()); - } + RpcSystem::new(Box::new(network), Some(service.client)) + .await + .map_err(|e| anyhow::anyhow!("AS RPC error: {e}")) +} - // 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(()); - } +/// Handle one Delivery Service connection. +async fn handle_ds_connection( + stream: TcpStream, + keypair: Arc, + store: DeliveryStore, +) -> Result<(), anyhow::Error> { + let transport = noise_handshake(stream, &keypair, "DS").await?; + let (reader, writer) = transport.into_capnp_io(); - Err(e) => return Err(e), - }; + let network = twoparty::VatNetwork::new( + reader.compat(), + writer.compat_write(), + Side::Server, + Default::default(), + ); - 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?; - } + let service: delivery_service::Client = + capnp_rpc::new_client(DeliveryServiceImpl { store }); - // All other message types are silently ignored in M1. - // M2 adds AS/DS RPC dispatch here. - _ => { - tracing::warn!("unexpected message type in M1 — ignoring"); - } - } - } + RpcSystem::new(Box::new(network), Some(service.client)) + .await + .map_err(|e| anyhow::anyhow!("DS RPC error: {e}")) +} + +/// Perform the Noise_XX handshake and log the remote key. +async fn noise_handshake( + stream: TcpStream, + keypair: &NoiseKeypair, + label: &str, +) -> anyhow::Result { + let transport = handshake_responder(stream, keypair) + .await + .map_err(|e| anyhow::anyhow!("{label} Noise handshake failed: {e}"))?; + + let remote = transport + .remote_static_public_key() + .map(|k| fmt_hex(&k[..4])) + .unwrap_or_else(|| "unknown".into()); + tracing::info!(remote_key = %remote, "{label} Noise_XX handshake complete"); + + Ok(transport) } // ── Helpers ─────────────────────────────────────────────────────────────────── -/// Format the first 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 +/// Format the first `n` bytes of a slice as lowercase hex with a trailing `…`. +fn fmt_hex(bytes: &[u8]) -> String { + let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + format!("{hex}…") } diff --git a/schemas/auth.capnp b/schemas/auth.capnp new file mode 100644 index 0000000..1023c23 --- /dev/null +++ b/schemas/auth.capnp @@ -0,0 +1,31 @@ +# auth.capnp — Authentication Service RPC interface. +# +# Clients call uploadKeyPackage before joining any group so that peers can +# fetch their key material to add them. Each KeyPackage is single-use (MLS +# requirement): fetchKeyPackage removes and returns one package atomically. +# +# The server indexes packages by the raw Ed25519 public key bytes (32 bytes), +# not a fingerprint, so callers must know the target's identity public key +# out-of-band (e.g. from a directory or QR code scan). +# +# ID generated with: capnp id +@0xb3a8f1c2e4d97650; + +interface AuthenticationService { + # Upload a single-use KeyPackage for later retrieval by peers. + # + # identityKey : Ed25519 public key bytes (exactly 32 bytes). + # package : openmls-serialised KeyPackage blob (TLS encoding). + # + # Returns the SHA-256 fingerprint of `package`. Clients should record this + # and compare it against the fingerprint returned by a peer's fetchKeyPackage + # to detect tampering. + uploadKeyPackage @0 (identityKey :Data, package :Data) -> (fingerprint :Data); + + # Fetch and atomically remove one KeyPackage for a given identity key. + # + # Returns empty Data if no KeyPackage is currently stored for this identity. + # Callers should handle the empty case by asking the target to upload more + # packages before retrying. + fetchKeyPackage @1 (identityKey :Data) -> (package :Data); +} diff --git a/schemas/delivery.capnp b/schemas/delivery.capnp new file mode 100644 index 0000000..4057cfb --- /dev/null +++ b/schemas/delivery.capnp @@ -0,0 +1,35 @@ +# delivery.capnp — Delivery Service RPC interface. +# +# The Delivery Service is a simple store-and-forward relay. It does not parse +# MLS messages — all payloads are opaque byte strings routed by recipient key. +# +# Callers are responsible for: +# - Routing Welcome messages to the correct new member after add_members(). +# - Routing Commit messages to any existing group members (other than self). +# - Routing Application messages to the intended recipient(s). +# +# The DS indexes queues by the recipient's raw Ed25519 public key (32 bytes), +# matching the indexing scheme used by the Authentication Service. +# +# ID generated with: capnp id +@0xc5d9e2b4f1a83076; + +interface DeliveryService { + # Enqueue an opaque payload for delivery to a recipient. + # + # recipientKey : Ed25519 public key of the intended recipient (exactly 32 bytes). + # payload : Opaque byte string — a TLS-encoded MlsMessageOut blob or any + # other framed data the application layer wants to deliver. + # + # The payload is appended to the recipient's FIFO queue. Returns immediately; + # the recipient retrieves it via `fetch`. + enqueue @0 (recipientKey :Data, payload :Data) -> (); + + # Fetch and atomically drain all queued payloads for a given recipient. + # + # recipientKey : Ed25519 public key of the caller (exactly 32 bytes). + # + # Returns the complete queue in FIFO order and clears it. Returns an empty + # list if there are no pending messages. + fetch @1 (recipientKey :Data) -> (payloads :List(Data)); +}