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