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<KeyPackageIn>) - 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
7.0 KiB
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:
- Delivery Service (DS) — store-and-forward relay for MLS messages (Cap'n Proto RPC on port 7001)
- MLS Group Lifecycle —
GroupMemberstruct: 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<IdentityKeypair>) -> Selfgenerate_key_package() -> Result<Vec<u8>, CoreError>— TLS-encoded KeyPackage bytescreate_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<Vec<u8>, CoreError>— returns TLS-encoded PrivateMessagereceive_message(bytes: &[u8]) -> Result<Option<Vec<u8>>, CoreError>— returns plaintext or None for Commitgroup_id() -> Option<Vec<u8>>identity() -> &IdentityKeypair
openmls 0.5 API gotchas resolved:
KeyPackageonly hasTlsSerialize, notTlsDeserialize→ useKeyPackageIn::tls_deserialize(...)?.validate(backend.crypto(), ProtocolVersion::Mls10)?MlsMessageIn::into_welcome()is#[cfg(any(feature = "test-utils", test))]→ usematch msg_in.extract() { MlsMessageInBody::Welcome(w) => w, ... }MlsMessageIn::into_protocol_message()is similarly feature-gated → usematch msg_in.extract() { MlsMessageInBody::PrivateMessage(m) => ProtocolMessage::PrivateMessage(m), MlsMessageInBody::PublicMessage(m) => ProtocolMessage::PublicMessage(m), ... }From<MlsMessageIn> for ProtocolMessageis also feature-gated- Must use
OpenMlsCryptoProvidertrait in scope forbackend.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; usesDashMap<Vec<u8>, VecDeque<Vec<u8>>>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 <NAME>— creates group, saves stateinvite --as-server --ds-server --peer-key <HEX>— fetches peer KP from AS, creates Welcome, enqueues to DSjoin --ds-server— fetches Welcome from DS, joins group, saves statesend --ds-server --peer-key <HEX> --msg <TEXT>— sends application message to DSrecv --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:
// 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):
async fn connect_ds(server: &str, keypair: &NoiseKeypair) -> anyhow::Result<delivery_service::Client> {
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
cd /home/c/projects/poc-mes
git log --oneline -5 # see where we are
cargo test -p noiseml-core # verify green
Then:
- Write
crates/noiseml-client/tests/mls_group.rs(integration test) — highest priority - 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.