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:
326
docs/src/internals/keypackage-exchange.md
Normal file
326
docs/src/internals/keypackage-exchange.md
Normal 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
|
||||
Reference in New Issue
Block a user