Skip to content

Commit 79ecad1

Browse files
committed
mls_group: reserve joiner leaf before applying Adds in external commits
Before this change, external commits silently miscomputed leaf placement when they carried by-value `Add` proposals alongside the implicit `ExternalInit`. The joiner's `own_leaf_index` was determined by `leftmost_free_index` at builder time (over the queued proposals only), but during `apply_proposals` the new addees called `add_leaf`, which also picks the leftmost-free slot, racing the joiner for the same index. The placeholder for the joiner's leaf was then planted in `apply_own_update_path` (send) / `apply_received_update_path` (receive), but by then the slot had been taken by a co-resident Add, leading to either an `InconsistentSenderIndex` rejection on the receive side or an encryption-failure when the path was computed against a leaf the addees had displaced. This commit moves the joiner-leaf placeholder reservation up into `apply_proposals`, between Remove processing and Add processing, when an `ExternalInit` proposal is present in the queue. After Removes have been applied to the diff, `diff.free_leaf_index()` matches the `PublicGroup::leftmost_free_index` computation used by both sides to derive the joiner's slot, so the placeholder lands in the right place without needing to thread additional indices through the function. The duplicate placeholder add in `apply_own_update_path` becomes a no-op (the leaf is already there); a defensive fallback is kept in case the function is invoked from a path that did not run `apply_proposals` (e.g. the treekem KAT tests). Adds two new integration tests in `openmls/tests/external_commit.rs`: * `test_external_commit_with_inline_add` — Alice creates a group, Bob1 joins via external commit including a by-value Add for Bob2. Asserts that the CommitMessageBundle includes a Welcome, that Alice can process the commit, that Bob2 can join via the Welcome, and that all three converge on the same epoch and tree. * `test_external_commit_with_inline_app_data_update` (gated on `extensions-draft-08`) — Bob joins via external commit and ships an `AppDataUpdate` proposal that mutates the AppDataDictionary atomically with the join. Asserts that the dictionary is updated on both sides post-commit. Existing single-leaf external commits, by-reference rejection (`test_valsem244`), and AppDataUpdate inside member commits are unaffected.
1 parent 5942de9 commit 79ecad1

3 files changed

Lines changed: 334 additions & 7 deletions

File tree

openmls/src/group/public_group/diff/apply_proposals.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,27 @@ impl PublicGroupDiff<'_> {
129129
}
130130
}
131131

132+
// For external commits, reserve the joiner's own leaf with a placeholder
133+
// before processing Add proposals. Otherwise the by-value Adds would
134+
// race for the leftmost-free slot and collide with the joiner's slot
135+
// (which was chosen by `leftmost_free_index` at external-commit-builder
136+
// time, before the joiner was aware of any subsequent by-value Adds).
137+
//
138+
// After Removes and SelfRemoves are processed above, `free_leaf_index`
139+
// on the diff matches the `leftmost_free_index` computation used both
140+
// by the joiner (when populating `own_leaf_index` for the new group)
141+
// and by receivers (when validating the commit, see
142+
// `PublicGroup::leftmost_free_index`). The placeholder we plant here
143+
// is later overwritten by the joiner's freshly-signed LeafNode in
144+
// `apply_own_update_path` on the send side, or by
145+
// `apply_received_update_path` on the receive side.
146+
if external_init_proposal_option.is_some() {
147+
let placeholder = LeafNode::new_placeholder();
148+
self.diff
149+
.add_leaf(placeholder)
150+
.map_err(|_| LibraryError::custom("Tree full: cannot reserve joiner leaf"))?;
151+
}
152+
132153
// Process adds
133154
let add_proposals = proposal_queue
134155
.filtered_by_type(ProposalType::Add)

openmls/src/treesync/diff.rs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -323,14 +323,20 @@ impl TreeSyncDiff<'_> {
323323
leaf_index: LeafNodeIndex,
324324
leaf_node_params: UpdateLeafNodeParams,
325325
) -> Result<UpdatePathResult, TreeSyncAddLeaf> {
326-
// For External Commits, we temporarily add a placeholder leaf node to the tree, because it
327-
// might be required to make the tree grow to the right size. If we
328-
// don't do that, calculating the direct path might fail. It's important
329-
// to not do anything with the value of that leaf until it has been
330-
// replaced.
326+
// For External Commits, the joiner's leaf is reserved with a placeholder
327+
// earlier, during `apply_proposals`, so that any by-value Add proposals
328+
// committed alongside the ExternalInit do not race for the leftmost-free
329+
// slot. We only need to grow the tree here if the placeholder somehow
330+
// didn't get inserted (e.g. a regular commit path is being computed).
331+
// It's important to not do anything with the value of that leaf until
332+
// it has been replaced below.
331333
if let CommitType::External(_) = commit_type {
332-
let leaf_node = LeafNode::new_placeholder();
333-
self.add_leaf(leaf_node)?;
334+
if self.diff.leaf(leaf_index).node().is_none()
335+
|| leaf_index.u32() >= self.leaf_count()
336+
{
337+
let leaf_node = LeafNode::new_placeholder();
338+
self.add_leaf(leaf_node)?;
339+
}
334340
}
335341

336342
// We calculate the parent hash so that we can use it for a fresh leaf

openmls/tests/external_commit.rs

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,303 @@ fn test_not_present_group_info() {
228228

229229
assert!(group_info.is_none());
230230
}
231+
232+
/// External commit carrying a by-value `Add` proposal alongside the implicit
233+
/// `ExternalInit` and the joiner's path leaf. This is the building block for
234+
/// atomic multi-leaf joins (e.g. a single device joining via external commit
235+
/// while introducing a co-resident sibling device).
236+
///
237+
/// Verifies:
238+
/// - The resulting [`CommitMessageBundle`] contains a Welcome (because an
239+
/// invitation list was produced).
240+
/// - The existing member (Alice) accepts and processes the external commit
241+
/// including the by-value Add.
242+
/// - The invitee from the by-value Add (Bob2) can join via the Welcome.
243+
/// - All three parties converge to the same epoch and tree.
244+
#[openmls_test]
245+
fn test_external_commit_with_inline_add() {
246+
let alice_provider = &Provider::default();
247+
let bob1_provider = &Provider::default();
248+
let bob2_provider = &Provider::default();
249+
250+
// Alice creates a group with ratchet tree extension so we can export a
251+
// self-contained GroupInfo for the external commit.
252+
let (mut alice_group, _alice_credential, alice_signer) =
253+
create_alice_group(ciphersuite, alice_provider, true);
254+
255+
// Self-update so that the exported GroupInfo carries an external_pub
256+
// extension.
257+
let group_info = alice_group
258+
.self_update(alice_provider, &alice_signer, LeafNodeParameters::default())
259+
.unwrap()
260+
.into_group_info()
261+
.expect("self-update should produce a GroupInfo");
262+
alice_group.merge_pending_commit(alice_provider).unwrap();
263+
264+
let verifiable_group_info = {
265+
let serialized = group_info.tls_serialize_detached().unwrap();
266+
VerifiableGroupInfo::tls_deserialize(&mut serialized.as_slice()).unwrap()
267+
};
268+
269+
// Bob1 (primary joiner) and Bob2 (co-resident sibling) generate
270+
// credentials. Bob2 also generates a KeyPackage that Bob1 will include as
271+
// an inline Add proposal in the external commit.
272+
let (bob1_credential, bob1_signer) =
273+
new_credential(bob1_provider, b"Bob1", ciphersuite.signature_algorithm());
274+
let (bob2_credential, bob2_signer) =
275+
new_credential(bob2_provider, b"Bob2", ciphersuite.signature_algorithm());
276+
277+
let bob2_key_package = KeyPackage::builder()
278+
.build(ciphersuite, bob2_provider, &bob2_signer, bob2_credential)
279+
.unwrap()
280+
.key_package()
281+
.clone();
282+
283+
// Bob1 builds an external commit that adds Bob2 by value.
284+
let join_config = MlsGroupJoinConfig::builder()
285+
.use_ratchet_tree_extension(true)
286+
.build();
287+
let (bob1_group, bundle) = MlsGroup::external_commit_builder()
288+
.with_config(join_config.clone())
289+
.build_group(bob1_provider, verifiable_group_info, bob1_credential)
290+
.unwrap()
291+
.propose_adds([bob2_key_package])
292+
.load_psks(bob1_provider.storage())
293+
.unwrap()
294+
.build(
295+
bob1_provider.rand(),
296+
bob1_provider.crypto(),
297+
&bob1_signer,
298+
|_| true,
299+
)
300+
.unwrap()
301+
.finalize(bob1_provider)
302+
.unwrap();
303+
304+
// The bundle MUST carry a Welcome for Bob2.
305+
assert!(
306+
bundle.welcome().is_some(),
307+
"external commit with by-value Add must produce a Welcome"
308+
);
309+
let welcome = bundle.welcome().unwrap().clone();
310+
let commit_msg: MlsMessageIn = bundle.commit().clone().into();
311+
312+
// Alice processes Bob1's external commit.
313+
let processed = alice_group
314+
.process_message(
315+
alice_provider,
316+
commit_msg.try_into_protocol_message().unwrap(),
317+
)
318+
.expect("Alice should accept external commit with inline Add");
319+
match processed.into_content() {
320+
ProcessedMessageContent::StagedCommitMessage(staged) => {
321+
alice_group
322+
.merge_staged_commit(alice_provider, *staged)
323+
.unwrap();
324+
}
325+
_ => panic!("Expected StagedCommitMessage"),
326+
}
327+
328+
// Bob2 joins via the Welcome.
329+
let bob2_group = StagedWelcome::new_from_welcome(
330+
bob2_provider,
331+
&join_config,
332+
welcome,
333+
Some(alice_group.export_ratchet_tree().into()),
334+
)
335+
.expect("Bob2 should accept the Welcome")
336+
.into_group(bob2_provider)
337+
.expect("Bob2 should be able to build the group");
338+
339+
// All three converge to the same epoch and group context.
340+
assert_eq!(alice_group.epoch(), bob1_group.epoch());
341+
assert_eq!(alice_group.epoch(), bob2_group.epoch());
342+
assert_eq!(
343+
alice_group.export_group_context(),
344+
bob1_group.export_group_context(),
345+
);
346+
assert_eq!(
347+
alice_group.export_group_context(),
348+
bob2_group.export_group_context(),
349+
);
350+
351+
// The group has three members: Alice, Bob1, Bob2.
352+
assert_eq!(alice_group.members().count(), 3);
353+
assert_eq!(bob1_group.members().count(), 3);
354+
assert_eq!(bob2_group.members().count(), 3);
355+
356+
// Sanity: silence unused warnings in case `bob2_signer` is not otherwise
357+
// referenced (it is captured by the closure during KeyPackage build above).
358+
let _ = bob2_signer;
359+
}
360+
361+
/// External commit carrying a by-value `AppDataUpdate` proposal. This exercises
362+
/// the `extensions-draft-08` code path: a non-member joining via external
363+
/// commit applies an update to the AppDataDictionary atomically with the
364+
/// join.
365+
#[cfg(feature = "extensions-draft-08")]
366+
#[openmls_test]
367+
fn test_external_commit_with_inline_app_data_update() {
368+
use openmls::component::*;
369+
use openmls::extensions::*;
370+
use openmls::messages::proposals::AppDataUpdateProposal;
371+
372+
const COMPONENT_ID: ComponentId = 16;
373+
374+
let alice_provider = &Provider::default();
375+
let bob_provider = &Provider::default();
376+
377+
// Alice configures the group to require the AppDataUpdate proposal type
378+
// and the AppDataDictionary extension. Her own leaf must advertise the
379+
// matching capabilities.
380+
let capabilities = Capabilities::new(
381+
None,
382+
None,
383+
Some(&[ExtensionType::AppDataDictionary]),
384+
Some(&[ProposalType::AppDataUpdate]),
385+
None,
386+
);
387+
let required_capabilities =
388+
Extension::RequiredCapabilities(RequiredCapabilitiesExtension::new(
389+
&[ExtensionType::AppDataDictionary],
390+
&[ProposalType::AppDataUpdate],
391+
&[],
392+
));
393+
394+
let group_config = MlsGroupCreateConfig::builder()
395+
.ciphersuite(ciphersuite)
396+
.capabilities(capabilities.clone())
397+
.use_ratchet_tree_extension(true)
398+
.with_group_context_extensions(Extensions::single(required_capabilities).unwrap())
399+
.build();
400+
401+
let (alice_credential, alice_signer) =
402+
new_credential(alice_provider, b"Alice", ciphersuite.signature_algorithm());
403+
404+
let mut alice_group = MlsGroup::new(
405+
alice_provider,
406+
&alice_signer,
407+
&group_config,
408+
alice_credential.clone(),
409+
)
410+
.unwrap();
411+
412+
// Self-update Alice so the exported GroupInfo includes an external_pub.
413+
let group_info = alice_group
414+
.self_update(alice_provider, &alice_signer, LeafNodeParameters::default())
415+
.unwrap()
416+
.into_group_info()
417+
.expect("self-update should produce a GroupInfo");
418+
alice_group.merge_pending_commit(alice_provider).unwrap();
419+
420+
let verifiable_group_info = {
421+
let serialized = group_info.tls_serialize_detached().unwrap();
422+
VerifiableGroupInfo::tls_deserialize(&mut serialized.as_slice()).unwrap()
423+
};
424+
425+
// Bob joins via external commit and includes an AppDataUpdate proposal.
426+
// His new leaf node must advertise the AppDataUpdate capability.
427+
let (bob_credential, bob_signer) =
428+
new_credential(bob_provider, b"Bob", ciphersuite.signature_algorithm());
429+
430+
let bob_leaf_node_parameters = LeafNodeParameters::builder()
431+
.with_credential_with_key(bob_credential.clone())
432+
.with_capabilities(capabilities)
433+
.build();
434+
435+
let app_data_update = Proposal::AppDataUpdate(Box::new(AppDataUpdateProposal::update(
436+
COMPONENT_ID,
437+
b"hello-from-external-commit",
438+
)));
439+
440+
let mut bob_commit_stage = MlsGroup::external_commit_builder()
441+
.with_config(group_config.join_config().clone())
442+
.build_group(bob_provider, verifiable_group_info, bob_credential)
443+
.unwrap()
444+
.leaf_node_parameters(bob_leaf_node_parameters)
445+
.add_proposal(app_data_update)
446+
.load_psks(bob_provider.storage())
447+
.unwrap();
448+
449+
// Stage the AppDataUpdate so the dictionary diff is included in the
450+
// commit's GroupContext.
451+
let mut app_data_updater = bob_commit_stage.app_data_dictionary_updater();
452+
for proposal in bob_commit_stage.app_data_update_proposals() {
453+
let component_id = proposal.component_id();
454+
if let AppDataUpdateOperation::Update(data) = proposal.operation() {
455+
app_data_updater
456+
.set(ComponentData::from_parts(component_id, data.clone()));
457+
}
458+
}
459+
let changes = app_data_updater.changes();
460+
assert!(
461+
changes.as_ref().is_some_and(|c| !c.is_empty()),
462+
"expected AppDataUpdate to produce dictionary changes"
463+
);
464+
bob_commit_stage.with_app_data_dictionary_updates(changes);
465+
466+
let (bob_group, bundle) = bob_commit_stage
467+
.build(
468+
bob_provider.rand(),
469+
bob_provider.crypto(),
470+
&bob_signer,
471+
|_| true,
472+
)
473+
.unwrap()
474+
.finalize(bob_provider)
475+
.unwrap();
476+
477+
// Alice processes Bob's external commit. She must compute the same
478+
// dictionary updates to validate the commit successfully.
479+
let commit_msg: MlsMessageIn = bundle.commit().clone().into();
480+
let unverified = alice_group
481+
.unprotect_message(alice_provider, commit_msg.into_protocol_message().unwrap())
482+
.unwrap();
483+
let mut alice_updater = alice_group.app_data_dictionary_updater();
484+
for proposal in unverified.committed_proposals().unwrap().iter() {
485+
let validated = proposal
486+
.clone()
487+
.validate(alice_provider.crypto(), ciphersuite, ProtocolVersion::Mls10)
488+
.unwrap();
489+
let proposal = match validated {
490+
ProposalOrRef::Proposal(p) => *p,
491+
ProposalOrRef::Reference(_) => continue,
492+
};
493+
if let Proposal::AppDataUpdate(p) = proposal {
494+
let component_id = p.component_id();
495+
if let AppDataUpdateOperation::Update(data) = p.operation() {
496+
alice_updater.set(ComponentData::from_parts(component_id, data.clone()));
497+
}
498+
}
499+
}
500+
let processed = alice_group
501+
.process_unverified_message_with_app_data_updates(
502+
alice_provider,
503+
unverified,
504+
alice_updater.changes(),
505+
)
506+
.expect("Alice should accept external commit with AppDataUpdate");
507+
match processed.into_content() {
508+
ProcessedMessageContent::StagedCommitMessage(staged) => {
509+
alice_group
510+
.merge_staged_commit(alice_provider, *staged)
511+
.unwrap();
512+
}
513+
_ => panic!("Expected StagedCommitMessage"),
514+
}
515+
516+
// Both parties should now agree on the AppDataDictionary contents.
517+
assert_eq!(alice_group.epoch(), bob_group.epoch());
518+
assert_eq!(
519+
alice_group.extensions().app_data_dictionary(),
520+
bob_group.extensions().app_data_dictionary(),
521+
);
522+
let alice_dict = alice_group
523+
.extensions()
524+
.app_data_dictionary()
525+
.expect("AppDataDictionary must be present");
526+
assert_eq!(
527+
alice_dict.dictionary().get(&COMPONENT_ID),
528+
Some(b"hello-from-external-commit".as_ref()),
529+
);
530+
}

0 commit comments

Comments
 (0)