Files
quicproquo/M3_STATUS.md
Christian Nennemann 9a0b02a012 feat: M2 + M3 — AuthService, MLS group lifecycle, Delivery Service
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
2026-02-19 23:39:49 +01:00

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:

  1. Delivery Service (DS) — store-and-forward relay for MLS messages (Cap'n Proto RPC on port 7001)
  2. MLS Group LifecycleGroupMember 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<IdentityKeypair>) -> Self
  • generate_key_package() -> Result<Vec<u8>, 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<Vec<u8>, CoreError> — returns TLS-encoded PrivateMessage
  • receive_message(bytes: &[u8]) -> Result<Option<Vec<u8>>, CoreError> — returns plaintext or None for Commit
  • group_id() -> Option<Vec<u8>>
  • 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<MlsMessageIn> 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<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 state
  • invite --as-server --ds-server --peer-key <HEX> — 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 <HEX> --msg <TEXT> — 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:

// 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:

  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.