Skip to main content

openmls/group/mls_group/
processing.rs

1//! Processing functions of an [`MlsGroup`] for incoming messages.
2
3use std::mem;
4
5#[cfg(any(not(feature = "virtual-clients-draft"), feature = "test-utils", test))]
6use errors::CommitToPendingProposalsError;
7use errors::MergePendingCommitError;
8#[cfg(feature = "extensions-draft")]
9use errors::ResolveAppDataCommitError;
10#[cfg(any(not(feature = "virtual-clients-draft"), feature = "test-utils", test))]
11use openmls_traits::signatures::Signer;
12use openmls_traits::{crypto::OpenMlsCrypto, storage::StorageProvider as _};
13
14#[cfg(any(not(feature = "virtual-clients-draft"), feature = "test-utils", test))]
15use crate::messages::group_info::GroupInfo;
16use crate::{
17    framing::mls_content::FramedContentBody,
18    group::{errors::MergeCommitError, StageCommitError, ValidationError},
19    storage::OpenMlsProvider,
20    tree::sender_ratchet::SenderRatchetConfiguration,
21};
22
23// `virtual-clients-draft` implies `extensions-draft`, so this gate covers
24// both the sibling-commit detection and the AppDataUpdate handling below.
25#[cfg(feature = "extensions-draft")]
26use crate::messages::Commit;
27
28#[cfg(feature = "extensions-draft")]
29use crate::{
30    component::{ComponentData, ComponentId},
31    extensions::AppDataDictionary,
32    messages::proposals::AppDataUpdateProposal,
33};
34
35#[cfg(feature = "extensions-draft")]
36use std::collections::BTreeMap;
37
38use super::{errors::ProcessMessageError, *};
39
40/// Result of unprotecting an inbound message.
41pub(crate) enum UnprotectedMessage {
42    /// A message from another sender that has been unprotected and is ready
43    /// for signature verification and content parsing.
44    Unverified(Box<UnverifiedMessage>),
45    /// A PrivateMessage whose sender data claims this client's own leaf. The
46    /// content cannot be decrypted; callers should surface
47    /// [`ProcessedMessageContent::OwnPrivateMessage`] and skip further
48    /// processing.
49    OwnPrivateMessage {
50        epoch: GroupEpoch,
51        authenticated_data: Vec<u8>,
52    },
53}
54
55#[cfg(feature = "extensions-draft")]
56/// Keeps the old dictionary as well as the values that are being overwritten
57pub struct AppDataDictionaryUpdater<'a> {
58    old_dict: Option<&'a AppDataDictionary>,
59    new_entries: Option<AppDataUpdates>,
60}
61
62/// A diff of update values that can be provided to [`MlsGroup::stage_app_data_commit`] or [`CommitBuilder::with_app_data_dictionary_updates`]
63///
64/// [`CommitBuilder::with_app_data_dictionary_updates`]: crate::group::CommitBuilder::with_app_data_dictionary_updates
65#[cfg(feature = "extensions-draft")]
66#[derive(Default, Debug)]
67pub struct AppDataUpdates(BTreeMap<ComponentId, Option<Vec<u8>>>);
68
69#[cfg(feature = "extensions-draft")]
70impl IntoIterator for AppDataUpdates {
71    type Item = (ComponentId, Option<Vec<u8>>);
72
73    type IntoIter = <BTreeMap<ComponentId, Option<Vec<u8>>> as IntoIterator>::IntoIter;
74
75    fn into_iter(self) -> Self::IntoIter {
76        self.0.into_iter()
77    }
78}
79
80#[cfg(feature = "extensions-draft")]
81impl AppDataUpdates {
82    /// Returns the number of changes.
83    pub fn len(&self) -> usize {
84        self.0.len()
85    }
86
87    /// Returns whether there are changes.
88    pub fn is_empty(&self) -> bool {
89        self.0.is_empty()
90    }
91}
92
93#[cfg(feature = "extensions-draft")]
94impl<'a> AppDataDictionaryUpdater<'a> {
95    /// Creates a new [`AppDataDictionaryUpdater`].
96    pub fn new(old_dict: Option<&'a AppDataDictionary>) -> Self {
97        Self {
98            old_dict,
99            new_entries: None,
100        }
101    }
102
103    /// Looks up the old value for a component.
104    pub fn old_value(&self, component_id: ComponentId) -> Option<&[u8]> {
105        self.old_dict?.get(&component_id)
106    }
107
108    /// Helper method that returns a mutable reference to the
109    /// [`AppDataUpdates`], creating the struct if it does not exist.
110    fn new_entries_mut(&mut self) -> &mut AppDataUpdates {
111        self.new_entries
112            .get_or_insert_with(|| AppDataUpdates(BTreeMap::new()))
113    }
114
115    /// Sets a value in the new_entries. if we already have data for that component id, overwrite
116    /// it. Else add it in the right position.
117    pub fn set(&mut self, component_data: ComponentData) {
118        let (id, data) = component_data.into_parts();
119
120        self.new_entries_mut().0.insert(id, Some(data.into()));
121    }
122
123    /// Flags an entry in the dictionary for removal
124    pub fn remove(&mut self, id: &ComponentId) {
125        self.new_entries_mut().0.insert(*id, None);
126    }
127
128    /// Consumes the updater and returns just the changes, so we can pass them into
129    /// [`MlsGroup::stage_app_data_commit`] or
130    /// [`CommitBuilder::with_app_data_dictionary_updates`].
131    /// Only returns Some if we actually called set.
132    ///
133    /// [`CommitBuilder::with_app_data_dictionary_updates`]: crate::group::CommitBuilder::with_app_data_dictionary_updates
134    pub fn changes(self) -> Option<AppDataUpdates> {
135        self.new_entries
136    }
137}
138
139/// A verified Commit covering AppDataUpdate proposals that cannot be staged
140/// yet.
141///
142/// The AppDataUpdate proposals carry diffs in an application-defined format,
143/// so the application has to interpret them and compute the resulting
144/// [`AppDataUpdates`] before the commit can be staged: the updated
145/// [`AppDataDictionary`] becomes part of the new epoch's GroupContext and
146/// feeds into the key schedule.
147///
148/// Returned by [`MlsGroup::process_message()`] and
149/// [`PublicGroup::process_message()`] as
150/// [`ProcessedMessageContent::UnresolvedAppDataCommit`]. Inspect the proposals
151/// via [`Self::app_data_update_proposals()`], compute the updates with the
152/// help of [`MlsGroup::app_data_dictionary_updater()`] (or
153/// [`PublicGroup::app_data_dictionary_updater()`]) and resume staging via
154/// [`MlsGroup::stage_app_data_commit()`] (or
155/// [`PublicGroup::stage_app_data_commit()`]).
156///
157/// The message signature has already been verified at this point. Dropping
158/// this value discards the commit.
159///
160/// [`PublicGroup::process_message()`]: crate::group::public_group::PublicGroup::process_message
161/// [`PublicGroup::app_data_dictionary_updater()`]: crate::group::public_group::PublicGroup::app_data_dictionary_updater
162/// [`PublicGroup::stage_app_data_commit()`]: crate::group::public_group::PublicGroup::stage_app_data_commit
163#[cfg(feature = "extensions-draft")]
164pub struct UnresolvedAppDataCommit {
165    content: AuthenticatedContent,
166    /// The AppDataUpdate proposals covered by the commit, with proposals sent
167    /// by reference already resolved from the proposal store, sorted by
168    /// component id.
169    proposals: Vec<AppDataUpdateProposal>,
170    #[cfg(feature = "virtual-clients-draft")]
171    vc_commit_material: Option<crate::components::vc_derivation_info::VcCommitMaterial>,
172}
173
174#[cfg(feature = "extensions-draft")]
175impl UnresolvedAppDataCommit {
176    /// Constructs an [`UnresolvedAppDataCommit`] from verified content and the
177    /// covered AppDataUpdate proposals. Used by public-group processing, which
178    /// carries no virtual-clients material.
179    pub(crate) fn new(
180        content: AuthenticatedContent,
181        proposals: Vec<AppDataUpdateProposal>,
182    ) -> Self {
183        Self {
184            content,
185            proposals,
186            #[cfg(feature = "virtual-clients-draft")]
187            vc_commit_material: None,
188        }
189    }
190
191    /// Consumes the commit and returns the verified [`AuthenticatedContent`],
192    /// so that [`PublicGroup::stage_app_data_commit`] can resume staging.
193    ///
194    /// [`PublicGroup::stage_app_data_commit`]: crate::group::public_group::PublicGroup::stage_app_data_commit
195    pub(crate) fn into_content(self) -> AuthenticatedContent {
196        self.content
197    }
198
199    /// Returns the AppDataUpdate proposals covered by the commit, sorted by
200    /// component id. Proposals that were committed by reference have already
201    /// been resolved from the proposal store.
202    pub fn app_data_update_proposals(&self) -> impl Iterator<Item = &AppDataUpdateProposal> {
203        self.proposals.iter()
204    }
205}
206
207#[cfg(feature = "extensions-draft")]
208impl core::fmt::Debug for UnresolvedAppDataCommit {
209    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
210        let mut debug_struct = f.debug_struct("UnresolvedAppDataCommit");
211        debug_struct
212            .field("content", &self.content)
213            .field("proposals", &self.proposals);
214        // vc_commit_material holds secret key material, so only the epoch id
215        // is printed.
216        #[cfg(feature = "virtual-clients-draft")]
217        debug_struct.field(
218            "vc_emulation_epoch_id",
219            &self
220                .vc_commit_material
221                .as_ref()
222                .map(|material| &material.epoch_id),
223        );
224        debug_struct.finish_non_exhaustive()
225    }
226}
227
228impl MlsGroup {
229    /// Parses incoming messages from the DS. Checks for syntactic errors and
230    /// makes some semantic checks as well. If the input is an encrypted
231    /// message, it will be decrypted. This processing function does syntactic
232    /// and semantic validation of the message. It returns a [ProcessedMessage]
233    /// enum.
234    ///
235    #[cfg_attr(
236        feature = "extensions-draft",
237        doc = "A commit covering AppDataUpdate proposals is returned as\n\
238        [`ProcessedMessageContent::UnresolvedAppDataCommit`], since the\n\
239        application has to interpret the proposals before the commit can be\n\
240        staged via [`MlsGroup::stage_app_data_commit()`].\n"
241    )]
242    /// # Errors:
243    /// Returns an [`ProcessMessageError`] when the validation checks fail
244    /// with the exact reason of the failure.
245    pub fn process_message<Provider: OpenMlsProvider>(
246        &mut self,
247        provider: &Provider,
248        message: impl Into<ProtocolMessage>,
249    ) -> Result<ProcessedMessage, ProcessMessageError<Provider::StorageError>> {
250        match self.unprotect_message(provider, message)? {
251            UnprotectedMessage::Unverified(m) => self.process_unverified_message(provider, *m),
252            // The content cannot be decrypted and the sender claim is unauthenticated,
253            // so we surface OwnPrivateMessage and skip all further processing.
254            UnprotectedMessage::OwnPrivateMessage {
255                epoch,
256                authenticated_data,
257            } => {
258                let credential = self.credential()?.clone();
259                #[cfg_attr(not(feature = "extensions-draft"), allow(unused_mut))]
260                let mut processed = ProcessedMessage::new(
261                    self.group_id().clone(),
262                    epoch,
263                    Sender::Member(self.own_leaf_index()),
264                    authenticated_data,
265                    ProcessedMessageContent::OwnPrivateMessage,
266                    credential,
267                    #[cfg(feature = "virtual-clients-draft")]
268                    None,
269                );
270                #[cfg(feature = "extensions-draft")]
271                if self.context().safe_aad_required() {
272                    processed
273                        .try_attach_safe_aad()
274                        .map_err(|_| ProcessMessageError::MalformedSafeAad)?;
275                }
276                Ok(processed)
277            }
278        }
279    }
280
281    #[cfg(feature = "extensions-draft")]
282    /// Returns a new helper struct for updating the app data
283    pub fn app_data_dictionary_updater<'a>(&'a self) -> AppDataDictionaryUpdater<'a> {
284        AppDataDictionaryUpdater::new(self.context().app_data_dict())
285    }
286
287    /// Parses and deprotects incoming messages from the DS. Checks for syntactic errors, but only
288    /// performs limited semantic checks.
289    pub(crate) fn unprotect_message<Provider: OpenMlsProvider>(
290        &mut self,
291        provider: &Provider,
292        message: impl Into<ProtocolMessage>,
293    ) -> Result<UnprotectedMessage, ProcessMessageError<Provider::StorageError>> {
294        // Make sure we are still a member of the group
295        if !self.is_active() {
296            return Err(ProcessMessageError::GroupStateError(
297                MlsGroupStateError::UseAfterEviction,
298            ));
299        }
300        let message = message.into();
301
302        // Check that handshake messages are compatible with the incoming wire format policy
303        if !message.is_external()
304            && message.is_handshake_message()
305            && !self
306                .configuration()
307                .wire_format_policy()
308                .incoming()
309                .is_compatible_with(message.wire_format())
310        {
311            return Err(ProcessMessageError::IncompatibleWireFormat);
312        }
313
314        // Parse the message
315        let sender_ratchet_configuration = *self.configuration().sender_ratchet_configuration();
316
317        // Check if this message will modify the secret tree when decrypting a
318        // private message
319        let will_modify_secret_tree = matches!(message, ProtocolMessage::PrivateMessage(_));
320
321        // Resolve the emulator reuse-guard context for `PrivateMessage`
322        // before calling `decrypt_message` so storage errors surface as
323        // `ProcessMessageError::StorageError`. `PublicMessage` carries no
324        // `reuse_guard`, so the lookup is skipped for it. The binding is
325        // looked up at the epoch the message was sent in: a delayed message
326        // from a past epoch must be deprotected with the emulation state
327        // that was bound then, not the latest one.
328        #[cfg(feature = "virtual-clients-draft")]
329        let emulation_state = if let ProtocolMessage::PrivateMessage(private_message) = &message {
330            self.vc_emulation_state_at_epoch(provider.storage(), private_message.epoch())
331                .map_err(|e| match e {
332                    super::VcEmulationStateError::Storage(e) => {
333                        ProcessMessageError::StorageError(e)
334                    }
335                    super::VcEmulationStateError::MissingEmulationEpochState => {
336                        ProcessMessageError::ValidationError(
337                            crate::group::ValidationError::UnableToDecrypt(
338                                crate::framing::errors::MessageDecryptionError::VirtualClientsError(
339                                    crate::components::vc_derivation_info::VirtualClientsError::MissingEmulationEpochState,
340                                ),
341                            ),
342                        )
343                    }
344                })?
345        } else {
346            None
347        };
348        #[cfg(feature = "virtual-clients-draft")]
349        let emulator_ctx: Option<crate::framing::EmulatorReuseGuardCtx<'_>> = emulation_state
350            .as_ref()
351            .map(|state| state.reuse_guard_inputs());
352
353        // Checks the following semantic validation:
354        //  - ValSem002
355        //  - ValSem003
356        //  - ValSem006
357        //  - ValSem007 MembershipTag presence
358        let decrypt_result = self.decrypt_message(
359            provider.crypto(),
360            message,
361            &sender_ratchet_configuration,
362            #[cfg(feature = "virtual-clients-draft")]
363            emulator_ctx.as_ref(),
364        )?;
365
366        // Persist the secret tree if it was modified to ensure forward secrecy
367        if will_modify_secret_tree {
368            provider
369                .storage()
370                .write_message_secrets(self.group_id(), &self.message_secrets_store)
371                .map_err(ProcessMessageError::StorageError)?;
372        }
373
374        let decrypted_message = match decrypt_result {
375            InboundDecryptionResult::Decrypted(decrypted_message) => decrypted_message,
376            // Own private messages short-circuit here: there is no content
377            // to parse or verify.
378            InboundDecryptionResult::OwnPrivateMessage {
379                epoch,
380                authenticated_data,
381            } => {
382                return Ok(UnprotectedMessage::OwnPrivateMessage {
383                    epoch,
384                    authenticated_data,
385                });
386            }
387        };
388
389        let unverified_message = self
390            .public_group
391            .parse_message(decrypted_message, &self.message_secrets_store)
392            .map_err(ProcessMessageError::from)?;
393
394        Ok(UnprotectedMessage::Unverified(Box::new(unverified_message)))
395    }
396
397    /// Stores a standalone proposal in the internal [ProposalStore]
398    pub fn store_pending_proposal<Storage: StorageProvider>(
399        &mut self,
400        storage: &Storage,
401        proposal: QueuedProposal,
402    ) -> Result<(), Storage::Error> {
403        storage.queue_proposal(self.group_id(), &proposal.proposal_reference(), &proposal)?;
404        // Store the proposal in in the internal ProposalStore
405        self.proposal_store_mut().add(proposal);
406
407        Ok(())
408    }
409
410    /// Returns true if there are pending proposals queued in the proposal store.
411    pub fn has_pending_proposals(&self) -> bool {
412        !self.proposal_store().is_empty()
413    }
414
415    /// Creates a Commit message that covers the pending proposals that are
416    /// currently stored in the group's [ProposalStore]. The Commit message is
417    /// created even if there are no valid pending proposals.
418    ///
419    /// Returns an error if there is a pending commit. Otherwise it returns a
420    /// tuple of `Commit, Option<Welcome>, Option<GroupInfo>`, where `Commit`
421    /// and [`Welcome`] are MlsMessages of the type [`MlsMessageOut`].
422    ///
423    /// Under the `virtual-clients-draft` feature this function is unavailable.
424    /// Use [`MlsGroup::commit_builder`], whose
425    /// [`CommitMessageBundle::confirmation`] surfaces the handshake confirmation
426    /// data.
427    ///
428    /// [`Welcome`]: crate::messages::Welcome
429    /// [`CommitMessageBundle::confirmation`]: crate::group::CommitMessageBundle::confirmation
430    // FIXME: #1217
431    #[cfg(any(not(feature = "virtual-clients-draft"), feature = "test-utils", test))]
432    #[allow(clippy::type_complexity)]
433    pub fn commit_to_pending_proposals<Provider: OpenMlsProvider>(
434        &mut self,
435        provider: &Provider,
436        signer: &impl Signer,
437    ) -> Result<
438        (MlsMessageOut, Option<MlsMessageOut>, Option<GroupInfo>),
439        CommitToPendingProposalsError<Provider::StorageError>,
440    > {
441        self.is_operational()?;
442
443        // Build and stage the commit using the commit builder
444        // TODO #751
445        let (commit, welcome, group_info) = self
446            .commit_builder()
447            // This forces committing to the proposals in the proposal store:
448            .consume_proposal_store(true)
449            .load_psks(provider.storage())?
450            .build(provider.rand(), provider.crypto(), signer, |_| true)?
451            .stage_commit(provider)?
452            .into_contents();
453
454        Ok((
455            commit,
456            // Turn the [`Welcome`] to an [`MlsMessageOut`], if there is one
457            welcome.map(|welcome| MlsMessageOut::from_welcome(welcome, self.version())),
458            group_info,
459        ))
460    }
461
462    /// Merge a [StagedCommit] into the group after inspection. As this advances
463    /// the epoch of the group, it also clears any pending commits.
464    pub fn merge_staged_commit<Provider: OpenMlsProvider>(
465        &mut self,
466        provider: &Provider,
467        staged_commit: StagedCommit,
468    ) -> Result<(), MergeCommitError<Provider::StorageError>> {
469        // Check if we were removed from the group
470        if staged_commit.self_removed() {
471            self.group_state = MlsGroupState::Inactive;
472        }
473        provider
474            .storage()
475            .write_group_state(self.group_id(), &self.group_state)
476            .map_err(MergeCommitError::StorageError)?;
477
478        // Update the per-epoch emulation bindings. Self-removal drops them.
479        // Otherwise the epoch the commit moves the group into is bound to
480        // the emulation epoch of the commit's VC leaf, or, if the commit
481        // does not install a new VC leaf, to the binding of the current
482        // epoch, since the VC leaf stays active across commits by other
483        // members.
484        #[cfg(feature = "virtual-clients-draft")]
485        if staged_commit.self_removed() {
486            provider
487                .storage()
488                .delete_vc_emulation_bindings(self.group_id())
489                .map_err(|e| {
490                    log::error!("vc: drop emulation bindings on self-removal failed: {e:?}");
491                    MergeCommitError::StorageError(e)
492                })?;
493            provider
494                .storage()
495                .delete_registered_vc_emulation_epoch(self.group_id())
496                .map_err(|e| {
497                    log::error!(
498                        "vc: drop registered emulation epoch on self-removal failed: {e:?}"
499                    );
500                    MergeCommitError::StorageError(e)
501                })?;
502        } else {
503            let mut bindings: crate::components::vc_derivation_info::VcEmulationBindings = provider
504                .storage()
505                .vc_emulation_bindings(self.group_id())
506                .map_err(MergeCommitError::StorageError)?
507                .unwrap_or_default();
508            let epoch_id = staged_commit
509                .vc_emulation_epoch_id
510                .clone()
511                .or_else(|| bindings.get(self.epoch()).cloned());
512            if let Some(epoch_id) = epoch_id {
513                // Keep one entry per retained message-secrets epoch plus
514                // the new current one, so bindings age out in lockstep
515                // with the message secrets they are needed for.
516                let max_entries = self.message_secrets_store.max_epochs.saturating_add(1);
517                bindings.insert(staged_commit.epoch(), epoch_id, max_entries);
518                provider
519                    .storage()
520                    .write_vc_emulation_bindings(self.group_id(), &bindings)
521                    .map_err(|e| {
522                        log::error!("vc: persist emulation bindings at merge failed: {e:?}");
523                        MergeCommitError::StorageError(e)
524                    })?;
525            }
526        }
527
528        // Merge staged commit
529        self.merge_commit(provider, staged_commit)?;
530
531        // Extract and store the resumption psk for the current epoch
532        let resumption_psk = self.group_epoch_secrets().resumption_psk();
533        self.resumption_psk_store
534            .add(self.context().epoch(), resumption_psk.clone());
535        provider
536            .storage()
537            .write_resumption_psk_store(self.group_id(), &self.resumption_psk_store)
538            .map_err(MergeCommitError::StorageError)?;
539
540        // Delete own KeyPackageBundles
541        self.own_leaf_nodes.clear();
542        provider
543            .storage()
544            .delete_own_leaf_nodes(self.group_id())
545            .map_err(MergeCommitError::StorageError)?;
546
547        // Delete a potential pending commit
548        self.clear_pending_commit(provider.storage())
549            .map_err(MergeCommitError::StorageError)?;
550
551        Ok(())
552    }
553
554    /// Merges the pending [`StagedCommit`] if there is one, and
555    /// clears the field by setting it to `None`.
556    pub fn merge_pending_commit<Provider: OpenMlsProvider>(
557        &mut self,
558        provider: &Provider,
559    ) -> Result<(), MergePendingCommitError<Provider::StorageError>> {
560        match &self.group_state {
561            MlsGroupState::PendingCommit(_) => {
562                let old_state = mem::replace(&mut self.group_state, MlsGroupState::Operational);
563                if let MlsGroupState::PendingCommit(pending_commit_state) = old_state {
564                    self.merge_staged_commit(provider, (*pending_commit_state).into())?;
565                }
566                Ok(())
567            }
568            MlsGroupState::Inactive => Err(MlsGroupStateError::UseAfterEviction)?,
569            MlsGroupState::Operational => Ok(()),
570        }
571    }
572
573    /// Resolve a commit's virtual-clients derivation info to the per-commit
574    /// `OperationSecret` the receiver needs in order to recreate the path of a
575    /// commit sent by a sibling emulator client, plus the `EpochId` the
576    /// commit binds the group to on merge.
577    ///
578    /// See [`is_sibling_vc_commit`] for the precondition the caller must
579    /// check before invoking this helper. Sibling commits come in two
580    /// shapes:
581    ///
582    ///   * own-leaf VC commits, where a sibling emulator committed through our
583    ///     shared higher-level leaf
584    ///   * sibling-resync external commits, where a sibling emulator joined this
585    ///     higher-level group externally onto a leaf of their own,
586    ///     inline-removing our previous leaf.
587    ///
588    /// Returns `Ok(None)` when the commit carries no virtual-clients
589    /// derivation-info entry on its update-path leaf (path-less commits, or
590    /// commits without an `app_data_dictionary`). Otherwise:
591    ///   - looks up the per-epoch `EmulationEpochState` and operation secret
592    ///     tree the application registered via `register_vc_emulation_epoch`,
593    ///   - decrypts the wrapped `DerivationInfoTbe` with the AEAD key/nonce
594    ///     derived from the epoch encryption key and the path leaf's
595    ///     serialized encryption key,
596    ///   - derives the operation secret positionally from the tree at the
597    ///     sender's emulation-leaf coordinates and persists the advanced
598    ///     tree,
599    ///   - returns the resulting `OperationSecret` and `EpochId`.
600    ///
601    /// A generation the tree reports as already consumed fails with
602    /// `OperationGenerationConsumed`. Operation secrets are consume-once,
603    /// matching the semantics of regular PrivateMessage decryption.
604    #[cfg(feature = "virtual-clients-draft")]
605    pub(super) fn load_vc_commit_material<Provider: OpenMlsProvider>(
606        &self,
607        provider: &Provider,
608        commit: &Commit,
609    ) -> Result<Option<crate::components::vc_derivation_info::VcCommitMaterial>, StageCommitError>
610    {
611        use tls_codec::{DeserializeBytes, Serialize as _};
612
613        use crate::{
614            components::vc_derivation_info::{
615                DerivationInfo, EmulationEpochState, VirtualClientOperationType,
616                VirtualClientsError, VC_COMPONENT_ID,
617            },
618            components::vc_operation_tree::OperationSecretTree,
619            treesync::node::leaf_node::LeafNodeSource,
620        };
621
622        let Some(path) = commit.path.as_ref() else {
623            return Ok(None);
624        };
625        let Some(app_data_dict) = path.leaf_node().extensions().app_data_dictionary() else {
626            return Ok(None);
627        };
628        let Some(derivation_info_bytes) = app_data_dict.dictionary().get(&VC_COMPONENT_ID) else {
629            return Ok(None);
630        };
631        let derivation_info = DerivationInfo::tls_deserialize_exact_bytes(derivation_info_bytes)
632            .map_err(|e| {
633                log::error!("vc: derivation info deserialize failed: {e:?}");
634                VirtualClientsError::DerivationInfoMalformed
635            })?;
636
637        let epoch_id = derivation_info.epoch_id();
638        let storage = provider.storage();
639        let state: EmulationEpochState = storage
640            .vc_emulation_epoch_state(epoch_id)
641            .map_err(|e| {
642                log::error!("vc: load emulation epoch state failed: {e:?}");
643                VirtualClientsError::StorageError
644            })?
645            .ok_or(VirtualClientsError::MissingEmulationEpochState)?;
646        let mut operation_tree: OperationSecretTree = storage
647            .vc_operation_tree(epoch_id)
648            .map_err(|e| {
649                log::error!("vc: load operation tree failed: {e:?}");
650                VirtualClientsError::StorageError
651            })?
652            .ok_or(VirtualClientsError::MissingOperationTree)?;
653        // The receiver uses the emulation epoch's AEAD key and ciphersuite
654        // for `DerivationInfoTbe`. The sender's emulation leaf index travels
655        // on the wire, so it doesn't have to come from storage on this side.
656        let (_state_leaf_index, epoch_encryption_key, emulation_ciphersuite) = state.into_parts();
657
658        let crypto = provider.crypto();
659        let leaf_encryption_key = path
660            .leaf_node()
661            .encryption_key()
662            .tls_serialize_detached()
663            .map_err(VirtualClientsError::from)?;
664        // The operation type is not on the wire. It is inferred from the
665        // carrying leaf's source: key-package leaves map to `KeyPackage`,
666        // update and commit leaves map to `LeafNode`. Only `LeafNode` is
667        // wired up today, and an update-path leaf always has a commit
668        // source. It selects the tagless `DerivationInfoTbe` variant the
669        // plaintext decodes into.
670        let operation_type = match path.leaf_node().leaf_node_source() {
671            LeafNodeSource::KeyPackage(_) => {
672                log::error!("vc: key-package leaf on an update path");
673                return Err(VirtualClientsError::DerivationInfoMalformed.into());
674            }
675            LeafNodeSource::Update | LeafNodeSource::Commit(_) => {
676                VirtualClientOperationType::LeafNode
677            }
678        };
679        let tbe = derivation_info.decrypt(
680            crypto,
681            emulation_ciphersuite,
682            &epoch_encryption_key,
683            &leaf_encryption_key,
684            operation_type,
685        )?;
686        // Carried by an external commit's leaf only; `None` for own-leaf
687        // (regular) VC commits. A sibling uses it as the new epoch's external
688        // init secret instead of decapsulating from the previous epoch's
689        // `external_secret`.
690        let external_init_secret = tbe.external_init_secret().cloned();
691        // The operation context for `LeafNode` operations is the
692        // higher-level group's id.
693        let operation_context = self.group_id().as_slice().to_vec();
694
695        // An already-consumed generation propagates as a hard error here:
696        // operation secrets are consume-once, like the per-generation keys
697        // of regular PrivateMessage decryption.
698        let operation_secret = operation_tree.derive_operation_secret(
699            crypto,
700            emulation_ciphersuite,
701            epoch_id,
702            tbe.leaf_index(),
703            operation_type,
704            tbe.generation(),
705            &operation_context,
706        )?;
707        // Persist the advanced tree immediately, before any key material is
708        // derived from the secret.
709        storage
710            .write_vc_operation_tree(epoch_id, &operation_tree)
711            .map_err(|e| {
712                log::error!("vc: persist advanced operation tree failed: {e:?}");
713                VirtualClientsError::StorageError
714            })?;
715
716        Ok(Some(
717            crate::components::vc_derivation_info::VcCommitMaterial {
718                epoch_id: epoch_id.clone(),
719                operation_secret,
720                external_init_secret,
721            },
722        ))
723    }
724
725    /// Helper function to read decryption keypairs.
726    pub(super) fn read_decryption_keypairs(
727        &self,
728        provider: &impl OpenMlsProvider,
729        own_leaf_nodes: &[LeafNode],
730    ) -> Result<(Vec<EncryptionKeyPair>, Vec<EncryptionKeyPair>), StageCommitError> {
731        // All keys from the previous epoch are potential decryption keypairs.
732        let old_epoch_keypairs = self.read_epoch_keypairs(provider.storage()).map_err(|e| {
733            log::error!("Error reading epoch keypairs: {e:?}");
734            StageCommitError::MissingDecryptionKey
735        })?;
736
737        // If we are processing an update proposal that originally came from
738        // us, the keypair corresponding to the leaf in the update is also a
739        // potential decryption keypair.
740        let leaf_node_keypairs = own_leaf_nodes
741            .iter()
742            .map(|leaf_node| {
743                EncryptionKeyPair::read(provider, leaf_node.encryption_key())
744                    .ok_or(StageCommitError::MissingDecryptionKey)
745            })
746            .collect::<Result<Vec<EncryptionKeyPair>, StageCommitError>>()?;
747
748        Ok((old_epoch_keypairs, leaf_node_keypairs))
749    }
750
751    /// Stages a Commit covering AppDataUpdate proposals, after the application
752    /// has interpreted the proposals and computed the resulting
753    /// [`AppDataUpdates`].
754    ///
755    /// The returned [`StagedCommit`] can be inspected and merged into the
756    /// group's state using [`MlsGroup::merge_staged_commit()`].
757    #[cfg(feature = "extensions-draft")]
758    pub fn stage_app_data_commit<Provider: OpenMlsProvider>(
759        &self,
760        provider: &Provider,
761        unresolved_commit: UnresolvedAppDataCommit,
762        app_data_dict_updates: Option<AppDataUpdates>,
763    ) -> Result<StagedCommit, StageCommitError> {
764        let content = unresolved_commit.content;
765        #[cfg(feature = "virtual-clients-draft")]
766        let vc_commit_material = unresolved_commit.vc_commit_material;
767
768        let (old_epoch_keypairs, leaf_node_keypairs) =
769            self.read_decryption_keypairs(provider, &self.own_leaf_nodes)?;
770
771        self.stage_commit_with_app_data_updates(
772            &content,
773            old_epoch_keypairs,
774            leaf_node_keypairs,
775            app_data_dict_updates,
776            provider,
777            #[cfg(feature = "virtual-clients-draft")]
778            vc_commit_material,
779        )
780    }
781
782    /// Resolves a [`ProcessedMessage`] carrying an
783    /// [`ProcessedMessageContent::UnresolvedAppDataCommit`]: stages the commit
784    /// with the application-computed [`AppDataUpdates`] and returns the same
785    /// message with the resulting [`StagedCommit`] as regular
786    /// [`ProcessedMessageContent::StagedCommitMessage`] content. All other
787    /// message fields (sender, credential, authenticated data) are preserved.
788    ///
789    /// Use this instead of [`MlsGroup::stage_app_data_commit()`] when the
790    /// caller needs the resolved commit in [`ProcessedMessage`] form, e.g. to
791    /// keep a single code path for commits with and without AppDataUpdate
792    /// proposals.
793    ///
794    /// Returns an error if the message content is not an unresolved app data
795    /// commit; the message is consumed either way.
796    #[cfg(feature = "extensions-draft")]
797    pub fn resolve_app_data_commit<Provider: OpenMlsProvider>(
798        &self,
799        provider: &Provider,
800        processed_message: ProcessedMessage,
801        app_data_dict_updates: Option<AppDataUpdates>,
802    ) -> Result<ProcessedMessage, ResolveAppDataCommitError> {
803        processed_message.resolve_app_data_commit(|unresolved_commit| {
804            self.stage_app_data_commit(provider, unresolved_commit, app_data_dict_updates)
805        })
806    }
807
808    /// This processing function does most of the semantic verifications.
809    /// It returns a [ProcessedMessage] enum.
810    ///
811    /// Checks the following semantic validation:
812    ///  - ValSem008
813    ///  - ValSem010
814    ///  - ValSem101
815    ///  - ValSem102
816    ///  - ValSem104
817    ///  - ValSem106
818    ///  - ValSem107
819    ///  - ValSem108
820    ///  - ValSem110
821    ///  - ValSem111
822    ///  - ValSem112
823    ///  - ValSem113: All Proposals: The proposal type must be supported by all
824    ///    members of the group
825    ///  - ValSem200
826    ///  - ValSem201
827    ///  - ValSem202: Path must be the right length
828    ///  - ValSem203: Path secrets must decrypt correctly
829    ///  - ValSem204: Public keys from Path must be verified and match the
830    ///    private keys from the direct path
831    ///  - ValSem205
832    pub(crate) fn process_unverified_message<Provider: OpenMlsProvider>(
833        &self,
834        provider: &Provider,
835        unverified_message: UnverifiedMessage,
836    ) -> Result<ProcessedMessage, ProcessMessageError<Provider::StorageError>> {
837        // Checks the following semantic validation:
838        //  - ValSem010
839        //  - ValSem246 (as part of ValSem010)
840        //  - https://validation.openmls.tech/#valn1302
841        //  - https://validation.openmls.tech/#valn1304
842        let verified =
843            unverified_message.verify(self.ciphersuite(), provider.crypto(), self.version())?;
844
845        #[cfg_attr(not(feature = "extensions-draft"), allow(unused_mut))]
846        let mut processed = match verified.content.sender() {
847            Sender::Member(_) | Sender::NewMemberProposal | Sender::NewMemberCommit => self
848                .process_internal_authenticated_content(
849                    provider,
850                    verified.content,
851                    verified.credential,
852                    #[cfg(feature = "virtual-clients-draft")]
853                    verified.emulator_sender_leaf_index,
854                )?,
855            Sender::External(_) => self.process_external_authenticated_content(
856                provider,
857                verified.content,
858                verified.credential,
859            )?,
860        };
861        #[cfg(feature = "extensions-draft")]
862        if self.context().safe_aad_required() {
863            processed
864                .try_attach_safe_aad()
865                .map_err(|_| ProcessMessageError::MalformedSafeAad)?;
866        }
867        Ok(processed)
868    }
869
870    fn process_internal_authenticated_content<Provider: OpenMlsProvider>(
871        &self,
872        provider: &Provider,
873        content: AuthenticatedContent,
874        credential: Credential,
875        #[cfg(feature = "virtual-clients-draft")] emulator_sender_leaf_index: Option<LeafNodeIndex>,
876    ) -> Result<ProcessedMessage, ProcessMessageError<Provider::StorageError>> {
877        let sender = content.sender().clone();
878        let authenticated_data = content.authenticated_data().to_owned();
879        let epoch = content.epoch();
880
881        let content = match content.content() {
882            FramedContentBody::Application(application_message) => {
883                ProcessedMessageContent::ApplicationMessage(ApplicationMessage::new(
884                    application_message.as_slice().to_owned(),
885                ))
886            }
887            FramedContentBody::Proposal(_) => {
888                let proposal = Box::new(QueuedProposal::from_authenticated_content_by_ref(
889                    self.ciphersuite(),
890                    provider.crypto(),
891                    content,
892                )?);
893
894                if matches!(sender, Sender::NewMemberProposal) {
895                    ProcessedMessageContent::ExternalJoinProposalMessage(proposal)
896                } else {
897                    ProcessedMessageContent::ProposalMessage(proposal)
898                }
899            }
900            FramedContentBody::Commit(commit) => {
901                let is_own_commit =
902                    matches!(&sender, Sender::Member(member) if member == &self.own_leaf_index());
903
904                if is_own_commit {
905                    let received_tag = content
906                        .confirmation_tag()
907                        .ok_or(StageCommitError::ConfirmationTagMissing)?;
908                    if self.matches_pending_commit(received_tag) {
909                        // The Commit is our pending commit this client got
910                        // fanned out by the delivery service: surface
911                        // `OwnPendingCommit` so the caller merges the pending
912                        // commit instead of staging the fanned-out Commit.
913                        return Ok(ProcessedMessage::new(
914                            self.group_id().clone(),
915                            epoch,
916                            sender,
917                            authenticated_data,
918                            ProcessedMessageContent::OwnPendingCommit,
919                            credential,
920                            #[cfg(feature = "virtual-clients-draft")]
921                            emulator_sender_leaf_index,
922                        ));
923                    }
924                }
925
926                // Load virtual-client derivation info when this commit was
927                // authored by a sibling emulator through a leaf shared with us.
928                // The pending-commit match above already ran, so an own commit
929                // echoed back by the delivery service never reaches this load
930                // and no operation-secret generation is consumed for it. A
931                // sibling's commit never matches our pending commit's
932                // confirmation tag, so sibling commits still take this path.
933                // The receiver only loads the material when the commit shape
934                // lets it identify itself as a sibling:
935                //
936                // * `Sender::Member(idx)` with `idx == own_leaf_index`: the
937                //   sender committed through our shared higher-level leaf, so
938                //   we are a sibling.
939                // * `Sender::NewMemberCommit` with an inline `Remove(own_leaf)`:
940                //   the sender is a sibling joining externally and the
941                //   auto-Remove targets our previous leaf, so we are the
942                //   sibling being resynced.
943                #[cfg(feature = "virtual-clients-draft")]
944                let (vc_commit_material, is_own_commit) = {
945                    let vc_commit_material =
946                        if is_sibling_vc_commit(commit, &sender, self.own_leaf_index()) {
947                            self.load_vc_commit_material(provider, commit)?
948                        } else {
949                            None
950                        };
951
952                    let is_own_commit = is_own_commit && vc_commit_material.is_none();
953
954                    (vc_commit_material, is_own_commit)
955                };
956
957                // An own Commit that did not match the pending commit above
958                // cannot be staged when it carries an UpdatePath: we cannot
959                // decrypt a path we encrypted to the other members. A Commit
960                // without an UpdatePath carries no author-private material and
961                // falls through to staging (a sibling's Commit without an
962                // UpdatePath, or our own commit replayed after the pending
963                // commit was cleared).
964                if is_own_commit && commit.path.is_some() {
965                    return Err(StageCommitError::OwnCommitMismatch.into());
966                }
967
968                // A commit covering AppDataUpdate proposals cannot be staged
969                // immediately: the proposals contain diffs in an
970                // application-defined format, so the application has to
971                // interpret them and supply the resulting dictionary entries
972                // first. The verified content is handed back to the caller,
973                // who resumes staging via `MlsGroup::stage_app_data_commit`.
974                #[cfg(feature = "extensions-draft")]
975                {
976                    let app_data_update_proposals =
977                        committed_app_data_update_proposals(commit, self.proposal_store());
978                    if !app_data_update_proposals.is_empty() {
979                        let unresolved_commit = UnresolvedAppDataCommit {
980                            content,
981                            proposals: app_data_update_proposals,
982                            #[cfg(feature = "virtual-clients-draft")]
983                            vc_commit_material,
984                        };
985                        return Ok(ProcessedMessage::new(
986                            self.group_id().clone(),
987                            epoch,
988                            sender,
989                            authenticated_data,
990                            ProcessedMessageContent::UnresolvedAppDataCommit(Box::new(
991                                unresolved_commit,
992                            )),
993                            credential,
994                            #[cfg(feature = "virtual-clients-draft")]
995                            emulator_sender_leaf_index,
996                        ));
997                    }
998                }
999
1000                // Since this is a commit, we need to load the private key material we need for decryption.
1001                let (old_epoch_keypairs, leaf_node_keypairs) =
1002                    self.read_decryption_keypairs(provider, &self.own_leaf_nodes)?;
1003
1004                let staged_commit = self.stage_commit(
1005                    &content,
1006                    old_epoch_keypairs,
1007                    leaf_node_keypairs,
1008                    provider,
1009                    #[cfg(feature = "virtual-clients-draft")]
1010                    vc_commit_material,
1011                )?;
1012
1013                ProcessedMessageContent::StagedCommitMessage(Box::new(staged_commit))
1014            }
1015        };
1016
1017        Ok(ProcessedMessage::new(
1018            self.group_id().clone(),
1019            epoch,
1020            sender,
1021            authenticated_data,
1022            content,
1023            credential,
1024            #[cfg(feature = "virtual-clients-draft")]
1025            emulator_sender_leaf_index,
1026        ))
1027    }
1028
1029    ///  - ValSem240
1030    ///  - ValSem241
1031    ///  - ValSem242
1032    ///  - ValSem244
1033    ///  - ValSem246 (as part of ValSem010)
1034    fn process_external_authenticated_content<Provider: OpenMlsProvider>(
1035        &self,
1036        provider: &Provider,
1037        content: AuthenticatedContent,
1038        credential: Credential,
1039    ) -> Result<ProcessedMessage, ProcessMessageError<Provider::StorageError>> {
1040        #[cfg(feature = "virtual-clients-draft")]
1041        let emulator_sender_leaf_index: Option<crate::binary_tree::LeafNodeIndex> = None;
1042        let sender = content.sender().clone();
1043        let data = content.authenticated_data().to_owned();
1044
1045        debug_assert!(matches!(sender, Sender::External(_)));
1046
1047        // https://validation.openmls.tech/#valn1501
1048        match content.content() {
1049            FramedContentBody::Application(_) => {
1050                Err(ProcessMessageError::UnauthorizedExternalApplicationMessage)
1051            }
1052            // TODO: https://validation.openmls.tech/#valn1502
1053            FramedContentBody::Proposal(Proposal::GroupContextExtensions(_)) => {
1054                let content = ProcessedMessageContent::ProposalMessage(Box::new(
1055                    QueuedProposal::from_authenticated_content_by_ref(
1056                        self.ciphersuite(),
1057                        provider.crypto(),
1058                        content,
1059                    )?,
1060                ));
1061                Ok(ProcessedMessage::new(
1062                    self.group_id().clone(),
1063                    self.context().epoch(),
1064                    sender,
1065                    data,
1066                    content,
1067                    credential,
1068                    #[cfg(feature = "virtual-clients-draft")]
1069                    emulator_sender_leaf_index,
1070                ))
1071            }
1072
1073            FramedContentBody::Proposal(Proposal::Remove(_)) => {
1074                let content = ProcessedMessageContent::ProposalMessage(Box::new(
1075                    QueuedProposal::from_authenticated_content_by_ref(
1076                        self.ciphersuite(),
1077                        provider.crypto(),
1078                        content,
1079                    )?,
1080                ));
1081                Ok(ProcessedMessage::new(
1082                    self.group_id().clone(),
1083                    self.context().epoch(),
1084                    sender,
1085                    data,
1086                    content,
1087                    credential,
1088                    #[cfg(feature = "virtual-clients-draft")]
1089                    emulator_sender_leaf_index,
1090                ))
1091            }
1092            FramedContentBody::Proposal(Proposal::Add(_)) => {
1093                let content = ProcessedMessageContent::ProposalMessage(Box::new(
1094                    QueuedProposal::from_authenticated_content_by_ref(
1095                        self.ciphersuite(),
1096                        provider.crypto(),
1097                        content,
1098                    )?,
1099                ));
1100                Ok(ProcessedMessage::new(
1101                    self.group_id().clone(),
1102                    self.context().epoch(),
1103                    sender,
1104                    data,
1105                    content,
1106                    credential,
1107                    #[cfg(feature = "virtual-clients-draft")]
1108                    emulator_sender_leaf_index,
1109                ))
1110            }
1111            // TODO #151/#106
1112            FramedContentBody::Proposal(_) => Err(ProcessMessageError::UnsupportedProposalType),
1113            FramedContentBody::Commit(_) => {
1114                Err(ProcessMessageError::UnauthorizedExternalCommitMessage)
1115            }
1116        }
1117    }
1118
1119    /// Performs framing validation and, if necessary, decrypts the given message.
1120    ///
1121    /// Returns the [`InboundDecryptionResult`] if processing is successful, or a
1122    /// [`ValidationError`] if it is not.
1123    ///
1124    /// Checks the following semantic validation:
1125    ///  - ValSem002
1126    ///  - ValSem003
1127    ///  - ValSem006
1128    ///  - ValSem007 MembershipTag presence
1129    ///  - https://validation.openmls.tech/#valn1202
1130    pub(crate) fn decrypt_message(
1131        &mut self,
1132        crypto: &impl OpenMlsCrypto,
1133        message: ProtocolMessage,
1134        sender_ratchet_configuration: &SenderRatchetConfiguration,
1135        #[cfg(feature = "virtual-clients-draft")] emulator_ctx: Option<
1136            &crate::framing::EmulatorReuseGuardCtx<'_>,
1137        >,
1138    ) -> Result<InboundDecryptionResult, ValidationError> {
1139        // Checks the following semantic validation:
1140        //  - ValSem002
1141        //  - ValSem003
1142        self.public_group.validate_framing(&message)?;
1143
1144        let epoch = message.epoch();
1145
1146        // Checks the following semantic validation:
1147        //  - ValSem006
1148        //  - ValSem007 MembershipTag presence
1149        match message {
1150            ProtocolMessage::PublicMessage(public_message) => {
1151                // If the message is older than the current epoch, we need to fetch the correct secret tree first.
1152                let message_secrets =
1153                    self.message_secrets_for_epoch(epoch).map_err(|e| match e {
1154                        SecretTreeError::TooDistantInThePast => ValidationError::NoPastEpochData,
1155                        _ => LibraryError::custom(
1156                            "Unexpected error while retrieving message secrets for epoch.",
1157                        )
1158                        .into(),
1159                    })?;
1160                DecryptedMessage::from_inbound_public_message(
1161                    *public_message,
1162                    message_secrets,
1163                    message_secrets.serialized_context().to_vec(),
1164                    crypto,
1165                    self.ciphersuite(),
1166                )
1167                .map(InboundDecryptionResult::Decrypted)
1168            }
1169            ProtocolMessage::PrivateMessage(ciphertext) => {
1170                // If the message is older than the current epoch, we need to fetch the correct secret tree first
1171                DecryptedMessage::from_inbound_ciphertext(
1172                    ciphertext,
1173                    crypto,
1174                    self,
1175                    sender_ratchet_configuration,
1176                    #[cfg(feature = "virtual-clients-draft")]
1177                    emulator_ctx,
1178                )
1179            }
1180        }
1181    }
1182}
1183
1184/// Collects the AppDataUpdate proposals covered by a commit, sorted by
1185/// component id.
1186///
1187/// Proposals sent by reference are resolved from the proposal store. A
1188/// reference that cannot be resolved is skipped here: staging fails on it
1189/// later with the regular missing-proposal error, so it does not need to be
1190/// surfaced at detection time.
1191#[cfg(feature = "extensions-draft")]
1192pub(crate) fn committed_app_data_update_proposals(
1193    commit: &Commit,
1194    proposal_store: &ProposalStore,
1195) -> Vec<AppDataUpdateProposal> {
1196    use crate::messages::proposals::ProposalOrRef;
1197
1198    let mut proposals: Vec<AppDataUpdateProposal> = commit
1199        .proposals
1200        .iter()
1201        .filter_map(|proposal_or_ref| match proposal_or_ref {
1202            ProposalOrRef::Proposal(proposal) => match proposal.as_ref() {
1203                Proposal::AppDataUpdate(proposal) => Some(proposal.as_ref().clone()),
1204                _ => None,
1205            },
1206            ProposalOrRef::Reference(reference) => proposal_store
1207                .proposals()
1208                .find(|queued_proposal| {
1209                    queued_proposal.proposal_reference_ref() == reference.as_ref()
1210                })
1211                .and_then(|queued_proposal| match queued_proposal.proposal() {
1212                    Proposal::AppDataUpdate(proposal) => Some(proposal.as_ref().clone()),
1213                    _ => None,
1214                }),
1215        })
1216        .collect();
1217
1218    proposals.sort_by_key(|proposal| proposal.component_id());
1219    proposals
1220}
1221
1222/// Determines from the commit's shape whether the receiver is a sibling virtual
1223/// client of the sender of a virtual-clients commit.
1224///
1225/// Returns `true` for:
1226///   * own-leaf commits (`Sender::Member(idx)` with `idx == own_leaf_index`),
1227///     where receiver and sender share the higher-level leaf
1228///   * sibling-resync external commits (`Sender::NewMemberCommit` whose
1229///     proposal list inlines a `Remove` of `own_leaf_index`.
1230///
1231/// `false` for everything else.
1232#[cfg(feature = "virtual-clients-draft")]
1233fn is_sibling_vc_commit(
1234    commit: &Commit,
1235    sender: &super::Sender,
1236    own_leaf_index: crate::binary_tree::LeafNodeIndex,
1237) -> bool {
1238    use crate::messages::proposals::{Proposal, ProposalOrRef};
1239
1240    match sender {
1241        super::Sender::Member(idx) => *idx == own_leaf_index,
1242        super::Sender::NewMemberCommit => commit.proposals.iter().any(|p| {
1243            matches!(
1244                p,
1245                ProposalOrRef::Proposal(boxed)
1246                    if matches!(boxed.as_ref(), Proposal::Remove(r) if r.removed() == own_leaf_index)
1247            )
1248        }),
1249        _ => false,
1250    }
1251}