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,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