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,205 @@
# Forward Secrecy
Forward secrecy (FS), also called perfect forward secrecy (PFS), is a property
of a cryptographic protocol that guarantees: **if a long-term secret key is
compromised, past session keys cannot be recovered.** In other words, an
attacker who obtains today's long-term key cannot use it to decrypt messages
recorded yesterday.
quicnprotochat provides forward secrecy at two independent layers: the transport
layer and the application layer. Even if one layer's FS mechanism is defeated,
the other continues to protect message confidentiality.
## Transport Layer Forward Secrecy
### TLS 1.3 (QUIC)
The QUIC transport (via `quinn 0.11` + `rustls 0.23`) uses TLS 1.3, which
mandates ephemeral key exchange in every handshake. Unlike TLS 1.2, which
allowed static RSA key exchange (no FS), TLS 1.3 exclusively uses ephemeral
ECDHE (Elliptic Curve Diffie-Hellman Ephemeral).
In each TLS 1.3 handshake:
1. Both client and server generate ephemeral ECDHE key pairs.
2. They exchange public keys and compute a shared secret via Diffie-Hellman.
3. Session keys are derived from the shared secret using HKDF.
4. The ephemeral private keys are discarded after key derivation.
Because the ephemeral keys exist only for the duration of the handshake,
compromising the server's long-term TLS certificate key (currently self-signed
in quicnprotochat) does not reveal past session keys.
### Noise\_XX
Inside the QUIC stream, the Noise\_XX handshake
(`Noise_XX_25519_ChaChaPoly_BLAKE2s`) provides an additional layer of forward
secrecy. The Noise\_XX pattern uses both ephemeral and static X25519 keys:
```text
→ e Initiator sends ephemeral public key
← e, ee, s, es Responder: ephemeral, DH(e,e), static, DH(e,s)
→ s, se Initiator: static, DH(s,e)
```
The `ee` DH (ephemeral-ephemeral) provides forward secrecy: even if both
parties' static keys (`s`) are later compromised, the ephemeral keys that
contributed to `ee` have already been discarded.
The `es` and `se` DH operations mix in the static keys for authentication, but
the session key depends on the ephemeral contribution. An attacker who
compromises only the static key learns the identity of the parties but cannot
recover the session key without the ephemeral key.
See [X25519 Transport Keys](transport-keys.md) for details on the static
keypair.
## Application Layer Forward Secrecy
### MLS Epoch Ratchet
The MLS protocol (RFC 9420) provides forward secrecy at the application layer
through its epoch ratchet mechanism. This is independent of the transport
layer's FS and protects message content even if transport session keys are
leaked.
Each MLS group maintains a **ratchet tree** -- a binary tree where each leaf
represents a group member and internal nodes hold derived key material. The
tree defines a current **epoch**, which determines the encryption keys for all
messages in that epoch.
When the epoch advances (via a Commit message):
1. The ratchet tree is updated with new key material from the committing member.
2. New epoch keys are derived from the updated tree.
3. **Old epoch keys are deleted.**
This deletion is the mechanism that provides forward secrecy: once old epoch
keys are erased, messages encrypted under those keys cannot be decrypted, even
if the current group state is compromised.
In quicnprotochat, epoch advancement occurs when:
- `add_member()` is called, which creates a Commit and calls
`merge_pending_commit()`.
- A received Commit is processed via `receive_message()`, which calls
`merge_staged_commit()`.
```rust
// Epoch advances here -- old keys deleted internally by openmls
group.merge_pending_commit(&self.backend)?; // sender side
group.merge_staged_commit(&self.backend, *staged)?; // receiver side
```
### Single-Use KeyPackages
MLS KeyPackages contain a single-use HPKE init public key. Each init key is
used exactly once -- to encrypt the Welcome message that bootstraps a new
member's group state. After the Welcome is processed, the init private key is
consumed and deleted from the `DiskKeyStore`.
This single-use design provides forward secrecy for the initial key exchange:
- Even if a member's long-term Ed25519 identity key is later compromised, the
attacker cannot reconstruct the HPKE init private key that was used to decrypt
the Welcome.
- The init key was ephemeral to the join operation and no longer exists.
This property is critical because the Welcome message contains the full ratchet
tree state, including the secrets needed to decrypt messages in the initial
epoch. If the init key could be reused or recovered, an attacker could
reconstruct the entire initial group state.
See [Key Lifecycle and Zeroization](key-lifecycle.md) for the full lifecycle of
HPKE init keys.
## Layered Forward Secrecy
A distinctive property of quicnprotochat's design is that forward secrecy
operates at two independent layers:
```text
+------------------------------------------------------+
| Network Adversary (records ciphertext) |
+------------------------------------------------------+
|
v
+------------------------------------------------------+
| TLS 1.3 / Noise_XX |
| Forward secrecy via ephemeral ECDHE / X25519 DH |
| Even if TLS cert or Noise static key is compromised,|
| past transport sessions are protected. |
+------------------------------------------------------+
|
v
+------------------------------------------------------+
| MLS (RFC 9420) |
| Forward secrecy via epoch ratchet |
| Even if current MLS state is compromised, |
| past epochs are protected (keys deleted). |
+------------------------------------------------------+
|
v
+------------------------------------------------------+
| Plaintext message content |
+------------------------------------------------------+
```
**Why this matters:** If the transport layer's forward secrecy is broken (e.g.,
an attacker obtains a TLS session key through a side channel), the MLS layer
still protects message content independently. The attacker would see the MLS
ciphertext but could not decrypt it without the MLS epoch keys.
Conversely, if MLS epoch keys are somehow leaked, the transport layer prevents
a network-level attacker from correlating them with specific network flows
unless they also break the transport encryption.
## Comparison with Signal
Signal's Double Ratchet protocol also provides forward secrecy, but the
mechanisms differ:
| Property | Signal Double Ratchet | MLS (quicnprotochat) |
|----------|----------------------|---------------------|
| Scope | Pairwise (1:1 sessions) | Group (n-party) |
| Ratchet granularity | Per message (symmetric ratchet) + per DH round (DH ratchet) | Per epoch (Commit) |
| FS granularity | Individual messages | All messages in an epoch |
| Group support | Sender Keys (no per-message FS in groups) | Native group FS via ratchet tree |
| Efficiency | O(1) per message | O(log n) per Commit, O(1) per message |
Signal achieves finer-grained forward secrecy in 1:1 conversations (per message
via the symmetric ratchet), but in group settings, Signal uses Sender Keys,
which do **not** provide per-message forward secrecy. A compromised Sender Key
reveals all past messages from that sender.
MLS provides forward secrecy at the epoch level for the entire group. Within an
epoch, all messages share the same key material. The trade-off is that FS
granularity is coarser (per epoch rather than per message), but it applies
uniformly to all group members.
## Practical Implications
1. **Epoch advancement frequency:** More frequent Commits provide more
fine-grained forward secrecy. In the current implementation, epochs advance
when members are added. Future milestones will add periodic Update proposals
to advance epochs even without membership changes.
2. **Key deletion timing:** Forward secrecy depends on old keys being actually
deleted from memory and disk. The `DiskKeyStore`'s flush-on-write behavior
ensures that consumed HPKE init keys are removed from the persistent store.
MLS epoch key deletion is handled internally by openmls.
3. **State file security:** The client state file contains the Ed25519 identity
seed and potentially the DiskKeyStore contents. If this file is compromised,
the attacker obtains the current identity key and any stored HPKE init keys
(for pending Welcome messages). Past epoch keys are not in the state file
(they have been deleted), so forward secrecy is preserved for past epochs.
## Related Pages
- [Cryptography Overview](overview.md) -- algorithm inventory
- [Key Lifecycle and Zeroization](key-lifecycle.md) -- when keys are created and destroyed
- [Post-Compromise Security](post-compromise-security.md) -- the complementary property (protecting the future)
- [Threat Model](threat-model.md) -- attacker models and what FS protects against
- [X25519 Transport Keys](transport-keys.md) -- Noise ephemeral DH details
- [Ed25519 Identity Keys](identity-keys.md) -- long-term key that FS protects against compromising

View File

@@ -0,0 +1,199 @@
# Ed25519 Identity Keys
The Ed25519 identity keypair is the long-term cryptographic identity of a
quicnprotochat client. It is generated once, persisted across sessions, and used
for MLS credential signing, Authentication Service registration, and delivery
queue addressing.
**Source:** `crates/quicnprotochat-core/src/identity.rs`
## Structure
The `IdentityKeypair` struct holds two fields:
```rust
pub struct IdentityKeypair {
/// Raw 32-byte private seed -- zeroized on drop.
seed: Zeroizing<[u8; 32]>,
/// Corresponding 32-byte public verifying key.
verifying: VerifyingKey,
}
```
| Field | Type | Size | Secret? |
|-------|------|------|---------|
| `seed` | `Zeroizing<[u8; 32]>` | 32 bytes | Yes -- zeroized on drop |
| `verifying` | `ed25519_dalek::VerifyingKey` | 32 bytes | No -- public |
The private seed is stored as raw bytes wrapped in `Zeroizing<[u8; 32]>` rather
than directly as a `SigningKey`. This design choice avoids a conflict with
`ed25519-dalek`'s own `Zeroize` implementation: the `Zeroizing<T>` wrapper
requires `T: DefaultIsZeroes`, which `[u8; 32]` satisfies (being `Copy +
Default`) but `SigningKey` does not.
## Key Generation
A fresh identity keypair is generated from the OS CSPRNG (`OsRng`) via
`ed25519-dalek`:
```rust
use quicnprotochat_core::identity::IdentityKeypair;
let identity = IdentityKeypair::generate();
// The signing key seed is generated from OsRng (getrandom on Linux).
// The verifying key is derived from the seed automatically.
```
Internally, `generate()` calls `SigningKey::generate(&mut OsRng)`, extracts the
32-byte seed with `to_bytes()`, wraps it in `Zeroizing`, and derives the
`VerifyingKey`:
```rust
pub fn generate() -> Self {
use rand::rngs::OsRng;
let signing = SigningKey::generate(&mut OsRng);
let verifying = signing.verifying_key();
let seed = Zeroizing::new(signing.to_bytes());
Self { seed, verifying }
}
```
## Fingerprint Computation
The fingerprint is a SHA-256 digest of the raw 32-byte Ed25519 public key. It
serves as a compact, collision-resistant identifier for logging and protocol
indexing:
```rust
pub fn fingerprint(&self) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(self.verifying.to_bytes());
hasher.finalize().into()
}
```
The `Debug` implementation uses the first 4 bytes of the fingerprint as a
human-readable prefix:
```rust
// Output example:
// IdentityKeypair { fingerprint: "a1b2c3d4...", .. }
```
This ensures the private seed is never accidentally printed to logs.
## Zeroization
The 32-byte private seed is wrapped in `Zeroizing<[u8; 32]>` from the `zeroize`
crate. When the `IdentityKeypair` struct is dropped, the `Zeroizing` wrapper
overwrites the seed bytes with zeros before deallocation. This mitigates the
risk of key material lingering in memory after the struct is no longer needed.
Key points about the zeroization strategy:
- **On drop:** The seed is overwritten with zeros automatically.
- **Serialization:** `seed_bytes()` returns a plain `[u8; 32]` copy for
persistence. The caller is responsible for securely handling this copy.
- **Reconstruction:** `from_seed(seed)` wraps the provided bytes in a fresh
`Zeroizing` immediately.
- **No `Clone`/`Copy`:** `IdentityKeypair` does not implement `Clone` or
`Copy`, preventing accidental duplication of secret material.
See [Key Lifecycle and Zeroization](key-lifecycle.md) for the full lifecycle of
this key type.
## Role in MLS
The `IdentityKeypair` implements the `openmls_traits::signatures::Signer` trait,
allowing it to be passed directly to `KeyPackage::builder().build(...)`:
```rust
impl Signer for IdentityKeypair {
fn sign(&self, payload: &[u8]) -> Result<Vec<u8>, MlsError> {
let sk = self.signing_key();
let sig: ed25519_dalek::Signature = sk.sign(payload);
Ok(sig.to_bytes().to_vec())
}
fn signature_scheme(&self) -> SignatureScheme {
SignatureScheme::ED25519
}
}
```
This integration means `IdentityKeypair`:
1. Signs MLS Commits, Proposals, and KeyPackages with Ed25519.
2. Is embedded in `BasicCredential` as the raw 32-byte public key bytes.
3. Provides the `signature_key` field in `CredentialWithKey` used throughout
the `GroupMember` lifecycle.
The MLS ciphersuite (`MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519`) mandates
Ed25519 for signing, making the `IdentityKeypair` the natural fit.
## Role in the Authentication Service
The Ed25519 public key bytes (`public_key_bytes()`) are used as the
`identityKey` in `auth.capnp` RPC calls. The Authentication Service stores
KeyPackages indexed by this key, and the Delivery Service routes messages to
queues indexed by the same key.
## Distinction from the X25519 Noise Keypair
It is critical to understand that the Ed25519 identity key and the X25519
transport key are **separate keys on different curves serving different
purposes**:
| Property | Ed25519 Identity Key | X25519 Noise Key |
|----------|---------------------|-----------------|
| Curve | Twisted Edwards (Ed25519) | Montgomery (Curve25519) |
| Operation | Digital signatures | Diffie-Hellman key exchange |
| Purpose | MLS credentials, AS registration | Noise\_XX mutual authentication |
| Lifetime | Permanent (per client) | Per server process or per connection |
| Persistence | Serialized to state file | Not serialized (M6 deferred) |
| Source | `identity.rs` | `keypair.rs` |
Although both curves are related (Curve25519 is birationally equivalent to
Ed25519's curve), the keys are **not interchangeable**. Converting between them
requires explicit birational mapping, which quicnprotochat intentionally avoids
to maintain clean separation of concerns.
## Serialization
`IdentityKeypair` implements `Serialize` and `Deserialize` (serde) by
serializing only the 32-byte seed. On deserialization, `from_seed()` is called
to reconstruct the verifying key:
```rust
impl Serialize for IdentityKeypair {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where S: serde::Serializer,
{
serializer.serialize_bytes(&self.seed[..])
}
}
impl<'de> Deserialize<'de> for IdentityKeypair {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where D: serde::Deserializer<'de>,
{
let bytes: Vec<u8> = serde::Deserialize::deserialize(deserializer)?;
let seed: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| serde::de::Error::custom("identity seed must be 32 bytes"))?;
Ok(IdentityKeypair::from_seed(seed))
}
}
```
This means the state file contains only the 32-byte seed, and the verifying key
is deterministically re-derived on load.
## Related Pages
- [Cryptography Overview](overview.md) -- algorithm inventory
- [X25519 Transport Keys](transport-keys.md) -- the other keypair
- [Key Lifecycle and Zeroization](key-lifecycle.md) -- full lifecycle diagram
- [Post-Compromise Security](post-compromise-security.md) -- how MLS credentials interact with PCS
- [Threat Model](threat-model.md) -- what identity keys protect and do not protect

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

View File

@@ -0,0 +1,102 @@
# Cryptography Overview
quicnprotochat layers multiple cryptographic protocols to provide confidentiality,
integrity, authentication, forward secrecy, and post-compromise security. This
page catalogues every algorithm in the system, the crate that supplies it, and
the security margin it provides.
## Algorithm Inventory
| Algorithm | Purpose | Crate | Security Level |
|-----------|---------|-------|----------------|
| Ed25519 | Identity signing, MLS credentials | `ed25519-dalek 2` | 128-bit classical |
| X25519 | Noise DH, MLS HPKE key exchange | `x25519-dalek 2` | 128-bit classical |
| ChaCha20-Poly1305 | Noise AEAD | `chacha20poly1305 0.10` | 256-bit key |
| AES-128-GCM | MLS AEAD | `openmls` (via RustCrypto) | 128-bit |
| BLAKE2s | Noise hash function | `snow 0.9` (built-in) | 128-bit |
| SHA-256 | Key fingerprints, HKDF | `sha2 0.10` | 128-bit collision resistance |
| ML-KEM-768 | Post-quantum KEM | `ml-kem 0.2` | NIST Level 3 (~192-bit PQ) |
| HKDF-SHA256 | Key derivation | `hkdf 0.12` | Depends on input entropy |
> **Note:** The system provides 128-bit classical security throughout. When the
> hybrid KEM is active (M5 onward), content encryption gains 192-bit
> post-quantum security via ML-KEM-768.
## Where Each Algorithm Appears
### Transport Layer
The transport layer uses two independent encryption substrates:
1. **QUIC/TLS 1.3** (via `quinn 0.11` + `rustls 0.23`): Provides the
outermost encrypted tunnel. The TLS 1.3 handshake negotiates an ephemeral
ECDHE key exchange (X25519 or P-256, depending on the peer) and an AEAD
cipher (AES-128-GCM or ChaCha20-Poly1305). This layer protects connection
metadata from passive network observers.
2. **Noise\_XX** (via `snow 0.9`): Runs inside the QUIC stream. The Noise
pattern `Noise_XX_25519_ChaChaPoly_BLAKE2s` provides mutual authentication
using static X25519 keys, with ChaCha20-Poly1305 as the AEAD and BLAKE2s
as the hash function. See [X25519 Transport Keys](transport-keys.md) for
details on the keypair.
### Application Layer
1. **MLS (RFC 9420)** (via `openmls 0.5`): Provides end-to-end encrypted
group messaging. The ciphersuite is
`MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519`, which uses:
- X25519 for DHKEM (HPKE key exchange)
- AES-128-GCM for content encryption
- SHA-256 for the KDF and transcript hashing
- Ed25519 for signing Commits, Proposals, and credentials
2. **Hybrid KEM** (via `ml-kem 0.2` + `x25519-dalek 2` + `hkdf 0.12`):
An outer encryption layer combining X25519 and ML-KEM-768. The combined
shared secret is derived through HKDF-SHA256 and used with
ChaCha20-Poly1305 for AEAD. See
[Post-Quantum Readiness](post-quantum-readiness.md) for integration plans.
### Identity Layer
- **Ed25519** provides long-term identity signing. Each client generates a
single Ed25519 keypair that serves as the MLS `BasicCredential`, the
Authentication Service registration key, and the delivery queue index. See
[Ed25519 Identity Keys](identity-keys.md).
- **SHA-256** computes key fingerprints -- a 32-byte digest of the Ed25519
public key bytes used for compact, collision-resistant identification in logs
and protocol messages.
## Security Level Summary
All classical algorithms in the system target at least 128-bit security. The
post-quantum component (ML-KEM-768) targets NIST Level 3, which corresponds to
roughly 192-bit security against quantum adversaries.
The weakest classical link is the 128-bit security level of AES-128-GCM in the
MLS ciphersuite. This is consistent with the IETF's recommended MLS ciphersuite
and is considered adequate for the foreseeable future.
```text
Layer Classical Security Post-Quantum Security
--------------------------------------------------------------------
QUIC/TLS 1.3 128-bit (ECDHE) None (classical only)
Noise_XX 128-bit (X25519) None (classical only)
MLS (content) 128-bit (AES-128-GCM) None (classical only)
Hybrid KEM (M5+) 128-bit (X25519) ~192-bit (ML-KEM-768)
```
See the [Threat Model](threat-model.md) for a discussion of what is and is not
protected, and [Forward Secrecy](forward-secrecy.md) and
[Post-Compromise Security](post-compromise-security.md) for the advanced
security properties these algorithms enable.
## Related Pages
- [Ed25519 Identity Keys](identity-keys.md) -- long-term signing keypair
- [X25519 Transport Keys](transport-keys.md) -- Noise handshake keypair
- [Key Lifecycle and Zeroization](key-lifecycle.md) -- creation through destruction
- [Forward Secrecy](forward-secrecy.md) -- past message protection
- [Post-Compromise Security](post-compromise-security.md) -- future message recovery
- [Post-Quantum Readiness](post-quantum-readiness.md) -- ML-KEM-768 hybrid KEM
- [Threat Model](threat-model.md) -- attacker models and known gaps

View File

@@ -0,0 +1,239 @@
# Post-Compromise Security
Post-compromise security (PCS) is a property of a cryptographic protocol that
guarantees: **after an attacker compromises a participant's state, the protocol
automatically heals so that future messages are protected.** The attacker's
window of access is limited to the current epoch; once the compromised member (or
any other member) issues an Update or Commit, the group state is re-randomized
and the attacker is locked out.
PCS is the complement of [forward secrecy](forward-secrecy.md):
- **Forward secrecy** protects the **past** from a future compromise.
- **Post-compromise security** protects the **future** from a past compromise.
MLS (RFC 9420) is specifically designed to provide both properties simultaneously
for group messaging. This is a key differentiator of quicnprotochat's design.
## How MLS Provides PCS
### The Ratchet Tree
At the heart of MLS's PCS mechanism is the **ratchet tree**, a binary tree where:
- Each **leaf** represents a group member and contains their public key material.
- Each **internal node** holds derived key material computed from its children.
- The **root** of the tree determines the epoch's group key material.
```text
[Root]
/ \
[A,B] [C,D]
/ \ / \
[A] [B] [C] [D]
```
When a member updates their leaf (by generating fresh key material and issuing an
Update proposal or Commit), the change propagates up the tree path from the leaf
to the root:
```text
[Root]* <- re-derived
/ \
[A,B]* [C,D] <- re-derived
/ \ / \
[A]* [B] [C] [D] <- A updated leaf
```
Every node marked with `*` receives new key material. This means the new epoch's
group secrets depend on A's freshly generated randomness. An attacker who
previously compromised A's state must now also compromise the new key material --
which they cannot do because it was generated after the compromise was detected
(or automatically healed by the protocol).
### Cost
The path from a leaf to the root has length O(log n) for a group of n members.
This means:
- An Update/Commit produces O(log n) encrypted path secrets.
- Each group member processes the Update in O(log n) time.
- This is dramatically more efficient than pairwise rekeying (O(n)) or
broadcast encryption.
For a group of 1,000 members, the path length is approximately 10 nodes --
making PCS practical even for large groups.
## Epoch Advancement in quicnprotochat
In the current implementation, epoch advancement occurs through the `GroupMember`
methods in `group.rs`:
### Adding a Member (Commit)
When `add_member()` is called, openmls creates a Commit that adds the new member
and updates the ratchet tree:
```rust
// Alice adds Bob -- this creates a Commit + Welcome
let (commit_bytes, welcome_bytes) = alice.add_member(&bob_kp)?;
// Alice merges the pending Commit, advancing her epoch
// Internally, openmls re-derives the ratchet tree with Bob's leaf
group.merge_pending_commit(&self.backend)?;
```
After `merge_pending_commit()`, Alice's group is in a new epoch with fresh key
material. Any attacker who had compromised Alice's state before this Commit
must now also compromise the new epoch's keys.
### Receiving a Commit
When a member receives and processes a Commit from another member:
```rust
ProcessedMessageContent::StagedCommitMessage(staged) => {
// Merge the Commit into local state -- epoch advances
group.merge_staged_commit(&self.backend, *staged)?;
Ok(None)
}
```
This advances the receiver's epoch, incorporating the committer's fresh key
material into the ratchet tree.
### Future: Periodic Updates
The current implementation only advances epochs when members are added. A more
robust PCS strategy involves periodic Update proposals, where members
re-randomize their leaf key material on a regular schedule (e.g., every hour, or
every N messages). This is planned for future milestones and will look like:
```text
1. Member generates fresh leaf key material
2. Member creates an Update proposal
3. Any member (or the updater) creates a Commit including the Update
4. All members process the Commit and advance to the new epoch
5. The attacker's compromised state is now stale
```
## PCS vs Forward Secrecy
These two properties are often confused but protect against different attack
scenarios:
| Property | Protects | Mechanism | Threat |
|----------|----------|-----------|--------|
| Forward Secrecy | Past messages | Delete old epoch keys | Attacker compromises state **now**, tries to read **past** |
| Post-Compromise Security | Future messages | Re-randomize ratchet tree | Attacker compromised state **before**, tries to read **future** |
Together, they provide a strong guarantee: the attacker's window of access is
limited to the **current epoch only**. Past epochs are protected by FS (old keys
deleted), and future epochs are protected by PCS (new key material generated).
```text
Past epochs Current epoch Future epochs
+-----------------+ +-----------------+ +-----------------+
| Protected by | | Attacker has | | Protected by |
| Forward | | access if they | | Post-Compromise|
| Secrecy | | hold current | | Security |
| (keys deleted) | | epoch keys | | (tree updated) |
+-----------------+ +-----------------+ +-----------------+
```
## Comparison with Signal Groups
Signal's group messaging uses **Sender Keys**, a fundamentally different
mechanism from MLS's ratchet tree. The comparison is instructive because it
highlights why MLS was chosen for quicnprotochat:
### Signal Sender Keys
In Signal's group protocol:
1. Each member generates a Sender Key -- a symmetric key used to encrypt all
messages they send to the group.
2. The Sender Key is distributed to all group members via pairwise Signal
sessions.
3. Each message from a sender is encrypted with their Sender Key.
4. The Sender Key includes a symmetric ratchet (hash ratchet) that advances per
message, providing forward secrecy within a sender's chain.
**The critical limitation:** Sender Keys do **not** provide post-compromise
security. If an attacker compromises a member's Sender Key:
- The attacker can derive all future message keys from that sender (the hash
ratchet is one-way, but the attacker has the current state).
- The key is only rotated when the member manually refreshes it or when group
membership changes.
- There is no automatic healing mechanism analogous to MLS's ratchet tree.
### MLS Ratchet Tree (quicnprotochat)
In contrast, MLS's ratchet tree provides PCS because:
1. Any member can issue an Update that re-randomizes their leaf.
2. The Commit propagates new key material up the tree, affecting the group
secret.
3. The attacker, who holds old state, cannot predict the new randomness.
4. The group automatically heals after at most one epoch advance.
| Property | Signal Sender Keys | MLS Ratchet Tree |
|----------|-------------------|-----------------|
| Forward secrecy (group) | Per-sender hash ratchet | Per-epoch (Commit) |
| Post-compromise security | **No** -- compromised Sender Key reveals all future messages from that sender | **Yes** -- any Commit/Update heals the tree |
| Key rotation | Manual or on membership change | Any Commit (add/remove/update) |
| Healing time | Until manual rotation | Next epoch (automatic) |
| Cost per update | O(n) pairwise re-encryption | O(log n) tree path |
## Practical Implications
### What happens during a compromise?
Suppose an attacker compromises Member A's MLS state (including the current epoch
keys):
1. **Current epoch:** The attacker can decrypt all messages in the current epoch
from all members (because epoch keys are shared group secrets).
2. **Past epochs:** Protected by forward secrecy. Old epoch keys have been
deleted by openmls.
3. **After the next Commit:** Any member (including A, after recovering) can
issue a Commit. The ratchet tree is updated with fresh key material. The
attacker's stale state cannot derive the new epoch keys. The attacker is
locked out.
### How quickly does healing occur?
In the current implementation, healing occurs whenever:
- A new member is added (`add_member()` issues a Commit).
- A member is removed (not yet implemented, but will issue a Commit).
- In the future: periodic Update proposals are issued on a timer.
The practical healing window is the time between Commits. For active groups
with frequent membership changes, this window is small. For static groups,
periodic Updates (planned) will bound the healing window.
### Server compromise does not prevent PCS
The quicnprotochat server is MLS-unaware -- it stores and forwards encrypted
MLS messages without access to the group state. A compromised server cannot:
- Prevent PCS by blocking Commits (it could perform denial-of-service, but
cannot selectively suppress Update proposals without being detected, because
MLS epoch numbers must be sequential).
- Inject fraudulent Commits (it lacks the signing key of any group member).
See [Threat Model](threat-model.md) for the full analysis of a compromised
server.
## Related Pages
- [Cryptography Overview](overview.md) -- algorithm inventory
- [Forward Secrecy](forward-secrecy.md) -- the complementary property
- [Key Lifecycle and Zeroization](key-lifecycle.md) -- how key deletion enables FS and PCS
- [Threat Model](threat-model.md) -- attacker models including compromised clients
- [Post-Quantum Readiness](post-quantum-readiness.md) -- PQ protection for PCS mechanisms
- [MLS (RFC 9420)](../protocol-layers/mls.md) -- protocol deep dive

View File

@@ -0,0 +1,256 @@
# Post-Quantum Readiness
quicnprotochat includes a fully implemented and tested hybrid key encapsulation
mechanism (KEM) combining X25519 (classical) with ML-KEM-768 (post-quantum).
This page describes the current implementation, the integration plan, the
security rationale, and the known gaps.
**Source:** `crates/quicnprotochat-core/src/hybrid_kem.rs`
## Current State
The hybrid KEM is **fully implemented and tested** in `quicnprotochat-core`. The
implementation provides:
- `HybridKeypair::generate()` -- generate a combined X25519 + ML-KEM-768 keypair
- `hybrid_encrypt(recipient_pk, plaintext)` -- encrypt to a hybrid public key
- `hybrid_decrypt(keypair, envelope)` -- decrypt with the hybrid private key
- Serialization/deserialization for both keypairs and public keys
- Comprehensive test coverage: round-trip, wrong-key rejection, tampering
detection, version validation, large payload handling
The test suite in `hybrid_kem.rs` includes 10 tests covering:
- Basic encrypt/decrypt round-trip
- Wrong key decryption failure
- Tampered AEAD ciphertext detection
- Tampered ML-KEM ciphertext detection
- Tampered X25519 ephemeral public key detection
- Unsupported version rejection
- Envelope-too-short rejection
- Keypair serialization round-trip
- Public key serialization round-trip
- Large payload (50 KB) round-trip
## ML-KEM-768 (FIPS 203)
ML-KEM (Module-Lattice-Based Key Encapsulation Mechanism) is the NIST-standardized
post-quantum KEM, published as FIPS 203. quicnprotochat uses ML-KEM-768, the
middle parameter set:
| Parameter Set | NIST Level | Security (PQ) | EK Size | CT Size | SS Size |
|---------------|-----------|---------------|---------|---------|---------|
| ML-KEM-512 | 1 | ~128-bit | 800 B | 768 B | 32 B |
| **ML-KEM-768** | **3** | **~192-bit** | **1184 B** | **1088 B** | **32 B** |
| ML-KEM-1024 | 5 | ~256-bit | 1568 B | 1568 B | 32 B |
### Why ML-KEM-768 (not 512 or 1024)?
- **Level 3 provides a strong security margin.** The ~192-bit post-quantum
security level exceeds the 128-bit classical security of the rest of the
system, providing headroom against future cryptanalytic advances.
- **Moderate key and ciphertext sizes.** The encapsulation key (1184 bytes) and
ciphertext (1088 bytes) are large compared to X25519 (32 bytes each) but
manageable for a messaging protocol. ML-KEM-1024 would add ~400 bytes to each
with diminishing returns.
- **Consistent with industry practice.** Signal, Google Chrome, and Cloudflare
have all deployed ML-KEM-768 (or its predecessor Kyber-768) in production.
The `ml-kem 0.2` crate provides a pure-Rust implementation of FIPS 203 with all
three parameter sets compiled in by default.
## Hybrid Construction
The hybrid KEM follows the combiner approach described in
`draft-ietf-tls-hybrid-design`. Both a classical and a post-quantum KEM are
executed, and their shared secrets are combined through a KDF:
### Key Derivation
```text
ikm = X25519_shared_secret(32 bytes) || ML-KEM_shared_secret(32 bytes)
key = HKDF-SHA256(salt=[], ikm, info="quicnprotochat-hybrid-v1", L=32)
nonce = HKDF-SHA256(salt=[], ikm, info="quicnprotochat-hybrid-nonce-v1", L=12)
```
The combined IKM (input key material) is wrapped in `Zeroizing<Vec<u8>>` and
cleared after HKDF expansion.
### Why a Hybrid?
A pure ML-KEM deployment would be vulnerable if lattice-based cryptography is
broken (which, while considered unlikely, cannot be ruled out for a newly
standardized algorithm). A pure X25519 deployment provides no post-quantum
protection. The hybrid approach provides a "belt and suspenders" guarantee:
- If ML-KEM-768 is broken, X25519 still provides 128-bit classical security.
- If X25519 is broken (by a quantum computer), ML-KEM-768 still provides
~192-bit post-quantum security.
- Both must be broken simultaneously to compromise the shared secret.
## Wire Format
The hybrid envelope uses a versioned binary format:
```text
Offset Length Field
------ ------ -----
0 1 Version byte (0x01)
1 32 X25519 ephemeral public key
33 1088 ML-KEM-768 ciphertext
1121 12 ChaCha20-Poly1305 nonce
1133 var ChaCha20-Poly1305 AEAD ciphertext (plaintext + 16-byte tag)
```
Total overhead: **1133 bytes** of header + 16 bytes of AEAD tag = **1149 bytes**
per message (in addition to the plaintext length).
The version byte enables future algorithm agility. Version `0x01` denotes
X25519 + ML-KEM-768 + HKDF-SHA256 + ChaCha20-Poly1305.
## Encryption Flow
```text
Sender Recipient
------ ---------
HybridKeypair::generate()
├── x25519_sk, x25519_pk
└── mlkem_dk, mlkem_ek
public_key()│
┌─── HybridPublicKey ◄──────────────────────────────┘
│ (x25519_pk: 32B, mlkem_ek: 1184B)
│ hybrid_encrypt(pk, plaintext):
│ 1. eph_sk = EphemeralSecret::random()
│ 2. eph_pk = PublicKey::from(&eph_sk)
│ 3. x25519_ss = eph_sk.diffie_hellman(pk.x25519_pk)
│ 4. (mlkem_ct, mlkem_ss) = pk.mlkem_ek.encapsulate()
│ 5. ikm = x25519_ss || mlkem_ss
│ 6. (key, nonce) = HKDF(ikm, info)
│ 7. ct = ChaCha20Poly1305::encrypt(key, nonce, plaintext)
│ 8. envelope = ver || eph_pk || mlkem_ct || nonce || ct
└──────────── envelope ──────────────────────────────►
hybrid_decrypt(kp, envelope):
1. Parse ver, eph_pk, mlkem_ct, nonce, ct
2. x25519_ss = kp.x25519_sk.dh(eph_pk)
3. mlkem_ss = kp.mlkem_dk.decapsulate(mlkem_ct)
4. ikm = x25519_ss || mlkem_ss
5. (key, _) = HKDF(ikm, info)
6. plaintext = ChaCha20Poly1305::decrypt(key, nonce, ct)
```
## Integration Plan (M5)
The hybrid KEM is currently a standalone module. Milestone M5 will integrate it
into the MLS pipeline by creating a custom `OpenMlsCryptoProvider` that uses the
hybrid KEM for HPKE init key exchange:
1. **Custom crypto provider:** Wrap the existing `StoreCrypto` with a hybrid KEM
layer that intercepts HPKE operations and replaces the classical X25519 DHKEM
with the hybrid X25519 + ML-KEM-768 KEM.
2. **KeyPackage extension:** Store the hybrid public key (1216 bytes:
32B X25519 + 1184B ML-KEM) in a custom MLS extension within the KeyPackage.
3. **Welcome encryption:** When creating a Welcome message, the hybrid KEM
encrypts the group secrets instead of (or in addition to) the standard HPKE.
4. **Backward compatibility:** Groups can negotiate whether to use hybrid KEM
via the MLS group context extensions. Classical-only clients can still
participate in groups that do not require PQ protection.
## The PQ Gap (ADR-006)
There is an important asymmetry in quicnprotochat's post-quantum protection:
```text
Layer Classical Protection Post-Quantum Protection
---------------------------------------------------------------------
QUIC/TLS 1.3 Yes (ECDHE) No
Noise_XX Yes (X25519) No
MLS content (M5+) Yes (X25519 DHKEM) Yes (hybrid KEM)
```
**What this means:**
- **Message content** (the MLS application data) is protected against quantum
adversaries from M5 onward. An attacker with a quantum computer cannot decrypt
the message payload.
- **Transport metadata** (who connects to the server, when, message sizes) is
protected only by classical cryptography. A quantum attacker who recorded the
TLS/Noise handshake transcripts could, in theory, recover the transport session
keys and observe the metadata.
This is the **PQ gap**: content is safe, but metadata is not.
### Why not PQ transport?
Post-quantum TLS (via ML-KEM in the TLS 1.3 handshake) is being standardized by
the IETF and is supported by some TLS libraries, but `rustls` does not yet
support it in a stable release. When `rustls` adds ML-KEM support, quicnprotochat
will adopt it to close the PQ gap at the transport layer.
Similarly, post-quantum Noise patterns are an active research area but are not
yet standardized. The `snow` crate does not currently support post-quantum DH
primitives.
## Harvest-Now, Decrypt-Later Risk
The "harvest-now, decrypt-later" (HNDL) threat model assumes an adversary who:
1. Records all encrypted traffic today (inexpensive storage).
2. Waits for a sufficiently powerful quantum computer (years or decades).
3. Decrypts the recorded traffic retroactively.
In quicnprotochat's case:
- **Content is safe from M5 onward.** The hybrid KEM wrapping MLS content uses
ML-KEM-768, which resists quantum attacks. Even if the recorded traffic is
decrypted at the transport layer, the MLS ciphertext inside is still protected.
- **Transport metadata is at risk.** An HNDL attacker who records TLS/Noise
handshakes today could, with a future quantum computer, recover the transport
session keys and observe:
- Which clients connected to the server and when.
- Message sizes and timing patterns.
- The encrypted MLS blobs (which they still cannot decrypt if hybrid KEM is
active).
- **Content before M5 is at risk.** Messages sent before the hybrid KEM
integration (M5) use classical-only MLS encryption. If the HPKE init key
exchange used X25519-only DHKEM, a quantum attacker could recover the HPKE
shared secret and decrypt the Welcome message, gaining access to the group
state.
This risk is the primary motivation for deploying the hybrid KEM as early as
possible.
## Key Sizes and Performance
| Component | Key/Ciphertext | Size |
|-----------|---------------|------|
| X25519 public key | `x25519_pk` | 32 bytes |
| X25519 shared secret | DH result | 32 bytes |
| ML-KEM-768 encapsulation key | `mlkem_ek` | 1,184 bytes |
| ML-KEM-768 decapsulation key | `mlkem_dk` | 2,400 bytes |
| ML-KEM-768 ciphertext | `mlkem_ct` | 1,088 bytes |
| ML-KEM-768 shared secret | KEM result | 32 bytes |
| Hybrid public key | `x25519_pk + mlkem_ek` | 1,216 bytes |
| Hybrid envelope overhead | Header + AEAD tag | 1,149 bytes |
The ML-KEM-768 operations (keygen, encapsulate, decapsulate) are fast in
software -- typically sub-millisecond on modern hardware. The primary cost is
bandwidth, not computation.
## Related Pages
- [Cryptography Overview](overview.md) -- algorithm inventory including ML-KEM-768
- [Key Lifecycle and Zeroization](key-lifecycle.md) -- hybrid KEM key lifecycle
- [Forward Secrecy](forward-secrecy.md) -- how FS interacts with PQ protection
- [Threat Model](threat-model.md) -- harvest-now-decrypt-later in context
- [Hybrid KEM: X25519 + ML-KEM-768](../protocol-layers/hybrid-kem.md) -- protocol layer details

View File

@@ -0,0 +1,332 @@
# Threat Model
This page defines the attacker models quicnprotochat is designed to resist,
catalogues what is and is not protected, identifies known gaps in the current
implementation, and outlines future mitigations.
## Attacker Models
### 1. Passive Eavesdropper
**Capabilities:** Records all network traffic between clients and the server.
Can observe IP addresses, connection timing, message sizes, and encrypted
payloads. Cannot modify traffic.
**What they learn:**
- Connection metadata: which IP addresses connect to the server and when.
- Message timing and sizes: observable patterns (e.g., message frequency,
payload lengths) that could reveal communication patterns.
- Encrypted payloads: TLS 1.3 ciphertext containing Noise ciphertext containing
MLS ciphertext. Three layers of encryption must be broken to access content.
**What they cannot learn:**
- Message content: protected by MLS encryption inside Noise inside TLS.
- Group membership details: MLS Commits are encrypted.
- Which specific recipient a message is destined for (from the network
perspective, all messages go to the server).
**Residual risk:** Traffic analysis. Even without decryption, the timing and
size of messages can reveal communication patterns. For example, a message sent
by Alice followed shortly by a message to Bob may indicate they are in the same
group. See [Future Mitigations](#future-mitigations) for countermeasures.
### 2. Active Network Attacker (MITM)
**Capabilities:** Can intercept, modify, drop, and inject network traffic.
Positioned between the client and server (e.g., compromised router, ISP, or
state-level adversary).
**What they can do:**
- Attempt TLS 1.3 MITM: TLS 1.3 prevents this if the client validates the
server's certificate. However, quicnprotochat currently uses **self-signed
certificates**, which means the client has no CA chain to verify. On the first
connection, a MITM could present their own certificate and intercept the
session (trust-on-first-use vulnerability).
- Denial of service: drop or delay packets.
- Traffic analysis: same as passive eavesdropper, with the added ability to
inject timing perturbations.
**What they cannot do (assuming no cert MITM):**
- Decrypt TLS/Noise traffic: both use authenticated ephemeral key exchange.
- Forge MLS messages: MLS Commits and application messages are signed by the
sender's Ed25519 identity key. The attacker does not possess any member's
signing key.
- Inject members into MLS groups: adding a member requires a valid KeyPackage
signed by the new member's identity key.
**Current weakness:** Self-signed TLS certificates. See
[Known Gaps](#known-gaps).
### 3. Compromised Server
**Capabilities:** Full access to the server's memory, disk, and network
interfaces. Can read all data stored on the server, modify server behavior,
and observe all client connections.
**What the server sees:**
- Connection metadata: which clients connect, when, how often, from which IPs.
- Ed25519 public keys: used as delivery queue indices. The server knows the
public identity key of every registered client.
- Message sizes and timing: the server forwards MLS messages and can observe
their sizes and the timing of enqueue/fetch operations.
- Encrypted MLS blobs: the server stores and forwards MLS ciphertext but cannot
decrypt it (the server is MLS-unaware by design, per ADR-004).
**What the server cannot do:**
- **Decrypt message content:** The server does not hold any MLS group keys. MLS
application messages are encrypted end-to-end between group members. The
server sees only opaque ciphertext.
- **Forge MLS messages:** MLS messages are signed by the sender's Ed25519 key.
The server does not possess any member's signing key and cannot produce valid
MLS signatures.
- **Read past messages:** Even if the server stored old MLS ciphertext, it
cannot decrypt it. Forward secrecy at the MLS layer (epoch key deletion)
ensures that even compromising a member's state in the future does not reveal
past epoch keys.
**What the server can do maliciously:**
- **Traffic analysis:** Correlate senders and recipients based on timing,
message sizes, and queue access patterns.
- **Selective denial of service:** Drop or delay specific messages or refuse
service to specific clients.
- **Metadata correlation:** Link Ed25519 public keys to IP addresses and
connection patterns.
- **Replay (limited):** Re-deliver an MLS message. MLS has replay protection
via epoch numbers and message counters, so the recipient will reject the
duplicate.
- **KeyPackage manipulation:** Withhold or substitute KeyPackages during the
join flow. If the server substitutes a KeyPackage, the resulting MLS group
would include the attacker's key, but the legitimate member would not be able
to join (they would not receive a matching Welcome). This is detectable.
### 4. Compromised Client
**Capabilities:** Full access to a group member's device, including the MLS
group state, Ed25519 identity key, and any stored messages.
**What the attacker learns:**
- **Current epoch messages:** The attacker can decrypt all messages in the
current MLS epoch from all group members (epoch keys are shared group secrets).
- **Identity key:** The attacker obtains the member's Ed25519 signing key and
can impersonate the member (sign messages, create KeyPackages).
**What the attacker cannot learn:**
- **Past epoch messages:** Protected by [forward secrecy](forward-secrecy.md).
Old epoch keys have been deleted by openmls.
- **Future epoch messages (after healing):** Protected by
[post-compromise security](post-compromise-security.md). After the next
Commit or Update, the ratchet tree is re-randomized and the attacker is
locked out.
**Healing mechanism:**
1. The compromised member (or any other member) issues a Commit.
2. The ratchet tree is updated with fresh key material.
3. The attacker's stale state cannot derive the new epoch keys.
4. The attacker is locked out of future epochs.
The healing window is the time between the compromise and the next Commit. See
[Post-Compromise Security](post-compromise-security.md) for details.
## What Is Protected
| Asset | Protection Mechanism | Against |
|-------|---------------------|---------|
| Message content | MLS end-to-end encryption (AES-128-GCM) | All attacker models |
| Message integrity | MLS signing (Ed25519) | Forgery by server or network |
| Group membership changes | MLS Commits (signed, authenticated) | Unauthorized modification |
| Key exchange material | Single-use HPKE init keys | Replay, forward compromise |
| Transport confidentiality | TLS 1.3 + Noise\_XX (double encryption) | Passive eavesdropper |
| Transport integrity | TLS 1.3 AEAD + Noise AEAD | Active network attacker |
| Past messages | Forward secrecy (epoch key deletion) | Future client compromise |
| Future messages | Post-compromise security (ratchet tree update) | Past client compromise |
## What Is NOT Protected (Current State)
| Asset | Visibility | Observer |
|-------|-----------|----------|
| Transport metadata (who connects, when) | IP addresses, connection timing | Network adversary, server |
| Message timing and sizes | Observable in TLS records | Network adversary, server |
| Recipient identity | Server routes by Ed25519 public key | Server |
| Sender identity (partial) | Server can correlate connections to senders | Server |
| Number of groups a client belongs to | Observable via message patterns | Server (with analysis) |
| Client IP address | Visible in TCP/QUIC connection | Server, network adversary |
## Known Gaps
### Self-Signed TLS Certificates
The server uses self-signed TLS certificates generated at startup via `rcgen`.
Clients currently accept any server certificate without CA chain validation.
This makes the system vulnerable to a man-in-the-middle attack on the first
connection: an attacker positioned between the client and server can present
their own certificate and intercept all traffic.
**Impact:** Complete loss of transport confidentiality and integrity for affected
connections. MLS content remains protected (the MITM cannot decrypt MLS
ciphertext or forge MLS signatures), but the attacker can observe encrypted MLS
blobs, perform traffic analysis, and potentially block or delay messages.
**Mitigation path:** Implement certificate pinning (trust-on-first-use) or
integrate with a public CA (e.g., Let's Encrypt). Certificate transparency logs
could provide an additional detection mechanism.
### No Client Authentication on the Delivery Service
The Delivery Service does not currently authenticate clients. Anyone who knows
a recipient's Ed25519 public key can enqueue messages for that recipient. This
enables spam and potential denial-of-service by flooding a recipient's queue.
**Impact:** Queue flooding, spam delivery. MLS provides its own authentication
(the recipient will reject messages not signed by a group member), so forged
content will not be accepted, but the recipient must still download and attempt
to process the spam.
**Mitigation path:** The AUTHZ\_PLAN introduces token-based authentication,
binding identityKey to accounts and requiring valid access tokens for all
DS operations.
### No Rate Limiting
The server does not currently enforce per-client or per-IP rate limits. A
malicious client could flood the server with requests, consuming resources and
degrading service for other users.
**Impact:** Denial of service.
**Mitigation path:** The AUTHZ\_PLAN specifies per-IP and per-account/device
rate limits (e.g., 50 requests/second, 5 MB payload cap).
### BasicCredential Only
MLS `BasicCredential` contains only the raw Ed25519 public key bytes. There is
no certificate authority chain, no credential revocation mechanism, and no
binding to a human-readable identity (e.g., phone number, email).
**Impact:** No way to verify that a public key belongs to a specific person
without out-of-band verification (e.g., comparing fingerprints in person). An
attacker who compromises the Authentication Service could substitute public keys.
**Mitigation path:** Implement X.509-based MLS credentials with a certificate
chain, or integrate with a Key Transparency system that provides a verifiable
log of public key bindings.
### Classical-Only Transport
As discussed in [Post-Quantum Readiness](post-quantum-readiness.md), the
transport layer (TLS 1.3, Noise\_XX) uses classical-only cryptography. An
adversary performing harvest-now-decrypt-later (HNDL) could record transport
traffic today and decrypt it with a future quantum computer, revealing transport
metadata.
**Impact:** Future exposure of transport metadata (not content, assuming
hybrid KEM is active for MLS).
**Mitigation path:** Adopt post-quantum TLS (ML-KEM in TLS 1.3 handshake) when
`rustls` supports it. Investigate post-quantum Noise patterns.
## Future Mitigations
### Sealed Sender
**Goal:** Hide the sender's identity from the server.
**Approach:** Encrypt the sender's identity inside the MLS ciphertext. The
server cannot determine who sent a message -- it only knows the recipient
(delivery queue index). Signal implements a version of this as "Sealed Sender."
**Benefit:** Reduces the server's metadata visibility from "who sent to whom"
to "someone sent to this recipient."
### Private Information Retrieval (PIR)
**Goal:** Fetch messages without revealing the recipient's identity to the
server.
**Approach:** Use PIR protocols (e.g., SealPIR, SimplePIR) to query the
delivery queue without the server learning which queue was accessed.
**Benefit:** Combined with Sealed Sender, this would make the server metadata-blind:
it would know only that "someone sent something to someone."
**Trade-off:** PIR is computationally expensive and may increase latency
significantly, especially for large mailboxes.
### Key Transparency
**Goal:** Detect public key substitution attacks.
**Approach:** Publish all Ed25519 public keys in a verifiable, append-only log
(similar to Certificate Transparency for TLS). Clients can audit the log to
verify that their public key has not been replaced by an attacker.
**Benefit:** Detects attacks where the server (or an attacker who compromised
the server) substitutes a victim's public key with the attacker's key.
### OPAQUE Authentication
**Goal:** Zero-knowledge password authentication.
**Approach:** Use the OPAQUE protocol (RFC 9497) for client-server
authentication. OPAQUE allows the client to prove knowledge of a password
without revealing it to the server, even during registration.
**Benefit:** The server never learns the client's password, preventing
credential theft in a server compromise.
### Tor/I2P Integration
**Goal:** Hide client IP addresses from the server and network adversaries.
**Approach:** Route QUIC connections through the Tor network or I2P. The server
sees only the Tor exit node's IP, not the client's real IP.
**Benefit:** Prevents the server and network adversaries from linking
connections to physical locations or ISP accounts.
**Trade-off:** Significant latency increase. QUIC over Tor requires careful
configuration to avoid leaking the real IP through WebRTC-style mechanisms.
### Padding and Traffic Shaping
**Goal:** Defeat traffic analysis based on message sizes and timing.
**Approach:** Pad all messages to fixed sizes (or random sizes from a
distribution) and send dummy messages at regular intervals to mask real
communication patterns.
**Benefit:** Makes it harder for network adversaries and the server to infer
communication patterns from traffic analysis.
**Trade-off:** Increased bandwidth usage.
## Summary Table
| Threat | Current Protection | Gap | Planned Fix |
|--------|-------------------|-----|-------------|
| Passive eavesdropper | TLS + Noise + MLS (3 layers) | Traffic analysis | Padding, Tor |
| Active MITM | TLS 1.3 + Noise\_XX | Self-signed certs | Cert pinning, CA |
| Compromised server | MLS E2E encryption | Metadata visible | Sealed Sender, PIR |
| Compromised client | FS + PCS | Current epoch exposed | Periodic Updates |
| Spam/flooding | None | No auth on DS | AUTHZ\_PLAN |
| Key substitution | None | BasicCredential only | Key Transparency |
| Quantum adversary (content) | Hybrid KEM (M5+) | Pre-M5 messages | Deploy hybrid ASAP |
| Quantum adversary (transport) | None | Classical TLS/Noise | PQ TLS, PQ Noise |
## Related Pages
- [Cryptography Overview](overview.md) -- algorithm inventory and security levels
- [Forward Secrecy](forward-secrecy.md) -- protecting past messages
- [Post-Compromise Security](post-compromise-security.md) -- protecting future messages
- [Post-Quantum Readiness](post-quantum-readiness.md) -- ML-KEM-768 and the PQ gap
- [Ed25519 Identity Keys](identity-keys.md) -- identity key used for MLS credentials
- [Key Lifecycle and Zeroization](key-lifecycle.md) -- key destruction guarantees

View File

@@ -0,0 +1,191 @@
# X25519 Transport Keys
The X25519 transport keypair is used for mutual authentication in the Noise\_XX
handshake. Unlike the [Ed25519 identity key](identity-keys.md), which is a
signing key, the X25519 key performs Diffie-Hellman key exchange to establish
encrypted transport sessions.
**Source:** `crates/quicnprotochat-core/src/keypair.rs`
## Structure
The `NoiseKeypair` struct holds two fields:
```rust
pub struct NoiseKeypair {
/// Private scalar -- zeroized on drop via x25519_dalek's ZeroizeOnDrop impl.
private: StaticSecret,
/// Corresponding public key -- derived from private at construction time.
public: PublicKey,
}
```
| Field | Type | Size | Secret? |
|-------|------|------|---------|
| `private` | `x25519_dalek::StaticSecret` | 32 bytes | Yes -- `ZeroizeOnDrop` |
| `public` | `x25519_dalek::PublicKey` | 32 bytes | No -- safe to log/transmit |
## Key Generation
A fresh keypair is generated from the OS CSPRNG:
```rust
use quicnprotochat_core::keypair::NoiseKeypair;
let keypair = NoiseKeypair::generate();
// private: random 32-byte scalar from OsRng
// public: derived via Curve25519 scalar multiplication
```
Internally:
```rust
pub fn generate() -> Self {
let private = StaticSecret::random_from_rng(OsRng);
let public = PublicKey::from(&private);
Self { private, public }
}
```
The `StaticSecret::random_from_rng` call uses the operating system's CSPRNG
(`getrandom` on Linux, `SecRandomCopyBytes` on macOS) and is suitable for
generating long-lived static identity keys.
## Accessing Key Material
### Private Key Bytes
The `private_bytes()` method returns the raw 32-byte private scalar wrapped in
`Zeroizing<[u8; 32]>`:
```rust
pub fn private_bytes(&self) -> Zeroizing<[u8; 32]> {
Zeroizing::new(self.private.to_bytes())
}
```
The `Zeroizing` wrapper ensures the caller's copy of the key material is
overwritten with zeros when it goes out of scope. The intended usage pattern is
to pass the bytes directly to `snow::Builder` and let the wrapper drop
immediately:
```rust
let private = keypair.private_bytes();
let session = snow::Builder::new(params)
.local_private_key(&private[..])
.build_initiator()?;
// private is zeroized here when it falls out of scope.
```
### Public Key Bytes
The `public_bytes()` method returns a plain `[u8; 32]`:
```rust
pub fn public_bytes(&self) -> [u8; 32] {
self.public.to_bytes()
}
```
The public key is not secret and may be freely cloned, logged, or transmitted
over the wire.
## Zeroization Strategy
The `NoiseKeypair` has two layers of zeroization protection:
1. **`StaticSecret` (inner):** The `x25519_dalek` crate implements
`ZeroizeOnDrop` on `StaticSecret`. When the `NoiseKeypair` struct is dropped,
the private scalar is automatically overwritten with zeros.
2. **`Zeroizing<[u8; 32]>` (accessor):** When callers use `private_bytes()`, the
returned copy is also wrapped in `Zeroizing`, so the caller's copy is zeroed
on drop too.
This dual-layer approach ensures that key material does not linger in memory
whether the key is accessed by value or held in the struct.
## Debug Redaction
The `Debug` implementation intentionally redacts the private key and shows only
the first 4 bytes of the public key as a sanity identifier:
```rust
impl std::fmt::Debug for NoiseKeypair {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let pub_bytes = self.public_bytes();
write!(
f,
"NoiseKeypair {{ public: {:02x}{:02x}{:02x}{:02x}..., private: [redacted] }}",
pub_bytes[0], pub_bytes[1], pub_bytes[2], pub_bytes[3],
)
}
}
```
This prevents accidental leakage of secret material through logging or
`println!("{:?}", keypair)`.
## Role in Noise\_XX
The Noise\_XX handshake pattern performs mutual authentication: both initiator
and responder prove possession of their static X25519 keys. The handshake
proceeds in three messages:
```text
→ e (initiator sends ephemeral public key)
← e, ee, s, es (responder sends ephemeral + static, DH results)
→ s, se (initiator sends static, final DH result)
```
After the handshake completes, both parties have:
- Authenticated each other's static X25519 public keys.
- Derived symmetric transport keys from the DH shared secrets.
- Established forward secrecy via the ephemeral keys (which are discarded).
The `NoiseKeypair` provides the `s` (static) key in this pattern. Ephemeral keys
(`e`) are generated internally by `snow` during each handshake.
## Ephemeral vs Static
In the context of Noise\_XX:
- **Ephemeral keys** are generated per-handshake by `snow` and discarded after
key derivation. They provide forward secrecy.
- **Static keys** (the `NoiseKeypair`) are longer-lived and provide identity.
In the current implementation, the server generates a new `NoiseKeypair` per
process start, and the client generates one per connection.
## Persistence
`NoiseKeypair` **intentionally does not implement `Serialize`**. Key persistence
to disk is deferred to milestone M6, which will add:
- Appropriate file permission checks (e.g., `0600` on Unix).
- Optional passphrase-based encryption of the key file.
- A key rotation mechanism.
Until M6, the transport key is ephemeral to the process lifetime. This is
acceptable because the Noise key is not used for MLS group membership -- that
role belongs to the [Ed25519 identity key](identity-keys.md).
## Comparison with Ed25519 Identity Key
| Property | X25519 Noise Key | Ed25519 Identity Key |
|----------|-----------------|---------------------|
| Curve | Montgomery (Curve25519) | Twisted Edwards (Ed25519) |
| Operation | Diffie-Hellman key exchange | Digital signatures |
| Purpose | Noise\_XX mutual authentication | MLS credentials, AS registration |
| Lifetime | Per process / per connection | Permanent (per client) |
| Serialization | Not implemented | Serde (seed bytes) |
| Zeroize | `ZeroizeOnDrop` (x25519-dalek) | `Zeroizing<[u8; 32]>` (manual) |
| Source file | `keypair.rs` | `identity.rs` |
## Related Pages
- [Cryptography Overview](overview.md) -- algorithm inventory
- [Ed25519 Identity Keys](identity-keys.md) -- the other keypair
- [Key Lifecycle and Zeroization](key-lifecycle.md) -- full lifecycle diagram
- [Forward Secrecy](forward-secrecy.md) -- how ephemeral DH provides FS at the transport layer
- [Noise\_XX Handshake](../protocol-layers/noise-xx.md) -- protocol details