Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Sub-group branching

Sometimes a subset of the members in a group want to continue in a separate, smaller group without setting up an new group from scratch. MLS supports this with sub-group branching (RFC 9420 §11.3): a new group is created with the same parameters as an existing (parent) group, and its key schedule is seeded with a resumption PSK derived from the parent. This cryptographically ties the new sub-group to the parent epoch it branched from.

The rest of this chapter assumes you already have a parent group (alice_group on the sender side, bob_group on the receiver side).

Exporting BranchInfo from the parent

Both the sender and each receiver export a BranchInfo from their own parent group. BranchInfo is an owned snapshot of the values a branch needs (protocol version, ciphersuite, group id, epoch, the parent’s resumption PSK secret, and the parent members’ credentials).

Warning

The BranchInfo carries the parent’s resumption PSK secret, which is sensitive key material.

The sender and every receiver must use a BranchInfo exported from the same parent epoch — the branch’s key schedule is derived from that epoch’s resumption PSK. A receiver’s view of the parent group may advance in the meantime (an unrelated commit arrives before the branch Welcome is processed), so its current branch_info() might no longer match the epoch the branch was taken from. Applications that need to tolerate this should keep the BranchInfo of several recent epochs (a sliding window) and join with the one for the parent epoch the branch was taken from. Because BranchInfo is an owned snapshot, this is just a matter of holding on to the values. The window size is bounded by how many resumption PSKs the group retains (number_of_resumption_psks).

    // Both sides export the information the branch needs from the parent group.
    let alice_branch_info: BranchInfo = alice_group.branch_info();
    let bob_branch_info: BranchInfo = bob_group.branch_info();

Sender: creating and branching the sub-group

Creating the sub-group and its branch commit is a single builder operation: MlsGroup::builder()...branch(branch_info).build_branch(...). The sub-group is created with the parent’s ciphersuite automatically (from BranchInfo); set any other group configuration on the MlsGroupBuilder before calling branch. The branch commit adds the given members, mixes the parent’s resumption PSK secret into the sub-group’s key schedule, and generates the Welcome message to add the other members to the group.

build_branch returns the new sub-group and the commit bundle, but does not merge the commit: as with any commit, merge it only once you’re certain it goes through, e.g. the delivery service has confirmed it.

    // Creating the sub-group and its branch commit is a single builder
    // operation. The sub-group uses the parent's ciphersuite automatically; set
    // any other group configuration on the builder before calling `branch`.
    let (mut alice_sub_group, commit_message_bundle) = MlsGroup::builder()
        .use_ratchet_tree_extension(true)
        .number_of_resumption_psks(5)
        .branch(alice_branch_info)
        .build_branch(
            alice_provider,
            &alice_signature_keys,
            alice_credential,
            vec![bob_sub_key_package.key_package().clone()],
        )
        .expect("Could not create the sub-group branch.");

    // The commit is staged but not merged yet.
    alice_sub_group
        .merge_pending_commit(alice_provider)
        .expect("Could not merge commit.");

    let welcome = commit_message_bundle
        .welcome()
        .expect("An unexpected error occurred.")
        .clone();

Receiver: joining the sub-group

A receiver’s view of the parent group may have advanced between when the branch was taken and when its Welcome arrives, so the receiver first needs to find out which parent epoch the branch derives from before it can pick the matching BranchInfo. The branch PSK that carries the parent group and epoch lives inside the Welcome’s encrypted GroupSecrets, so reading it requires decrypting the Welcome.

StagedWelcome::process_branch_welcome does exactly one decryption and returns a PendingBranchWelcome. Call parent() on it to read the parent (group_id, epoch) the branch was taken from (see RFC 9420 §8.4), select the BranchInfo for that parent epoch (e.g. from the sliding window above), then finish the join with build_from_branch, which reuses the already-decrypted state.

In addition to the regular join processing, this injects the parent’s resumption PSK secret and enforces the RFC 9420 §11.3 receiver checks. build_from_branch verifies that the branch PSK in the Welcome references the same parent group and epoch as the BranchInfo you selected (before that secret is mixed into the key schedule); a BranchInfo from the wrong parent epoch fails with WelcomeError::SubgroupParentMismatch. The remaining checks run when build is called: the protocol version and ciphersuite must match the parent, the sub-group must be at epoch 1, and every sub-group member must also be a member of the parent group. The membership check is on by default and can be disabled with .check_members(false).

    // Bob decrypts the branch welcome once with `process_branch_welcome`, then
    // reads which parent group and epoch it derives from. This lets him pick the
    // matching `BranchInfo` from his window even if his view of the parent group
    // has advanced (see the sliding-window note above), without decrypting the
    // welcome twice.
    let pending = StagedWelcome::process_branch_welcome(
        bob_provider,
        mls_group_create_config.join_config(),
        welcome,
    )
    .expect("Error processing the branch welcome.");

    // The parent group and epoch the branch was taken from.
    let (_parent_group_id, parent_epoch) = pending
        .parent()
        .expect("This is a sub-group branch welcome.");

    // Select the `BranchInfo` exported from that parent epoch.
    let bob_branch_info = bob_branch_window
        .into_iter()
        .find_map(|(epoch, info)| (epoch == parent_epoch).then_some(info))
        .expect("No BranchInfo for the branch's parent epoch.");

    // Finish the join from the same carrier, reusing the already-decrypted state.
    // `build_from_branch` checks that the branch PSK references the same parent
    // group/epoch as the `BranchInfo`; the remaining receiver checks (matching
    // version, ciphersuite, sub-group epoch, and membership) run when `build` is
    // called.
    let bob_sub_group = pending
        .build_from_branch(bob_provider, bob_branch_info)
        .expect("Error joining the branch.")
        .with_ratchet_tree(sub_group_ratchet_tree.into())
        // .check_members(false) // optional; the membership check is on by default
        .build()
        .expect("Error joining the sub-group.")
        .into_group(bob_provider)
        .expect("Error creating the sub-group.");

If you already know the parent epoch (so no peek is needed), the one-shot StagedWelcome::build_from_branch(provider, join_config, welcome, branch_info) takes the Welcome and BranchInfo directly and also decrypts only once. It fails with WelcomeError::SubgroupParentMismatch if the BranchInfo is from the wrong parent epoch.