# 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, // long-term Ed25519 signing keypair group: Option, // 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` | 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` | `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 { 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>` | 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