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