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,316 @@
# GroupMember Lifecycle
The `GroupMember` struct in `quicnprotochat-core` is the core MLS state machine
that manages a single client's membership in an MLS group. It wraps an openmls
`MlsGroup`, a persistent crypto backend, and the long-term Ed25519 identity
keypair. Every MLS operation -- key package generation, group creation, member
addition, joining, sending, and receiving -- flows through this struct.
**Source:** `crates/quicnprotochat-core/src/group.rs`
---
## Struct Fields
```rust
pub struct GroupMember {
backend: StoreCrypto, // persistent crypto backend (key store + RustCrypto)
identity: Arc<IdentityKeypair>, // long-term Ed25519 signing keypair
group: Option<MlsGroup>, // active MLS group (None before create/join)
config: MlsGroupConfig, // shared group configuration
}
```
| Field | Type | Purpose |
|------------|-------------------------|---------|
| `backend` | `StoreCrypto` | Implements `OpenMlsCryptoProvider`. Couples a `RustCrypto` engine with a `DiskKeyStore` that holds HPKE init private keys. The backend is **persistent** -- the same instance must be used from `generate_key_package()` through `join_group()`. See [Storage Backend](storage-backend.md) for details on `DiskKeyStore`. |
| `identity` | `Arc<IdentityKeypair>` | The client's long-term Ed25519 keypair. Used as the MLS `Signer` for all group operations (signing Commits, KeyPackages, credentials). Also used to build the MLS `BasicCredential`. See [Ed25519 Identity Keys](../cryptography/identity-keys.md). |
| `group` | `Option<MlsGroup>` | `None` until the client creates or joins a group. Once set, all message operations (`send_message`, `receive_message`) operate on this group. |
| `config` | `MlsGroupConfig` | Shared configuration for all groups created by this member. Built once in the constructor. |
### MlsGroupConfig
The configuration is constructed as:
```rust
MlsGroupConfig::builder()
.use_ratchet_tree_extension(true)
.build()
```
Setting `use_ratchet_tree_extension = true` embeds the ratchet tree inside
Welcome messages (in the `GroupInfo` extension). This means `new_from_welcome`
can be called with `ratchet_tree = None` -- openmls extracts the tree from the
Welcome itself. This simplifies the protocol by eliminating the need for a
separate ratchet tree distribution mechanism.
---
## State Transition Diagram
```text
GroupMember::new(identity) -----> [No Group]
| group = None
|
+-- generate_key_package() --> [Has KeyPackage, waiting for Welcome]
| Returns TLS-encoded HPKE init key stored in backend
| KeyPackage bytes
|
+-- create_group(group_id) --> [Group Creator, epoch 0]
| group = Some(MlsGroup) Sole member of the group
| |
| +-- add_member(kp_bytes) --> [epoch N+1]
| Returns (commit_bytes, welcome_bytes)
| Pending commit merged locally
| Creator ready to encrypt immediately
|
+-- join_group(welcome_bytes) --> [Group Member, epoch N]
group = Some(MlsGroup) Joined via Welcome
|
+-- send_message(plaintext) --> encrypted PrivateMessage bytes
|
+-- receive_message(bytes) --> Some(plaintext) [ApplicationMessage]
| None [Commit or Proposal]
```
### Transitions in Detail
1. **`new(identity)`** -- Creates a `GroupMember` with an ephemeral
`DiskKeyStore` and no active group. The `StoreCrypto` backend is initialized
fresh. An alternative constructor, `new_with_state`, accepts a pre-existing
`DiskKeyStore` and optional serialized `MlsGroup` for session resumption.
2. **`generate_key_package()`** -- Generates a fresh single-use MLS KeyPackage.
The HPKE init private key is stored in `self.backend`'s key store. Returns
TLS-encoded KeyPackage bytes suitable for upload to the
[Authentication Service](authentication-service.md).
3. **`create_group(group_id)`** -- Creates a new MLS group where the caller
becomes the sole member at epoch 0. The `group_id` can be any non-empty byte
string (SHA-256 of a human-readable name is recommended).
4. **`add_member(key_package_bytes)`** -- Adds a peer using their TLS-encoded
KeyPackage. Produces a Commit and a Welcome. The Commit is merged locally
(advancing the epoch), so the creator is immediately ready to encrypt. The
caller is responsible for distributing the Welcome to the new member via the
[Delivery Service](delivery-service.md).
5. **`join_group(welcome_bytes)`** -- Joins an existing group from a TLS-encoded
Welcome message. The caller must have previously called
`generate_key_package()` on **this same instance** so the HPKE init private
key is available in the backend.
6. **`send_message(plaintext)`** -- Encrypts plaintext as an MLS Application
message (PrivateMessage variant). Returns TLS-encoded bytes for delivery.
7. **`receive_message(bytes)`** -- Processes an incoming MLS message. Returns
`Some(plaintext)` for application messages, `None` for Commits (which advance
the group epoch) and Proposals (which are stored for a future Commit).
---
## Critical Invariant: Backend Identity
The same `GroupMember` instance must be used from `generate_key_package()`
through `join_group()`. This is the most important invariant in the system.
**Why:** When `generate_key_package()` runs, openmls creates an HPKE key pair
and stores the private key in the `StoreCrypto` backend's in-memory key store
(the `DiskKeyStore`). When `join_group()` later processes the Welcome, openmls
calls `new_from_welcome`, which reads the HPKE init private key from the key
store to decrypt the Welcome's encrypted group secrets. If a different backend
instance is used, the private key will not be found, and `new_from_welcome` will
fail with a key-not-found error.
```text
generate_key_package() join_group(welcome)
| |
v v
KeyPackage::builder().build() MlsGroup::new_from_welcome()
| |
v v
backend.key_store().store( backend.key_store().read(
init_key_ref, hpke_private_key) init_key_ref) -> hpke_private_key
| |
+----------- MUST BE SAME BACKEND ------+
```
For persistent clients, the `DiskKeyStore::persistent(path)` constructor is used
so that the HPKE init keys survive process restarts. The client state file
stores the path alongside the identity seed and serialized group, and
`new_with_state` reconstructs the `GroupMember` with the persisted key store.
---
## Credential Construction
The `make_credential_with_key` helper builds the MLS `CredentialWithKey` used
for KeyPackage generation and group creation:
```rust
fn make_credential_with_key(&self) -> Result<CredentialWithKey, CoreError> {
let credential = Credential::new(
self.identity.public_key_bytes().to_vec(),
CredentialType::Basic,
)?;
Ok(CredentialWithKey {
credential,
signature_key: self.identity.public_key_bytes().to_vec().into(),
})
}
```
Key points:
- **Credential type:** `CredentialType::Basic` -- the simplest MLS credential
form, containing only the raw public key bytes.
- **Credential identity:** The raw 32-byte Ed25519 public key. This is what
peers use to identify the member within the group.
- **Signature key:** The same Ed25519 public key bytes, wrapped in the openmls
`SignaturePublicKey` type.
- **Signer:** The `IdentityKeypair` struct implements the openmls `Signer`
trait directly, so it can be passed to `KeyPackage::builder().build()` and
`MlsGroup::new_with_group_id()` without the external
`openmls_basic_credential` crate.
---
## MLS Ciphersuite
All operations use a single ciphersuite:
```text
MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519
```
This provides:
| Component | Algorithm | Security Level |
|---------------|--------------------|----------------|
| HPKE KEM | DHKEM(X25519) | 128-bit classical |
| AEAD | AES-128-GCM | 128-bit |
| KDF / Hash | SHA-256 | 128-bit collision resistance |
| Signature | Ed25519 | 128-bit classical |
See [Cryptography Overview](../cryptography/overview.md) for the full algorithm
inventory across all protocol layers.
---
## KeyPackage Deserialization (openmls 0.5)
openmls 0.5 separates serializable and deserializable types. `KeyPackage`
derives `TlsSerialize` but not `TlsDeserialize`. To deserialize an incoming
KeyPackage:
```rust
let key_package: KeyPackage =
KeyPackageIn::tls_deserialize(&mut bytes.as_ref())?
.validate(backend.crypto(), ProtocolVersion::Mls10)?;
```
The `KeyPackageIn` type derives `TlsDeserialize` and provides `validate()`,
which verifies the KeyPackage's signature and returns a trusted `KeyPackage`.
Similarly, `MlsMessageIn` is used to deserialize incoming MLS messages, and its
`extract()` method returns the inner message body (`MlsMessageInBody`). The
`into_welcome()` and `into_protocol_message()` methods that existed in earlier
openmls versions are feature-gated in 0.5; `extract()` with pattern matching is
the public API:
```rust
let msg_in = MlsMessageIn::tls_deserialize(&mut bytes.as_ref())?;
match msg_in.extract() {
MlsMessageInBody::Welcome(w) => { /* join_group path */ }
MlsMessageInBody::PrivateMessage(m) => ProtocolMessage::PrivateMessage(m),
MlsMessageInBody::PublicMessage(m) => ProtocolMessage::PublicMessage(m),
_ => { /* error: unexpected message type */ }
}
```
---
## Message Processing
`receive_message` handles four variants of `ProcessedMessageContent`:
| Variant | Action | Return Value |
|----------------------------------|--------------------------------------------|--------------|
| `ApplicationMessage` | Extract plaintext bytes | `Some(plaintext)` |
| `StagedCommitMessage` | `merge_staged_commit()` -- epoch advances | `None` |
| `ProposalMessage` | `store_pending_proposal()` -- cached | `None` |
| `ExternalJoinProposalMessage` | `store_pending_proposal()` -- cached | `None` |
For Commit messages, `merge_staged_commit` advances the group's epoch and
updates the ratchet tree. Proposals are stored for inclusion in a future Commit;
this allows the group to accumulate multiple proposals before committing them as
a batch.
---
## Error Handling
All `GroupMember` methods return `Result<_, CoreError>`. The MLS-specific error
variant is:
```rust
#[error("MLS error: {0}")]
Mls(String)
```
The inner string is the debug representation of the openmls error. This is a
deliberate design choice: openmls error types are complex enums with many
variants, and wrapping the debug output provides sufficient diagnostic
information without coupling `CoreError` to openmls's internal error hierarchy.
Common error scenarios:
| Operation | Failure Mode |
|------------------------|-------------------------------------------------|
| `generate_key_package` | Backend RNG failure (extremely unlikely) |
| `create_group` | Group already exists in state |
| `add_member` | Malformed KeyPackage, no active group |
| `join_group` | Welcome does not match any stored init key |
| `send_message` | No active group |
| `receive_message` | Malformed message, decryption failure, wrong epoch |
---
## Accessors
| Method | Returns | Purpose |
|-------------------|---------------------------------------|---------|
| `group_id()` | `Option<Vec<u8>>` | MLS group ID bytes, or `None` if no group is active |
| `identity()` | `&IdentityKeypair` | Reference to the long-term Ed25519 keypair |
| `identity_seed()` | `[u8; 32]` | Private seed bytes for state persistence |
| `backend()` | `&StoreCrypto` | Reference to the crypto provider |
| `group_ref()` | `Option<&MlsGroup>` | Reference to the MLS group for serialization |
---
## Unit Tests
The `two_party_mls_round_trip` test exercises the complete lifecycle:
1. Alice and Bob each create a `GroupMember` with fresh identities.
2. Bob generates a KeyPackage (stored in his backend).
3. Alice creates a group and adds Bob using his KeyPackage.
4. Bob joins via the Welcome message.
5. Alice sends "hello bob" -- Bob decrypts and verifies.
6. Bob sends "hello alice" -- Alice decrypts and verifies.
This test runs entirely in-memory (no server) and validates that the HPKE init
key invariant is maintained when the same `GroupMember` instance is used
throughout.
---
## Related Pages
- [KeyPackage Exchange Flow](keypackage-exchange.md) -- upload and fetch of KeyPackages via the server
- [Delivery Service Internals](delivery-service.md) -- how Commits and Welcomes are relayed
- [Authentication Service Internals](authentication-service.md) -- server-side KeyPackage storage
- [Storage Backend](storage-backend.md) -- `DiskKeyStore` and `FileBackedStore` persistence
- [Cryptography Overview](../cryptography/overview.md) -- algorithm inventory
- [Ed25519 Identity Keys](../cryptography/identity-keys.md) -- the `IdentityKeypair` struct