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>
14 KiB
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
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 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. |
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:
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
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
-
new(identity)-- Creates aGroupMemberwith an ephemeralDiskKeyStoreand no active group. TheStoreCryptobackend is initialized fresh. An alternative constructor,new_with_state, accepts a pre-existingDiskKeyStoreand optional serializedMlsGroupfor session resumption. -
generate_key_package()-- Generates a fresh single-use MLS KeyPackage. The HPKE init private key is stored inself.backend's key store. Returns TLS-encoded KeyPackage bytes suitable for upload to the Authentication Service. -
create_group(group_id)-- Creates a new MLS group where the caller becomes the sole member at epoch 0. Thegroup_idcan be any non-empty byte string (SHA-256 of a human-readable name is recommended). -
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. -
join_group(welcome_bytes)-- Joins an existing group from a TLS-encoded Welcome message. The caller must have previously calledgenerate_key_package()on this same instance so the HPKE init private key is available in the backend. -
send_message(plaintext)-- Encrypts plaintext as an MLS Application message (PrivateMessage variant). Returns TLS-encoded bytes for delivery. -
receive_message(bytes)-- Processes an incoming MLS message. ReturnsSome(plaintext)for application messages,Nonefor 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.
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:
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
SignaturePublicKeytype. - Signer: The
IdentityKeypairstruct implements the openmlsSignertrait directly, so it can be passed toKeyPackage::builder().build()andMlsGroup::new_with_group_id()without the externalopenmls_basic_credentialcrate.
MLS Ciphersuite
All operations use a single ciphersuite:
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 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:
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:
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:
#[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:
- Alice and Bob each create a
GroupMemberwith fresh identities. - Bob generates a KeyPackage (stored in his backend).
- Alice creates a group and adds Bob using his KeyPackage.
- Bob joins via the Welcome message.
- Alice sends "hello bob" -- Bob decrypts and verifies.
- 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 -- upload and fetch of KeyPackages via the server
- Delivery Service Internals -- how Commits and Welcomes are relayed
- Authentication Service Internals -- server-side KeyPackage storage
- Storage Backend --
DiskKeyStoreandFileBackedStorepersistence - Cryptography Overview -- algorithm inventory
- Ed25519 Identity Keys -- the
IdentityKeypairstruct