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