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:
402
docs/src/cryptography/key-lifecycle.md
Normal file
402
docs/src/cryptography/key-lifecycle.md
Normal 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
|
||||
Reference in New Issue
Block a user