Files
quicproquo/docs/src/internals/group-member-lifecycle.md
Chris Nennemann 853ca4fec0 chore: rename project quicnprotochat -> quicproquo (binaries: qpq)
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>
2026-03-01 20:11:51 +01:00

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

  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.

  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.

  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.

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 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:

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:

  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.