openmls/group/mls_group/
staged_commit.rs

1use core::fmt::Debug;
2use std::mem;
3
4#[cfg(feature = "extensions-draft-08")]
5use openmls_traits::crypto::OpenMlsCrypto;
6use openmls_traits::storage::StorageProvider as _;
7use serde::{Deserialize, Serialize};
8use tls_codec::Serialize as _;
9
10use super::proposal_store::{
11    QueuedAddProposal, QueuedPskProposal, QueuedRemoveProposal, QueuedUpdateProposal,
12};
13
14use super::{
15    super::errors::*, load_psks, Credential, Extension, GroupContext, GroupEpochSecrets, GroupId,
16    JoinerSecret, KeySchedule, LeafNode, LibraryError, MessageSecrets, MlsGroup, OpenMlsProvider,
17    Proposal, ProposalQueue, PskSecret, QueuedProposal, Sender,
18};
19#[cfg(feature = "extensions-draft-08")]
20use crate::schedule::application_export_tree::ApplicationExportTree;
21
22use crate::{
23    ciphersuite::{hash_ref::ProposalRef, Secret},
24    framing::mls_auth_content::AuthenticatedContent,
25    group::public_group::{
26        diff::{apply_proposals::ApplyProposalsValues, StagedPublicGroupDiff},
27        staged_commit::PublicStagedCommitState,
28    },
29    schedule::{CommitSecret, EpochAuthenticator, EpochSecretsResult, InitSecret, PreSharedKeyId},
30    treesync::node::encryption_keys::EncryptionKeyPair,
31};
32
33impl MlsGroup {
34    fn derive_epoch_secrets(
35        &self,
36        provider: &impl OpenMlsProvider,
37        apply_proposals_values: ApplyProposalsValues,
38        epoch_secrets: &GroupEpochSecrets,
39        commit_secret: CommitSecret,
40        serialized_provisional_group_context: &[u8],
41    ) -> Result<EpochSecretsResult, StageCommitError> {
42        // Check if we need to include the init secret from an external commit
43        // we applied earlier or if we use the one from the previous epoch.
44        let joiner_secret = if let Some(ref external_init_proposal) =
45            apply_proposals_values.external_init_proposal_option
46        {
47            // Decrypt the content and derive the external init secret.
48            let external_priv = epoch_secrets
49                .external_secret()
50                .derive_external_keypair(provider.crypto(), self.ciphersuite())
51                .map_err(LibraryError::unexpected_crypto_error)?
52                .private;
53            let init_secret = InitSecret::from_kem_output(
54                provider.crypto(),
55                self.ciphersuite(),
56                self.version(),
57                &external_priv,
58                external_init_proposal.kem_output(),
59            )?;
60            JoinerSecret::new(
61                provider.crypto(),
62                self.ciphersuite(),
63                commit_secret,
64                &init_secret,
65                serialized_provisional_group_context,
66            )
67            .map_err(LibraryError::unexpected_crypto_error)?
68        } else {
69            JoinerSecret::new(
70                provider.crypto(),
71                self.ciphersuite(),
72                commit_secret,
73                epoch_secrets.init_secret(),
74                serialized_provisional_group_context,
75            )
76            .map_err(LibraryError::unexpected_crypto_error)?
77        };
78
79        // Prepare the PskSecret
80        // Fails if PSKs are missing ([valn1205](https://validation.openmls.tech/#valn1205))
81        let psk_secret = {
82            let psks: Vec<(&PreSharedKeyId, Secret)> = load_psks(
83                provider.storage(),
84                &self.resumption_psk_store,
85                &apply_proposals_values.presharedkeys,
86            )?;
87
88            PskSecret::new(provider.crypto(), self.ciphersuite(), psks)?
89        };
90
91        // Create key schedule
92        let mut key_schedule = KeySchedule::init(
93            self.ciphersuite(),
94            provider.crypto(),
95            &joiner_secret,
96            psk_secret,
97        )?;
98
99        key_schedule
100            .add_context(provider.crypto(), serialized_provisional_group_context)
101            .map_err(|_| LibraryError::custom("Using the key schedule in the wrong state"))?;
102        Ok(key_schedule
103            .epoch_secrets(provider.crypto(), self.ciphersuite())
104            .map_err(|_| LibraryError::custom("Using the key schedule in the wrong state"))?)
105    }
106
107    /// Stages a commit message that was sent by another group member. This
108    /// function does the following:
109    ///  - Applies the proposals covered by the commit to the tree
110    ///  - Applies the (optional) update path to the tree
111    ///  - Decrypts and calculates the path secrets
112    ///  - Initializes the key schedule for epoch rollover
113    ///  - Verifies the confirmation tag
114    ///
115    /// Returns a [StagedCommit] that can be inspected and later merged into the
116    /// group state with [MlsGroup::merge_commit()]. If the member was not
117    /// removed from the group, the function also returns an
118    /// [ApplicationExportSecret].
119    ///
120    /// This function does the following checks:
121    ///  - ValSem101
122    ///  - ValSem102
123    ///  - ValSem104
124    ///  - ValSem105
125    ///  - ValSem106
126    ///  - ValSem107
127    ///  - ValSem108
128    ///  - ValSem110
129    ///  - ValSem111
130    ///  - ValSem112
131    ///  - ValSem113: All Proposals: The proposal type must be supported by all
132    ///    members of the group
133    ///  - ValSem200
134    ///  - ValSem201
135    ///  - ValSem202: Path must be the right length
136    ///  - ValSem203: Path secrets must decrypt correctly
137    ///  - ValSem204: Public keys from Path must be verified and match the
138    ///    private keys from the direct path
139    ///  - ValSem205
140    ///  - ValSem240
141    ///  - ValSem241
142    ///  - ValSem242
143    ///  - ValSem244 Returns an error if the given commit was sent by the owner
144    ///    of this group.
145    pub(crate) fn stage_commit(
146        &self,
147        mls_content: &AuthenticatedContent,
148        old_epoch_keypairs: Vec<EncryptionKeyPair>,
149        leaf_node_keypairs: Vec<EncryptionKeyPair>,
150        provider: &impl OpenMlsProvider,
151    ) -> Result<StagedCommit, StageCommitError> {
152        // Check that the sender is another member of the group
153        if let Sender::Member(member) = mls_content.sender() {
154            if member == &self.own_leaf_index() {
155                return Err(StageCommitError::OwnCommit);
156            }
157        }
158
159        let ciphersuite = self.ciphersuite();
160
161        let (commit, proposal_queue, sender_index) = self
162            .public_group
163            .validate_commit(mls_content, provider.crypto())?;
164
165        // Create the provisional public group state (including the tree and
166        // group context) and apply proposals.
167        let mut diff = self.public_group.empty_diff();
168
169        let apply_proposals_values =
170            diff.apply_proposals(&proposal_queue, self.own_leaf_index())?;
171
172        // Determine if Commit has a path
173        let (commit_secret, new_keypairs, new_leaf_keypair_option, update_path_leaf_node) =
174            if let Some(path) = commit.path.clone() {
175                // Update the public group
176                // ValSem202: Path must be the right length
177                diff.apply_received_update_path(
178                    provider.crypto(),
179                    ciphersuite,
180                    sender_index,
181                    &path,
182                )?;
183
184                // Update group context
185                diff.update_group_context(
186                    provider.crypto(),
187                    apply_proposals_values.extensions.clone(),
188                )?;
189
190                // Check if we were removed from the group
191                if apply_proposals_values.self_removed {
192                    // If so, we return here, because we can't decrypt the path
193                    let staged_diff = diff.into_staged_diff(provider.crypto(), ciphersuite)?;
194                    let staged_state = PublicStagedCommitState::new(
195                        staged_diff,
196                        commit.path.as_ref().map(|path| path.leaf_node().clone()),
197                    );
198                    let staged_commit = StagedCommit::new(
199                        proposal_queue,
200                        StagedCommitState::PublicState(Box::new(staged_state)),
201                    );
202                    return Ok(staged_commit);
203                }
204
205                let decryption_keypairs: Vec<&EncryptionKeyPair> = old_epoch_keypairs
206                    .iter()
207                    .chain(leaf_node_keypairs.iter())
208                    .collect();
209
210                // ValSem203: Path secrets must decrypt correctly
211                // ValSem204: Public keys from Path must be verified and match the private keys from the direct path
212                let (new_keypairs, commit_secret) = diff.decrypt_path(
213                    provider.crypto(),
214                    &decryption_keypairs,
215                    self.own_leaf_index(),
216                    sender_index,
217                    path.nodes(),
218                    &apply_proposals_values.exclusion_list(),
219                )?;
220
221                // Check if one of our update proposals was applied. If so, we
222                // need to store that keypair separately, because after merging
223                // it needs to be removed from the key store separately and in
224                // addition to the removal of the keypairs of the previous
225                // epoch.
226                let new_leaf_keypair_option = if let Some(leaf) = diff.leaf(self.own_leaf_index()) {
227                    leaf_node_keypairs.into_iter().find_map(|keypair| {
228                        if leaf.encryption_key() == keypair.public_key() {
229                            Some(keypair)
230                        } else {
231                            None
232                        }
233                    })
234                } else {
235                    // We should have an own leaf at this point.
236                    debug_assert!(false);
237                    None
238                };
239
240                // Return the leaf node in the update path so the credential can be validated.
241                // Since the diff has already been updated, this should be the same as the leaf
242                // at the sender index.
243                let update_path_leaf_node = Some(path.leaf_node().clone());
244                debug_assert_eq!(diff.leaf(sender_index), path.leaf_node().into());
245
246                (
247                    commit_secret,
248                    new_keypairs,
249                    new_leaf_keypair_option,
250                    update_path_leaf_node,
251                )
252            } else {
253                if apply_proposals_values.path_required {
254                    // ValSem201
255                    return Err(StageCommitError::RequiredPathNotFound);
256                }
257
258                // Even if there is no path, we have to update the group context.
259                diff.update_group_context(
260                    provider.crypto(),
261                    apply_proposals_values.extensions.clone(),
262                )?;
263
264                (CommitSecret::zero_secret(ciphersuite), vec![], None, None)
265            };
266
267        // Update the confirmed transcript hash before we compute the confirmation tag.
268        diff.update_confirmed_transcript_hash(provider.crypto(), mls_content)?;
269
270        let received_confirmation_tag = mls_content
271            .confirmation_tag()
272            .ok_or(StageCommitError::ConfirmationTagMissing)?;
273
274        let serialized_provisional_group_context = diff
275            .group_context()
276            .tls_serialize_detached()
277            .map_err(LibraryError::missing_bound_check)?;
278
279        let EpochSecretsResult {
280            epoch_secrets,
281            #[cfg(feature = "extensions-draft-08")]
282            application_exporter,
283        } = self.derive_epoch_secrets(
284            provider,
285            apply_proposals_values,
286            self.group_epoch_secrets(),
287            commit_secret,
288            &serialized_provisional_group_context,
289        )?;
290        let (provisional_group_secrets, provisional_message_secrets) = epoch_secrets.split_secrets(
291            serialized_provisional_group_context,
292            diff.tree_size(),
293            self.own_leaf_index(),
294        );
295
296        // Verify confirmation tag
297        // ValSem205
298        let own_confirmation_tag = provisional_message_secrets
299            .confirmation_key()
300            .tag(
301                provider.crypto(),
302                self.ciphersuite(),
303                diff.group_context().confirmed_transcript_hash(),
304            )
305            .map_err(LibraryError::unexpected_crypto_error)?;
306        if &own_confirmation_tag != received_confirmation_tag {
307            log::error!("Confirmation tag mismatch");
308            log_crypto!(trace, "  Got:      {:x?}", received_confirmation_tag);
309            log_crypto!(trace, "  Expected: {:x?}", own_confirmation_tag);
310            // TODO: We have tests expecting this error.
311            //       They need to be rewritten.
312            // debug_assert!(false, "Confirmation tag mismatch");
313
314            // in some tests we need to be able to proceed despite the tag being wrong,
315            // e.g. to test whether a later validation check is performed correctly.
316            if !crate::skip_validation::is_disabled::confirmation_tag() {
317                return Err(StageCommitError::ConfirmationTagMismatch);
318            }
319        }
320
321        diff.update_interim_transcript_hash(ciphersuite, provider.crypto(), own_confirmation_tag)?;
322
323        let staged_diff = diff.into_staged_diff(provider.crypto(), ciphersuite)?;
324        #[cfg(feature = "extensions-draft-08")]
325        let application_export_tree = ApplicationExportTree::new(application_exporter);
326        let staged_commit_state =
327            StagedCommitState::GroupMember(Box::new(MemberStagedCommitState::new(
328                provisional_group_secrets,
329                provisional_message_secrets,
330                staged_diff,
331                new_keypairs,
332                new_leaf_keypair_option,
333                update_path_leaf_node,
334                #[cfg(feature = "extensions-draft-08")]
335                application_export_tree,
336            )));
337        let staged_commit = StagedCommit::new(proposal_queue, staged_commit_state);
338
339        Ok(staged_commit)
340    }
341
342    /// Merges a [StagedCommit] into the group state and optionally return a [`SecretTree`]
343    /// from the previous epoch. The secret tree is returned if the Commit does not contain a self removal.
344    ///
345    /// This function should not fail and only returns a [`Result`], because it
346    /// might throw a `LibraryError`.
347    pub(crate) fn merge_commit<Provider: OpenMlsProvider>(
348        &mut self,
349        provider: &Provider,
350        staged_commit: StagedCommit,
351    ) -> Result<(), MergeCommitError<Provider::StorageError>> {
352        // Get all keypairs from the old epoch, so we can later store the ones
353        // that are still relevant in the new epoch.
354        let old_epoch_keypairs = self
355            .read_epoch_keypairs(provider.storage())
356            .map_err(MergeCommitError::StorageError)?;
357        match staged_commit.state {
358            StagedCommitState::PublicState(staged_state) => {
359                self.public_group
360                    .merge_diff(staged_state.into_staged_diff());
361                self.store(provider.storage())
362                    .map_err(MergeCommitError::StorageError)?;
363                Ok(())
364            }
365            StagedCommitState::GroupMember(state) => {
366                // Save the past epoch
367                let past_epoch = self.context().epoch();
368                // Get all the full leaves
369                let leaves = self.public_group().members().collect();
370                // Merge the staged commit into the group state and store the secret tree from the
371                // previous epoch in the message secrets store.
372                self.group_epoch_secrets = state.group_epoch_secrets;
373
374                // Replace the previous message secrets with the new ones and return the previous message secrets
375                let mut message_secrets = state.message_secrets;
376                mem::swap(
377                    &mut message_secrets,
378                    self.message_secrets_store.message_secrets_mut(),
379                );
380                self.message_secrets_store
381                    .add(past_epoch, message_secrets, leaves);
382
383                // Replace the previous exporter tree with the new one.
384                #[cfg(feature = "extensions-draft-08")]
385                {
386                    // The application exporter is only None if the group was
387                    // stored using an older version of OpenMLS that did not
388                    // support the application exporter.
389                    if let Some(application_export_tree) = state.application_export_tree {
390                        // Overwrite the existing exporter tree in the storage.
391
392                        use openmls_traits::storage::StorageProvider as _;
393                        provider
394                            .storage()
395                            .write_application_export_tree(
396                                self.group_id(),
397                                &application_export_tree,
398                            )
399                            .map_err(MergeCommitError::StorageError)?;
400
401                        self.application_export_tree = Some(application_export_tree);
402                    }
403                }
404
405                self.public_group.merge_diff(state.staged_diff);
406
407                let leaf_keypair = if let Some(keypair) = &state.new_leaf_keypair_option {
408                    vec![keypair.clone()]
409                } else {
410                    vec![]
411                };
412
413                // Figure out which keys we need in the new epoch.
414                let new_owned_encryption_keys = self
415                    .public_group()
416                    .owned_encryption_keys(self.own_leaf_index());
417                // From the old and new keys, keep the ones that are still relevant in the new epoch.
418                let epoch_keypairs: Vec<EncryptionKeyPair> = old_epoch_keypairs
419                    .into_iter()
420                    .chain(state.new_keypairs)
421                    .chain(leaf_keypair)
422                    .filter(|keypair| new_owned_encryption_keys.contains(keypair.public_key()))
423                    .collect();
424
425                // We should have private keys for all owned encryption keys.
426                debug_assert_eq!(new_owned_encryption_keys.len(), epoch_keypairs.len());
427                if new_owned_encryption_keys.len() != epoch_keypairs.len() {
428                    return Err(LibraryError::custom(
429                        "We should have all the private key material we need.",
430                    )
431                    .into());
432                }
433
434                // Store the updated group state
435                let storage = provider.storage();
436                let group_id = self.group_id();
437
438                self.public_group
439                    .store(storage)
440                    .map_err(MergeCommitError::StorageError)?;
441                storage
442                    .write_group_epoch_secrets(group_id, &self.group_epoch_secrets)
443                    .map_err(MergeCommitError::StorageError)?;
444                storage
445                    .write_message_secrets(group_id, &self.message_secrets_store)
446                    .map_err(MergeCommitError::StorageError)?;
447
448                // Store the relevant keys under the new epoch
449                self.store_epoch_keypairs(storage, epoch_keypairs.as_slice())
450                    .map_err(MergeCommitError::StorageError)?;
451
452                // Delete the old keys.
453                self.delete_previous_epoch_keypairs(storage)
454                    .map_err(MergeCommitError::StorageError)?;
455                if let Some(keypair) = state.new_leaf_keypair_option {
456                    keypair
457                        .delete(storage)
458                        .map_err(MergeCommitError::StorageError)?;
459                }
460
461                // Empty the proposal store
462                storage
463                    .clear_proposal_queue::<GroupId, ProposalRef>(group_id)
464                    .map_err(MergeCommitError::StorageError)?;
465                self.proposal_store_mut().empty();
466
467                Ok(())
468            }
469        }
470    }
471}
472
473#[derive(Debug, Serialize, Deserialize)]
474#[cfg_attr(any(test, feature = "test-utils"), derive(Clone, PartialEq))]
475pub(crate) enum StagedCommitState {
476    PublicState(Box<PublicStagedCommitState>),
477    GroupMember(Box<MemberStagedCommitState>),
478}
479
480/// Contains the changes from a commit to the group state.
481#[derive(Debug, Serialize, Deserialize)]
482#[cfg_attr(any(test, feature = "test-utils"), derive(Clone, PartialEq))]
483pub struct StagedCommit {
484    staged_proposal_queue: ProposalQueue,
485    state: StagedCommitState,
486}
487
488impl StagedCommit {
489    /// Create a new [`StagedCommit`] from the provisional group state created
490    /// during the commit process.
491    pub(crate) fn new(staged_proposal_queue: ProposalQueue, state: StagedCommitState) -> Self {
492        StagedCommit {
493            staged_proposal_queue,
494            state,
495        }
496    }
497
498    /// Returns the Add proposals that are covered by the Commit message as in iterator over [QueuedAddProposal].
499    pub fn add_proposals(&self) -> impl Iterator<Item = QueuedAddProposal<'_>> {
500        self.staged_proposal_queue.add_proposals()
501    }
502
503    /// Returns the Remove proposals that are covered by the Commit message as in iterator over [QueuedRemoveProposal].
504    pub fn remove_proposals(&self) -> impl Iterator<Item = QueuedRemoveProposal<'_>> {
505        self.staged_proposal_queue.remove_proposals()
506    }
507
508    /// Returns the Update proposals that are covered by the Commit message as in iterator over [QueuedUpdateProposal].
509    pub fn update_proposals(&self) -> impl Iterator<Item = QueuedUpdateProposal<'_>> {
510        self.staged_proposal_queue.update_proposals()
511    }
512
513    /// Returns the PresharedKey proposals that are covered by the Commit message as in iterator over [QueuedPskProposal].
514    pub fn psk_proposals(&self) -> impl Iterator<Item = QueuedPskProposal<'_>> {
515        self.staged_proposal_queue.psk_proposals()
516    }
517
518    /// Returns an iterator over all [`QueuedProposal`]s.
519    pub fn queued_proposals(&self) -> impl Iterator<Item = &QueuedProposal> {
520        self.staged_proposal_queue.queued_proposals()
521    }
522
523    /// Returns the leaf node of the (optional) update path.
524    pub fn update_path_leaf_node(&self) -> Option<&LeafNode> {
525        match self.state {
526            StagedCommitState::PublicState(ref public_state) => {
527                public_state.update_path_leaf_node()
528            }
529            StagedCommitState::GroupMember(ref group_member_state) => {
530                group_member_state.update_path_leaf_node.as_ref()
531            }
532        }
533    }
534
535    /// Returns the credentials that the caller needs to verify are valid.
536    pub fn credentials_to_verify(&self) -> impl Iterator<Item = &Credential> {
537        let update_path_leaf_node_cred = if let Some(node) = self.update_path_leaf_node() {
538            vec![node.credential()]
539        } else {
540            vec![]
541        };
542
543        update_path_leaf_node_cred
544            .into_iter()
545            .chain(
546                self.queued_proposals()
547                    .flat_map(|proposal: &QueuedProposal| match proposal.proposal() {
548                        Proposal::Update(update_proposal) => {
549                            vec![update_proposal.leaf_node().credential()].into_iter()
550                        }
551                        Proposal::Add(add_proposal) => {
552                            vec![add_proposal.key_package().leaf_node().credential()].into_iter()
553                        }
554                        Proposal::GroupContextExtensions(gce_proposal) => gce_proposal
555                            .extensions()
556                            .iter()
557                            .flat_map(|extension| {
558                                match extension {
559                                    Extension::ExternalSenders(external_senders) => {
560                                        external_senders
561                                            .iter()
562                                            .map(|external_sender| external_sender.credential())
563                                            .collect()
564                                    }
565                                    _ => vec![],
566                                }
567                                .into_iter()
568                            })
569                            // TODO: ideally we wouldn't collect in between here, but the match arms
570                            //       have to all return the same type. We solve this by having them all
571                            //       be vec::IntoIter, but it would be nice if we just didn't have to
572                            //       do this.
573                            //       It might be possible to solve this by letting all match arms
574                            //       evaluate to a dyn Iterator.
575                            .collect::<Vec<_>>()
576                            .into_iter(),
577                        _ => vec![].into_iter(),
578                    }),
579            )
580    }
581
582    /// Returns `true` if the member was removed through a proposal covered by this Commit message
583    /// and `false` otherwise.
584    pub fn self_removed(&self) -> bool {
585        matches!(self.state, StagedCommitState::PublicState(_))
586    }
587
588    /// Returns the [`GroupContext`] of the staged commit state.
589    pub fn group_context(&self) -> &GroupContext {
590        match self.state {
591            StagedCommitState::PublicState(ref ps) => ps.staged_diff().group_context(),
592            StagedCommitState::GroupMember(ref gm) => gm.group_context(),
593        }
594    }
595
596    /// Consume this [`StagedCommit`] and return the internal [`StagedCommitState`].
597    pub(crate) fn into_state(self) -> StagedCommitState {
598        self.state
599    }
600
601    /// Returns the [`EpochAuthenticator`] of the staged commit state if the
602    /// owner of the originating group state is a member of the group. Returns
603    /// `None` otherwise.
604    pub fn epoch_authenticator(&self) -> Option<&EpochAuthenticator> {
605        if let StagedCommitState::GroupMember(ref gm) = self.state {
606            Some(gm.group_epoch_secrets.epoch_authenticator())
607        } else {
608            None
609        }
610    }
611
612    #[cfg(feature = "extensions-draft-08")]
613    pub(crate) fn safe_export_secret(
614        &mut self,
615        crypto: &impl OpenMlsCrypto,
616        component_id: u16,
617    ) -> Result<Vec<u8>, StagedSafeExportSecretError> {
618        let ciphersuite = self.group_context().ciphersuite();
619        let StagedCommitState::GroupMember(ref mut staged_commit) = self.state else {
620            return Err(StagedSafeExportSecretError::NotGroupMember);
621        };
622        let Some(application_export_tree) = staged_commit.application_export_tree.as_mut() else {
623            return Err(StagedSafeExportSecretError::Unsupported);
624        };
625        let secret =
626            application_export_tree.safe_export_secret(crypto, ciphersuite, component_id)?;
627        Ok(secret.as_slice().to_vec())
628    }
629}
630
631/// This struct is used internally by [StagedCommit] to encapsulate all the modified group state.
632#[derive(Debug, Serialize, Deserialize)]
633#[cfg_attr(any(test, feature = "test-utils"), derive(Clone, PartialEq))]
634pub(crate) struct MemberStagedCommitState {
635    group_epoch_secrets: GroupEpochSecrets,
636    message_secrets: MessageSecrets,
637    staged_diff: StagedPublicGroupDiff,
638    new_keypairs: Vec<EncryptionKeyPair>,
639    new_leaf_keypair_option: Option<EncryptionKeyPair>,
640    update_path_leaf_node: Option<LeafNode>,
641    #[cfg(feature = "extensions-draft-08")]
642    #[serde(default)]
643    // This is `None` only if the group was stored using an older version of
644    // OpenMLS that did not support the application exporter.
645    application_export_tree: Option<ApplicationExportTree>,
646}
647
648impl MemberStagedCommitState {
649    pub(crate) fn new(
650        group_epoch_secrets: GroupEpochSecrets,
651        message_secrets: MessageSecrets,
652        staged_diff: StagedPublicGroupDiff,
653        new_keypairs: Vec<EncryptionKeyPair>,
654        new_leaf_keypair_option: Option<EncryptionKeyPair>,
655        update_path_leaf_node: Option<LeafNode>,
656        #[cfg(feature = "extensions-draft-08")] application_export_tree: ApplicationExportTree,
657    ) -> Self {
658        Self {
659            group_epoch_secrets,
660            message_secrets,
661            staged_diff,
662            new_keypairs,
663            new_leaf_keypair_option,
664            update_path_leaf_node,
665            #[cfg(feature = "extensions-draft-08")]
666            application_export_tree: Some(application_export_tree),
667        }
668    }
669
670    /// Get the staged [`GroupContext`].
671    pub(crate) fn group_context(&self) -> &GroupContext {
672        self.staged_diff.group_context()
673    }
674}