Skip to main content

openmls/group/mls_group/
staged_commit.rs

1use core::fmt::Debug;
2
3use openmls_traits::crypto::OpenMlsCrypto;
4use openmls_traits::storage::StorageProvider as _;
5use serde::{Deserialize, Serialize};
6use tls_codec::Serialize as _;
7
8use super::proposal_store::{
9    QueuedAddProposal, QueuedPskProposal, QueuedRemoveProposal, QueuedUpdateProposal,
10};
11
12#[cfg(feature = "virtual-clients-draft")]
13use super::Sender;
14#[cfg(feature = "extensions-draft")]
15use super::StagedCommitSafeExport;
16use super::{
17    super::errors::*, Credential, ExportedSecret, Extension, GroupContext, GroupEpochSecrets,
18    GroupId, JoinerSecret, KeySchedule, LeafNode, LibraryError, MessageSecrets, MlsGroup,
19    MlsGroupState, OpenMlsProvider, PendingCommitState, Proposal, ProposalQueue, PskSecret,
20    QueuedProposal, ReInitProposal, StagedCommitExport,
21};
22use crate::group::diff::PublicGroupDiff;
23use crate::group::{GroupEpoch, LeafNodeLifetimePolicy};
24use crate::messages::ConfirmationTag;
25use crate::prelude::{Commit, LeafNodeIndex};
26use crate::schedule::psk::load_psks;
27#[cfg(feature = "extensions-draft")]
28use crate::{component::ComponentId, schedule::application_export_tree::ApplicationExportTree};
29
30use crate::treesync::errors::TreeSyncFromNodesError;
31use crate::treesync::RatchetTree;
32use crate::{
33    ciphersuite::{hash_ref::ProposalRef, Secret},
34    framing::mls_auth_content::AuthenticatedContent,
35    group::public_group::{
36        diff::{apply_proposals::ApplyProposalsValues, StagedPublicGroupDiff},
37        staged_commit::PublicStagedCommitState,
38    },
39    schedule::{
40        CommitSecret, EpochAuthenticator, EpochSecretsResult, InitSecret, PreSharedKeyId,
41        ResumptionPskSecret,
42    },
43    treesync::node::encryption_keys::EncryptionKeyPair,
44};
45
46#[cfg(feature = "extensions-draft")]
47use super::proposal_store::{QueuedAppDataUpdateProposal, QueuedAppEphemeralProposal};
48#[cfg(feature = "extensions-draft")]
49use crate::prelude::processing::AppDataUpdates;
50
51#[cfg(feature = "virtual-clients-draft")]
52fn validate_vc_external_init_secret(
53    is_sibling_resync: bool,
54    has_external_init_proposal: bool,
55    has_vc_external_init_secret: bool,
56) -> Result<(), StageCommitError> {
57    if is_sibling_resync && !has_vc_external_init_secret {
58        return Err(
59            crate::components::vc_derivation_info::VirtualClientsError::DerivationInfoMalformed
60                .into(),
61        );
62    }
63    if has_vc_external_init_secret && !has_external_init_proposal {
64        return Err(
65            crate::components::vc_derivation_info::VirtualClientsError::DerivationInfoMalformed
66                .into(),
67        );
68    }
69    Ok(())
70}
71
72/// Returns whether a commit's virtual-clients Safe AAD item carries a
73/// `new_derivation_epoch` action, reading it off the commit's assembled
74/// `authenticated_data`.
75///
76/// The wire bytes are authoritative: an application that staged the marker
77/// itself is treated exactly like one that asked the commit builder for it, on
78/// both the sending and the receiving side.
79#[cfg(feature = "virtual-clients-draft")]
80fn commit_marks_new_vc_derivation_epoch(
81    authenticated_data: &[u8],
82) -> Result<bool, StageCommitError> {
83    use crate::components::vc_commit_data::VirtualClientCommitData;
84
85    let (safe_aad, _prefix_len) =
86        crate::framing::safe_aad::parse_authenticated_data_prefix(authenticated_data)
87            .map_err(|e| StageCommitError::MalformedVcCommitData(e.to_string()))?;
88    let commit_data = VirtualClientCommitData::from_safe_aad(&safe_aad)
89        .map_err(|e| StageCommitError::MalformedVcCommitData(e.to_string()))?;
90    Ok(commit_data.is_some_and(|commit_data| commit_data.creates_derivation_epoch()))
91}
92
93impl MlsGroup {
94    /// Returns `true` when `received_tag` is the confirmation tag produced by
95    /// our pending member commit, i.e. the incoming Commit is that pending
96    /// commit this client got fanned out by the delivery service. Returns
97    /// `false` when we hold no member pending commit or its tag differs.
98    ///
99    /// We compare confirmation tags rather than the full Commit contents: the
100    /// signature has already authenticated the Commit as ours, and a matching
101    /// confirmation tag binds the confirmed transcript hash of the new epoch.
102    pub(crate) fn matches_pending_commit(&self, received_tag: &ConfirmationTag) -> bool {
103        let MlsGroupState::PendingCommit(pending_commit_state) = &self.group_state else {
104            return false;
105        };
106        let PendingCommitState::Member(staged_commit) = pending_commit_state.as_ref() else {
107            return false;
108        };
109        let StagedCommitState::GroupMember(member_state) = &staged_commit.state else {
110            return false;
111        };
112        member_state.staged_diff.confirmation_tag() == received_tag
113    }
114
115    #[openmls_traits::maybe_async]
116    async fn derive_epoch_secrets(
117        &self,
118        provider: &impl OpenMlsProvider,
119        apply_proposals_values: ApplyProposalsValues,
120        epoch_secrets: &GroupEpochSecrets,
121        commit_secret: CommitSecret,
122        serialized_provisional_group_context: &[u8],
123        #[cfg(feature = "virtual-clients-draft")] vc_external_init_secret: Option<
124            &crate::components::vc_derivation_info::ExternalInitSecret,
125        >,
126    ) -> Result<EpochSecretsResult, StageCommitError> {
127        // Check if we need to include the init secret from an external commit
128        // we applied earlier or if we use the one from the previous epoch.
129        let joiner_secret = if let Some(ref external_init_proposal) =
130            apply_proposals_values.external_init_proposal_option
131        {
132            // A sibling emulator client processing the virtual client's
133            // external commit uses the external init secret carried in the
134            // commit's derivation info, since it may not hold the previous
135            // epoch's `external_secret` (always absent without the feature).
136            // Everyone else (ordinary external commits) decapsulates the
137            // carried `kem_output` as usual.
138            #[cfg(feature = "virtual-clients-draft")]
139            let carried_init_secret = vc_external_init_secret
140                .map(|carried| InitSecret::from(Secret::from_slice(carried.as_slice())));
141            #[cfg(not(feature = "virtual-clients-draft"))]
142            let carried_init_secret: Option<InitSecret> = None;
143
144            let init_secret = match carried_init_secret {
145                Some(init_secret) => init_secret,
146                None => {
147                    let external_priv = epoch_secrets
148                        .external_secret()
149                        .derive_external_keypair(provider.crypto(), self.ciphersuite())
150                        .map_err(LibraryError::unexpected_crypto_error)?
151                        .private;
152                    InitSecret::from_kem_output(
153                        provider.crypto(),
154                        self.ciphersuite(),
155                        self.version(),
156                        &external_priv,
157                        external_init_proposal.kem_output(),
158                    )?
159                }
160            };
161            JoinerSecret::new(
162                provider.crypto(),
163                self.ciphersuite(),
164                commit_secret,
165                &init_secret,
166                serialized_provisional_group_context,
167            )
168            .map_err(LibraryError::unexpected_crypto_error)?
169        } else {
170            JoinerSecret::new(
171                provider.crypto(),
172                self.ciphersuite(),
173                commit_secret,
174                epoch_secrets.init_secret(),
175                serialized_provisional_group_context,
176            )
177            .map_err(LibraryError::unexpected_crypto_error)?
178        };
179
180        // Prepare the PskSecret
181        // Fails if PSKs are missing ([valn1205](https://validation.openmls.tech/#valn1205))
182        let psk_secret = {
183            let psks: Vec<(&PreSharedKeyId, Secret)> = load_psks(
184                provider.storage(),
185                &self.resumption_psk_store,
186                Some(self.group_id()),
187                None,
188                &apply_proposals_values.presharedkeys,
189            )
190            .await?;
191
192            PskSecret::new(provider.crypto(), self.ciphersuite(), psks)?
193        };
194
195        // Create key schedule
196        let mut key_schedule = KeySchedule::init(
197            self.ciphersuite(),
198            provider.crypto(),
199            &joiner_secret,
200            psk_secret,
201        )?;
202
203        key_schedule
204            .add_context(provider.crypto(), serialized_provisional_group_context)
205            .map_err(|_| LibraryError::custom("Using the key schedule in the wrong state"))?;
206        Ok(key_schedule
207            .epoch_secrets(provider.crypto(), self.ciphersuite())
208            .map_err(|_| LibraryError::custom("Using the key schedule in the wrong state"))?)
209    }
210
211    /// Stages a commit message. The commit may have been sent by another group
212    /// member or be our own Commit without an UpdatePath. This
213    /// function does the following:
214    ///  - Applies the proposals covered by the commit to the tree
215    ///  - Applies the (optional) update path to the tree
216    ///  - Decrypts and calculates the path secrets
217    ///  - Initializes the key schedule for epoch rollover
218    ///  - Verifies the confirmation tag
219    ///
220    /// Returns a [StagedCommit] that can be inspected and later merged into the
221    /// group state with [MlsGroup::merge_commit()]. If the member was not
222    /// removed from the group, the function also returns an
223    /// [ApplicationExportSecret].
224    ///
225    /// This function does the following checks:
226    ///  - ValSem101
227    ///  - ValSem102
228    ///  - ValSem104
229    ///  - ValSem105
230    ///  - ValSem106
231    ///  - ValSem107
232    ///  - ValSem108
233    ///  - ValSem110
234    ///  - ValSem111
235    ///  - ValSem112
236    ///  - ValSem113: All Proposals: The proposal type must be supported by all
237    ///    members of the group
238    ///  - ValSem200
239    ///  - ValSem201
240    ///  - ValSem202: Path must be the right length
241    ///  - ValSem203: Path secrets must decrypt correctly
242    ///  - ValSem204: Public keys from Path must be verified and match the
243    ///    private keys from the direct path
244    ///  - ValSem205
245    ///  - ValSem240
246    ///  - ValSem241
247    ///  - ValSem242
248    ///  - ValSem244
249    #[openmls_traits::maybe_async]
250    pub(crate) async fn stage_commit(
251        &self,
252        mls_content: &AuthenticatedContent,
253        old_epoch_keypairs: Vec<EncryptionKeyPair>,
254        leaf_node_keypairs: Vec<EncryptionKeyPair>,
255        provider: &impl OpenMlsProvider,
256        lifetime_policy: LeafNodeLifetimePolicy,
257        #[cfg(feature = "virtual-clients-draft")] vc_commit_material: Option<
258            crate::components::vc_derivation_info::VcCommitMaterial,
259        >,
260    ) -> Result<StagedCommit, StageCommitError> {
261        let (commit, proposal_queue, sender_index) =
262            self.public_group
263                .validate_commit(mls_content, provider.crypto(), lifetime_policy)?;
264
265        // Create the provisional public group state (including the tree and
266        // group context) and apply proposals.
267        let mut diff = self.public_group.empty_diff();
268
269        #[cfg(not(feature = "extensions-draft"))]
270        let apply_proposals_values =
271            diff.apply_proposals(&proposal_queue, self.own_leaf_index())?;
272
273        #[cfg(feature = "extensions-draft")]
274        let apply_proposals_values = diff.apply_proposals_with_app_data_updates(
275            &proposal_queue,
276            self.own_leaf_index(),
277            None,
278        )?;
279        self.stage_applied_proposal_values(
280            apply_proposals_values,
281            diff,
282            commit,
283            proposal_queue,
284            sender_index,
285            mls_content,
286            old_epoch_keypairs,
287            leaf_node_keypairs,
288            provider,
289            #[cfg(feature = "virtual-clients-draft")]
290            vc_commit_material,
291        )
292        .await
293    }
294
295    #[cfg(feature = "extensions-draft")]
296    #[allow(clippy::too_many_arguments)]
297    #[openmls_traits::maybe_async]
298    pub(crate) async fn stage_commit_with_app_data_updates(
299        &self,
300        mls_content: &AuthenticatedContent,
301        old_epoch_keypairs: Vec<EncryptionKeyPair>,
302        leaf_node_keypairs: Vec<EncryptionKeyPair>,
303        app_data_dict_updates: Option<AppDataUpdates>,
304        provider: &impl OpenMlsProvider,
305        lifetime_policy: LeafNodeLifetimePolicy,
306        #[cfg(feature = "virtual-clients-draft")] vc_commit_material: Option<
307            crate::components::vc_derivation_info::VcCommitMaterial,
308        >,
309    ) -> Result<StagedCommit, StageCommitError> {
310        let (commit, proposal_queue, sender_index) =
311            self.public_group
312                .validate_commit(mls_content, provider.crypto(), lifetime_policy)?;
313
314        // Create the provisional public group state (including the tree and
315        // group context) and apply proposals.
316        let mut diff = self.public_group.empty_diff();
317
318        let apply_proposals_values = diff.apply_proposals_with_app_data_updates(
319            &proposal_queue,
320            self.own_leaf_index(),
321            app_data_dict_updates,
322        )?;
323
324        self.stage_applied_proposal_values(
325            apply_proposals_values,
326            diff,
327            commit,
328            proposal_queue,
329            sender_index,
330            mls_content,
331            old_epoch_keypairs,
332            leaf_node_keypairs,
333            provider,
334            #[cfg(feature = "virtual-clients-draft")]
335            vc_commit_material,
336        )
337        .await
338    }
339
340    #[allow(clippy::too_many_arguments)]
341    #[openmls_traits::maybe_async]
342    async fn stage_applied_proposal_values(
343        &self,
344        apply_proposals_values: ApplyProposalsValues,
345        mut diff: PublicGroupDiff<'_>,
346        commit: &Commit,
347        proposal_queue: ProposalQueue,
348        sender_index: LeafNodeIndex,
349        mls_content: &AuthenticatedContent,
350        old_epoch_keypairs: Vec<EncryptionKeyPair>,
351        leaf_node_keypairs: Vec<EncryptionKeyPair>,
352        provider: &impl OpenMlsProvider,
353        #[cfg(feature = "virtual-clients-draft")] vc_commit_material: Option<
354            crate::components::vc_derivation_info::VcCommitMaterial,
355        >,
356    ) -> Result<StagedCommit, StageCommitError> {
357        let ciphersuite = self.ciphersuite();
358
359        // Only an emulation group with Safe AAD framing acts on the marker, so
360        // only there does a malformed item fail the commit.
361        #[cfg(feature = "virtual-clients-draft")]
362        let marks_new_vc_derivation_epoch =
363            if self.is_emulation_group() && self.context().safe_aad_required() {
364                commit_marks_new_vc_derivation_epoch(mls_content.authenticated_data())?
365            } else {
366                false
367            };
368
369        // Unbundle the sibling-VC commit material: the per-commit operation
370        // secret recreates the path, the emulation `epoch_id` is recorded on
371        // the staged commit, and the external init secret (external commits
372        // only) feeds the key schedule.
373        #[cfg(feature = "virtual-clients-draft")]
374        let (vc_material, vc_derivation_epoch_id, vc_external_init_secret) =
375            match vc_commit_material {
376                Some(material) => (
377                    Some(material.operation_secret),
378                    Some(material.epoch_id),
379                    material.external_init_secret,
380                ),
381                None => (None, None, None),
382            };
383
384        // A sibling-resync external commit is a VC external commit sent by a
385        // sibling emulator client to onboard itself into this higher-level
386        // group, inline-removing our existing leaf. The receiver-side gate in
387        // `process_internal_authenticated_content[_with_app_data_updates]` (see
388        // `is_sibling_vc_commit`) sets `vc_material = Some(_)` only for
389        // own-leaf VC commits and sibling-resync external commits, so here the
390        // two-condition check fully identifies the resync case:
391        //
392        //   - `vc_material` is `Some`, which after the upstream gate means the
393        //     commit carries a VC derivation-info entry and we hold per-epoch
394        //     state for the referenced `epoch_id`.
395        //   - the sender is `NewMemberCommit`, since own-leaf VC commits arrive as
396        //     `Sender::Member`, so this disambiguates the two sibling shapes.
397        //
398        // When this holds, we (a) skip the `self_removed` short-circuit so we
399        // don't transition to `Inactive`, (b) derive the path from the
400        // per-commit `OperationSecret` (we have no HPKE recipient on the path),
401        // and (c) record the new leaf index on the staged commit so
402        // `merge_commit` can update `own_leaf_index` before filtering owned
403        // encryption keypairs.
404        #[cfg(feature = "virtual-clients-draft")]
405        let is_sibling_resync =
406            vc_material.is_some() && matches!(mls_content.sender(), Sender::NewMemberCommit);
407        #[cfg(not(feature = "virtual-clients-draft"))]
408        let is_sibling_resync = false;
409
410        // A sibling-resync external commit MUST carry the external init secret
411        // in its derivation info (mls-virtual-clients draft): the
412        // sibling uses it as the new epoch's external init secret. Reject the
413        // commit if it is absent.
414        #[cfg(feature = "virtual-clients-draft")]
415        validate_vc_external_init_secret(
416            is_sibling_resync,
417            apply_proposals_values
418                .external_init_proposal_option
419                .is_some(),
420            vc_external_init_secret.is_some(),
421        )?;
422
423        // Determine if Commit has a path
424        let (commit_secret, new_keypairs, new_leaf_keypair_option, update_path_leaf_node) =
425            if let Some(path) = commit.path.clone() {
426                // Update the public group
427                // ValSem202: Path must be the right length
428                diff.apply_received_update_path(
429                    provider.crypto(),
430                    ciphersuite,
431                    sender_index,
432                    &path,
433                )?;
434
435                // Update group context
436                diff.update_group_context(
437                    provider.crypto(),
438                    apply_proposals_values.extensions.clone(),
439                )?;
440
441                // Check if we were removed from the group. The sibling-resync
442                // discriminator carves out the case where the `Remove` of our
443                // leaf is the auto-Remove paired with a sibling emulator's
444                // external commit. In that case our state survives on the
445                // joiner's new leaf, so we must continue processing.
446                if apply_proposals_values.self_removed && !is_sibling_resync {
447                    // If so, we return here, because we can't decrypt the path
448                    let staged_diff = diff.into_staged_diff(provider.crypto(), ciphersuite)?;
449                    let staged_state = PublicStagedCommitState::new(
450                        staged_diff,
451                        commit.path.as_ref().map(|path| path.leaf_node().clone()),
452                    );
453                    let staged_commit = StagedCommit::new(
454                        proposal_queue,
455                        StagedCommitState::PublicState(Box::new(staged_state)),
456                        #[cfg(feature = "virtual-clients-draft")]
457                        None,
458                    );
459                    return Ok(staged_commit);
460                }
461
462                // When processing a commit sent by a sibling virtual client
463                // (either our own leaf, or a sibling-resync external commit
464                // onto a new leaf), we have no HPKE recipient on the path.
465                // Re-derive the path from the per-commit `OperationSecret`
466                // the receiver derived from the per-epoch operation secret
467                // tree, and verify the resulting public keys against the
468                // commit. The non-VC `decrypt_path` is the fallback for
469                // everyone else.
470                #[cfg(feature = "virtual-clients-draft")]
471                let vc_path: Option<(Vec<EncryptionKeyPair>, CommitSecret)> = if sender_index
472                    == self.own_leaf_index()
473                    || is_sibling_resync
474                {
475                    let operation_secret = vc_material.ok_or(
476                            crate::components::vc_derivation_info::VirtualClientsError::MissingOperationTree,
477                        )?;
478                    Some(self.recreate_path_for_own_commit(
479                        &diff,
480                        &path,
481                        ciphersuite,
482                        self.group_id(),
483                        provider.crypto(),
484                        sender_index,
485                        operation_secret,
486                    )?)
487                } else {
488                    None
489                };
490                #[cfg(not(feature = "virtual-clients-draft"))]
491                let vc_path: Option<(Vec<EncryptionKeyPair>, CommitSecret)> = None;
492
493                // ValSem203: Path secrets must decrypt correctly
494                // ValSem204: Public keys from Path must be verified and match the private keys from the direct path
495                let (new_keypairs, commit_secret) = if let Some(pair) = vc_path {
496                    pair
497                } else {
498                    let decryption_keypairs: Vec<&EncryptionKeyPair> = old_epoch_keypairs
499                        .iter()
500                        .chain(leaf_node_keypairs.iter())
501                        .collect();
502                    diff.decrypt_path(
503                        provider.crypto(),
504                        &decryption_keypairs,
505                        self.own_leaf_index(),
506                        sender_index,
507                        path.nodes(),
508                        &apply_proposals_values.exclusion_list(),
509                    )?
510                };
511
512                // Check if one of our update proposals was applied. If so, we
513                // need to store that keypair separately, because after merging
514                // it needs to be removed from the key store separately and in
515                // addition to the removal of the keypairs of the previous
516                // epoch.
517                let new_leaf_keypair_option = if is_sibling_resync {
518                    None
519                } else if let Some(leaf) = diff.leaf(self.own_leaf_index()) {
520                    leaf_node_keypairs.into_iter().find_map(|keypair| {
521                        if leaf.encryption_key() == keypair.public_key() {
522                            Some(keypair)
523                        } else {
524                            None
525                        }
526                    })
527                } else {
528                    // We should have an own leaf at this point.
529                    debug_assert!(false);
530                    None
531                };
532
533                // Return the leaf node in the update path so the credential can be validated.
534                // Since the diff has already been updated, this should be the same as the leaf
535                // at the sender index.
536                let update_path_leaf_node = Some(path.leaf_node().clone());
537                debug_assert_eq!(diff.leaf(sender_index), path.leaf_node().into());
538
539                (
540                    commit_secret,
541                    new_keypairs,
542                    new_leaf_keypair_option,
543                    update_path_leaf_node,
544                )
545            } else {
546                if apply_proposals_values.path_required {
547                    // ValSem201
548                    return Err(StageCommitError::RequiredPathNotFound);
549                }
550
551                // Even if there is no path, we have to update the group context.
552                diff.update_group_context(
553                    provider.crypto(),
554                    apply_proposals_values.extensions.clone(),
555                )?;
556
557                (CommitSecret::zero_secret(ciphersuite), vec![], None, None)
558            };
559
560        // Update the confirmed transcript hash before we compute the confirmation tag.
561        diff.update_confirmed_transcript_hash(provider.crypto(), mls_content)?;
562
563        let received_confirmation_tag = mls_content
564            .confirmation_tag()
565            .ok_or(StageCommitError::ConfirmationTagMissing)?;
566
567        let serialized_provisional_group_context = diff
568            .group_context()
569            .tls_serialize_detached()
570            .map_err(LibraryError::missing_bound_check)?;
571
572        #[cfg(feature = "virtual-clients-draft")]
573        let provisional_own_leaf_index = if is_sibling_resync {
574            sender_index
575        } else {
576            self.own_leaf_index()
577        };
578        #[cfg(not(feature = "virtual-clients-draft"))]
579        let provisional_own_leaf_index = self.own_leaf_index();
580
581        let EpochSecretsResult {
582            epoch_secrets,
583            #[cfg(feature = "extensions-draft")]
584            application_exporter,
585        } = self
586            .derive_epoch_secrets(
587                provider,
588                apply_proposals_values,
589                self.group_epoch_secrets(),
590                commit_secret,
591                &serialized_provisional_group_context,
592                #[cfg(feature = "virtual-clients-draft")]
593                vc_external_init_secret.as_ref(),
594            )
595            .await?;
596        let (provisional_group_secrets, provisional_message_secrets) = epoch_secrets.split_secrets(
597            serialized_provisional_group_context,
598            diff.tree_size(),
599            provisional_own_leaf_index,
600        );
601
602        // Verify confirmation tag
603        // ValSem205
604        let own_confirmation_tag = provisional_message_secrets
605            .confirmation_key()
606            .tag(
607                provider.crypto(),
608                self.ciphersuite(),
609                diff.group_context().confirmed_transcript_hash(),
610            )
611            .map_err(LibraryError::unexpected_crypto_error)?;
612        if &own_confirmation_tag != received_confirmation_tag {
613            log::error!("Confirmation tag mismatch");
614            log_crypto!(trace, "  Got:      {:x?}", received_confirmation_tag);
615            log_crypto!(trace, "  Expected: {:x?}", own_confirmation_tag);
616            // TODO: We have tests expecting this error.
617            //       They need to be rewritten.
618            // debug_assert!(false, "Confirmation tag mismatch");
619
620            // in some tests we need to be able to proceed despite the tag being wrong,
621            // e.g. to test whether a later validation check is performed correctly.
622            if !crate::skip_validation::is_disabled::confirmation_tag() {
623                return Err(StageCommitError::ConfirmationTagMismatch);
624            }
625        }
626
627        diff.update_interim_transcript_hash(ciphersuite, provider.crypto(), own_confirmation_tag)?;
628
629        let staged_diff = diff.into_staged_diff(provider.crypto(), ciphersuite)?;
630        #[cfg(feature = "extensions-draft")]
631        let application_export_tree = ApplicationExportTree::new(application_exporter);
632        #[cfg(feature = "virtual-clients-draft")]
633        let new_own_leaf_index = is_sibling_resync.then_some(provisional_own_leaf_index);
634        let staged_commit_state =
635            StagedCommitState::GroupMember(Box::new(MemberStagedCommitState::new(
636                provisional_group_secrets,
637                provisional_message_secrets,
638                staged_diff,
639                new_keypairs,
640                new_leaf_keypair_option,
641                update_path_leaf_node,
642                #[cfg(feature = "extensions-draft")]
643                application_export_tree,
644                #[cfg(feature = "virtual-clients-draft")]
645                new_own_leaf_index,
646            )));
647        #[cfg_attr(not(feature = "virtual-clients-draft"), allow(unused_mut))]
648        let mut staged_commit = StagedCommit::new(
649            proposal_queue,
650            staged_commit_state,
651            #[cfg(feature = "virtual-clients-draft")]
652            vc_derivation_epoch_id,
653        );
654        #[cfg(feature = "virtual-clients-draft")]
655        {
656            staged_commit.marks_new_vc_derivation_epoch = marks_new_vc_derivation_epoch;
657        }
658
659        Ok(staged_commit)
660    }
661
662    /// Re-derive the path of a commit sent by a sibling virtual client
663    /// (either through our own higher-level leaf, or onto a new leaf via a
664    /// sibling-resync external commit) from the per-commit `OperationSecret`
665    /// resolved by the caller (in `process_message`, via PPRF evaluation), and
666    /// verify that the public keys derived from the path secret match the
667    /// leaf node in the path. Returns the derived parent-node keypairs and
668    /// commit secret, ready to be slotted in where `decrypt_path` would
669    /// normally produce them.
670    ///
671    /// `sender_index` is the leaf the path originates from. For an own-leaf
672    /// VC commit this equals `self.own_leaf_index()`. For a sibling-resync
673    /// external commit it is the joiner's new leaf (the `leftmost_free_index`
674    /// the external-commit builder chose), which is where the path actually
675    /// starts.
676    ///
677    /// Signature key changes are not verified here: not every signature
678    /// scheme has an interoperable seed-to-keypair construction, so the
679    /// application is responsible for supplying any rotated signature key
680    /// pair to the storage provider out-of-band. The new public key on the
681    /// path leaf is already authenticated by the commit's standard
682    /// path-validation against the previous signature key.
683    #[cfg(feature = "virtual-clients-draft")]
684    #[expect(clippy::too_many_arguments)]
685    fn recreate_path_for_own_commit(
686        &self,
687        diff: &PublicGroupDiff,
688        path: &crate::treesync::treekem::UpdatePath,
689        group_ciphersuite: openmls_traits::types::Ciphersuite,
690        group_id: &crate::prelude::GroupId,
691        crypto: &impl OpenMlsCrypto,
692        sender_index: LeafNodeIndex,
693        operation_secret: crate::components::vc_derivation_info::OperationSecret,
694    ) -> Result<(Vec<EncryptionKeyPair>, CommitSecret), StageCommitError> {
695        use crate::components::vc_derivation_info::VirtualClientsError;
696
697        let target_operation_secret =
698            operation_secret.derive_target_operation_secret(crypto, group_ciphersuite, group_id)?;
699
700        let path_secret = target_operation_secret
701            .derive_path_generation_secret(crypto, group_ciphersuite)?
702            .into();
703        let (encryption_key_pairs, commit_secret) =
704            diff.recreate_path_from_path_secret(crypto, path_secret, sender_index, path.nodes())?;
705
706        // Verify that the leaf encryption key in the path matches the one
707        // derived from the operation secret.
708        let leaf_keypair = target_operation_secret
709            .derive_encryption_key_secret(crypto, group_ciphersuite)?
710            .generate_encryption_key_pair(crypto, group_ciphersuite)?;
711        drop(target_operation_secret);
712        if leaf_keypair.public_key() != path.leaf_node().encryption_key() {
713            return Err(VirtualClientsError::EncryptionKeyMismatch.into());
714        }
715
716        // Mirror the sender's `apply_own_update_path`: the leaf keypair is
717        // prepended to the parent keypairs so the merge step has private
718        // keys for all of the new epoch's owned encryption keys.
719        let mut keypairs = Vec::with_capacity(1 + encryption_key_pairs.len());
720        keypairs.push(leaf_keypair);
721        keypairs.extend(encryption_key_pairs);
722        Ok((keypairs, commit_secret))
723    }
724
725    /// Returns whether merging `staged_commit` has to register the epoch it
726    /// moves this group into as a virtual-clients derivation epoch.
727    ///
728    /// That is the case when all of the following hold:
729    ///
730    /// - this group is an emulation group, since no other group derives
731    ///   virtual-client secrets from its exporter,
732    /// - the merge installs member state, since a self-removal or a
733    ///   public-state merge leaves no exporter to puncture, and
734    /// - the commit changes membership or carries a `new_derivation_epoch`
735    ///   action in its virtual-clients Safe AAD item.
736    #[cfg(feature = "virtual-clients-draft")]
737    pub(crate) fn commit_creates_vc_derivation_epoch(&self, staged_commit: &StagedCommit) -> bool {
738        self.is_emulation_group()
739            && matches!(staged_commit.state, StagedCommitState::GroupMember(_))
740            && (staged_commit.marks_new_vc_derivation_epoch || staged_commit.changes_membership())
741    }
742
743    /// Merges a [StagedCommit] into the group state and optionally return a [`SecretTree`]
744    /// from the previous epoch. The secret tree is returned if the Commit does not contain a self removal.
745    ///
746    /// Beyond a `LibraryError`, this fails on a storage error, and on an
747    /// emulation group it also fails if registering the derivation epoch the
748    /// commit creates fails. The group is already advanced in memory by then,
749    /// and a storage transaction does not roll that back, so a caller that sees
750    /// an error has to discard this group and load it again.
751    #[openmls_traits::maybe_async]
752    pub(crate) async fn merge_commit<Provider: OpenMlsProvider>(
753        &mut self,
754        provider: &Provider,
755        staged_commit: StagedCommit,
756    ) -> Result<(), MergeCommitError<Provider::StorageError>> {
757        // Get all keypairs from the old epoch, so we can later store the ones
758        // that are still relevant in the new epoch.
759        let old_epoch_keypairs = self
760            .read_epoch_keypairs(provider.storage())
761            .await
762            .map_err(MergeCommitError::StorageError)?;
763
764        #[cfg(feature = "virtual-clients-draft")]
765        let creates_vc_derivation_epoch = self.commit_creates_vc_derivation_epoch(&staged_commit);
766
767        match staged_commit.state {
768            StagedCommitState::PublicState(staged_state) => {
769                self.public_group
770                    .merge_diff(staged_state.into_staged_diff());
771                self.store(provider.storage())
772                    .await
773                    .map_err(MergeCommitError::StorageError)?;
774                Ok(())
775            }
776            StagedCommitState::GroupMember(state) => {
777                // Save the past epoch
778                let past_epoch = self.context().epoch();
779                // Get all the full leaves
780                let leaves = self.public_group().members().collect();
781                // Merge the staged commit into the group state and store the secret tree from the
782                // previous epoch in the message secrets store.
783                self.group_epoch_secrets = state.group_epoch_secrets;
784
785                // Replace the previous message secrets with the new ones and return the previous message secrets
786                let old_message_secrets = self
787                    .message_secrets_store
788                    .replace_current_message_secrets(state.message_secrets);
789                self.message_secrets_store.add_past_epoch_tree(
790                    past_epoch,
791                    old_message_secrets,
792                    leaves,
793                );
794
795                self.public_group.merge_diff(state.staged_diff);
796
797                #[cfg(feature = "virtual-clients-draft")]
798                let previous_own_leaf_index = self.own_leaf_index;
799
800                // Sibling-resync external commit: install the joiner's new
801                // leaf as our own before filtering keypairs. The call to
802                // `owned_encryption_keys(self.own_leaf_index())` below relies
803                // on this value.
804                #[cfg(feature = "virtual-clients-draft")]
805                if let Some(new_idx) = state.new_own_leaf_index {
806                    self.own_leaf_index = new_idx;
807                }
808
809                // Replace the previous exporter tree with the new one. This
810                // happens after the public group is merged, so that a
811                // virtual-clients registration sees the new epoch's tree size
812                // and own leaf index.
813                #[cfg(feature = "extensions-draft")]
814                {
815                    // The application exporter is only None if the group was
816                    // stored using an older version of OpenMLS that did not
817                    // support the application exporter. Registration then fails,
818                    // which it has to: merging without registering would
819                    // silently keep the old derivation epoch active.
820                    #[cfg_attr(not(feature = "virtual-clients-draft"), allow(unused_mut))]
821                    let mut application_export_tree = state.application_export_tree;
822
823                    // The registration punctures the new epoch's exporter, so it
824                    // has to run before the tree is persisted.
825                    #[cfg(feature = "virtual-clients-draft")]
826                    if creates_vc_derivation_epoch {
827                        crate::components::vc_derivation_info::register_vc_derivation_epoch(
828                            provider.crypto(),
829                            provider.storage(),
830                            application_export_tree.as_mut(),
831                            crate::components::vc_derivation_info::VcDerivationEpochParams::for_public_group(
832                                self.public_group(),
833                                self.own_leaf_index(),
834                                self.mls_group_config
835                                    .vc_derivation_epoch_retention_policy()
836                                    .clone(),
837                            ),
838                        )
839                        .await?;
840                    }
841
842                    if let Some(application_export_tree) = application_export_tree {
843                        // Overwrite the existing exporter tree in the storage.
844                        use openmls_traits::storage::StorageProvider as _;
845                        provider
846                            .storage()
847                            .write_application_export_tree(
848                                self.group_id(),
849                                &application_export_tree,
850                            )
851                            .await
852                            .map_err(MergeCommitError::StorageError)?;
853
854                        self.application_export_tree = Some(application_export_tree);
855                    }
856                }
857
858                let leaf_keypair = if let Some(keypair) = &state.new_leaf_keypair_option {
859                    vec![keypair.clone()]
860                } else {
861                    vec![]
862                };
863
864                // Figure out which keys we need in the new epoch.
865                let new_owned_encryption_keys = self
866                    .public_group()
867                    .owned_encryption_keys(self.own_leaf_index());
868                // From the old and new keys, keep the ones that are still relevant in the new epoch.
869                let epoch_keypairs: Vec<EncryptionKeyPair> = old_epoch_keypairs
870                    .into_iter()
871                    .chain(state.new_keypairs)
872                    .chain(leaf_keypair)
873                    .filter(|keypair| new_owned_encryption_keys.contains(keypair.public_key()))
874                    .collect();
875
876                // We should have private keys for all owned encryption keys.
877                debug_assert_eq!(new_owned_encryption_keys.len(), epoch_keypairs.len());
878                if new_owned_encryption_keys.len() != epoch_keypairs.len() {
879                    return Err(LibraryError::custom(
880                        "We should have all the private key material we need.",
881                    )
882                    .into());
883                }
884
885                // Store the updated group state
886                let storage = provider.storage();
887                let group_id = self.group_id();
888
889                self.public_group
890                    .store(storage)
891                    .await
892                    .map_err(MergeCommitError::StorageError)?;
893                storage
894                    .write_own_leaf_index(group_id, &self.own_leaf_index)
895                    .await
896                    .map_err(MergeCommitError::StorageError)?;
897                storage
898                    .write_group_epoch_secrets(group_id, &self.group_epoch_secrets)
899                    .await
900                    .map_err(MergeCommitError::StorageError)?;
901                storage
902                    .write_message_secrets(group_id, &self.message_secrets_store)
903                    .await
904                    .map_err(MergeCommitError::StorageError)?;
905
906                // Store the relevant keys under the new epoch
907                self.store_epoch_keypairs(storage, epoch_keypairs.as_slice())
908                    .await
909                    .map_err(MergeCommitError::StorageError)?;
910
911                // Delete the old keys.
912                self.delete_previous_epoch_keypairs(
913                    storage,
914                    #[cfg(feature = "virtual-clients-draft")]
915                    previous_own_leaf_index,
916                )
917                .await
918                .map_err(MergeCommitError::StorageError)?;
919                if let Some(keypair) = state.new_leaf_keypair_option {
920                    keypair
921                        .delete(storage)
922                        .await
923                        .map_err(MergeCommitError::StorageError)?;
924                }
925
926                // Empty the proposal store
927                storage
928                    .clear_proposal_queue::<GroupId, ProposalRef>(group_id)
929                    .await
930                    .map_err(MergeCommitError::StorageError)?;
931                self.proposal_store_mut().empty();
932
933                Ok(())
934            }
935        }
936    }
937}
938
939#[derive(Debug, Serialize, Deserialize)]
940#[cfg_attr(any(test, feature = "test-utils"), derive(Clone, PartialEq))]
941pub(crate) enum StagedCommitState {
942    PublicState(Box<PublicStagedCommitState>),
943    /// The group member variant of the staged commit state.
944    GroupMember(Box<MemberStagedCommitState>),
945}
946
947/// Contains the changes from a commit to the group state.
948#[derive(Debug, Serialize, Deserialize)]
949#[cfg_attr(any(test, feature = "test-utils"), derive(Clone, PartialEq))]
950pub struct StagedCommit {
951    /// A queue containing the proposals associated with the commit.
952    pub staged_proposal_queue: ProposalQueue,
953    /// The staged commit state.
954    pub(super) state: StagedCommitState,
955    /// Derivation epoch this commit binds the group to on merge, when
956    /// the commit was built via `CommitBuilder::vc_emulation`.
957    #[cfg(feature = "virtual-clients-draft")]
958    #[serde(default, alias = "vc_emulation_epoch_id")]
959    // alias for backwards compatibility after renaming field
960    pub(super) vc_derivation_epoch_id: Option<crate::components::vc_derivation_info::EpochId>,
961    /// Whether the commit's virtual-clients Safe AAD item carries a
962    /// `new_derivation_epoch` action. Set when the commit is staged, and read
963    /// again at merge, which is why it is persisted with an own pending commit.
964    /// Membership changes are not covered here, they are read off the proposal
965    /// queue by [`Self::changes_membership`].
966    #[cfg(feature = "virtual-clients-draft")]
967    #[serde(default)]
968    pub(super) marks_new_vc_derivation_epoch: bool,
969}
970
971impl StagedCommit {
972    /// Create a new [`StagedCommit`] from the provisional group state created
973    /// during the commit process.
974    pub(crate) fn new(
975        staged_proposal_queue: ProposalQueue,
976        state: StagedCommitState,
977        #[cfg(feature = "virtual-clients-draft")] vc_derivation_epoch_id: Option<
978            crate::components::vc_derivation_info::EpochId,
979        >,
980    ) -> Self {
981        StagedCommit {
982            staged_proposal_queue,
983            state,
984            #[cfg(feature = "virtual-clients-draft")]
985            vc_derivation_epoch_id,
986            #[cfg(feature = "virtual-clients-draft")]
987            marks_new_vc_derivation_epoch: false,
988        }
989    }
990
991    /// Returns whether the commit adds or removes a member. External commits
992    /// count, since the sender joins the group through them.
993    #[cfg(feature = "virtual-clients-draft")]
994    fn changes_membership(&self) -> bool {
995        self.staged_proposal_queue
996            .queued_proposals()
997            .any(|queued| match queued.proposal() {
998                Proposal::Add(_)
999                | Proposal::Remove(_)
1000                | Proposal::SelfRemove
1001                | Proposal::ExternalInit(_) => true,
1002                Proposal::Update(_)
1003                | Proposal::PreSharedKey(_)
1004                | Proposal::ReInit(_)
1005                | Proposal::GroupContextExtensions(_)
1006                | Proposal::AppDataUpdate(_)
1007                | Proposal::AppEphemeral(_)
1008                | Proposal::Custom(_) => false,
1009            })
1010    }
1011
1012    /// Returns the epoch that this commit moves the group into
1013    pub fn epoch(&self) -> GroupEpoch {
1014        self.group_context().epoch()
1015    }
1016
1017    /// Returns the ratchet tree of the staged commit state.
1018    pub fn export_ratchet_tree(
1019        &self,
1020        crypto: &impl OpenMlsCrypto,
1021        original_tree: RatchetTree,
1022    ) -> Result<Option<RatchetTree>, TreeSyncFromNodesError> {
1023        match &self.state {
1024            StagedCommitState::PublicState(_public_staged_commit_state) => Ok(None),
1025            StagedCommitState::GroupMember(member_staged_commit_state) => Ok(Some(
1026                member_staged_commit_state.staged_diff.export_ratchet_tree(
1027                    crypto,
1028                    self.group_context().ciphersuite(),
1029                    original_tree,
1030                )?,
1031            )),
1032        }
1033    }
1034
1035    /// Returns the Add proposals that are covered by the Commit message as in iterator over [QueuedAddProposal].
1036    pub fn add_proposals(&self) -> impl Iterator<Item = QueuedAddProposal<'_>> {
1037        self.staged_proposal_queue.add_proposals()
1038    }
1039
1040    /// Returns the Remove proposals that are covered by the Commit message as in iterator over [QueuedRemoveProposal].
1041    pub fn remove_proposals(&self) -> impl Iterator<Item = QueuedRemoveProposal<'_>> {
1042        self.staged_proposal_queue.remove_proposals()
1043    }
1044
1045    /// Returns the Update proposals that are covered by the Commit message as in iterator over [QueuedUpdateProposal].
1046    pub fn update_proposals(&self) -> impl Iterator<Item = QueuedUpdateProposal<'_>> {
1047        self.staged_proposal_queue.update_proposals()
1048    }
1049
1050    /// Returns the PresharedKey proposals that are covered by the Commit message as in iterator over [QueuedPskProposal].
1051    pub fn psk_proposals(&self) -> impl Iterator<Item = QueuedPskProposal<'_>> {
1052        self.staged_proposal_queue.psk_proposals()
1053    }
1054
1055    /// Returns the [`ReInitProposal`] covered by the Commit message, if any.
1056    ///
1057    /// A commit that carries a ReInit proposal reinitializes the group: on
1058    /// merge the group is suspended and this proposal describes the parameters
1059    /// of the successor group.
1060    pub fn reinit_proposal(&self) -> Option<&ReInitProposal> {
1061        self.staged_proposal_queue.reinit_proposal()
1062    }
1063
1064    #[cfg(feature = "extensions-draft")]
1065    /// Returns the AppEphemeral proposals that are covered by the Commit message as an iterator
1066    /// over [`QueuedAppEphemeralProposal`].
1067    pub fn queued_app_ephemeral_proposals(
1068        &self,
1069    ) -> impl Iterator<Item = QueuedAppEphemeralProposal<'_>> {
1070        self.staged_proposal_queue.app_ephemeral_proposals()
1071    }
1072    // NOTE: this is not a default proposal type
1073    #[cfg(feature = "extensions-draft")]
1074    /// Returns the AppDataUpdate proposals that are covered by the Commit message as an iterator
1075    /// over [`QueuedAppDataUpdateProposal`].
1076    pub fn app_data_update_proposals(
1077        &self,
1078    ) -> impl Iterator<Item = QueuedAppDataUpdateProposal<'_>> {
1079        self.staged_proposal_queue.app_data_update_proposals()
1080    }
1081
1082    /// Returns an iterator over all [`QueuedProposal`]s.
1083    pub fn queued_proposals(&self) -> impl Iterator<Item = &QueuedProposal> {
1084        self.staged_proposal_queue.queued_proposals()
1085    }
1086
1087    /// Returns the leaf node of the (optional) update path.
1088    pub fn update_path_leaf_node(&self) -> Option<&LeafNode> {
1089        match self.state {
1090            StagedCommitState::PublicState(ref public_state) => {
1091                public_state.update_path_leaf_node()
1092            }
1093            StagedCommitState::GroupMember(ref group_member_state) => {
1094                group_member_state.update_path_leaf_node.as_ref()
1095            }
1096        }
1097    }
1098
1099    /// Returns the credentials that the caller needs to verify are valid.
1100    pub fn credentials_to_verify(&self) -> impl Iterator<Item = &Credential> {
1101        let update_path_leaf_node_cred = if let Some(node) = self.update_path_leaf_node() {
1102            vec![node.credential()]
1103        } else {
1104            vec![]
1105        };
1106
1107        update_path_leaf_node_cred
1108            .into_iter()
1109            .chain(
1110                self.queued_proposals()
1111                    .flat_map(|proposal: &QueuedProposal| match proposal.proposal() {
1112                        Proposal::Update(update_proposal) => {
1113                            vec![update_proposal.leaf_node().credential()].into_iter()
1114                        }
1115                        Proposal::Add(add_proposal) => {
1116                            vec![add_proposal.key_package().leaf_node().credential()].into_iter()
1117                        }
1118                        Proposal::GroupContextExtensions(gce_proposal) => gce_proposal
1119                            .extensions()
1120                            .iter()
1121                            .flat_map(|extension| {
1122                                match extension {
1123                                    Extension::ExternalSenders(external_senders) => {
1124                                        external_senders
1125                                            .iter()
1126                                            .map(|external_sender| external_sender.credential())
1127                                            .collect()
1128                                    }
1129                                    _ => vec![],
1130                                }
1131                                .into_iter()
1132                            })
1133                            // TODO: ideally we wouldn't collect in between here, but the match arms
1134                            //       have to all return the same type. We solve this by having them all
1135                            //       be vec::IntoIter, but it would be nice if we just didn't have to
1136                            //       do this.
1137                            //       It might be possible to solve this by letting all match arms
1138                            //       evaluate to a dyn Iterator.
1139                            .collect::<Vec<_>>()
1140                            .into_iter(),
1141                        _ => vec![].into_iter(),
1142                    }),
1143            )
1144    }
1145
1146    /// Returns `true` if the member was removed through a proposal covered by this Commit message
1147    /// and `false` otherwise.
1148    //
1149    // Sibling-resync external commits intentionally land in `GroupMember`
1150    // rather than `PublicState`, even though the proposal queue contains a
1151    // Remove of our own leaf. The new leaf carries our state forward, so
1152    // `self_removed()` returns `false` and `merge_staged_commit` keeps the
1153    // group active.
1154    pub fn self_removed(&self) -> bool {
1155        matches!(self.state, StagedCommitState::PublicState(_))
1156    }
1157
1158    /// Returns the [`GroupContext`] of the staged commit state.
1159    pub fn group_context(&self) -> &GroupContext {
1160        match self.state {
1161            StagedCommitState::PublicState(ref ps) => ps.staged_diff().group_context(),
1162            StagedCommitState::GroupMember(ref gm) => gm.group_context(),
1163        }
1164    }
1165    /// Consume this [`StagedCommit`] and return the internal [`StagedCommitState`].
1166    pub(crate) fn into_state(self) -> StagedCommitState {
1167        self.state
1168    }
1169
1170    /// Returns the [`EpochAuthenticator`] of the staged commit state if the
1171    /// owner of the originating group state is a member of the group. Returns
1172    /// `None` otherwise.
1173    pub fn epoch_authenticator(&self) -> Option<&EpochAuthenticator> {
1174        if let StagedCommitState::GroupMember(ref gm) = self.state {
1175            Some(gm.group_epoch_secrets.epoch_authenticator())
1176        } else {
1177            None
1178        }
1179    }
1180
1181    /// Returns the [`ResumptionPskSecret`] of the staged commit state if the
1182    /// owner of the originating group state is a member of the group. Returns
1183    /// `None` otherwise.
1184    pub fn resumption_psk_secret(&self) -> Option<&ResumptionPskSecret> {
1185        if let StagedCommitState::GroupMember(ref gm) = self.state {
1186            Some(gm.group_epoch_secrets.resumption_psk())
1187        } else {
1188            None
1189        }
1190    }
1191
1192    /// Safely exports a secret for the given `component_id` from the epoch the
1193    /// staged commit moves to, before the commit is merged.
1194    ///
1195    /// This is needed by components that feed a secret exported from one
1196    /// commit into the processing of a related commit, e.g. a PSK derived
1197    /// from one group's staged commit and consumed by another group's key
1198    /// schedule.
1199    #[cfg(feature = "extensions-draft")]
1200    pub fn safe_export_secret(
1201        &mut self,
1202        crypto: &impl OpenMlsCrypto,
1203        component_id: ComponentId,
1204    ) -> Result<ExportedSecret<StagedCommitSafeExport>, StagedSafeExportSecretError> {
1205        let ciphersuite = self.group_context().ciphersuite();
1206        let StagedCommitState::GroupMember(ref mut staged_commit) = self.state else {
1207            return Err(StagedSafeExportSecretError::NotGroupMember);
1208        };
1209        let Some(application_export_tree) = staged_commit.application_export_tree.as_mut() else {
1210            return Err(StagedSafeExportSecretError::Unsupported);
1211        };
1212        let secret =
1213            application_export_tree.safe_export_secret(crypto, ciphersuite, component_id)?;
1214        Ok(ExportedSecret::new(secret))
1215    }
1216
1217    /// Exports a secret from the epoch that the staged commit moves to.
1218    /// Returns [`ExportSecretError::KeyLengthTooLong`] if the requested
1219    /// key length is too long.
1220    /// Returns [`ExportSecretError::GroupStateError(MlsGroupStateError::UseAfterEviction)`]
1221    /// if the commit removed us from the group.
1222    ///
1223    /// [`ExportSecretError::GroupStateError(MlsGroupStateError::UseAfterEviction)`]: MlsGroupStateError::UseAfterEviction
1224    pub fn export_secret<CryptoProvider: OpenMlsCrypto>(
1225        &self,
1226        crypto: &CryptoProvider,
1227        label: &str,
1228        context: &[u8],
1229        key_length: usize,
1230    ) -> Result<ExportedSecret<StagedCommitExport>, ExportSecretError> {
1231        if key_length > u16::MAX as usize {
1232            log::error!("Got a key that is larger than u16::MAX");
1233            return Err(ExportSecretError::KeyLengthTooLong);
1234        }
1235
1236        match &self.state {
1237            StagedCommitState::PublicState(_public_staged_commit_state) => Err(
1238                ExportSecretError::GroupStateError(MlsGroupStateError::UseAfterEviction),
1239            ),
1240            StagedCommitState::GroupMember(member_staged_commit_state) => Ok(ExportedSecret::new(
1241                member_staged_commit_state
1242                    .group_epoch_secrets
1243                    .exporter_secret()
1244                    .derive_exported_secret(
1245                        self.group_context().ciphersuite(),
1246                        crypto,
1247                        label,
1248                        context,
1249                        key_length,
1250                    )
1251                    .map_err(LibraryError::unexpected_crypto_error)?,
1252            )),
1253        }
1254    }
1255}
1256
1257/// This struct is used internally by [`StagedCommit`] to encapsulate all the modified group state.
1258#[derive(Debug, Serialize, Deserialize)]
1259#[cfg_attr(any(test, feature = "test-utils"), derive(Clone, PartialEq))]
1260pub(crate) struct MemberStagedCommitState {
1261    group_epoch_secrets: GroupEpochSecrets,
1262    message_secrets: MessageSecrets,
1263    staged_diff: StagedPublicGroupDiff,
1264    new_keypairs: Vec<EncryptionKeyPair>,
1265    new_leaf_keypair_option: Option<EncryptionKeyPair>,
1266    update_path_leaf_node: Option<LeafNode>,
1267    #[cfg(feature = "extensions-draft")]
1268    #[serde(default)]
1269    // This is `None` only if the group was stored using an older version of
1270    // OpenMLS that did not support the application exporter.
1271    application_export_tree: Option<ApplicationExportTree>,
1272    // The new leaf index to install on the receiving group at merge time
1273    // when this staged commit is a sibling-resync external commit (a VC
1274    // external commit from a sibling emulator that inline-removes the
1275    // receiver's existing leaf). `None` for all other commit kinds.
1276    #[cfg(feature = "virtual-clients-draft")]
1277    #[serde(default)]
1278    new_own_leaf_index: Option<LeafNodeIndex>,
1279}
1280
1281impl MemberStagedCommitState {
1282    #[allow(clippy::too_many_arguments)]
1283    pub(crate) fn new(
1284        group_epoch_secrets: GroupEpochSecrets,
1285        message_secrets: MessageSecrets,
1286        staged_diff: StagedPublicGroupDiff,
1287        new_keypairs: Vec<EncryptionKeyPair>,
1288        new_leaf_keypair_option: Option<EncryptionKeyPair>,
1289        update_path_leaf_node: Option<LeafNode>,
1290        #[cfg(feature = "extensions-draft")] application_export_tree: ApplicationExportTree,
1291        #[cfg(feature = "virtual-clients-draft")] new_own_leaf_index: Option<LeafNodeIndex>,
1292    ) -> Self {
1293        Self {
1294            group_epoch_secrets,
1295            message_secrets,
1296            staged_diff,
1297            new_keypairs,
1298            new_leaf_keypair_option,
1299            update_path_leaf_node,
1300            #[cfg(feature = "extensions-draft")]
1301            application_export_tree: Some(application_export_tree),
1302            #[cfg(feature = "virtual-clients-draft")]
1303            new_own_leaf_index,
1304        }
1305    }
1306
1307    /// Get the staged [`GroupContext`].
1308    pub(crate) fn group_context(&self) -> &GroupContext {
1309        self.staged_diff.group_context()
1310    }
1311}
1312
1313#[cfg(all(test, feature = "virtual-clients-draft"))]
1314mod tests {
1315    use super::validate_vc_external_init_secret;
1316    use crate::{
1317        components::vc_derivation_info::VirtualClientsError, group::errors::StageCommitError,
1318    };
1319
1320    /// The two spec MUSTs behind `validate_vc_external_init_secret`: a
1321    /// sibling external commit whose derivation info omits the external init
1322    /// secret is rejected, and a carried init secret on a commit without an
1323    /// ExternalInit proposal is rejected. The conforming combinations pass.
1324    #[test]
1325    fn external_init_secret_presence_is_validated() {
1326        let malformed: Result<(), StageCommitError> =
1327            Err(VirtualClientsError::DerivationInfoMalformed.into());
1328
1329        // Sibling external commit without a carried init secret.
1330        assert_eq!(
1331            validate_vc_external_init_secret(true, true, false),
1332            malformed
1333        );
1334        // Carried init secret on a commit without an ExternalInit proposal.
1335        assert_eq!(
1336            validate_vc_external_init_secret(false, false, true),
1337            malformed
1338        );
1339        assert_eq!(
1340            validate_vc_external_init_secret(true, false, true),
1341            malformed
1342        );
1343
1344        // Conforming: external commit carrying the secret, and a regular
1345        // commit carrying none.
1346        assert_eq!(validate_vc_external_init_secret(true, true, true), Ok(()));
1347        assert_eq!(validate_vc_external_init_secret(false, true, false), Ok(()));
1348        assert_eq!(
1349            validate_vc_external_init_secret(false, false, false),
1350            Ok(())
1351        );
1352    }
1353}