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:
316
docs/src/internals/group-member-lifecycle.md
Normal file
316
docs/src/internals/group-member-lifecycle.md
Normal 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
|
||||
Reference in New Issue
Block a user