8.3 KiB
quicnprotochat
End-to-end encrypted group messaging over Noise_XX + MLS (RFC 9420), written in Rust.
Every byte on the wire is double-protected: the outer Noise_XX channel authenticates both sides and provides forward secrecy for the transport, while the inner MLS layer provides post-compromise security and ratcheted group key agreement across any number of participants. Messages are framed with Cap'n Proto, keeping serialisation zero-copy and schema-versioned.
Protocol stack
┌─────────────────────────────────────────────┐
│ Application / MLS ciphertext │ <- group key ratchet (RFC 9420)
├─────────────────────────────────────────────┤
│ Cap'n Proto RPC │ <- typed, schema-versioned framing
├─────────────────────────────────────────────┤
│ Noise_XX (X25519 · ChaChaPoly · SHA-256) │ <- mutual auth + transport secrecy
├─────────────────────────────────────────────┤
│ TCP │
└─────────────────────────────────────────────┘
| Property | Mechanism |
|---|---|
| Transport confidentiality | Noise_XX (ChaCha20-Poly1305) |
| Transport authentication | Noise_XX static X25519 keys |
| Group key agreement | MLS MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 |
| Post-compromise security | MLS epoch ratchet |
| Identity | Ed25519 (MLS credential + leaf node signature) |
| Message framing | Cap'n Proto (unpacked wire format) |
Repository layout
quicnprotochat/
├── crates/
│ ├── quicnprotochat-core/ # Crypto primitives, Noise transport, MLS group state machine
│ │ ├── src/codec.rs # LengthPrefixedCodec — Tokio Encoder + Decoder
│ │ ├── src/keypair.rs # NoiseKeypair — X25519 static key, zeroize-on-drop
│ │ ├── src/identity.rs # IdentityKeypair — Ed25519 identity + MLS Signer
│ │ ├── src/keypackage.rs# generate_key_package — standalone KeyPackage helper
│ │ ├── src/noise.rs # handshake_initiator / handshake_responder / NoiseTransport
│ │ └── src/group.rs # GroupMember — full MLS group lifecycle
│ │
│ ├── quicnprotochat-proto/ # Cap'n Proto schemas + generated types + serde helpers
│ │ └── schemas/ → # (symlinked to workspace root schemas/)
│ │
│ ├── quicnprotochat-server/ # Authentication Service (AS) + Delivery Service (DS) binary
│ └── quicnprotochat-client/ # CLI client (ping, register, fetch-key, …)
│
└── schemas/
├── envelope.capnp # Top-level wire envelope (MsgType discriminant + payload)
├── auth.capnp # AuthenticationService RPC (KeyPackage upload / fetch)
└── delivery.capnp # DeliveryService RPC (enqueue / fetch MLS messages)
Services
Authentication Service (AS) — port 7000
Stores single-use MLS KeyPackages so peers can add each other to groups.
uploadKeyPackage(identityKey: Data, package: Data) -> (fingerprint: Data)
fetchKeyPackage(identityKey: Data) -> (package: Data)
Packages are indexed by the raw Ed25519 public key (32 bytes) and consumed exactly once on fetch, matching the MLS single-use KeyPackage requirement.
Delivery Service (DS) — port 7001
A simple store-and-forward relay for MLS messages. The DS never inspects payloads — it routes opaque blobs by recipient public key.
enqueue(recipientKey: Data, payload: Data) -> ()
fetch(recipientKey: Data) -> (payloads: List(Data))
fetch atomically drains the entire queue in FIFO order.
MLS group lifecycle
GroupMember::new(identity)
│
├─ generate_key_package() → upload bytes to AS
│
├─ create_group(group_id) → epoch 0, sole member
│ └─ add_member(kp_bytes)→ (commit_bytes, welcome_bytes)
│ ↑ │ │
│ fetched from AS discard send to joiner via DS
│
└─ join_group(welcome_bytes) → joined; ready to encrypt
├─ send_message(plain) → TLS-encoded PrivateMessage → DS
└─ receive_message(ct) → Some(plaintext) | None (Commit)
The OpenMlsRustCrypto backend is persistent across calls on the same
GroupMember instance — it holds the HPKE init private key in its in-memory
key store between generate_key_package and join_group.
Building
Prerequisites:
- Rust (stable, 1.77+)
capnpCLI — the Cap'n Proto schema compiler
# Debian / Ubuntu
apt-get install capnproto
# macOS
brew install capnp
Build everything:
cargo build --workspace
Run tests:
cargo test --workspace
Running
Start the server (AS on :7000, DS on :7001):
cargo run -p quicnprotochat-server
# or with custom ports:
cargo run -p quicnprotochat-server -- --listen 0.0.0.0:7000 --ds-listen 0.0.0.0:7001
Client commands:
# Check connectivity
cargo run -p quicnprotochat-client -- ping
# Generate a fresh identity + KeyPackage, upload to AS
# Prints your identity_key (hex) — share this with peers
cargo run -p quicnprotochat-client -- register
# Fetch a peer's KeyPackage (they must have registered first)
cargo run -p quicnprotochat-client -- fetch-key <64-hex-char identity key>
# Run an end-to-end Alice↔Bob demo against live AS + DS
cargo run -p quicnprotochat-client -- demo-group \
--server 127.0.0.1:7000 \
--ds-server 127.0.0.1:7001
# Persistent group CLI (stateful)
cargo run -p quicnprotochat-client -- register-state --state state.bin --server 127.0.0.1:7000
cargo run -p quicnprotochat-client -- create-group --state state.bin --group-id my-group
cargo run -p quicnprotochat-client -- invite --state state.bin --peer-key <peer hex> --server 127.0.0.1:7000 --ds-server 127.0.0.1:7001
cargo run -p quicnprotochat-client -- join --state state.bin --ds-server 127.0.0.1:7001
cargo run -p quicnprotochat-client -- send --state state.bin --peer-key <peer hex> --msg "hello" --ds-server 127.0.0.1:7001
cargo run -p quicnprotochat-client -- recv --state state.bin --ds-server 127.0.0.1:7001
Server address defaults to 127.0.0.1:7000; override with --server or
QUICNPROTOCHAT_SERVER. Delivery Service defaults to 127.0.0.1:7001; override with
--ds-server or QUICNPROTOCHAT_DS_SERVER.
State file notes: the persisted state stores your identity and MLS group state
after you have joined. If you generate a KeyPackage (register-state) and then
restart before consuming the Welcome, the join may fail because the HPKE init
key is not retained; run join in the same session you register.
Milestones
| # | Name | Status | What it adds |
|---|---|---|---|
| M1 | Noise transport | ✅ | Noise_XX handshake, length-prefixed framing, Ping/Pong |
| M2 | Authentication Service | ✅ | Ed25519 identity, KeyPackage generation, AS upload/fetch |
| M3 | Delivery Service + MLS groups | ✅ | DS relay, GroupMember create/join/add/send/recv |
| M4 | Group CLI subcommands | 🔜 | Persistent CLI (create-group, invite, join, send, recv); demo-group already available |
| M5 | Multi-party groups | 🔜 | N > 2 members, Commit fan-out, Proposal handling |
| M6 | Persistence | 🔜 | SQLite key store, durable group state |
| M7 | Post-quantum | 🔜 | ML-KEM-768 hybrid in Noise layer |
Security notes
- This is a proof-of-concept. It has not been audited.
- The server Noise keypair is ephemeral — regenerated on every restart. Clients perform no server key pinning in the current milestone.
- MLS credentials use
CredentialType::Basic(public key only). A real deployment would bind credentials to a certificate authority. - The Delivery Service does no authentication of the
recipientKeyfield — anyone can enqueue for any recipient. Access control is a future milestone.
License
MIT