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