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:
2026-02-22 08:07:48 +01:00
parent d1ddef4cea
commit f334ed3d43
81 changed files with 14502 additions and 2289 deletions

View File

@@ -0,0 +1,326 @@
# KeyPackage Exchange Flow
MLS KeyPackages are single-use tokens that enable a group creator to add a new
member. The KeyPackage contains the member's HPKE init public key, their MLS
credential (Ed25519 public key), and a signature proving ownership. The
quicnprotochat Authentication Service (AS) provides a simple upload/fetch
interface for distributing KeyPackages between clients.
This page describes the end-to-end flow: from client-side generation through
server-side storage to peer-side retrieval and consumption.
**Sources:**
- `crates/quicnprotochat-core/src/group.rs` (client-side generation)
- `crates/quicnprotochat-server/src/main.rs` (server-side handlers)
- `crates/quicnprotochat-server/src/storage.rs` (server-side persistence)
- `crates/quicnprotochat-client/src/lib.rs` (client-side RPC calls)
- `schemas/node.capnp` (wire schema)
---
## Upload Flow
The upload flow moves a freshly generated KeyPackage from a client to the
server, where it is stored for later retrieval by a peer.
```text
Client Server (AS)
| |
| 1. GroupMember::generate_key_package() |
| -> TLS-encoded KeyPackage bytes |
| -> HPKE init key stored in backend |
| |
| 2. uploadKeyPackage RPC |
| identityKey = Ed25519 pub key (32 B) |
| package = TLS-encoded bytes |
| auth = Auth struct |
| ----------------------------------------> |
| | 3. Validate inputs:
| | - identityKey == 32 bytes
| | - package non-empty
| | - package < 1 MB
| | - auth version valid
| |
| | 4. Compute SHA-256(package)
| |
| | 5. Store: push_back to
| | DashMap<Vec<u8>, VecDeque<Vec<u8>>>
| | keyed by identity_key
| |
| 6. Response: fingerprint (SHA-256 hash) |
| <---------------------------------------- |
| |
| 7. Verify: local SHA-256 == server SHA-256|
| |
```
### Step-by-Step
1. **Client generates KeyPackage.** The client calls
`GroupMember::generate_key_package()`, which internally:
- Builds an MLS `CredentialWithKey` from the Ed25519 public key
(`CredentialType::Basic`).
- Calls `KeyPackage::builder().build()` with the ciphersuite
`MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519`, the `StoreCrypto` backend,
and the `IdentityKeypair` as the signer.
- openmls generates an ephemeral HPKE key pair (X25519) and stores the
private key in the backend's `DiskKeyStore`.
- Returns the TLS-serialized KeyPackage bytes.
See [GroupMember Lifecycle](group-member-lifecycle.md) for the critical
invariant about backend identity.
2. **Client sends `uploadKeyPackage` RPC.** The request includes:
- `identityKey`: The raw 32-byte Ed25519 public key.
- `package`: The TLS-encoded KeyPackage bytes.
- `auth`: An [Auth struct](../wire-format/auth-schema.md) with version and
optional access token.
3. **Server validates inputs.** The server checks:
- `identityKey` is exactly 32 bytes (Ed25519 public key size).
- `package` is non-empty.
- `package` does not exceed `MAX_KEYPACKAGE_BYTES` (1 MB).
- The `Auth` struct version is acceptable (0 for legacy, 1 for token-based).
4. **Server computes fingerprint.** `SHA-256(package_bytes)` produces a 32-byte
digest used as a tamper-detection fingerprint.
5. **Server stores the KeyPackage.** The package bytes are pushed to the back of
a `VecDeque<Vec<u8>>` keyed by the identity key in the server's
`FileBackedStore`. This allows multiple KeyPackages per identity (clients
should upload several to handle concurrent invitations). The store flushes to
disk after every mutation.
6. **Server returns the fingerprint.** The SHA-256 digest is sent back in the
response's `fingerprint` field.
7. **Client verifies the fingerprint.** The client computes its own
`SHA-256(package_bytes)` and compares it to the server-returned value. A
mismatch indicates tampering (the server or a MITM modified the package in
transit) and the client aborts with a `fingerprint mismatch` error.
---
## Fetch Flow
The fetch flow allows a peer to retrieve a stored KeyPackage for a target
identity, consuming it in the process (single-use per RFC 9420).
```text
Peer Server (AS)
| |
| 1. fetchKeyPackage RPC |
| identityKey = target's Ed25519 pub key |
| auth = Auth struct |
| ----------------------------------------> |
| | 2. Validate inputs:
| | - identityKey == 32 bytes
| | - auth version valid
| |
| | 3. Pop front of VecDeque
| | (FIFO, single-use)
| | Flush updated map to disk
| |
| 4. Response: package bytes (or empty) |
| <---------------------------------------- |
| |
| 5. If non-empty: |
| KeyPackageIn::tls_deserialize() |
| .validate(crypto, MLS10) |
| -> trusted KeyPackage for add_member() |
| |
```
### Step-by-Step
1. **Peer sends `fetchKeyPackage` RPC.** The request includes the target's
Ed25519 public key (32 bytes) and an Auth context.
2. **Server validates inputs.** Same identity key length check as upload (32
bytes).
3. **Server pops from the front of the queue.** `VecDeque::pop_front()` returns
the oldest uploaded KeyPackage. This enforces FIFO ordering and **single-use
semantics**: once fetched, the KeyPackage is permanently removed from the
server. This is a hard requirement of the MLS specification -- reusing a
KeyPackage would allow an attacker to create conflicting group states.
The store is flushed to disk after the pop, ensuring the removal survives
server restarts.
4. **Server returns the package bytes.** If the queue was empty (no KeyPackages
available), the response contains an empty `Data` field. The client checks
for emptiness to distinguish "no packages available" from "package fetched."
5. **Peer deserializes and validates.** The peer uses `KeyPackageIn::tls_deserialize()`
followed by `.validate(crypto, ProtocolVersion::Mls10)` to verify the
KeyPackage signature. The validated `KeyPackage` can then be passed to
`GroupMember::add_member()`.
---
## Fingerprint Verification
The fingerprint mechanism provides a simple tamper-detection check:
```text
Client Server Client
SHA-256(pkg) ---------> store pkg -----------> SHA-256(pkg)
| SHA-256(pkg) --------> |
| | |
+---- compare: local_fp == server_fp --------+
```
**What it detects:**
- A malicious server replacing the package bytes.
- A network-layer MITM modifying the package in transit (though QUIC/TLS
already prevents this).
**What it does NOT detect:**
- A malicious server that simply returns the correct fingerprint for a package
it has replaced (since the server computes the hash itself). True
KeyPackage authenticity requires verifying the Ed25519 signature inside
the KeyPackage, which openmls does during `validate()`.
The fingerprint is best understood as a transport-level integrity check, not a
cryptographic proof of authenticity. The real authenticity guarantee comes from
the MLS KeyPackage signature verified on the receiving side.
---
## Storage Model
On the server, KeyPackages are stored in a `FileBackedStore`:
```text
FileBackedStore
+-- key_packages: Mutex<HashMap<Vec<u8>, VecDeque<Vec<u8>>>>
| ^ ^
| | |
| identity_key queue of TLS-encoded
| (32 bytes) KeyPackage bytes
|
+-- Persisted to: data/keypackages.bin (bincode serialized)
```
Each identity key maps to a FIFO queue of KeyPackage bytes. A client should
upload multiple KeyPackages so that peers can concurrently fetch them without
contention. If the queue is exhausted, fetches return empty until the client
uploads more.
The storage format uses the `QueueMapV1` wrapper for bincode serialization:
```rust
#[derive(Serialize, Deserialize, Default)]
struct QueueMapV1 {
map: HashMap<Vec<u8>, VecDeque<Vec<u8>>>,
}
```
See [Storage Backend](storage-backend.md) for details on persistence,
flush-on-write semantics, and the V1/V2 delivery map migration.
---
## Input Validation Summary
| Field | Constraint | Error on Violation |
|----------------|---------------------------|--------------------|
| `identityKey` | Exactly 32 bytes | `"identityKey must be exactly 32 bytes, got {n}"` |
| `package` | Non-empty | `"package must not be empty"` |
| `package` | At most 1 MB (1,048,576) | `"package exceeds max size (1048576 bytes)"` |
| `auth.version` | 0 (legacy) or 1 (current) | `"unsupported auth version {v}"` |
| `auth.token` | Non-empty when version=1 | `"auth.version=1 requires non-empty accessToken"` |
---
## Wire Schema
From `schemas/node.capnp`:
```capnp
uploadKeyPackage @0 (identityKey :Data, package :Data, auth :Auth)
-> (fingerprint :Data);
fetchKeyPackage @1 (identityKey :Data, auth :Auth)
-> (package :Data);
```
The `Auth` struct is shared across all RPC methods:
```capnp
struct Auth {
version @0 :UInt16; # 0 = legacy/none, 1 = token-based
accessToken @1 :Data; # opaque bearer token
deviceId @2 :Data; # optional UUID for auditing
}
```
See [NodeService Schema](../wire-format/node-service-schema.md) for the
complete schema reference.
---
## Client-Side Usage
The CLI exposes two commands for KeyPackage exchange:
### `register` / `register-state`
Generates a fresh KeyPackage and uploads it. `register` uses an ephemeral
identity; `register-state` loads from (or initializes) a persistent state file.
```bash
# Ephemeral registration (for testing)
quicnprotochat register --server 127.0.0.1:7000
# Persistent registration (production)
quicnprotochat register-state --state alice.bin --server 127.0.0.1:7000
```
Output:
```
identity_key : 7a3f... (64 hex chars, 32 bytes)
fingerprint : 9e1c... (SHA-256 of KeyPackage)
KeyPackage uploaded successfully.
```
### `fetch-key`
Fetches a peer's KeyPackage by their hex-encoded Ed25519 public key:
```bash
quicnprotochat fetch-key --server 127.0.0.1:7000 7a3f...
```
---
## Security Considerations
1. **Single-use enforcement.** The server's `pop_front()` semantics ensure each
KeyPackage is consumed exactly once, satisfying RFC 9420's requirement.
However, a malicious server could duplicate KeyPackages before deletion. True
single-use is enforced at the MLS protocol level: duplicate KeyPackage usage
would be detected when processing the Welcome (mismatched group state).
2. **No authentication on fetch.** Currently, anyone can fetch any identity's
KeyPackage. This is intentional for the MVP but means an attacker could
exhaust a victim's KeyPackage supply. The
[Auth, Devices, and Tokens](../roadmap/authz-plan.md) plan addresses this
with token-based access control.
3. **HPKE init key lifetime.** The HPKE init private key lives in the
`DiskKeyStore` from generation until the Welcome is processed. For persistent
clients using `DiskKeyStore::persistent()`, this key survives process
restarts. For ephemeral clients, the key exists only in memory and is lost if
the process exits before `join_group()` is called.
---
## Related Pages
- [GroupMember Lifecycle](group-member-lifecycle.md) -- the MLS state machine that generates and consumes KeyPackages
- [Authentication Service Internals](authentication-service.md) -- server-side KeyPackage handling
- [Delivery Service Internals](delivery-service.md) -- how the Welcome message is relayed after `add_member()`
- [Storage Backend](storage-backend.md) -- `FileBackedStore` persistence model
- [NodeService Schema](../wire-format/node-service-schema.md) -- Cap'n Proto schema reference