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,402 @@
# Key Lifecycle and Zeroization
quicnprotochat uses multiple key types with different lifetimes, creation
patterns, and destruction guarantees. This page provides a comprehensive
lifecycle diagram for every key type in the system, from generation through
zeroization.
## Lifecycle Overview
```text
Key Type Creation Distribution Use Destruction
--------------------------------------------------------------------------------------------------------------
Ed25519 Identity Once per client AS registration MLS signing, Zeroizing<[u8;32]>
(OsRng) + MLS credential credential binding on struct drop
X25519 Noise Per server process Noise_XX handshake DH key exchange ZeroizeOnDrop
or per client conn (in-band) (transport session) on struct drop
HPKE Init Key Per KeyPackage Uploaded to AS Decrypt Welcome Consumed by openmls;
(openmls backend) in KeyPackage (join_group) deleted from keystore
MLS Epoch Keys Per Commit Internal (ratchet Encrypt/decrypt Old epoch keys deleted
(openmls ratchet) tree derivation) application messages after processing Commit
Hybrid KEM Keys Per peer (future) Public portion X25519+ML-KEM-768 Ephemeral per encrypt;
(OsRng) sent to peers hybrid encryption static part on drop
```
## Ed25519 Identity Key
**Source:** `crates/quicnprotochat-core/src/identity.rs`
The Ed25519 identity key is the most long-lived secret in the system. It
represents the client's cryptographic identity across all sessions and groups.
### Lifecycle
```text
+-----------------+
| OsRng |
| (getrandom) |
+--------+--------+
|
generate()
|
+--------v--------+
| IdentityKeypair |
| seed: Zeroizing | <-- 32-byte Ed25519 seed
| verifying: Vk | <-- 32-byte public key
+--------+--------+
|
+--------------+--------------+
| | |
Persist to Register with Embed in MLS
state file Auth Service BasicCredential
(seed_bytes) (public_key) (CredentialWithKey)
| | |
| | Sign KeyPackages,
| | Commits, Proposals
| | |
Load on next Server stores Used for lifetime
client start public key of client
(from_seed) as queue index |
| | |
+------+-------+--------------+
|
struct dropped
|
+--------v--------+
| Zeroizing<T> |
| overwrites |
| seed with 0x00 |
+-----------------+
```
### Key Properties
- **Generation:** `SigningKey::generate(&mut OsRng)` produces 32 bytes of
entropy from the OS CSPRNG. The seed is immediately wrapped in `Zeroizing`.
- **Persistence:** The `seed_bytes()` method returns a plain `[u8; 32]` for
serialization to the client state file. The caller must handle this copy
securely.
- **Reconstruction:** `from_seed(seed)` re-derives the `VerifyingKey` from the
seed deterministically. The seed is wrapped in `Zeroizing` upon construction.
- **Destruction:** When the `IdentityKeypair` struct is dropped, the
`Zeroizing<[u8; 32]>` wrapper overwrites the 32 seed bytes with zeros.
- **No Clone/Copy:** The struct does not implement `Clone` or `Copy`, preventing
accidental duplication of the secret seed.
### Fingerprint
The fingerprint (`SHA-256(public_key_bytes)`) is derived from the public key and
is used as a compact identifier in logs. It is not secret and does not require
zeroization.
## X25519 Noise Key
**Source:** `crates/quicnprotochat-core/src/keypair.rs`
The X25519 Noise key provides mutual authentication during the Noise\_XX
handshake. It is shorter-lived than the identity key and is not currently
persisted.
### Lifecycle
```text
+-----------------+
| OsRng |
| (getrandom) |
+--------+--------+
|
generate()
|
+--------v--------+
| NoiseKeypair |
| private: Secret | <-- StaticSecret (ZeroizeOnDrop)
| public: PubKey | <-- 32-byte public key
+--------+--------+
|
+--------------+--------------+
| |
private_bytes() public_bytes()
-> Zeroizing<[u8;32]> -> [u8; 32]
| |
Passed to snow::Builder Exchanged during
local_private_key() Noise_XX handshake
| |
Zeroizing copy drops Stored by peer
immediately after use (not secret)
| |
+-------------+---------------+
|
Noise handshake completes
|
+-------------v--------------+
| Transport session holds |
| derived symmetric keys |
| (managed by snow) |
+-------------+--------------+
|
Connection closes
|
+-------------v--------------+
| NoiseKeypair dropped |
| StaticSecret::drop() |
| overwrites scalar with 0 |
+----------------------------+
```
### Key Properties
- **Generation:** `StaticSecret::random_from_rng(OsRng)` generates a 32-byte
Curve25519 scalar.
- **Dual zeroization:** The `StaticSecret` itself implements `ZeroizeOnDrop`,
and `private_bytes()` returns a `Zeroizing<[u8; 32]>` wrapper.
- **Debug redaction:** The `Debug` impl shows only the first 4 bytes of the
public key and prints `[redacted]` for the private key.
- **No serialization:** `NoiseKeypair` does not implement `Serialize`. Persistence
is deferred to M6.
- **Current lifetime:** Per server process start (server) or per connection
attempt (client). After M6, keys may be persisted with passphrase encryption.
## HPKE Init Keys
**Source:** `crates/quicnprotochat-core/src/keystore.rs` and
`crates/quicnprotochat-core/src/group.rs`
HPKE init keys are generated by the openmls backend as part of MLS KeyPackage
creation. They are single-use: each init key is consumed exactly once when
processing a Welcome message.
### Lifecycle
```text
+----------------------------+
| generate_key_package() |
| (GroupMember method) |
+-------------+--------------+
|
openmls internally generates
HPKE init keypair (X25519)
|
+-------------v--------------+
| DiskKeyStore / StoreCrypto |
| stores init private key |
| keyed by init key ref |
+-------------+--------------+
|
KeyPackage (containing init
public key) is TLS-encoded
and uploaded to Auth Service
|
+-------------v--------------+
| Peer fetches KeyPackage |
| from AS; includes it in |
| Welcome message |
+-------------+--------------+
|
+-------------v--------------+
| join_group(welcome) |
| openmls calls |
| new_from_welcome() |
| reads init private key |
| from DiskKeyStore |
| decrypts Welcome's HPKE |
| ciphertext |
+-------------+--------------+
|
Init key is consumed
(never reused per MLS spec)
|
+-------------v--------------+
| Key deleted from store |
| (openmls manages cleanup) |
+----------------------------+
```
### Key Properties
- **Generation:** Handled internally by the openmls backend (`StoreCrypto`).
The `generate_key_package()` method on `GroupMember` triggers this.
- **Storage:** The `DiskKeyStore` is either ephemeral (in-memory `HashMap`) or
persistent (bincode-serialized to disk). The init private key is stored as a
JSON-serialized `MlsEntity` keyed by the init key reference bytes.
- **Single use:** Per RFC 9420, each HPKE init key is used exactly once. This
prevents replay attacks and ensures forward secrecy of the initial key exchange.
- **Critical constraint:** The same `GroupMember` instance (and therefore the same
`StoreCrypto` backend) that called `generate_key_package()` must later call
`join_group()`. If a different backend is used, the init private key will not be
found and `new_from_welcome()` will fail.
- **Persistence mode:** `DiskKeyStore::persistent(path)` writes the entire
key-value map to disk on every store/delete operation, ensuring HPKE init keys
survive process restarts.
## MLS Epoch Keys
**Managed by:** `openmls` (internal to the `MlsGroup` state machine)
MLS epoch keys are derived internally by the openmls ratchet tree. They are not
directly accessible in quicnprotochat code but are critical to understanding the
system's security properties.
### Lifecycle
```text
+----------------------------+
| create_group(group_id) |
| or join_group(welcome) |
+-------------+--------------+
|
Epoch 0: initial key material
derived from ratchet tree
|
+-------------v--------------+
| send_message(plaintext) |
| encrypts with current |
| epoch's application key |
+-------------+--------------+
|
+-------------v--------------+
| add_member() / Commit |
| merge_pending_commit() |
| or merge_staged_commit() |
+-------------+--------------+
|
Epoch advances: new key
material derived from
updated ratchet tree
|
+-------------v--------------+
| Old epoch keys deleted |
| by openmls internally |
| (forward secrecy) |
+----------------------------+
```
### Key Properties
- **Derivation:** Each epoch's key material is derived from the ratchet tree, a
binary tree where each leaf represents a group member. Internal nodes hold
derived key material. See
[Post-Compromise Security](post-compromise-security.md) for details.
- **Advancement:** Epochs advance when a Commit is processed --
`merge_pending_commit()` (for the sender) or `merge_staged_commit()` (for
receivers).
- **Deletion:** Old epoch keys are deleted after the Commit is processed. This
deletion is what provides [forward secrecy](forward-secrecy.md) at the MLS
layer.
- **No direct access:** quicnprotochat code interacts with epoch keys only
indirectly through `send_message()` and `receive_message()`.
## Hybrid KEM Keys (Future -- M5+)
**Source:** `crates/quicnprotochat-core/src/hybrid_kem.rs`
The hybrid KEM keypair combines X25519 (classical) with ML-KEM-768
(post-quantum) for content encryption that resists both classical and quantum
attacks.
### Lifecycle
```text
+----------------------------+
| HybridKeypair::generate() |
| OsRng for both components |
+-------------+--------------+
|
+-------------v--------------+
| HybridKeypair |
| x25519_sk: StaticSecret | 32B (ZeroizeOnDrop)
| x25519_pk: PublicKey | 32B
| mlkem_dk: DecapsulationKey | 2400B
| mlkem_ek: EncapsulationKey | 1184B
+-------------+--------------+
|
+--------------+--------------+
| |
public_key() to_bytes()
-> HybridPublicKey -> HybridKeypairBytes
(32B + 1184B) (for persistence)
| |
Distributed to peers Stored securely
for encryption (serde Serialize)
|
+----v----+
| Sender |
| hybrid_encrypt(pk, pt) |
| 1. Ephemeral X25519 DH |
| 2. ML-KEM-768 encapsulate |
| 3. HKDF(x25519_ss||mlkem_ss)|
| 4. ChaCha20-Poly1305 AEAD |
+----+----+
|
Envelope: ver(1) | eph_pk(32) | mlkem_ct(1088) | nonce(12) | ct(var)
|
+----v----+
| Recipient |
| hybrid_decrypt(kp, env) |
| 1. X25519 DH with eph_pk |
| 2. ML-KEM-768 decapsulate |
| 3. HKDF derive same key |
| 4. ChaCha20-Poly1305 decrypt|
+---------+
```
### Key Properties
- **Generation:** Both X25519 and ML-KEM-768 components use `OsRng`.
- **Key sizes:** X25519 secret: 32B, ML-KEM-768 decapsulation key: 2400B,
encapsulation key: 1184B.
- **Serialization:** `HybridKeypairBytes` (serde) stores all components for
persistence. `HybridPublicKey` stores only the public portions.
- **Zeroization of IKM:** The combined shared secret (`x25519_ss || mlkem_ss`)
is wrapped in `Zeroizing<Vec<u8>>` and cleared after HKDF derivation.
- **Ephemeral per encryption:** Each call to `hybrid_encrypt` generates a fresh
`EphemeralSecret` for X25519, ensuring that even if the static keypair is
compromised, past encryptions remain protected.
- **Integration timeline:** M5 will integrate this into the MLS crypto provider.
See [Post-Quantum Readiness](post-quantum-readiness.md).
## Zeroization Summary
| Key Type | Zeroization Mechanism | When |
|----------|----------------------|------|
| Ed25519 seed | `Zeroizing<[u8; 32]>` | `IdentityKeypair` drop |
| Ed25519 seed (accessor) | Plain `[u8; 32]` copy | Caller responsibility |
| X25519 private | `ZeroizeOnDrop` (x25519-dalek) | `NoiseKeypair` drop |
| X25519 private (accessor) | `Zeroizing<[u8; 32]>` | Accessor drop |
| HPKE init private | Managed by openmls/`DiskKeyStore` | After Welcome processing |
| MLS epoch keys | Managed by openmls internally | After Commit processing |
| Hybrid IKM | `Zeroizing<Vec<u8>>` | After HKDF derivation |
| Hybrid X25519 static | `ZeroizeOnDrop` (x25519-dalek) | `HybridKeypair` drop |
| Hybrid ephemeral | `EphemeralSecret` (x25519-dalek) | After DH computation |
## Security Considerations
1. **Memory residue:** Zeroization prevents key material from lingering in freed
memory, but it does not protect against an attacker with live memory access
(e.g., a debugger or cold-boot attack). Full protection would require
hardware-backed key storage (e.g., HSM, TPM, or OS keychain), which is not
yet implemented.
2. **Swap and core dumps:** If the process's memory is swapped to disk or
written to a core dump, key material may persist on non-volatile storage.
Mitigations include `mlock()` (not yet implemented) and disabling core dumps.
3. **Compiler optimizations:** The `zeroize` crate uses compiler barriers
(`core::sync::atomic::compiler_fence`) to prevent the optimizer from eliding
the zeroing write as a dead store.
4. **Copies via `seed_bytes()`:** The `IdentityKeypair::seed_bytes()` method
returns a plain `[u8; 32]`. The caller (typically the persistence layer) is
responsible for zeroizing this copy after writing it to disk.
## Related Pages
- [Cryptography Overview](overview.md) -- algorithm inventory
- [Ed25519 Identity Keys](identity-keys.md) -- identity key details
- [X25519 Transport Keys](transport-keys.md) -- transport key details
- [Forward Secrecy](forward-secrecy.md) -- how key deletion enables FS
- [Post-Compromise Security](post-compromise-security.md) -- epoch advancement
- [Post-Quantum Readiness](post-quantum-readiness.md) -- hybrid KEM integration