Rename the entire workspace:
- Crate packages: quicnprotochat-{core,proto,server,client,gui,p2p,mobile} -> quicproquo-*
- Binary names: quicnprotochat -> qpq, quicnprotochat-server -> qpq-server,
quicnprotochat-gui -> qpq-gui
- Default files: *-state.bin -> qpq-state.bin, *-server.toml -> qpq-server.toml,
*.db -> qpq.db
- Environment variable prefix: QUICNPROTOCHAT_* -> QPQ_*
- App identifier: chat.quicnproto.gui -> chat.quicproquo.gui
- Proto package: quicnprotochat.bench -> quicproquo.bench
- All documentation, Docker, CI, and script references updated
HKDF domain-separation strings and P2P ALPN remain unchanged for
backward compatibility with existing encrypted state and wire protocol.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
317 lines
14 KiB
Markdown
317 lines
14 KiB
Markdown
# GroupMember Lifecycle
|
|
|
|
The `GroupMember` struct in `quicproquo-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/quicproquo-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
|