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
BranchInfocarries 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.