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;
10use openmls_traits::crypto::OpenMlsCrypto;
11#[cfg(any(not(feature = "virtual-clients-draft"), feature = "test-utils", test))]
12use openmls_traits::signatures::Signer;
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_derivation_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 derivation epoch state
327        // that was bound then, not the latest one.
328        #[cfg(feature = "virtual-clients-draft")]
329        let derivation_state = if let ProtocolMessage::PrivateMessage(private_message) = &message {
330            self.vc_derivation_state_at_epoch(provider.storage(), private_message.epoch())
331                .map_err(|e| match e {
332                    super::VcDerivationStateError::Storage(e) => {
333                        ProcessMessageError::StorageError(e)
334                    }
335                    super::VcDerivationStateError::MissingDerivationEpochState => {
336                        ProcessMessageError::ValidationError(
337                            crate::group::ValidationError::UnableToDecrypt(
338                                crate::framing::errors::MessageDecryptionError::VirtualClientsError(
339                                    crate::components::vc_derivation_info::VirtualClientsError::MissingDerivationEpochState,
340                                ),
341                            ),
342                        )
343                    }
344                })?
345        } else {
346            None
347        };
348        #[cfg(feature = "virtual-clients-draft")]
349        let emulator_ctx: Option<crate::framing::EmulatorReuseGuardCtx<'_>> = derivation_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        // along with the group's own derivation-epoch log. Otherwise the epoch
480        // the commit moves the group into is bound to the derivation epoch of
481        // the commit's VC leaf, or, if the commit does not install a new VC
482        // leaf, to the binding of the current epoch, since the VC leaf stays
483        // active across commits by other members. Either way the derivation
484        // epochs that lost their last reference are released.
485        #[cfg(feature = "virtual-clients-draft")]
486        if staged_commit.self_removed() {
487            self.drop_all_vc_derivation_epoch_references(provider.storage())
488                .map_err(|e| {
489                    log::error!("vc: drop derivation epoch references on self-removal: {e:?}");
490                    MergeCommitError::StorageError(e)
491                })?;
492        } else {
493            use crate::components::vc_derivation_info::{EpochId, VcEmulationBinding};
494
495            let epoch_id = match staged_commit.vc_derivation_epoch_id.clone() {
496                Some(epoch_id) => Some(epoch_id),
497                None => provider
498                    .storage()
499                    .vc_emulation_binding(self.group_id(), &self.epoch())
500                    .map_err(MergeCommitError::StorageError)?
501                    .map(VcEmulationBinding::into_epoch_id),
502            };
503            if let Some(epoch_id) = epoch_id {
504                // Keep one binding per retained message-secrets epoch plus
505                // the new current one, so bindings age out in lockstep
506                // with the message secrets they are needed for.
507                let max_entries = self.message_secrets_store.max_epochs.saturating_add(1);
508                crate::components::vc_derivation_info::write_vc_emulation_binding_with_pruning(
509                    provider.storage(),
510                    self.group_id(),
511                    staged_commit.epoch(),
512                    epoch_id,
513                    max_entries,
514                )
515                .map_err(|e| {
516                    log::error!("vc: persist emulation binding at merge failed: {e:?}");
517                    MergeCommitError::StorageError(e)
518                })?;
519                // The epochs whose bindings just aged out lost a reference, so
520                // sweep the ones that are now unreferenced.
521                provider
522                    .storage()
523                    .delete_unreferenced_vc_derivation_epoch_states::<EpochId>()
524                    .map_err(|e| {
525                        log::error!("vc: release unbound derivation epochs at merge: {e:?}");
526                        MergeCommitError::StorageError(e)
527                    })?;
528            }
529        }
530
531        // Merge staged commit
532        self.merge_commit(provider, staged_commit)?;
533
534        // Extract and store the resumption psk for the current epoch
535        let resumption_psk = self.group_epoch_secrets().resumption_psk();
536        self.resumption_psk_store
537            .add(self.context().epoch(), resumption_psk.clone());
538        provider
539            .storage()
540            .write_resumption_psk_store(self.group_id(), &self.resumption_psk_store)
541            .map_err(MergeCommitError::StorageError)?;
542
543        // Delete own KeyPackageBundles
544        self.own_leaf_nodes.clear();
545        provider
546            .storage()
547            .delete_own_leaf_nodes(self.group_id())
548            .map_err(MergeCommitError::StorageError)?;
549
550        // Delete a potential pending commit
551        self.clear_pending_commit(provider.storage())
552            .map_err(MergeCommitError::StorageError)?;
553
554        Ok(())
555    }
556
557    /// Merges the pending [`StagedCommit`] if there is one, and
558    /// clears the field by setting it to `None`.
559    pub fn merge_pending_commit<Provider: OpenMlsProvider>(
560        &mut self,
561        provider: &Provider,
562    ) -> Result<(), MergePendingCommitError<Provider::StorageError>> {
563        match &self.group_state {
564            MlsGroupState::PendingCommit(_) => {
565                let old_state = mem::replace(&mut self.group_state, MlsGroupState::Operational);
566                if let MlsGroupState::PendingCommit(pending_commit_state) = old_state {
567                    self.merge_staged_commit(provider, (*pending_commit_state).into())?;
568                }
569                Ok(())
570            }
571            MlsGroupState::Inactive => Err(MlsGroupStateError::UseAfterEviction)?,
572            MlsGroupState::Operational => Ok(()),
573        }
574    }
575
576    /// Resolve a commit's virtual-clients derivation info to the per-commit
577    /// `OperationSecret` the receiver needs in order to recreate the path of a
578    /// commit sent by a sibling emulator client, plus the `EpochId` the
579    /// commit binds the group to on merge.
580    ///
581    /// See [`is_sibling_vc_commit`] for the precondition the caller must
582    /// check before invoking this helper. Sibling commits come in two
583    /// shapes:
584    ///
585    ///   * own-leaf VC commits, where a sibling emulator committed through our
586    ///     shared higher-level leaf
587    ///   * sibling-resync external commits, where a sibling emulator joined this
588    ///     higher-level group externally onto a leaf of their own,
589    ///     inline-removing our previous leaf.
590    ///
591    /// Returns `Ok(None)` when the commit carries no virtual-clients
592    /// derivation-info entry on its update-path leaf (path-less commits, or
593    /// commits without an `app_data_dictionary`). Otherwise:
594    ///   - looks up the per-epoch `VcDerivationEpochState` and operation secret
595    ///     tree registered for the commit's derivation epoch,
596    ///   - decrypts the wrapped `DerivationInfoTbe` with the AEAD key/nonce
597    ///     derived from the epoch encryption key and the path leaf's
598    ///     serialized encryption key,
599    ///   - derives the operation secret positionally from the tree at the
600    ///     sender's emulation-leaf coordinates and persists the advanced
601    ///     tree,
602    ///   - returns the resulting `OperationSecret` and `EpochId`.
603    ///
604    /// A generation the tree reports as already consumed fails with
605    /// `OperationGenerationConsumed`. Operation secrets are consume-once,
606    /// matching the semantics of regular PrivateMessage decryption.
607    #[cfg(feature = "virtual-clients-draft")]
608    pub(super) fn load_vc_commit_material<Provider: OpenMlsProvider>(
609        &self,
610        provider: &Provider,
611        commit: &Commit,
612    ) -> Result<Option<crate::components::vc_derivation_info::VcCommitMaterial>, StageCommitError>
613    {
614        use tls_codec::Serialize as _;
615
616        use crate::{
617            components::vc_derivation_info::{
618                VcDerivationEpochState, VirtualClientOperationType, VirtualClientsError,
619            },
620            components::vc_operation_tree::OperationSecretTree,
621            treesync::node::leaf_node::LeafNodeSource,
622        };
623
624        let Some(path) = commit.path.as_ref() else {
625            return Ok(None);
626        };
627        let Some(derivation_info) = path.leaf_node().vc_derivation_info()? else {
628            return Ok(None);
629        };
630
631        let epoch_id = derivation_info.epoch_id();
632        let storage = provider.storage();
633        let state: VcDerivationEpochState = storage
634            .vc_derivation_epoch_state(epoch_id)
635            .map_err(|e| {
636                log::error!("vc: load derivation epoch state failed: {e:?}");
637                VirtualClientsError::StorageError
638            })?
639            .ok_or(VirtualClientsError::MissingDerivationEpochState)?;
640        let mut operation_tree: OperationSecretTree = storage
641            .vc_operation_tree(epoch_id)
642            .map_err(|e| {
643                log::error!("vc: load operation tree failed: {e:?}");
644                VirtualClientsError::StorageError
645            })?
646            .ok_or(VirtualClientsError::MissingOperationTree)?;
647        // The receiver uses the derivation epoch's AEAD key and ciphersuite
648        // for `DerivationInfoTbe`. The sender's emulation leaf index travels
649        // on the wire, so it doesn't have to come from storage on this side.
650        let (_state_leaf_index, epoch_encryption_key, emulation_ciphersuite) = state.into_parts();
651
652        let crypto = provider.crypto();
653        let leaf_encryption_key = path
654            .leaf_node()
655            .encryption_key()
656            .tls_serialize_detached()
657            .map_err(VirtualClientsError::from)?;
658        // The operation type is not on the wire. It is inferred from the
659        // carrying leaf's source: key-package leaves map to `KeyPackage`,
660        // update and commit leaves map to `LeafNode`. Only `LeafNode` is
661        // wired up today, and an update-path leaf always has a commit
662        // source. It selects the tagless `DerivationInfoTbe` variant the
663        // plaintext decodes into.
664        let operation_type = match path.leaf_node().leaf_node_source() {
665            LeafNodeSource::KeyPackage(_) => {
666                log::error!("vc: key-package leaf on an update path");
667                return Err(VirtualClientsError::DerivationInfoMalformed.into());
668            }
669            LeafNodeSource::Update | LeafNodeSource::Commit(_) => {
670                VirtualClientOperationType::LeafNode
671            }
672        };
673        let tbe = derivation_info.decrypt(
674            crypto,
675            emulation_ciphersuite,
676            &epoch_encryption_key,
677            &leaf_encryption_key,
678            operation_type,
679        )?;
680        // Carried by an external commit's leaf only; `None` for own-leaf
681        // (regular) VC commits. A sibling uses it as the new epoch's external
682        // init secret instead of decapsulating from the previous epoch's
683        // `external_secret`.
684        let external_init_secret = tbe.external_init_secret().cloned();
685        // The operation context for `LeafNode` operations is the
686        // higher-level group's id.
687        let operation_context = self.group_id().as_slice().to_vec();
688
689        // An already-consumed generation propagates as a hard error here:
690        // operation secrets are consume-once, like the per-generation keys
691        // of regular PrivateMessage decryption.
692        let operation_secret = operation_tree.derive_operation_secret(
693            crypto,
694            emulation_ciphersuite,
695            epoch_id,
696            tbe.leaf_index(),
697            operation_type,
698            tbe.generation(),
699            &operation_context,
700        )?;
701        // Persist the advanced tree immediately, before any key material is
702        // derived from the secret.
703        storage
704            .write_vc_operation_tree(epoch_id, &operation_tree)
705            .map_err(|e| {
706                log::error!("vc: persist advanced operation tree failed: {e:?}");
707                VirtualClientsError::StorageError
708            })?;
709
710        Ok(Some(
711            crate::components::vc_derivation_info::VcCommitMaterial {
712                epoch_id: epoch_id.clone(),
713                operation_secret,
714                external_init_secret,
715            },
716        ))
717    }
718
719    /// Helper function to read decryption keypairs.
720    pub(super) fn read_decryption_keypairs(
721        &self,
722        provider: &impl OpenMlsProvider,
723        own_leaf_nodes: &[LeafNode],
724    ) -> Result<(Vec<EncryptionKeyPair>, Vec<EncryptionKeyPair>), StageCommitError> {
725        // All keys from the previous epoch are potential decryption keypairs.
726        let old_epoch_keypairs = self.read_epoch_keypairs(provider.storage()).map_err(|e| {
727            log::error!("Error reading epoch keypairs: {e:?}");
728            StageCommitError::MissingDecryptionKey
729        })?;
730
731        // If we are processing an update proposal that originally came from
732        // us, the keypair corresponding to the leaf in the update is also a
733        // potential decryption keypair.
734        let leaf_node_keypairs = own_leaf_nodes
735            .iter()
736            .map(|leaf_node| {
737                EncryptionKeyPair::read(provider, leaf_node.encryption_key())
738                    .ok_or(StageCommitError::MissingDecryptionKey)
739            })
740            .collect::<Result<Vec<EncryptionKeyPair>, StageCommitError>>()?;
741
742        Ok((old_epoch_keypairs, leaf_node_keypairs))
743    }
744
745    /// Stages a Commit covering AppDataUpdate proposals, after the application
746    /// has interpreted the proposals and computed the resulting
747    /// [`AppDataUpdates`].
748    ///
749    /// The returned [`StagedCommit`] can be inspected and merged into the
750    /// group's state using [`MlsGroup::merge_staged_commit()`].
751    #[cfg(feature = "extensions-draft")]
752    pub fn stage_app_data_commit<Provider: OpenMlsProvider>(
753        &self,
754        provider: &Provider,
755        unresolved_commit: UnresolvedAppDataCommit,
756        app_data_dict_updates: Option<AppDataUpdates>,
757    ) -> Result<StagedCommit, StageCommitError> {
758        let content = unresolved_commit.content;
759        #[cfg(feature = "virtual-clients-draft")]
760        let vc_commit_material = unresolved_commit.vc_commit_material;
761
762        let (old_epoch_keypairs, leaf_node_keypairs) =
763            self.read_decryption_keypairs(provider, &self.own_leaf_nodes)?;
764
765        self.stage_commit_with_app_data_updates(
766            &content,
767            old_epoch_keypairs,
768            leaf_node_keypairs,
769            app_data_dict_updates,
770            provider,
771            #[cfg(feature = "virtual-clients-draft")]
772            vc_commit_material,
773        )
774    }
775
776    /// Resolves a [`ProcessedMessage`] carrying an
777    /// [`ProcessedMessageContent::UnresolvedAppDataCommit`]: stages the commit
778    /// with the application-computed [`AppDataUpdates`] and returns the same
779    /// message with the resulting [`StagedCommit`] as regular
780    /// [`ProcessedMessageContent::StagedCommitMessage`] content. All other
781    /// message fields (sender, credential, authenticated data) are preserved.
782    ///
783    /// Use this instead of [`MlsGroup::stage_app_data_commit()`] when the
784    /// caller needs the resolved commit in [`ProcessedMessage`] form, e.g. to
785    /// keep a single code path for commits with and without AppDataUpdate
786    /// proposals.
787    ///
788    /// Returns an error if the message content is not an unresolved app data
789    /// commit; the message is consumed either way.
790    #[cfg(feature = "extensions-draft")]
791    pub fn resolve_app_data_commit<Provider: OpenMlsProvider>(
792        &self,
793        provider: &Provider,
794        processed_message: ProcessedMessage,
795        app_data_dict_updates: Option<AppDataUpdates>,
796    ) -> Result<ProcessedMessage, ResolveAppDataCommitError> {
797        processed_message.resolve_app_data_commit(|unresolved_commit| {
798            self.stage_app_data_commit(provider, unresolved_commit, app_data_dict_updates)
799        })
800    }
801
802    /// This processing function does most of the semantic verifications.
803    /// It returns a [ProcessedMessage] enum.
804    ///
805    /// Checks the following semantic validation:
806    ///  - ValSem008
807    ///  - ValSem010
808    ///  - ValSem101
809    ///  - ValSem102
810    ///  - ValSem104
811    ///  - ValSem106
812    ///  - ValSem107
813    ///  - ValSem108
814    ///  - ValSem110
815    ///  - ValSem111
816    ///  - ValSem112
817    ///  - ValSem113: All Proposals: The proposal type must be supported by all
818    ///    members of the group
819    ///  - ValSem200
820    ///  - ValSem201
821    ///  - ValSem202: Path must be the right length
822    ///  - ValSem203: Path secrets must decrypt correctly
823    ///  - ValSem204: Public keys from Path must be verified and match the
824    ///    private keys from the direct path
825    ///  - ValSem205
826    pub(crate) fn process_unverified_message<Provider: OpenMlsProvider>(
827        &self,
828        provider: &Provider,
829        unverified_message: UnverifiedMessage,
830    ) -> Result<ProcessedMessage, ProcessMessageError<Provider::StorageError>> {
831        // Checks the following semantic validation:
832        //  - ValSem010
833        //  - ValSem246 (as part of ValSem010)
834        //  - https://validation.openmls.tech/#valn1302
835        //  - https://validation.openmls.tech/#valn1304
836        let verified =
837            unverified_message.verify(self.ciphersuite(), provider.crypto(), self.version())?;
838
839        #[cfg_attr(not(feature = "extensions-draft"), allow(unused_mut))]
840        let mut processed = match verified.content.sender() {
841            Sender::Member(_) | Sender::NewMemberProposal | Sender::NewMemberCommit => self
842                .process_internal_authenticated_content(
843                    provider,
844                    verified.content,
845                    verified.credential,
846                    #[cfg(feature = "virtual-clients-draft")]
847                    verified.emulator_sender_leaf_index,
848                )?,
849            Sender::External(_) => self.process_external_authenticated_content(
850                provider,
851                verified.content,
852                verified.credential,
853            )?,
854        };
855        #[cfg(feature = "extensions-draft")]
856        if self.context().safe_aad_required() {
857            processed
858                .try_attach_safe_aad()
859                .map_err(|_| ProcessMessageError::MalformedSafeAad)?;
860        }
861        Ok(processed)
862    }
863
864    fn process_internal_authenticated_content<Provider: OpenMlsProvider>(
865        &self,
866        provider: &Provider,
867        content: AuthenticatedContent,
868        credential: Credential,
869        #[cfg(feature = "virtual-clients-draft")] emulator_sender_leaf_index: Option<LeafNodeIndex>,
870    ) -> Result<ProcessedMessage, ProcessMessageError<Provider::StorageError>> {
871        let sender = content.sender().clone();
872        let authenticated_data = content.authenticated_data().to_owned();
873        let epoch = content.epoch();
874
875        let content = match content.content() {
876            FramedContentBody::Application(application_message) => {
877                ProcessedMessageContent::ApplicationMessage(ApplicationMessage::new(
878                    application_message.as_slice().to_owned(),
879                ))
880            }
881            FramedContentBody::Proposal(_) => {
882                let proposal = Box::new(QueuedProposal::from_authenticated_content_by_ref(
883                    self.ciphersuite(),
884                    provider.crypto(),
885                    content,
886                )?);
887
888                if matches!(sender, Sender::NewMemberProposal) {
889                    ProcessedMessageContent::ExternalJoinProposalMessage(proposal)
890                } else {
891                    ProcessedMessageContent::ProposalMessage(proposal)
892                }
893            }
894            FramedContentBody::Commit(commit) => {
895                let is_own_commit =
896                    matches!(&sender, Sender::Member(member) if member == &self.own_leaf_index());
897
898                if is_own_commit {
899                    let received_tag = content
900                        .confirmation_tag()
901                        .ok_or(StageCommitError::ConfirmationTagMissing)?;
902                    if self.matches_pending_commit(received_tag) {
903                        // The Commit is our pending commit this client got
904                        // fanned out by the delivery service: surface
905                        // `OwnPendingCommit` so the caller merges the pending
906                        // commit instead of staging the fanned-out Commit.
907                        return Ok(ProcessedMessage::new(
908                            self.group_id().clone(),
909                            epoch,
910                            sender,
911                            authenticated_data,
912                            ProcessedMessageContent::OwnPendingCommit,
913                            credential,
914                            #[cfg(feature = "virtual-clients-draft")]
915                            emulator_sender_leaf_index,
916                        ));
917                    }
918                }
919
920                // Load virtual-client derivation info when this commit was
921                // authored by a sibling emulator through a leaf shared with us.
922                // The pending-commit match above already ran, so an own commit
923                // echoed back by the delivery service never reaches this load
924                // and no operation-secret generation is consumed for it. A
925                // sibling's commit never matches our pending commit's
926                // confirmation tag, so sibling commits still take this path.
927                // The receiver only loads the material when the commit shape
928                // lets it identify itself as a sibling:
929                //
930                // * `Sender::Member(idx)` with `idx == own_leaf_index`: the
931                //   sender committed through our shared higher-level leaf, so
932                //   we are a sibling.
933                // * `Sender::NewMemberCommit` with an inline `Remove(own_leaf)`:
934                //   the sender is a sibling joining externally and the
935                //   auto-Remove targets our previous leaf, so we are the
936                //   sibling being resynced.
937                #[cfg(feature = "virtual-clients-draft")]
938                let (vc_commit_material, is_own_commit) = {
939                    let vc_commit_material =
940                        if is_sibling_vc_commit(commit, &sender, self.own_leaf_index()) {
941                            self.load_vc_commit_material(provider, commit)?
942                        } else {
943                            None
944                        };
945
946                    let is_own_commit = is_own_commit && vc_commit_material.is_none();
947
948                    (vc_commit_material, is_own_commit)
949                };
950
951                // An own Commit that did not match the pending commit above
952                // cannot be staged when it carries an UpdatePath: we cannot
953                // decrypt a path we encrypted to the other members. A Commit
954                // without an UpdatePath carries no author-private material and
955                // falls through to staging (a sibling's Commit without an
956                // UpdatePath, or our own commit replayed after the pending
957                // commit was cleared).
958                if is_own_commit && commit.path.is_some() {
959                    return Err(StageCommitError::OwnCommitMismatch.into());
960                }
961
962                // A commit covering AppDataUpdate proposals cannot be staged
963                // immediately: the proposals contain diffs in an
964                // application-defined format, so the application has to
965                // interpret them and supply the resulting dictionary entries
966                // first. The verified content is handed back to the caller,
967                // who resumes staging via `MlsGroup::stage_app_data_commit`.
968                #[cfg(feature = "extensions-draft")]
969                {
970                    let app_data_update_proposals =
971                        committed_app_data_update_proposals(commit, self.proposal_store());
972                    if !app_data_update_proposals.is_empty() {
973                        let unresolved_commit = UnresolvedAppDataCommit {
974                            content,
975                            proposals: app_data_update_proposals,
976                            #[cfg(feature = "virtual-clients-draft")]
977                            vc_commit_material,
978                        };
979                        return Ok(ProcessedMessage::new(
980                            self.group_id().clone(),
981                            epoch,
982                            sender,
983                            authenticated_data,
984                            ProcessedMessageContent::UnresolvedAppDataCommit(Box::new(
985                                unresolved_commit,
986                            )),
987                            credential,
988                            #[cfg(feature = "virtual-clients-draft")]
989                            emulator_sender_leaf_index,
990                        ));
991                    }
992                }
993
994                // Since this is a commit, we need to load the private key material we need for decryption.
995                let (old_epoch_keypairs, leaf_node_keypairs) =
996                    self.read_decryption_keypairs(provider, &self.own_leaf_nodes)?;
997
998                let staged_commit = self.stage_commit(
999                    &content,
1000                    old_epoch_keypairs,
1001                    leaf_node_keypairs,
1002                    provider,
1003                    #[cfg(feature = "virtual-clients-draft")]
1004                    vc_commit_material,
1005                )?;
1006
1007                ProcessedMessageContent::StagedCommitMessage(Box::new(staged_commit))
1008            }
1009        };
1010
1011        Ok(ProcessedMessage::new(
1012            self.group_id().clone(),
1013            epoch,
1014            sender,
1015            authenticated_data,
1016            content,
1017            credential,
1018            #[cfg(feature = "virtual-clients-draft")]
1019            emulator_sender_leaf_index,
1020        ))
1021    }
1022
1023    ///  - ValSem240
1024    ///  - ValSem241
1025    ///  - ValSem242
1026    ///  - ValSem244
1027    ///  - ValSem246 (as part of ValSem010)
1028    fn process_external_authenticated_content<Provider: OpenMlsProvider>(
1029        &self,
1030        provider: &Provider,
1031        content: AuthenticatedContent,
1032        credential: Credential,
1033    ) -> Result<ProcessedMessage, ProcessMessageError<Provider::StorageError>> {
1034        #[cfg(feature = "virtual-clients-draft")]
1035        let emulator_sender_leaf_index: Option<crate::binary_tree::LeafNodeIndex> = None;
1036        let sender = content.sender().clone();
1037        let data = content.authenticated_data().to_owned();
1038
1039        debug_assert!(matches!(sender, Sender::External(_)));
1040
1041        // https://validation.openmls.tech/#valn1501
1042        match content.content() {
1043            FramedContentBody::Application(_) => {
1044                Err(ProcessMessageError::UnauthorizedExternalApplicationMessage)
1045            }
1046            // TODO: https://validation.openmls.tech/#valn1502
1047            FramedContentBody::Proposal(Proposal::GroupContextExtensions(_)) => {
1048                let content = ProcessedMessageContent::ProposalMessage(Box::new(
1049                    QueuedProposal::from_authenticated_content_by_ref(
1050                        self.ciphersuite(),
1051                        provider.crypto(),
1052                        content,
1053                    )?,
1054                ));
1055                Ok(ProcessedMessage::new(
1056                    self.group_id().clone(),
1057                    self.context().epoch(),
1058                    sender,
1059                    data,
1060                    content,
1061                    credential,
1062                    #[cfg(feature = "virtual-clients-draft")]
1063                    emulator_sender_leaf_index,
1064                ))
1065            }
1066
1067            FramedContentBody::Proposal(Proposal::Remove(_)) => {
1068                let content = ProcessedMessageContent::ProposalMessage(Box::new(
1069                    QueuedProposal::from_authenticated_content_by_ref(
1070                        self.ciphersuite(),
1071                        provider.crypto(),
1072                        content,
1073                    )?,
1074                ));
1075                Ok(ProcessedMessage::new(
1076                    self.group_id().clone(),
1077                    self.context().epoch(),
1078                    sender,
1079                    data,
1080                    content,
1081                    credential,
1082                    #[cfg(feature = "virtual-clients-draft")]
1083                    emulator_sender_leaf_index,
1084                ))
1085            }
1086            FramedContentBody::Proposal(Proposal::Add(_)) => {
1087                let content = ProcessedMessageContent::ProposalMessage(Box::new(
1088                    QueuedProposal::from_authenticated_content_by_ref(
1089                        self.ciphersuite(),
1090                        provider.crypto(),
1091                        content,
1092                    )?,
1093                ));
1094                Ok(ProcessedMessage::new(
1095                    self.group_id().clone(),
1096                    self.context().epoch(),
1097                    sender,
1098                    data,
1099                    content,
1100                    credential,
1101                    #[cfg(feature = "virtual-clients-draft")]
1102                    emulator_sender_leaf_index,
1103                ))
1104            }
1105            // RFC 9420 ยง12.1.8 permits external senders to send PreSharedKey
1106            // proposals.
1107            FramedContentBody::Proposal(Proposal::PreSharedKey(_)) => {
1108                let content = ProcessedMessageContent::ProposalMessage(Box::new(
1109                    QueuedProposal::from_authenticated_content_by_ref(
1110                        self.ciphersuite(),
1111                        provider.crypto(),
1112                        content,
1113                    )?,
1114                ));
1115                Ok(ProcessedMessage::new(
1116                    self.group_id().clone(),
1117                    self.context().epoch(),
1118                    sender,
1119                    data,
1120                    content,
1121                    credential,
1122                    #[cfg(feature = "virtual-clients-draft")]
1123                    emulator_sender_leaf_index,
1124                ))
1125            }
1126            // TODO #151/#106
1127            FramedContentBody::Proposal(_) => Err(ProcessMessageError::UnsupportedProposalType),
1128            FramedContentBody::Commit(_) => {
1129                Err(ProcessMessageError::UnauthorizedExternalCommitMessage)
1130            }
1131        }
1132    }
1133
1134    /// Performs framing validation and, if necessary, decrypts the given message.
1135    ///
1136    /// Returns the [`InboundDecryptionResult`] if processing is successful, or a
1137    /// [`ValidationError`] if it is not.
1138    ///
1139    /// Checks the following semantic validation:
1140    ///  - ValSem002
1141    ///  - ValSem003
1142    ///  - ValSem006
1143    ///  - ValSem007 MembershipTag presence
1144    ///  - https://validation.openmls.tech/#valn1202
1145    pub(crate) fn decrypt_message(
1146        &mut self,
1147        crypto: &impl OpenMlsCrypto,
1148        message: ProtocolMessage,
1149        sender_ratchet_configuration: &SenderRatchetConfiguration,
1150        #[cfg(feature = "virtual-clients-draft")] emulator_ctx: Option<
1151            &crate::framing::EmulatorReuseGuardCtx<'_>,
1152        >,
1153    ) -> Result<InboundDecryptionResult, ValidationError> {
1154        // Checks the following semantic validation:
1155        //  - ValSem002
1156        //  - ValSem003
1157        self.public_group.validate_framing(&message)?;
1158
1159        let epoch = message.epoch();
1160
1161        // Checks the following semantic validation:
1162        //  - ValSem006
1163        //  - ValSem007 MembershipTag presence
1164        match message {
1165            ProtocolMessage::PublicMessage(public_message) => {
1166                // If the message is older than the current epoch, we need to fetch the correct secret tree first.
1167                let message_secrets =
1168                    self.message_secrets_for_epoch(epoch).map_err(|e| match e {
1169                        SecretTreeError::TooDistantInThePast => ValidationError::NoPastEpochData,
1170                        _ => LibraryError::custom(
1171                            "Unexpected error while retrieving message secrets for epoch.",
1172                        )
1173                        .into(),
1174                    })?;
1175                DecryptedMessage::from_inbound_public_message(
1176                    *public_message,
1177                    message_secrets,
1178                    message_secrets.serialized_context().to_vec(),
1179                    crypto,
1180                    self.ciphersuite(),
1181                )
1182                .map(InboundDecryptionResult::Decrypted)
1183            }
1184            ProtocolMessage::PrivateMessage(ciphertext) => {
1185                // If the message is older than the current epoch, we need to fetch the correct secret tree first
1186                DecryptedMessage::from_inbound_ciphertext(
1187                    ciphertext,
1188                    crypto,
1189                    self,
1190                    sender_ratchet_configuration,
1191                    #[cfg(feature = "virtual-clients-draft")]
1192                    emulator_ctx,
1193                )
1194            }
1195        }
1196    }
1197}
1198
1199/// Collects the AppDataUpdate proposals covered by a commit, sorted by
1200/// component id.
1201///
1202/// Proposals sent by reference are resolved from the proposal store. A
1203/// reference that cannot be resolved is skipped here: staging fails on it
1204/// later with the regular missing-proposal error, so it does not need to be
1205/// surfaced at detection time.
1206#[cfg(feature = "extensions-draft")]
1207pub(crate) fn committed_app_data_update_proposals(
1208    commit: &Commit,
1209    proposal_store: &ProposalStore,
1210) -> Vec<AppDataUpdateProposal> {
1211    use crate::messages::proposals::ProposalOrRef;
1212
1213    let mut proposals: Vec<AppDataUpdateProposal> = commit
1214        .proposals
1215        .iter()
1216        .filter_map(|proposal_or_ref| match proposal_or_ref {
1217            ProposalOrRef::Proposal(proposal) => match proposal.as_ref() {
1218                Proposal::AppDataUpdate(proposal) => Some(proposal.as_ref().clone()),
1219                _ => None,
1220            },
1221            ProposalOrRef::Reference(reference) => proposal_store
1222                .proposals()
1223                .find(|queued_proposal| {
1224                    queued_proposal.proposal_reference_ref() == reference.as_ref()
1225                })
1226                .and_then(|queued_proposal| match queued_proposal.proposal() {
1227                    Proposal::AppDataUpdate(proposal) => Some(proposal.as_ref().clone()),
1228                    _ => None,
1229                }),
1230        })
1231        .collect();
1232
1233    proposals.sort_by_key(|proposal| proposal.component_id());
1234    proposals
1235}
1236
1237/// Determines from the commit's shape whether the receiver is a sibling virtual
1238/// client of the sender of a virtual-clients commit.
1239///
1240/// Returns `true` for:
1241///   * own-leaf commits (`Sender::Member(idx)` with `idx == own_leaf_index`),
1242///     where receiver and sender share the higher-level leaf
1243///   * sibling-resync external commits (`Sender::NewMemberCommit` whose
1244///     proposal list inlines a `Remove` of `own_leaf_index`.
1245///
1246/// `false` for everything else.
1247#[cfg(feature = "virtual-clients-draft")]
1248fn is_sibling_vc_commit(
1249    commit: &Commit,
1250    sender: &super::Sender,
1251    own_leaf_index: crate::binary_tree::LeafNodeIndex,
1252) -> bool {
1253    use crate::messages::proposals::{Proposal, ProposalOrRef};
1254
1255    match sender {
1256        super::Sender::Member(idx) => *idx == own_leaf_index,
1257        super::Sender::NewMemberCommit => commit.proposals.iter().any(|p| {
1258            matches!(
1259                p,
1260                ProposalOrRef::Proposal(boxed)
1261                    if matches!(boxed.as_ref(), Proposal::Remove(r) if r.removed() == own_leaf_index)
1262            )
1263        }),
1264        _ => false,
1265    }
1266}