feat: add post-quantum hybrid KEM + SQLCipher persistence
Feature 1 — Post-Quantum Hybrid KEM (X25519 + ML-KEM-768): - Create hybrid_kem.rs with keygen, encrypt, decrypt + 11 unit tests - Wire format: version(1) | x25519_eph_pk(32) | mlkem_ct(1088) | nonce(12) | ct - Add uploadHybridKey/fetchHybridKey RPCs to node.capnp schema - Server: hybrid key storage in FileBackedStore + RPC handlers - Client: hybrid keypair in StoredState, auto-wrap/unwrap in send/recv/invite/join - demo-group runs full hybrid PQ envelope round-trip Feature 2 — SQLCipher Persistence: - Extract Store trait from FileBackedStore API - Create SqlStore (rusqlite + bundled-sqlcipher) with encrypted-at-rest SQLite - Schema: key_packages, deliveries, hybrid_keys tables with indexes - Server CLI: --store-backend=sql, --db-path, --db-key flags - 5 unit tests for SqlStore (FIFO, round-trip, upsert, channel isolation) Also includes: client lib.rs refactor, auth config, TOML config file support, mdBook documentation, and various cleanups by user. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
232
docs/src/architecture/crate-responsibilities.md
Normal file
232
docs/src/architecture/crate-responsibilities.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Crate Responsibilities
|
||||
|
||||
The quicnprotochat workspace is split into four crates with strict layering
|
||||
rules. Each crate owns one concern and depends only on the crates below it.
|
||||
This page documents what each crate provides, what it explicitly avoids, and
|
||||
how the crates relate to one another.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Flow Diagram
|
||||
|
||||
```text
|
||||
┌──────────────────────────┐
|
||||
│ quicnprotochat-client │
|
||||
│ (CLI, QUIC client, │
|
||||
│ GroupMember orchestr.) │
|
||||
└─────────┬───────┬────────┘
|
||||
│ │
|
||||
┌───────┘ └────────┐
|
||||
▼ ▼
|
||||
┌────────────────────────┐ ┌────────────────────────┐
|
||||
│ quicnprotochat-core │ │ quicnprotochat-server │
|
||||
│ (crypto, Noise, │ │ (QUIC listener, │
|
||||
│ MLS, hybrid KEM) │ │ NodeService RPC, │
|
||||
│ │ │ storage) │
|
||||
└──────────┬─────────────┘ └─────────┬──────────────┘
|
||||
│ │
|
||||
│ ┌───────────────────┘
|
||||
▼ ▼
|
||||
┌────────────────────────┐
|
||||
│ quicnprotochat-proto │
|
||||
│ (Cap'n Proto schemas, │
|
||||
│ codegen, helpers) │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
**Arrows point from dependant to dependency.** The proto crate sits at the base
|
||||
of the dependency graph. The core crate depends on proto for envelope
|
||||
serialisation. The server and client crates both depend on core and proto.
|
||||
|
||||
---
|
||||
|
||||
## quicnprotochat-core
|
||||
|
||||
**Role:** Pure cryptographic logic and transport primitives. No network I/O
|
||||
(except for the Noise handshake helpers that take an existing `TcpStream`). No
|
||||
async runtime dependency beyond what Noise transport needs.
|
||||
|
||||
### Modules
|
||||
|
||||
| Module | Public API | Description |
|
||||
|---------------|-----------------------------------------------------------------------------|-------------|
|
||||
| `keypair` | `NoiseKeypair` | Static X25519 keypair for Noise_XX. `StaticSecret` is `ZeroizeOnDrop`. `private_bytes()` returns `Zeroizing<[u8; 32]>`. |
|
||||
| `identity` | `IdentityKeypair` | Ed25519 signing keypair for MLS credentials. Seed stored as `Zeroizing<[u8; 32]>`. Implements `openmls_traits::Signer`. |
|
||||
| `noise` | `handshake_initiator`, `handshake_responder`, `NoiseTransport` | Noise_XX_25519_ChaChaPoly_BLAKE2s handshake over TCP. `NoiseTransport` provides `send_frame`/`recv_frame`, envelope helpers, and `into_capnp_io()` bridge. |
|
||||
| `codec` | `LengthPrefixedCodec`, `NOISE_MAX_MSG` | Tokio `Encoder<Bytes>` + `Decoder`. 4-byte LE length prefix. Max frame 65,535 bytes. |
|
||||
| `group` | `GroupMember` | MLS group state machine wrapping `openmls::MlsGroup`. Lifecycle: `new` -> `generate_key_package` -> `create_group` / `join_group` -> `send_message` / `receive_message`. |
|
||||
| `keypackage` | `generate_key_package` | Standalone KeyPackage generation (returns TLS-encoded bytes + SHA-256 fingerprint). |
|
||||
| `keystore` | `DiskKeyStore`, `StoreCrypto` | `OpenMlsKeyStore` implementation backed by an in-memory `HashMap` with optional bincode flush to disk. `StoreCrypto` couples `RustCrypto` + `DiskKeyStore` into an `OpenMlsCryptoProvider`. |
|
||||
| `hybrid_kem` | `HybridKeypair`, `HybridPublicKey`, `hybrid_encrypt`, `hybrid_decrypt` | X25519 + ML-KEM-768 hybrid KEM. HKDF-SHA256 key derivation, ChaCha20-Poly1305 AEAD. Versioned envelope wire format. |
|
||||
| `error` | `CoreError`, `CodecError`, `MAX_PLAINTEXT_LEN` | Unified error types. `CoreError` covers Noise, Codec, Cap'n Proto, MLS, and hybrid KEM failures. |
|
||||
|
||||
### What this crate does NOT do
|
||||
|
||||
- No network I/O beyond the Noise helpers (which take a pre-connected `TcpStream`).
|
||||
- No QUIC or TLS -- that is the server and client crates' concern.
|
||||
- No async runtime setup (it uses Tokio types internally but does not spawn or
|
||||
manage a runtime).
|
||||
- No CLI parsing.
|
||||
|
||||
### Key dependencies
|
||||
|
||||
`snow`, `x25519-dalek`, `ed25519-dalek`, `openmls`, `openmls_rust_crypto`,
|
||||
`openmls_traits`, `tls_codec`, `ml-kem`, `chacha20poly1305`, `hkdf`, `sha2`,
|
||||
`zeroize`, `capnp`, `quicnprotochat-proto`, `tokio`, `tokio-util`, `futures`,
|
||||
`bytes`, `serde`, `bincode`, `serde_json`, `thiserror`.
|
||||
|
||||
---
|
||||
|
||||
## quicnprotochat-proto
|
||||
|
||||
**Role:** Cap'n Proto schema definitions, compile-time code generation, and
|
||||
pure-synchronous serialisation helpers. This crate is the single source of truth
|
||||
for the wire format.
|
||||
|
||||
### Contents
|
||||
|
||||
| Item | Description |
|
||||
|---------------------------|-------------|
|
||||
| `schemas/envelope.capnp` | `Envelope` struct and `MsgType` enum -- top-level wire message for Noise-channel traffic. |
|
||||
| `schemas/auth.capnp` | `AuthenticationService` interface -- `uploadKeyPackage`, `fetchKeyPackage`. |
|
||||
| `schemas/delivery.capnp` | `DeliveryService` interface -- `enqueue`, `fetch`. |
|
||||
| `schemas/node.capnp` | `NodeService` interface (unified AS+DS) -- all RPC methods plus `Auth` struct. |
|
||||
| `build.rs` | Invokes `capnpc` to generate Rust types from the four `.capnp` files. |
|
||||
| `lib.rs` | `pub mod envelope_capnp`, `auth_capnp`, `delivery_capnp`, `node_capnp` -- re-exports generated modules. |
|
||||
| `MsgType` | Re-exported enum from `envelope_capnp::envelope::MsgType`. |
|
||||
| `ParsedEnvelope` | Owned, `Send + 'static` representation of a decoded `Envelope`. All byte fields are eagerly copied out of the Cap'n Proto reader. |
|
||||
| `build_envelope` | Serialise a `ParsedEnvelope` to unpacked Cap'n Proto wire bytes. |
|
||||
| `parse_envelope` | Deserialise wire bytes into a `ParsedEnvelope`. |
|
||||
| `to_bytes` / `from_bytes` | Low-level Cap'n Proto message <-> byte conversions. |
|
||||
|
||||
### What this crate does NOT do
|
||||
|
||||
- **No crypto** -- key material never enters this crate.
|
||||
- **No I/O** -- callers own the transport; this crate only converts bytes to
|
||||
types and back.
|
||||
- **No async** -- pure synchronous data-layer code.
|
||||
|
||||
### Key dependencies
|
||||
|
||||
`capnp` (runtime), `capnpc` (build-time only).
|
||||
|
||||
---
|
||||
|
||||
## quicnprotochat-server
|
||||
|
||||
**Role:** Network-facing server binary. Accepts QUIC + TLS 1.3 connections,
|
||||
dispatches Cap'n Proto RPC calls to `NodeServiceImpl`, and persists state to
|
||||
disk via `FileBackedStore`.
|
||||
|
||||
### Components
|
||||
|
||||
| Component | Description |
|
||||
|----------------------|-------------|
|
||||
| `NodeServiceImpl` | Implements `node_service::Server` (Cap'n Proto generated trait). Handles all eight RPC methods: `uploadKeyPackage`, `fetchKeyPackage`, `enqueue`, `fetch`, `fetchWait`, `health`, `uploadHybridKey`, `fetchHybridKey`. |
|
||||
| `FileBackedStore` | Mutex-guarded `HashMap`s for KeyPackages (keyed by Ed25519 public key), delivery queues (keyed by `ChannelKey = (channelId, recipientKey)`), and hybrid public keys. Each mutation flushes the full map to a bincode file on disk. |
|
||||
| `DashMap` waiters | `DashMap<Vec<u8>, Arc<Notify>>` -- per-recipient `tokio::sync::Notify` instances for `fetchWait` long-polling. `enqueue` calls `notify_waiters()` after appending. |
|
||||
| TLS config | Self-signed certificate auto-generated on first run (`rcgen`). TLS 1.3 only, ALPN `capnp`. |
|
||||
| CLI (`clap`) | `--listen` (default `0.0.0.0:7000`), `--data-dir`, `--tls-cert`, `--tls-key`. |
|
||||
|
||||
### Connection lifecycle
|
||||
|
||||
```text
|
||||
QUIC accept
|
||||
└─ TLS 1.3 handshake (self-signed cert, ALPN "capnp")
|
||||
└─ accept_bi() -> bidirectional QUIC stream
|
||||
└─ tokio_util::compat adapters (AsyncRead/AsyncWrite)
|
||||
└─ capnp-rpc twoparty::VatNetwork (Side::Server)
|
||||
└─ RpcSystem drives NodeServiceImpl
|
||||
```
|
||||
|
||||
Because `capnp-rpc` uses `Rc<RefCell<>>` internally and is therefore `!Send`,
|
||||
the entire RPC stack runs on a `tokio::task::LocalSet`. Each incoming connection
|
||||
is handled by `spawn_local`.
|
||||
|
||||
### What this crate does NOT do
|
||||
|
||||
- No direct crypto operations (it delegates to `quicnprotochat-core` types
|
||||
for fingerprinting and storage only).
|
||||
- No MLS processing -- all payloads are opaque byte strings.
|
||||
- No Noise transport (QUIC/TLS only).
|
||||
|
||||
### Key dependencies
|
||||
|
||||
`quicnprotochat-core`, `quicnprotochat-proto`, `quinn`, `quinn-proto`,
|
||||
`rustls`, `rcgen`, `capnp`, `capnp-rpc`, `tokio`, `tokio-util`, `dashmap`,
|
||||
`sha2`, `clap`, `tracing`, `anyhow`, `thiserror`, `bincode`, `serde`.
|
||||
|
||||
---
|
||||
|
||||
## quicnprotochat-client
|
||||
|
||||
**Role:** CLI client binary. Connects to the server over QUIC + TLS 1.3,
|
||||
orchestrates MLS group operations via `GroupMember`, and persists identity and
|
||||
group state to disk.
|
||||
|
||||
### Components
|
||||
|
||||
| Component | Description |
|
||||
|-------------------------|-------------|
|
||||
| `connect_node` | Establishes a QUIC/TLS connection, opens a bidirectional stream, and bootstraps a `capnp-rpc` `RpcSystem` to obtain a `node_service::Client`. |
|
||||
| CLI subcommands (`clap`)| `ping`, `register`, `fetch-key`, `demo-group`, `register-state`, `create-group`, `invite`, `join`, `send`, `recv`. |
|
||||
| `GroupMember` usage | The client creates a `GroupMember` (from `quicnprotochat-core`), calls `generate_key_package` / `create_group` / `add_member` / `join_group` / `send_message` / `receive_message`. |
|
||||
| State persistence | `StoredState` holds `identity_seed` (32 bytes) and optional serialised `MlsGroup`. A companion `.ks` file stores the `DiskKeyStore` with HPKE init private keys. |
|
||||
| Auth context | `ClientAuth` bundles an optional bearer token and device ID. Passed to every RPC via the `Auth` struct in `node.capnp`. |
|
||||
|
||||
### CLI subcommand summary
|
||||
|
||||
| Subcommand | What it does |
|
||||
|-------------------|--------------|
|
||||
| `ping` | Call `health()` and print RTT. |
|
||||
| `register` | Generate a fresh identity + KeyPackage, upload to AS, print identity key. |
|
||||
| `register-state` | Same as `register` but uses/creates persistent state file. |
|
||||
| `fetch-key` | Fetch a peer's KeyPackage by hex identity key. |
|
||||
| `create-group` | Create a new MLS group and save state. |
|
||||
| `invite` | Fetch peer's KeyPackage, add to group, enqueue Welcome via DS. |
|
||||
| `join` | Fetch Welcome from DS, join the MLS group. |
|
||||
| `send` | Encrypt a message with MLS, enqueue via DS. |
|
||||
| `recv` | Fetch pending payloads from DS, decrypt with MLS. Supports `--stream` for continuous long-polling. |
|
||||
| `demo-group` | End-to-end Alice+Bob round-trip (ephemeral identities). |
|
||||
|
||||
### What this crate does NOT do
|
||||
|
||||
- No server-side logic.
|
||||
- No Noise transport (QUIC/TLS only for server communication).
|
||||
- No direct crypto beyond calling `GroupMember` and verifying SHA-256
|
||||
fingerprints.
|
||||
|
||||
### Key dependencies
|
||||
|
||||
`quicnprotochat-core`, `quicnprotochat-proto`, `quinn`, `quinn-proto`,
|
||||
`rustls`, `capnp`, `capnp-rpc`, `tokio`, `tokio-util`, `clap`, `sha2`,
|
||||
`serde`, `bincode`, `anyhow`, `thiserror`, `tracing`.
|
||||
|
||||
---
|
||||
|
||||
## Layering Rules
|
||||
|
||||
1. **proto** depends on nothing in-workspace. It is pure data definition.
|
||||
2. **core** depends on **proto** (for `ParsedEnvelope` and envelope helpers).
|
||||
It does not depend on server or client.
|
||||
3. **server** depends on **core** and **proto**. It does not depend on client.
|
||||
4. **client** depends on **core** and **proto**. It does not depend on server.
|
||||
5. **server** and **client** never depend on each other. They communicate
|
||||
exclusively via the Cap'n Proto RPC wire protocol.
|
||||
|
||||
This layering ensures that:
|
||||
|
||||
- Crypto code can be tested in isolation (`cargo test -p quicnprotochat-core`).
|
||||
- Schema changes propagate automatically through codegen.
|
||||
- The server binary contains no client-side MLS orchestration logic.
|
||||
- The client binary contains no server-side storage or listener logic.
|
||||
|
||||
---
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Architecture Overview](overview.md) -- high-level system diagram
|
||||
- [Service Architecture](service-architecture.md) -- NodeService RPC details
|
||||
- [Wire Format Overview](../wire-format/overview.md) -- Cap'n Proto schema reference
|
||||
- [GroupMember Lifecycle](../internals/group-member-lifecycle.md) -- MLS state machine details
|
||||
- [Storage Backend](../internals/storage-backend.md) -- FileBackedStore internals
|
||||
350
docs/src/architecture/data-flow.md
Normal file
350
docs/src/architecture/data-flow.md
Normal file
@@ -0,0 +1,350 @@
|
||||
# End-to-End Data Flow
|
||||
|
||||
This page traces the three core data flows through the quicnprotochat system:
|
||||
registration, group creation, and message exchange. Each flow is illustrated
|
||||
with an ASCII sequence diagram showing control-plane (AS) and data-plane (DS)
|
||||
traffic.
|
||||
|
||||
Throughout these flows the server is **MLS-unaware** -- it stores and forwards
|
||||
opaque byte blobs without parsing their MLS content.
|
||||
|
||||
---
|
||||
|
||||
## 1. Registration Flow
|
||||
|
||||
Before a client can join any MLS group, it must generate an Ed25519 identity
|
||||
keypair and upload at least one KeyPackage to the Authentication Service. Peers
|
||||
fetch these KeyPackages to add the client to groups.
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```text
|
||||
Client (Alice) NodeService (AS)
|
||||
────────────── ────────────────
|
||||
│ │
|
||||
│ 1. Generate Ed25519 identity keypair │
|
||||
│ (IdentityKeypair::generate) │
|
||||
│ │
|
||||
│ 2. Generate MLS KeyPackage │
|
||||
│ (GroupMember::generate_key_package) │
|
||||
│ - Creates HPKE init keypair │
|
||||
│ - Embeds Ed25519 pk in credential │
|
||||
│ - Signs leaf node with Ed25519 sk │
|
||||
│ - TLS-encodes the KeyPackage │
|
||||
│ │
|
||||
│ 3. QUIC connect + TLS 1.3 handshake │
|
||||
│ ────────────────────────────────────────>│
|
||||
│ │
|
||||
│ 4. uploadKeyPackage(identityKey, pkg) │
|
||||
│ ────────────────────────────────────────>│
|
||||
│ │ 5. Validate:
|
||||
│ │ - identityKey == 32 bytes
|
||||
│ │ - package non-empty, <= 1 MB
|
||||
│ │ - auth version allowed
|
||||
│ │
|
||||
│ │ 6. Compute SHA-256(package)
|
||||
│ │
|
||||
│ │ 7. Append to per-identity queue:
|
||||
│ │ keyPackages[identityKey].push(pkg)
|
||||
│ │
|
||||
│ │ 8. Flush keypackages.bin to disk
|
||||
│ │
|
||||
│ fingerprint (SHA-256) │
|
||||
│ <────────────────────────────────────────│
|
||||
│ │
|
||||
│ 9. Compare local fingerprint with │
|
||||
│ server-returned fingerprint │
|
||||
│ (tamper detection) │
|
||||
│ │
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
- **KeyPackages are single-use** (RFC 9420 requirement). Each `fetchKeyPackage`
|
||||
call atomically removes and returns one package. The client should upload
|
||||
multiple KeyPackages if it expects to be added to several groups.
|
||||
|
||||
- The `identityKey` used as the AS index is the **raw 32-byte Ed25519 public
|
||||
key**, not a fingerprint or hash. Peers must know Alice's public key out-of-
|
||||
band (QR code, directory, etc.) to fetch her KeyPackage.
|
||||
|
||||
- The HPKE init private key generated during `generate_key_package` is stored
|
||||
in the client's `DiskKeyStore`. The **same `GroupMember` instance** (or a
|
||||
restored instance with the same key store) must later call `join_group` to
|
||||
decrypt the Welcome message.
|
||||
|
||||
- The optional hybrid public key (`uploadHybridKey`) can also be uploaded
|
||||
during registration for post-quantum envelope encryption.
|
||||
|
||||
---
|
||||
|
||||
## 2. Group Creation Flow
|
||||
|
||||
Alice creates a new MLS group, fetches Bob's KeyPackage from the AS, adds Bob
|
||||
to the group (producing a Commit and a Welcome), and delivers the Welcome to
|
||||
Bob via the DS.
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```text
|
||||
Alice NodeService (AS+DS) Bob
|
||||
───── ────────────────── ───
|
||||
│ │ │
|
||||
│ 1. create_group("my-group") │ │
|
||||
│ (local MLS operation -- │ │
|
||||
│ Alice is sole member, │ │
|
||||
│ epoch 0) │ │
|
||||
│ │ │
|
||||
│ 2. fetchKeyPackage(bob_pk) │ │
|
||||
│ ───────────────────────────────>│ │
|
||||
│ │ 3. Pop bob's KeyPackage │
|
||||
│ │ from queue (atomic) │
|
||||
│ bob_kp bytes │ │
|
||||
│ <───────────────────────────────│ │
|
||||
│ │ │
|
||||
│ 4. add_member(bob_kp) │ │
|
||||
│ Local MLS operations: │ │
|
||||
│ a. Deserialise & validate │ │
|
||||
│ Bob's KeyPackage │ │
|
||||
│ b. Produce Commit message │ │
|
||||
│ (adds Bob to ratchet │ │
|
||||
│ tree, advances epoch) │ │
|
||||
│ c. Produce Welcome message │ │
|
||||
│ (encrypted to Bob's │ │
|
||||
│ HPKE init key, contains │ │
|
||||
│ group secrets + tree) │ │
|
||||
│ d. merge_pending_commit() │ │
|
||||
│ (Alice advances to │ │
|
||||
│ epoch 1 locally) │ │
|
||||
│ │ │
|
||||
│ 5. enqueue(bob_pk, welcome) │ │
|
||||
│ ───────────────────────────────>│ │
|
||||
│ │ 6. Append welcome to │
|
||||
│ │ deliveries[(ch, bob_pk)] │
|
||||
│ │ │
|
||||
│ │ 7. Notify bob_pk waiters │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ 8. Bob connects and fetches │
|
||||
│ │ <─────────────────────────────│
|
||||
│ │ fetch(bob_pk) │
|
||||
│ │ │
|
||||
│ │ 9. Drain bob's queue │
|
||||
│ │ (returns [welcome]) │
|
||||
│ │ │
|
||||
│ │ [welcome_bytes] │
|
||||
│ │ ─────────────────────────────>│
|
||||
│ │ │
|
||||
│ │ │ 10. join_group(welcome)
|
||||
│ │ │ - Decrypt Welcome with
|
||||
│ │ │ HPKE init private key
|
||||
│ │ │ - Extract ratchet tree
|
||||
│ │ │ from GroupInfo ext
|
||||
│ │ │ - Initialise MlsGroup
|
||||
│ │ │ at epoch 1
|
||||
│ │ │
|
||||
│ │ │ Bob is now a group member
|
||||
│ │ │
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
- The **Commit** message is relevant for groups with more than two members. In
|
||||
the two-party case, Alice is the sole existing member and merges the commit
|
||||
herself. In a multi-member group, the Commit would be sent to all existing
|
||||
members via the DS so they can advance their epoch.
|
||||
|
||||
- The **Welcome** message is encrypted to Bob's HPKE init key (derived from
|
||||
the KeyPackage). Only the `GroupMember` instance that generated that
|
||||
KeyPackage holds the corresponding private key.
|
||||
|
||||
- The `use_ratchet_tree_extension = true` MLS config embeds the full ratchet
|
||||
tree in the Welcome's `GroupInfo` extension. This means Bob does not need a
|
||||
separate tree fetch -- `new_from_welcome` extracts it automatically.
|
||||
|
||||
- The DS routes solely by `recipientKey` (Bob's Ed25519 public key). It does
|
||||
not parse the Welcome, the Commit, or any MLS structure.
|
||||
|
||||
---
|
||||
|
||||
## 3. Message Exchange Flow
|
||||
|
||||
After both Alice and Bob are group members, they exchange MLS Application
|
||||
messages through the DS.
|
||||
|
||||
### Sequence Diagram
|
||||
|
||||
```text
|
||||
Alice NodeService (DS) Bob
|
||||
───── ────────────────── ───
|
||||
│ │ │
|
||||
│ ─── Alice sends a message to Bob ─── │
|
||||
│ │ │
|
||||
│ 1. send_message("hello bob") │ │
|
||||
│ MLS create_message(): │ │
|
||||
│ - Derive message key from │ │
|
||||
│ epoch secret + gen counter│ │
|
||||
│ - Encrypt plaintext with │ │
|
||||
│ AES-128-GCM │ │
|
||||
│ - Produce MlsMessageOut │ │
|
||||
│ (PrivateMessage variant) │ │
|
||||
│ - TLS-encode to bytes │ │
|
||||
│ │ │
|
||||
│ 2. enqueue(bob_pk, ciphertext) │ │
|
||||
│ ───────────────────────────────>│ │
|
||||
│ │ 3. Store in bob's queue │
|
||||
│ │ 4. Notify bob_pk waiters │
|
||||
│ │ │
|
||||
│ │ (time passes) │
|
||||
│ │ │
|
||||
│ │ 5. Bob polls for messages │
|
||||
│ │ <─────────────────────────────│
|
||||
│ │ fetchWait(bob_pk, 30000) │
|
||||
│ │ │
|
||||
│ │ 6. Drain bob's queue │
|
||||
│ │ [ciphertext] │
|
||||
│ │ ─────────────────────────────>│
|
||||
│ │ │
|
||||
│ │ │ 7. receive_message(ct)
|
||||
│ │ │ MLS process_message():
|
||||
│ │ │ - Identify sender from
|
||||
│ │ │ PrivateMessage header
|
||||
│ │ │ - Derive decryption key
|
||||
│ │ │ from epoch secret
|
||||
│ │ │ - Decrypt AES-128-GCM
|
||||
│ │ │ - Return plaintext:
|
||||
│ │ │ "hello bob"
|
||||
│ │ │
|
||||
│ ─── Bob replies to Alice ─── │
|
||||
│ │ │
|
||||
│ │ │ 8. send_message("hello alice")
|
||||
│ │ │ (same MLS encrypt flow)
|
||||
│ │ │
|
||||
│ │ 9. enqueue(alice_pk, ct) │
|
||||
│ │ <─────────────────────────────│
|
||||
│ │ 10. Store + notify │
|
||||
│ │ │
|
||||
│ 11. fetch(alice_pk) │ │
|
||||
│ ───────────────────────────────>│ │
|
||||
│ [ciphertext] │ │
|
||||
│ <───────────────────────────────│ │
|
||||
│ │ │
|
||||
│ 12. receive_message(ct) │ │
|
||||
│ -> "hello alice" │ │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
- **MLS provides forward secrecy**: each message is encrypted with a key
|
||||
derived from the current epoch secret and a per-sender generation counter.
|
||||
Compromising a future key does not reveal past messages.
|
||||
|
||||
- **The DS is a dumb relay**: it does not decrypt, inspect, or reorder
|
||||
messages. It stores opaque byte blobs in a FIFO queue keyed by recipient.
|
||||
|
||||
- **Long-polling** via `fetchWait` avoids the need for persistent connections
|
||||
or WebSocket-style push. The client specifies a timeout in milliseconds; the
|
||||
server blocks up to that duration using `tokio::sync::Notify`. The `recv
|
||||
--stream` CLI flag loops `fetchWait` indefinitely for continuous message
|
||||
reception.
|
||||
|
||||
- **Channel-aware routing** is supported: the `channelId` field in `enqueue`
|
||||
and `fetch` allows scoping queues by channel (e.g., a 16-byte UUID for
|
||||
1:1 conversations). When `channelId` is empty, messages go to the default
|
||||
(legacy) queue.
|
||||
|
||||
---
|
||||
|
||||
## Control-Plane vs. Data-Plane Summary
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Control Plane (AS) │
|
||||
│ │
|
||||
│ uploadKeyPackage ────> Store KeyPackage for identity │
|
||||
│ fetchKeyPackage <──── Pop and return one KeyPackage │
|
||||
│ uploadHybridKey ────> Store hybrid PQ public key │
|
||||
│ fetchHybridKey <──── Return hybrid PQ public key │
|
||||
│ │
|
||||
│ Traffic: Infrequent. Once per group join (upload before, │
|
||||
│ fetch during group add). │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Data Plane (DS) │
|
||||
│ │
|
||||
│ enqueue ────> Append payload to recipient queue │
|
||||
│ fetch <──── Drain and return all queued payloads │
|
||||
│ fetchWait <──── Long-poll drain with timeout │
|
||||
│ │
|
||||
│ Traffic: High-frequency. Every MLS message (Welcome, Commit, │
|
||||
│ Application) flows through the DS. │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The separation means the AS can be rate-limited or placed behind stricter
|
||||
access controls without affecting message throughput on the DS.
|
||||
|
||||
---
|
||||
|
||||
## State Transitions
|
||||
|
||||
The following diagram summarises the client-side state machine across all three
|
||||
flows:
|
||||
|
||||
```text
|
||||
┌──────────────┐
|
||||
│ No State │
|
||||
└──────┬───────┘
|
||||
│
|
||||
IdentityKeypair::generate()
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Identity │ Ed25519 keypair exists
|
||||
│ Generated │ No KeyPackage, no group
|
||||
└──────┬───────┘
|
||||
│
|
||||
generate_key_package() + uploadKeyPackage()
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Registered │ KeyPackage on AS
|
||||
│ │ HPKE init key in DiskKeyStore
|
||||
└──────┬───────┘
|
||||
│
|
||||
┌──────────────┴──────────────┐
|
||||
│ │
|
||||
create_group() join_group(welcome)
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────┐ ┌──────────────┐
|
||||
│ Group Owner │ │ Group Member │
|
||||
│ (epoch 0) │ │ (epoch N) │
|
||||
└──────┬──────┘ └──────┬───────┘
|
||||
│ │
|
||||
add_member() │
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Active Group Member │
|
||||
│ │
|
||||
│ send_message() -> enqueue via DS │
|
||||
│ receive_message() <- fetch from DS │
|
||||
│ │
|
||||
│ Epoch advances on each Commit │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Architecture Overview](overview.md) -- system diagram and two-service model
|
||||
- [Service Architecture](service-architecture.md) -- RPC method details and long-polling internals
|
||||
- [GroupMember Lifecycle](../internals/group-member-lifecycle.md) -- detailed MLS state machine
|
||||
- [KeyPackage Exchange Flow](../internals/keypackage-exchange.md) -- single-use semantics and AS internals
|
||||
- [MLS (RFC 9420)](../protocol-layers/mls.md) -- key schedule, ratchet tree, and ciphersuite details
|
||||
- [Forward Secrecy](../cryptography/forward-secrecy.md) -- how MLS provides forward secrecy
|
||||
- [Post-Compromise Security](../cryptography/post-compromise-security.md) -- group healing after key compromise
|
||||
171
docs/src/architecture/overview.md
Normal file
171
docs/src/architecture/overview.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Architecture Overview
|
||||
|
||||
quicnprotochat is an end-to-end encrypted group messaging system built in Rust.
|
||||
This page describes the high-level architecture: the services that compose the
|
||||
system, the dual-key cryptographic model, and how the pieces fit together.
|
||||
|
||||
---
|
||||
|
||||
## Two-Service Model
|
||||
|
||||
The server exposes two logical services through a single **NodeService** RPC
|
||||
interface, bound to **port 7000** over QUIC + TLS 1.3:
|
||||
|
||||
| Logical Service | Responsibility |
|
||||
|--------------------------|-----------------------------------------------------------------|
|
||||
| **Authentication Service (AS)** | Stores and distributes single-use MLS KeyPackages. Clients upload KeyPackages after identity generation; peers fetch them to add new members to a group. |
|
||||
| **Delivery Service (DS)** | Store-and-forward relay for opaque payloads. The DS never inspects MLS ciphertext -- it routes solely by recipient Ed25519 public key (and optional channel ID). |
|
||||
|
||||
Combining both services into a single endpoint simplifies deployment and
|
||||
reduces round-trips. The schema is defined in
|
||||
[`schemas/node.capnp`](../wire-format/node-service-schema.md) as a unified
|
||||
`NodeService` interface.
|
||||
|
||||
See [Service Architecture](service-architecture.md) for per-method details,
|
||||
connection lifecycle, and the long-polling `fetchWait` mechanism.
|
||||
|
||||
---
|
||||
|
||||
## Dual-Key Model
|
||||
|
||||
quicnprotochat uses two independent asymmetric key pairs per client, each
|
||||
serving a distinct role:
|
||||
|
||||
```text
|
||||
quicnprotochat Key Model
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ X25519 static keypair (Noise transport) │
|
||||
│ ───────────────────────────────────── │
|
||||
│ - Generated once per node identity │
|
||||
│ - Used in the Noise_XX handshake (M1 stack) │
|
||||
│ - Provides mutual authentication + │
|
||||
│ channel confidentiality at the TCP layer │
|
||||
│ - Classical only (no PQ protection) │
|
||||
│ - Managed by NoiseKeypair, zeroize-on-drop │
|
||||
│ │
|
||||
│ Ed25519 signing keypair (MLS identity) │
|
||||
│ ────────────────────────────────────── │
|
||||
│ - Generated once per user/device │
|
||||
│ - Embedded in MLS BasicCredential │
|
||||
│ - Signs KeyPackages, Commits, and group ops │
|
||||
│ - Raw 32-byte public key is the AS index │
|
||||
│ - Managed by IdentityKeypair, zeroize-on-drop │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Property | X25519 (Noise) | Ed25519 (MLS) |
|
||||
|-------------------|-------------------------------------|--------------------------------------------|
|
||||
| Curve | Curve25519 (Montgomery) | Ed25519 (Twisted Edwards) |
|
||||
| Purpose | Transport authentication + secrecy | Identity binding, signing, MLS credentials |
|
||||
| Crate | `x25519-dalek` | `ed25519-dalek` |
|
||||
| Zeroize on drop | Yes (`StaticSecret`) | Yes (`Zeroizing<[u8; 32]>`) |
|
||||
| PQ protection | None (classical X25519) | MLS key schedule uses DHKEM(X25519); hybrid PQ KEM available at envelope level |
|
||||
|
||||
For details on the cryptographic properties of each key type, see
|
||||
[Ed25519 Identity Keys](../cryptography/identity-keys.md) and
|
||||
[X25519 Transport Keys](../cryptography/transport-keys.md).
|
||||
|
||||
---
|
||||
|
||||
## System Diagram
|
||||
|
||||
```text
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Alice Client │ │ Bob Client │
|
||||
│ │ │ │
|
||||
│ IdentityKeypair │ │ IdentityKeypair │
|
||||
│ (Ed25519) │ │ (Ed25519) │
|
||||
│ │ │ │
|
||||
│ GroupMember │ │ GroupMember │
|
||||
│ (MLS state) │ │ (MLS state) │
|
||||
│ │ │ │
|
||||
│ NoiseKeypair* │ │ NoiseKeypair* │
|
||||
│ (X25519, M1) │ │ (X25519, M1) │
|
||||
└────────┬─────────┘ └────────┬─────────┘
|
||||
│ │
|
||||
│ QUIC + TLS 1.3 (quinn/rustls) │
|
||||
│ ─── or ─── │
|
||||
│ Noise_XX over TCP (snow, M1 stack) │
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ NodeService (port 7000) │
|
||||
│ │
|
||||
│ ┌──────────────────────────┐ ┌───────────────────────────────────┐ │
|
||||
│ │ Authentication Service │ │ Delivery Service │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ uploadKeyPackage() │ │ enqueue(recipientKey, payload) │ │
|
||||
│ │ fetchKeyPackage() │ │ fetch(recipientKey) │ │
|
||||
│ │ uploadHybridKey() │ │ fetchWait(recipientKey, timeout) │ │
|
||||
│ │ fetchHybridKey() │ │ │ │
|
||||
│ │ │ │ Queues: DashMap + FileBackedStore│ │
|
||||
│ │ Store: DashMap + │ │ │ │
|
||||
│ │ FileBackedStore │ │ │ │
|
||||
│ └──────────────────────────┘ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
│ health() │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key observations:**
|
||||
|
||||
1. The server never sees plaintext message content. MLS ciphertext is opaque to
|
||||
the DS -- it merely routes by `recipientKey`.
|
||||
|
||||
2. KeyPackages are single-use (RFC 9420 requirement). The AS atomically removes
|
||||
a KeyPackage on fetch to enforce this invariant.
|
||||
|
||||
3. The QUIC + TLS 1.3 stack is the primary transport (M3+). The Noise_XX over
|
||||
TCP stack from M1 remains available for environments where QUIC is blocked.
|
||||
|
||||
---
|
||||
|
||||
## Protocol Layering
|
||||
|
||||
The system stacks three protocol layers:
|
||||
|
||||
1. **Transport** -- QUIC + TLS 1.3 (primary) or Noise_XX over TCP (M1
|
||||
fallback). Provides confidentiality, integrity, and server authentication.
|
||||
See [Protocol Stack](protocol-stack.md).
|
||||
|
||||
2. **Framing / RPC** -- Cap'n Proto serialisation and RPC. Provides zero-copy
|
||||
typed messages, schema versioning, and async method dispatch.
|
||||
See [Cap'n Proto Serialisation and RPC](../protocol-layers/capn-proto.md).
|
||||
|
||||
3. **End-to-End Encryption** -- MLS (RFC 9420). Provides group key agreement,
|
||||
forward secrecy, and post-compromise security. The server never holds group
|
||||
keys.
|
||||
See [MLS (RFC 9420)](../protocol-layers/mls.md).
|
||||
|
||||
An optional fourth layer -- the **hybrid KEM envelope** (X25519 + ML-KEM-768)
|
||||
-- wraps MLS payloads for post-quantum confidentiality at the per-message level.
|
||||
See [Hybrid KEM](../protocol-layers/hybrid-kem.md).
|
||||
|
||||
---
|
||||
|
||||
## Crate Map
|
||||
|
||||
The implementation is split across four workspace crates:
|
||||
|
||||
| Crate | Role |
|
||||
|----------------------------|-------------------------------------------------------------------|
|
||||
| `quicnprotochat-core` | Crypto primitives, Noise transport, MLS state machine, hybrid KEM |
|
||||
| `quicnprotochat-proto` | Cap'n Proto schemas, codegen, and serialisation helpers |
|
||||
| `quicnprotochat-server` | QUIC listener, NodeService RPC, storage |
|
||||
| `quicnprotochat-client` | QUIC client, CLI subcommands, state persistence |
|
||||
|
||||
See [Crate Responsibilities](crate-responsibilities.md) for a full breakdown
|
||||
and dependency diagram.
|
||||
|
||||
---
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Protocol Stack](protocol-stack.md) -- layered comparison of the two transport stacks
|
||||
- [Service Architecture](service-architecture.md) -- NodeService RPC methods, connection lifecycle, long-polling
|
||||
- [End-to-End Data Flow](data-flow.md) -- registration, group creation, and message exchange sequence diagrams
|
||||
- [Wire Format Overview](../wire-format/overview.md) -- Cap'n Proto schema reference
|
||||
- [Cryptography Overview](../cryptography/overview.md) -- detailed cryptographic properties and threat model
|
||||
208
docs/src/architecture/protocol-stack.md
Normal file
208
docs/src/architecture/protocol-stack.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Protocol Stack
|
||||
|
||||
quicnprotochat layers three protocol stages to move a plaintext message from
|
||||
sender to recipient with end-to-end encryption, typed RPC framing, and
|
||||
authenticated transport. This page describes each layer, explains why both the
|
||||
QUIC and Noise transport stacks exist, and provides a side-by-side comparison.
|
||||
|
||||
---
|
||||
|
||||
## Primary Stack (M3+): QUIC + TLS 1.3
|
||||
|
||||
Starting from milestone M3, the primary transport is QUIC over UDP with TLS 1.3
|
||||
negotiated by `quinn` and `rustls`. Cap'n Proto RPC rides on a bidirectional
|
||||
QUIC stream.
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Application / MLS ciphertext │ <- group key ratchet (RFC 9420)
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Cap'n Proto RPC │ <- typed, schema-versioned framing
|
||||
├─────────────────────────────────────────────┤
|
||||
│ QUIC + TLS 1.3 (quinn / rustls) │ <- mutual auth + transport secrecy
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### What each layer provides
|
||||
|
||||
**QUIC + TLS 1.3** (`quinn`, `rustls`)
|
||||
|
||||
- Encrypted, authenticated transport with 0-RTT connection establishment
|
||||
(where resumed).
|
||||
- TLS 1.3 provides perfect forward secrecy per connection via ephemeral ECDHE.
|
||||
- The server presents a self-signed certificate by default; the client pins
|
||||
the server certificate via `--ca-cert`.
|
||||
- ALPN protocol identifier: `capnp`.
|
||||
- Multiplexed streams over a single UDP socket -- one bidirectional stream
|
||||
per RPC session.
|
||||
|
||||
**Cap'n Proto RPC** (`capnp`, `capnp-rpc`)
|
||||
|
||||
- Zero-copy, schema-versioned serialisation.
|
||||
- Asynchronous RPC with promise pipelining (multiple in-flight calls).
|
||||
- The `NodeService` interface (defined in `schemas/node.capnp`) multiplexes
|
||||
Authentication and Delivery operations on a single connection.
|
||||
- The two-party VatNetwork runs over `tokio::io::compat` adapters wrapping
|
||||
QUIC send/recv streams.
|
||||
|
||||
**MLS (RFC 9420)** (`openmls`, `openmls_rust_crypto`)
|
||||
|
||||
- Group key agreement with ratchet-tree-based key schedule.
|
||||
- Forward secrecy: past messages remain confidential if a member's key is
|
||||
compromised.
|
||||
- Post-compromise security (PCS): the group heals after a compromise once an
|
||||
honest update occurs.
|
||||
- Identity binding: each member's Ed25519 public key is embedded in the MLS
|
||||
`BasicCredential`.
|
||||
- Ciphersuite: `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519`.
|
||||
|
||||
---
|
||||
|
||||
## M1 Stack: Noise_XX over TCP
|
||||
|
||||
The original milestone-1 transport uses a Noise Protocol Framework handshake
|
||||
directly over TCP. This stack is retained for environments where QUIC (UDP) is
|
||||
blocked by middleboxes.
|
||||
|
||||
```text
|
||||
TCP connection
|
||||
└── Noise_XX handshake (snow)
|
||||
└── Authenticated encrypted channel (ChaCha20-Poly1305)
|
||||
└── [u32 frame_len LE][Cap'n Proto encoded message]
|
||||
└── Cap'n Proto RPC (capnp-rpc)
|
||||
```
|
||||
|
||||
### Layer details
|
||||
|
||||
**TCP**
|
||||
|
||||
- Reliable, ordered byte stream.
|
||||
- No built-in encryption or authentication.
|
||||
|
||||
**Noise_XX** (`snow`)
|
||||
|
||||
- Pattern: `Noise_XX_25519_ChaChaPoly_BLAKE2s`.
|
||||
- Three-message handshake that mutually authenticates both peers' static
|
||||
X25519 keys:
|
||||
|
||||
```text
|
||||
XX handshake (3 messages):
|
||||
-> e (initiator sends ephemeral public key)
|
||||
<- e, ee, s, es (responder: DH + static key)
|
||||
-> s, se (initiator: static key + final DH)
|
||||
```
|
||||
|
||||
- After the handshake, every frame is encrypted with ChaCha20-Poly1305 (AEAD)
|
||||
using session keys derived from the Noise key schedule.
|
||||
- Maximum Noise message size: 65,535 bytes.
|
||||
|
||||
**Length-Prefixed Codec** (`LengthPrefixedCodec` in `quicnprotochat-core`)
|
||||
|
||||
- Each frame is prefixed by a 4-byte little-endian `u32` length field.
|
||||
- Little-endian was chosen for consistency with Cap'n Proto's segment table
|
||||
encoding.
|
||||
- Wire format:
|
||||
|
||||
```text
|
||||
┌──────────────────────────┬──────────────────────────────────────┐
|
||||
│ length (4 bytes, LE u32)│ payload (length bytes) │
|
||||
└──────────────────────────┴──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Maximum payload size is `NOISE_MAX_MSG` (65,535 bytes), enforced on both
|
||||
encode and decode.
|
||||
- See [Length-Prefixed Framing Codec](../wire-format/framing-codec.md) for the
|
||||
full specification.
|
||||
|
||||
**Cap'n Proto RPC**
|
||||
|
||||
- Same schema and RPC interface as the QUIC stack.
|
||||
- The `NoiseTransport::into_capnp_io()` method bridges the message-oriented
|
||||
Noise channel to the byte-stream interface that `capnp-rpc`'s
|
||||
`twoparty::VatNetwork` expects, using a `tokio::io::duplex` pipe and a
|
||||
background shuttle task.
|
||||
|
||||
---
|
||||
|
||||
## Why Both Stacks Exist
|
||||
|
||||
| Concern | QUIC + TLS 1.3 | Noise_XX over TCP |
|
||||
|------------------------|----------------------------------------|----------------------------------------|
|
||||
| **Milestone** | M3+ (primary) | M1 (original, retained) |
|
||||
| **UDP availability** | Requires UDP; may be blocked on some networks | TCP-only; works everywhere |
|
||||
| **Connection setup** | 1-RTT (or 0-RTT on resumption) | 1-RTT TCP + 1.5-RTT Noise handshake |
|
||||
| **Multiplexing** | Native QUIC stream multiplexing | Single TCP connection, single stream |
|
||||
| **Authentication** | Server cert (self-signed / CA-issued) | Mutual static-key authentication |
|
||||
| **PQ gap** | TLS 1.3 key exchange is classical ECDHE | Noise key exchange is classical X25519 |
|
||||
| **Crate** | `quinn`, `rustls` | `snow` |
|
||||
|
||||
Both stacks carry the same Cap'n Proto RPC and MLS layers on top, so
|
||||
application logic is transport-agnostic. The Noise_XX stack may also serve as a
|
||||
peer-to-peer transport in future mesh topologies where a QUIC server
|
||||
certificate model does not apply.
|
||||
|
||||
---
|
||||
|
||||
## Comparison Table
|
||||
|
||||
| Layer | Provides | Crate(s) |
|
||||
|-------------|------------------------------------------------------------------|-----------------------------------------|
|
||||
| **Transport: QUIC + TLS 1.3** | Confidentiality, server authentication, forward secrecy, multiplexed streams, congestion control | `quinn`, `rustls` |
|
||||
| **Transport: Noise_XX** | Confidentiality, mutual authentication, forward secrecy (per-session) | `snow` |
|
||||
| **Framing: Cap'n Proto** | Zero-copy typed serialisation, schema versioning, async RPC with promise pipelining | `capnp`, `capnp-rpc` |
|
||||
| **Encryption: MLS** | Group key agreement, forward secrecy, post-compromise security, identity binding | `openmls`, `openmls_rust_crypto` |
|
||||
| **Encryption: Hybrid KEM** (optional) | Post-quantum confidentiality for individual payloads (X25519 + ML-KEM-768) | `ml-kem`, `x25519-dalek`, `chacha20poly1305`, `hkdf` |
|
||||
|
||||
---
|
||||
|
||||
## Data Path Summary
|
||||
|
||||
A plaintext message traverses the stack as follows:
|
||||
|
||||
```text
|
||||
Sender Recipient
|
||||
────── ─────────
|
||||
|
||||
plaintext bytes
|
||||
│
|
||||
▼
|
||||
MLS create_message()
|
||||
│ ── encrypts with group AEAD key (AES-128-GCM) ──
|
||||
▼
|
||||
TLS-encoded MlsMessageOut (opaque ciphertext blob)
|
||||
│
|
||||
▼
|
||||
Cap'n Proto: enqueue(recipientKey, payload)
|
||||
│ ── serialised into NodeService RPC call ──
|
||||
▼
|
||||
QUIC stream (TLS 1.3 encrypted) ─── or ─── Noise frame (ChaCha20-Poly1305)
|
||||
│ │
|
||||
▼ ▼
|
||||
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ network ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
|
||||
│ │
|
||||
▼ ▼
|
||||
Server: NodeService.enqueue() stores payload in FIFO queue
|
||||
│
|
||||
▼
|
||||
Cap'n Proto: fetch() / fetchWait() returns payload
|
||||
│
|
||||
▼
|
||||
MLS process_message()
|
||||
│ ── decrypts with group AEAD key ──
|
||||
▼
|
||||
plaintext bytes
|
||||
```
|
||||
|
||||
The server **never** holds the MLS group key. It sees only the encrypted
|
||||
`MlsMessageOut` blob.
|
||||
|
||||
---
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Architecture Overview](overview.md) -- high-level system diagram and dual-key model
|
||||
- [Noise_XX Handshake](../protocol-layers/noise-xx.md) -- deep dive into the three-message handshake
|
||||
- [QUIC + TLS 1.3](../protocol-layers/quic-tls.md) -- QUIC configuration, ALPN, and certificate handling
|
||||
- [Cap'n Proto Serialisation and RPC](../protocol-layers/capn-proto.md) -- schema design and VatNetwork wiring
|
||||
- [MLS (RFC 9420)](../protocol-layers/mls.md) -- ciphersuite selection, key schedule, and ratchet tree
|
||||
- [Hybrid KEM: X25519 + ML-KEM-768](../protocol-layers/hybrid-kem.md) -- post-quantum envelope encryption
|
||||
259
docs/src/architecture/service-architecture.md
Normal file
259
docs/src/architecture/service-architecture.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# Service Architecture
|
||||
|
||||
The quicnprotochat server exposes a single **NodeService** RPC endpoint that
|
||||
combines Authentication and Delivery operations. This page documents the RPC
|
||||
interface, per-connection lifecycle, storage model, long-polling mechanism, and
|
||||
authentication context.
|
||||
|
||||
---
|
||||
|
||||
## NodeService Endpoint
|
||||
|
||||
A single QUIC + TLS 1.3 listener on **port 7000** serves all operations.
|
||||
The schema is defined in `schemas/node.capnp` and documented in
|
||||
[NodeService Schema](../wire-format/node-service-schema.md).
|
||||
|
||||
```text
|
||||
NodeService (port 7000)
|
||||
├── Authentication methods
|
||||
│ ├── uploadKeyPackage(identityKey, package, auth) -> fingerprint
|
||||
│ ├── fetchKeyPackage(identityKey, auth) -> package
|
||||
│ ├── uploadHybridKey(identityKey, hybridPublicKey) -> ()
|
||||
│ └── fetchHybridKey(identityKey) -> hybridPublicKey
|
||||
│
|
||||
├── Delivery methods
|
||||
│ ├── enqueue(recipientKey, payload, channelId, version, auth) -> ()
|
||||
│ ├── fetch(recipientKey, channelId, version, auth) -> payloads
|
||||
│ └── fetchWait(recipientKey, channelId, version, timeoutMs, auth) -> payloads
|
||||
│
|
||||
└── Operational
|
||||
└── health() -> status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RPC Method Reference
|
||||
|
||||
### Authentication Service Methods
|
||||
|
||||
| Method | Params | Returns | Semantics |
|
||||
|----------------------|-------------------------------------|------------------|-----------|
|
||||
| `uploadKeyPackage` | `identityKey` (32 B Ed25519 pk), `package` (TLS-encoded KeyPackage), `auth` | `fingerprint` (SHA-256 of package) | Appends the KeyPackage to a per-identity FIFO queue. The fingerprint lets the client detect server-side tampering. Max package size: 1 MB. |
|
||||
| `fetchKeyPackage` | `identityKey` (32 B), `auth` | `package` (or empty `Data`) | Atomically pops and returns the oldest KeyPackage for the identity. Returns empty bytes if none are stored. Single-use semantics per RFC 9420. |
|
||||
| `uploadHybridKey` | `identityKey` (32 B), `hybridPublicKey` (X25519 pk + ML-KEM-768 ek) | `()` | Stores (or replaces) the hybrid PQ public key for envelope-level post-quantum encryption. |
|
||||
| `fetchHybridKey` | `identityKey` (32 B) | `hybridPublicKey` (or empty `Data`) | Returns the stored hybrid public key for a peer, or empty if none. |
|
||||
|
||||
### Delivery Service Methods
|
||||
|
||||
| Method | Params | Returns | Semantics |
|
||||
|--------------|------------------------------------------------------------------------|----------------------|-----------|
|
||||
| `enqueue` | `recipientKey` (32 B), `payload` (opaque), `channelId`, `version`, `auth` | `()` | Appends `payload` to the recipient's FIFO queue. Max payload: 5 MB. Wakes any `fetchWait` waiter for this recipient. Supported versions: 0 (legacy), 1 (current). |
|
||||
| `fetch` | `recipientKey` (32 B), `channelId`, `version`, `auth` | `payloads: List(Data)` | Atomically drains and returns the full queue in FIFO order. Returns empty list if nothing is pending. |
|
||||
| `fetchWait` | `recipientKey` (32 B), `channelId`, `version`, `timeoutMs`, `auth` | `payloads: List(Data)` | Same as `fetch`, but if the queue is empty and `timeoutMs > 0`, blocks up to `timeoutMs` milliseconds waiting for a `Notify` signal from `enqueue`. Returns whatever is in the queue when the wait completes or times out. |
|
||||
|
||||
### Operational Methods
|
||||
|
||||
| Method | Params | Returns | Semantics |
|
||||
|----------|--------|-----------------|-----------|
|
||||
| `health` | none | `status: Text` | Returns `"ok"`. Used for liveness/readiness probes. |
|
||||
|
||||
---
|
||||
|
||||
## Per-Connection Lifecycle
|
||||
|
||||
Each incoming QUIC connection follows this sequence:
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ Client Server │
|
||||
│ │
|
||||
│ 1. UDP packet -> │
|
||||
│ QUIC INITIAL │
|
||||
│ │
|
||||
│ 2. <- QUIC HANDSHAKE │
|
||||
│ TLS 1.3 ServerHello + │
|
||||
│ Certificate (self-signed) │
|
||||
│ ALPN: "capnp" │
|
||||
│ │
|
||||
│ 3. Client verifies server │
|
||||
│ cert against pinned CA │
|
||||
│ cert (--ca-cert flag) │
|
||||
│ │
|
||||
│ 4. QUIC connection established │
|
||||
│ │
|
||||
│ 5. Client opens bidirectional ──────────> Server accepts bi stream │
|
||||
│ QUIC stream (open_bi) (accept_bi) │
|
||||
│ │
|
||||
│ 6. tokio_util::compat adapters wrap the send/recv halves │
|
||||
│ into AsyncRead + AsyncWrite │
|
||||
│ │
|
||||
│ 7. capnp-rpc twoparty::VatNetwork │
|
||||
│ Client Side::Client Server Side::Server │
|
||||
│ │
|
||||
│ 8. RpcSystem::new() starts │
|
||||
│ promise-pipelined RPC loop │
|
||||
│ │
|
||||
│ 9. Client bootstraps │
|
||||
│ node_service::Client NodeServiceImpl created │
|
||||
│ (shares Arc<FileBackedStore>, │
|
||||
│ Arc<DashMap<..., Notify>>) │
|
||||
│ │
|
||||
│ 10. RPC calls flow over the bidirectional stream │
|
||||
│ until either side closes the connection. │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### LocalSet requirement
|
||||
|
||||
`capnp-rpc` uses `Rc<RefCell<>>` internally, making it `!Send`. Therefore:
|
||||
|
||||
- The server runs the entire accept loop inside a `tokio::task::LocalSet`.
|
||||
- Each connection handler is `spawn_local`, ensuring all RPC futures stay on a
|
||||
single thread.
|
||||
- The client wraps each subcommand invocation in its own `LocalSet::run_until`.
|
||||
|
||||
This is a fundamental constraint of the Cap'n Proto RPC runtime in Rust.
|
||||
Attempts to spawn RPC futures on the multi-threaded Tokio executor will fail
|
||||
with a compile error.
|
||||
|
||||
---
|
||||
|
||||
## Storage Model
|
||||
|
||||
`NodeServiceImpl` holds two pieces of shared state:
|
||||
|
||||
### FileBackedStore
|
||||
|
||||
```text
|
||||
FileBackedStore
|
||||
├── key_packages: Mutex<HashMap<Vec<u8>, VecDeque<Vec<u8>>>>
|
||||
│ Key: Ed25519 public key (32 bytes)
|
||||
│ Value: FIFO queue of TLS-encoded KeyPackage blobs
|
||||
│ File: data/keypackages.bin (bincode)
|
||||
│
|
||||
├── deliveries: Mutex<HashMap<ChannelKey, VecDeque<Vec<u8>>>>
|
||||
│ ChannelKey: { channel_id: Vec<u8>, recipient_key: Vec<u8> }
|
||||
│ Value: FIFO queue of opaque payload blobs
|
||||
│ File: data/deliveries.bin (bincode, v2 format)
|
||||
│
|
||||
└── hybrid_keys: Mutex<HashMap<Vec<u8>, Vec<u8>>>
|
||||
Key: Ed25519 public key (32 bytes)
|
||||
Value: serialised HybridPublicKey blob
|
||||
File: data/hybridkeys.bin (bincode)
|
||||
```
|
||||
|
||||
Every mutation (upload, fetch, enqueue) acquires the relevant `Mutex`, modifies
|
||||
the in-memory `HashMap`, and then flushes the entire map to disk as a bincode
|
||||
blob. This is intentionally simple for MVP-scale workloads. A production
|
||||
deployment would replace this with an embedded database or external store.
|
||||
|
||||
The delivery map supports a **v1 -> v2 upgrade path**: if `deliveries.bin`
|
||||
contains the legacy `QueueMapV1` format (keyed by `recipientKey` only), the
|
||||
store transparently upgrades entries by wrapping them in `ChannelKey` with an
|
||||
empty `channel_id`.
|
||||
|
||||
### DashMap Waiters
|
||||
|
||||
```text
|
||||
Arc<DashMap<Vec<u8>, Arc<Notify>>>
|
||||
Key: recipient Ed25519 public key (32 bytes)
|
||||
Value: tokio::sync::Notify instance
|
||||
```
|
||||
|
||||
The waiters map is orthogonal to `FileBackedStore`. It lives entirely in
|
||||
memory and serves the `fetchWait` long-polling mechanism:
|
||||
|
||||
1. `enqueue` calls `waiter(&recipient_key).notify_waiters()` after storing the
|
||||
payload.
|
||||
2. `fetchWait` first tries a regular `fetch`. If the queue is empty and
|
||||
`timeoutMs > 0`:
|
||||
- Look up or insert a `Notify` for the recipient.
|
||||
- `tokio::time::timeout(Duration::from_millis(timeoutMs), notify.notified())`
|
||||
- When notified (or on timeout), perform a second `fetch` and return
|
||||
whatever is available.
|
||||
|
||||
This design avoids busy-polling while keeping the implementation lock-free
|
||||
(DashMap uses sharded RwLocks internally).
|
||||
|
||||
---
|
||||
|
||||
## Auth Struct
|
||||
|
||||
Every RPC method that modifies or reads user-specific state accepts an `Auth`
|
||||
parameter:
|
||||
|
||||
```capnp
|
||||
struct Auth {
|
||||
version @0 :UInt16; # 0 = legacy/none, 1 = token-based auth
|
||||
accessToken @1 :Data; # opaque bearer token
|
||||
deviceId @2 :Data; # optional UUID for auditing/rate limiting
|
||||
}
|
||||
```
|
||||
|
||||
### Version semantics
|
||||
|
||||
| Version | Meaning |
|
||||
|---------|------------------------------------------------------------|
|
||||
| 0 | Legacy / no authentication. The server accepts the request without checking credentials. Suitable for development and testing. |
|
||||
| 1 | Token-based authentication. The `accessToken` field should contain an opaque bearer token issued at login. The server validates the token against a token store (not yet implemented -- see [Auth, Devices, and Tokens](../roadmap/authz-plan.md)). |
|
||||
|
||||
The server validates the `version` field on every request via `validate_auth()`.
|
||||
Requests with unsupported versions are rejected with a Cap'n Proto error.
|
||||
|
||||
### Client-side usage
|
||||
|
||||
The client CLI accepts `--access-token` and `--device-id` flags (or the
|
||||
corresponding environment variables). These are bundled into a `ClientAuth`
|
||||
struct and injected into every outgoing RPC call via the `set_auth()` helper.
|
||||
|
||||
Currently, the client sends `version = 0` with empty token and device ID by
|
||||
default. When the token-based auth flow is implemented, the client will populate
|
||||
these fields.
|
||||
|
||||
---
|
||||
|
||||
## Validation and Limits
|
||||
|
||||
The server enforces the following constraints on every RPC call:
|
||||
|
||||
| Constraint | Value | Error on violation |
|
||||
|-----------------------------|--------------------|--------------------|
|
||||
| `identityKey` / `recipientKey` length | Exactly 32 bytes | Cap'n Proto error: "must be exactly 32 bytes" |
|
||||
| KeyPackage size | <= 1 MB | Cap'n Proto error: "package exceeds max size" |
|
||||
| Payload size | <= 5 MB | Cap'n Proto error: "payload exceeds max size" |
|
||||
| Wire version | 0 or 1 | Cap'n Proto error: "unsupported wire version" |
|
||||
| Auth version | 0 or 1 | Cap'n Proto error: "unsupported auth version" |
|
||||
| KeyPackage non-empty | `package.len() > 0`| Cap'n Proto error: "package must not be empty" |
|
||||
| Payload non-empty | `payload.len() > 0`| Cap'n Proto error: "payload must not be empty" |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
The server binary is configured via CLI flags or environment variables:
|
||||
|
||||
| Flag | Env var | Default | Description |
|
||||
|----------------|----------------------------|----------------------|-------------|
|
||||
| `--listen` | `QUICNPROTOCHAT_LISTEN` | `0.0.0.0:7000` | QUIC listen address (host:port). |
|
||||
| `--data-dir` | `QUICNPROTOCHAT_DATA_DIR` | `data` | Directory for persisted KeyPackages, delivery queues, and hybrid keys. |
|
||||
| `--tls-cert` | `QUICNPROTOCHAT_TLS_CERT` | `data/server-cert.der` | Path to TLS certificate (DER). Auto-generated if missing. |
|
||||
| `--tls-key` | `QUICNPROTOCHAT_TLS_KEY` | `data/server-key.der` | Path to TLS private key (DER). Auto-generated if missing. |
|
||||
|
||||
If the TLS certificate or key files do not exist at startup, the server
|
||||
auto-generates a self-signed certificate for `localhost`, `127.0.0.1`, and
|
||||
`::1` using `rcgen`.
|
||||
|
||||
Logging level is controlled by the `RUST_LOG` environment variable (default:
|
||||
`info`).
|
||||
|
||||
---
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Architecture Overview](overview.md) -- two-service model and dual-key overview
|
||||
- [NodeService Schema](../wire-format/node-service-schema.md) -- full Cap'n Proto schema
|
||||
- [End-to-End Data Flow](data-flow.md) -- sequence diagrams showing registration, group creation, and messaging
|
||||
- [Delivery Service Internals](../internals/delivery-service.md) -- queue routing and channel-aware delivery
|
||||
- [Authentication Service Internals](../internals/authentication-service.md) -- KeyPackage lifecycle
|
||||
- [Storage Backend](../internals/storage-backend.md) -- FileBackedStore details and upgrade path
|
||||
- [Auth, Devices, and Tokens](../roadmap/authz-plan.md) -- planned token-based authentication
|
||||
Reference in New Issue
Block a user