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                        provider.crypto(),
437                        sender_index,
438                        operation_secret,
439                    )?)
440                } else {
441                    None
442                };
443                #[cfg(not(feature = "virtual-clients-draft"))]
444                let vc_path: Option<(Vec<EncryptionKeyPair>, CommitSecret)> = None;
445
446                // ValSem203: Path secrets must decrypt correctly
447                // ValSem204: Public keys from Path must be verified and match the private keys from the direct path
448                let (new_keypairs, commit_secret) = if let Some(pair) = vc_path {
449                    pair
450                } else {
451                    let decryption_keypairs: Vec<&EncryptionKeyPair> = old_epoch_keypairs
452                        .iter()
453                        .chain(leaf_node_keypairs.iter())
454                        .collect();
455                    diff.decrypt_path(
456                        provider.crypto(),
457                        &decryption_keypairs,
458                        self.own_leaf_index(),
459                        sender_index,
460                        path.nodes(),
461                        &apply_proposals_values.exclusion_list(),
462                    )?
463                };
464
465                // Check if one of our update proposals was applied. If so, we
466                // need to store that keypair separately, because after merging
467                // it needs to be removed from the key store separately and in
468                // addition to the removal of the keypairs of the previous
469                // epoch.
470                let new_leaf_keypair_option = if is_sibling_resync {
471                    None
472                } else if let Some(leaf) = diff.leaf(self.own_leaf_index()) {
473                    leaf_node_keypairs.into_iter().find_map(|keypair| {
474                        if leaf.encryption_key() == keypair.public_key() {
475                            Some(keypair)
476                        } else {
477                            None
478                        }
479                    })
480                } else {
481                    // We should have an own leaf at this point.
482                    debug_assert!(false);
483                    None
484                };
485
486                // Return the leaf node in the update path so the credential can be validated.
487                // Since the diff has already been updated, this should be the same as the leaf
488                // at the sender index.
489                let update_path_leaf_node = Some(path.leaf_node().clone());
490                debug_assert_eq!(diff.leaf(sender_index), path.leaf_node().into());
491
492                (
493                    commit_secret,
494                    new_keypairs,
495                    new_leaf_keypair_option,
496                    update_path_leaf_node,
497                )
498            } else {
499                if apply_proposals_values.path_required {
500                    // ValSem201
501                    return Err(StageCommitError::RequiredPathNotFound);
502                }
503
504                // Even if there is no path, we have to update the group context.
505                diff.update_group_context(
506                    provider.crypto(),
507                    apply_proposals_values.extensions.clone(),
508                )?;
509
510                (CommitSecret::zero_secret(ciphersuite), vec![], None, None)
511            };
512
513        // Update the confirmed transcript hash before we compute the confirmation tag.
514        diff.update_confirmed_transcript_hash(provider.crypto(), mls_content)?;
515
516        let received_confirmation_tag = mls_content
517            .confirmation_tag()
518            .ok_or(StageCommitError::ConfirmationTagMissing)?;
519
520        let serialized_provisional_group_context = diff
521            .group_context()
522            .tls_serialize_detached()
523            .map_err(LibraryError::missing_bound_check)?;
524
525        #[cfg(feature = "virtual-clients-draft")]
526        let provisional_own_leaf_index = if is_sibling_resync {
527            sender_index
528        } else {
529            self.own_leaf_index()
530        };
531        #[cfg(not(feature = "virtual-clients-draft"))]
532        let provisional_own_leaf_index = self.own_leaf_index();
533
534        let EpochSecretsResult {
535            epoch_secrets,
536            #[cfg(feature = "extensions-draft")]
537            application_exporter,
538        } = self.derive_epoch_secrets(
539            provider,
540            apply_proposals_values,
541            self.group_epoch_secrets(),
542            commit_secret,
543            &serialized_provisional_group_context,
544            #[cfg(feature = "virtual-clients-draft")]
545            vc_external_init_secret.as_ref(),
546        )?;
547        let (provisional_group_secrets, provisional_message_secrets) = epoch_secrets.split_secrets(
548            serialized_provisional_group_context,
549            diff.tree_size(),
550            provisional_own_leaf_index,
551        );
552
553        // Verify confirmation tag
554        // ValSem205
555        let own_confirmation_tag = provisional_message_secrets
556            .confirmation_key()
557            .tag(
558                provider.crypto(),
559                self.ciphersuite(),
560                diff.group_context().confirmed_transcript_hash(),
561            )
562            .map_err(LibraryError::unexpected_crypto_error)?;
563        if &own_confirmation_tag != received_confirmation_tag {
564            log::error!("Confirmation tag mismatch");
565            log_crypto!(trace, "  Got:      {:x?}", received_confirmation_tag);
566            log_crypto!(trace, "  Expected: {:x?}", own_confirmation_tag);
567            // TODO: We have tests expecting this error.
568            //       They need to be rewritten.
569            // debug_assert!(false, "Confirmation tag mismatch");
570
571            // in some tests we need to be able to proceed despite the tag being wrong,
572            // e.g. to test whether a later validation check is performed correctly.
573            if !crate::skip_validation::is_disabled::confirmation_tag() {
574                return Err(StageCommitError::ConfirmationTagMismatch);
575            }
576        }
577
578        diff.update_interim_transcript_hash(ciphersuite, provider.crypto(), own_confirmation_tag)?;
579
580        let staged_diff = diff.into_staged_diff(provider.crypto(), ciphersuite)?;
581        #[cfg(feature = "extensions-draft")]
582        let application_export_tree = ApplicationExportTree::new(application_exporter);
583        #[cfg(feature = "virtual-clients-draft")]
584        let new_own_leaf_index = is_sibling_resync.then_some(provisional_own_leaf_index);
585        let staged_commit_state =
586            StagedCommitState::GroupMember(Box::new(MemberStagedCommitState::new(
587                provisional_group_secrets,
588                provisional_message_secrets,
589                staged_diff,
590                new_keypairs,
591                new_leaf_keypair_option,
592                update_path_leaf_node,
593                #[cfg(feature = "extensions-draft")]
594                application_export_tree,
595                #[cfg(feature = "virtual-clients-draft")]
596                new_own_leaf_index,
597            )));
598        let staged_commit = StagedCommit::new(
599            proposal_queue,
600            staged_commit_state,
601            #[cfg(feature = "virtual-clients-draft")]
602            vc_emulation_epoch_id,
603        );
604
605        Ok(staged_commit)
606    }
607
608    /// Re-derive the path of a commit sent by a sibling virtual client
609    /// (either through our own higher-level leaf, or onto a new leaf via a
610    /// sibling-resync external commit) from the per-commit `OperationSecret`
611    /// resolved by the caller (in `process_message`, via PPRF evaluation), and
612    /// verify that the public keys derived from the path secret match the
613    /// leaf node in the path. Returns the derived parent-node keypairs and
614    /// commit secret, ready to be slotted in where `decrypt_path` would
615    /// normally produce them.
616    ///
617    /// `sender_index` is the leaf the path originates from. For an own-leaf
618    /// VC commit this equals `self.own_leaf_index()`. For a sibling-resync
619    /// external commit it is the joiner's new leaf (the `leftmost_free_index`
620    /// the external-commit builder chose), which is where the path actually
621    /// starts.
622    ///
623    /// Signature key changes are not verified here: not every signature
624    /// scheme has an interoperable seed-to-keypair construction, so the
625    /// application is responsible for supplying any rotated signature key
626    /// pair to the storage provider out-of-band. The new public key on the
627    /// path leaf is already authenticated by the commit's standard
628    /// path-validation against the previous signature key.
629    #[cfg(feature = "virtual-clients-draft")]
630    fn recreate_path_for_own_commit(
631        &self,
632        diff: &PublicGroupDiff,
633        path: &crate::treesync::treekem::UpdatePath,
634        ciphersuite: openmls_traits::types::Ciphersuite,
635        crypto: &impl OpenMlsCrypto,
636        sender_index: LeafNodeIndex,
637        operation_secret: crate::components::vc_derivation_info::OperationSecret,
638    ) -> Result<(Vec<EncryptionKeyPair>, CommitSecret), StageCommitError> {
639        use crate::components::vc_derivation_info::VirtualClientsError;
640
641        let path_secret = operation_secret
642            .derive_path_generation_secret(crypto, ciphersuite)?
643            .into();
644        let (encryption_key_pairs, commit_secret) =
645            diff.recreate_path_from_path_secret(crypto, path_secret, sender_index, path.nodes())?;
646
647        // Verify that the leaf encryption key in the path matches the one
648        // derived from the operation secret.
649        let leaf_keypair = operation_secret
650            .derive_encryption_key_secret(crypto, ciphersuite)?
651            .generate_encryption_key_pair(crypto, ciphersuite)?;
652        if leaf_keypair.public_key() != path.leaf_node().encryption_key() {
653            return Err(VirtualClientsError::EncryptionKeyMismatch.into());
654        }
655
656        // Mirror the sender's `apply_own_update_path`: the leaf keypair is
657        // prepended to the parent keypairs so the merge step has private
658        // keys for all of the new epoch's owned encryption keys.
659        let mut keypairs = Vec::with_capacity(1 + encryption_key_pairs.len());
660        keypairs.push(leaf_keypair);
661        keypairs.extend(encryption_key_pairs);
662        Ok((keypairs, commit_secret))
663    }
664
665    /// Merges a [StagedCommit] into the group state and optionally return a [`SecretTree`]
666    /// from the previous epoch. The secret tree is returned if the Commit does not contain a self removal.
667    ///
668    /// This function should not fail and only returns a [`Result`], because it
669    /// might throw a `LibraryError`.
670    pub(crate) fn merge_commit<Provider: OpenMlsProvider>(
671        &mut self,
672        provider: &Provider,
673        staged_commit: StagedCommit,
674    ) -> Result<(), MergeCommitError<Provider::StorageError>> {
675        // Get all keypairs from the old epoch, so we can later store the ones
676        // that are still relevant in the new epoch.
677        let old_epoch_keypairs = self
678            .read_epoch_keypairs(provider.storage())
679            .map_err(MergeCommitError::StorageError)?;
680        match staged_commit.state {
681            StagedCommitState::PublicState(staged_state) => {
682                self.public_group
683                    .merge_diff(staged_state.into_staged_diff());
684                self.store(provider.storage())
685                    .map_err(MergeCommitError::StorageError)?;
686                Ok(())
687            }
688            StagedCommitState::GroupMember(state) => {
689                // Save the past epoch
690                let past_epoch = self.context().epoch();
691                // Get all the full leaves
692                let leaves = self.public_group().members().collect();
693                // Merge the staged commit into the group state and store the secret tree from the
694                // previous epoch in the message secrets store.
695                self.group_epoch_secrets = state.group_epoch_secrets;
696
697                // Replace the previous message secrets with the new ones and return the previous message secrets
698                let old_message_secrets = self
699                    .message_secrets_store
700                    .replace_current_message_secrets(state.message_secrets);
701                self.message_secrets_store.add_past_epoch_tree(
702                    past_epoch,
703                    old_message_secrets,
704                    leaves,
705                );
706
707                // Replace the previous exporter tree with the new one.
708                #[cfg(feature = "extensions-draft")]
709                {
710                    // The application exporter is only None if the group was
711                    // stored using an older version of OpenMLS that did not
712                    // support the application exporter.
713                    if let Some(application_export_tree) = state.application_export_tree {
714                        // Overwrite the existing exporter tree in the storage.
715
716                        use openmls_traits::storage::StorageProvider as _;
717                        provider
718                            .storage()
719                            .write_application_export_tree(
720                                self.group_id(),
721                                &application_export_tree,
722                            )
723                            .map_err(MergeCommitError::StorageError)?;
724
725                        self.application_export_tree = Some(application_export_tree);
726                    }
727                }
728
729                self.public_group.merge_diff(state.staged_diff);
730
731                #[cfg(feature = "virtual-clients-draft")]
732                let previous_own_leaf_index = self.own_leaf_index;
733
734                // Sibling-resync external commit: install the joiner's new
735                // leaf as our own before filtering keypairs. The call to
736                // `owned_encryption_keys(self.own_leaf_index())` below relies
737                // on this value.
738                #[cfg(feature = "virtual-clients-draft")]
739                if let Some(new_idx) = state.new_own_leaf_index {
740                    self.own_leaf_index = new_idx;
741                }
742
743                let leaf_keypair = if let Some(keypair) = &state.new_leaf_keypair_option {
744                    vec![keypair.clone()]
745                } else {
746                    vec![]
747                };
748
749                // Figure out which keys we need in the new epoch.
750                let new_owned_encryption_keys = self
751                    .public_group()
752                    .owned_encryption_keys(self.own_leaf_index());
753                // From the old and new keys, keep the ones that are still relevant in the new epoch.
754                let epoch_keypairs: Vec<EncryptionKeyPair> = old_epoch_keypairs
755                    .into_iter()
756                    .chain(state.new_keypairs)
757                    .chain(leaf_keypair)
758                    .filter(|keypair| new_owned_encryption_keys.contains(keypair.public_key()))
759                    .collect();
760
761                // We should have private keys for all owned encryption keys.
762                debug_assert_eq!(new_owned_encryption_keys.len(), epoch_keypairs.len());
763                if new_owned_encryption_keys.len() != epoch_keypairs.len() {
764                    return Err(LibraryError::custom(
765                        "We should have all the private key material we need.",
766                    )
767                    .into());
768                }
769
770                // Store the updated group state
771                let storage = provider.storage();
772                let group_id = self.group_id();
773
774                self.public_group
775                    .store(storage)
776                    .map_err(MergeCommitError::StorageError)?;
777                storage
778                    .write_own_leaf_index(group_id, &self.own_leaf_index)
779                    .map_err(MergeCommitError::StorageError)?;
780                storage
781                    .write_group_epoch_secrets(group_id, &self.group_epoch_secrets)
782                    .map_err(MergeCommitError::StorageError)?;
783                storage
784                    .write_message_secrets(group_id, &self.message_secrets_store)
785                    .map_err(MergeCommitError::StorageError)?;
786
787                // Store the relevant keys under the new epoch
788                self.store_epoch_keypairs(storage, epoch_keypairs.as_slice())
789                    .map_err(MergeCommitError::StorageError)?;
790
791                // Delete the old keys.
792                self.delete_previous_epoch_keypairs(
793                    storage,
794                    #[cfg(feature = "virtual-clients-draft")]
795                    previous_own_leaf_index,
796                )
797                .map_err(MergeCommitError::StorageError)?;
798                if let Some(keypair) = state.new_leaf_keypair_option {
799                    keypair
800                        .delete(storage)
801                        .map_err(MergeCommitError::StorageError)?;
802                }
803
804                // Empty the proposal store
805                storage
806                    .clear_proposal_queue::<GroupId, ProposalRef>(group_id)
807                    .map_err(MergeCommitError::StorageError)?;
808                self.proposal_store_mut().empty();
809
810                Ok(())
811            }
812        }
813    }
814}
815
816#[derive(Debug, Serialize, Deserialize)]
817#[cfg_attr(any(test, feature = "test-utils"), derive(Clone, PartialEq))]
818pub(crate) enum StagedCommitState {
819    PublicState(Box<PublicStagedCommitState>),
820    /// The group member variant of the staged commit state.
821    GroupMember(Box<MemberStagedCommitState>),
822}
823
824/// Contains the changes from a commit to the group state.
825#[derive(Debug, Serialize, Deserialize)]
826#[cfg_attr(any(test, feature = "test-utils"), derive(Clone, PartialEq))]
827pub struct StagedCommit {
828    /// A queue containing the proposals associated with the commit.
829    pub staged_proposal_queue: ProposalQueue,
830    /// The staged commit state.
831    pub(super) state: StagedCommitState,
832    /// Emulation epoch this commit binds the group to on merge, when
833    /// the commit was built via `CommitBuilder::vc_emulation`.
834    #[cfg(feature = "virtual-clients-draft")]
835    #[serde(default)]
836    pub(super) vc_emulation_epoch_id: Option<crate::components::vc_derivation_info::EpochId>,
837}
838
839impl StagedCommit {
840    /// Create a new [`StagedCommit`] from the provisional group state created
841    /// during the commit process.
842    pub(crate) fn new(
843        staged_proposal_queue: ProposalQueue,
844        state: StagedCommitState,
845        #[cfg(feature = "virtual-clients-draft")] vc_emulation_epoch_id: Option<
846            crate::components::vc_derivation_info::EpochId,
847        >,
848    ) -> Self {
849        StagedCommit {
850            staged_proposal_queue,
851            state,
852            #[cfg(feature = "virtual-clients-draft")]
853            vc_emulation_epoch_id,
854        }
855    }
856
857    /// Returns the epoch that this commit moves the group into
858    pub fn epoch(&self) -> GroupEpoch {
859        self.group_context().epoch()
860    }
861
862    /// Returns the ratchet tree of the staged commit state.
863    pub fn export_ratchet_tree(
864        &self,
865        crypto: &impl OpenMlsCrypto,
866        original_tree: RatchetTree,
867    ) -> Result<Option<RatchetTree>, TreeSyncFromNodesError> {
868        match &self.state {
869            StagedCommitState::PublicState(_public_staged_commit_state) => Ok(None),
870            StagedCommitState::GroupMember(member_staged_commit_state) => Ok(Some(
871                member_staged_commit_state.staged_diff.export_ratchet_tree(
872                    crypto,
873                    self.group_context().ciphersuite(),
874                    original_tree,
875                )?,
876            )),
877        }
878    }
879
880    /// Returns the Add proposals that are covered by the Commit message as in iterator over [QueuedAddProposal].
881    pub fn add_proposals(&self) -> impl Iterator<Item = QueuedAddProposal<'_>> {
882        self.staged_proposal_queue.add_proposals()
883    }
884
885    /// Returns the Remove proposals that are covered by the Commit message as in iterator over [QueuedRemoveProposal].
886    pub fn remove_proposals(&self) -> impl Iterator<Item = QueuedRemoveProposal<'_>> {
887        self.staged_proposal_queue.remove_proposals()
888    }
889
890    /// Returns the Update proposals that are covered by the Commit message as in iterator over [QueuedUpdateProposal].
891    pub fn update_proposals(&self) -> impl Iterator<Item = QueuedUpdateProposal<'_>> {
892        self.staged_proposal_queue.update_proposals()
893    }
894
895    /// Returns the PresharedKey proposals that are covered by the Commit message as in iterator over [QueuedPskProposal].
896    pub fn psk_proposals(&self) -> impl Iterator<Item = QueuedPskProposal<'_>> {
897        self.staged_proposal_queue.psk_proposals()
898    }
899
900    #[cfg(feature = "extensions-draft")]
901    /// Returns the AppEphemeral proposals that are covered by the Commit message as an iterator
902    /// over [`QueuedAppEphemeralProposal`].
903    pub fn queued_app_ephemeral_proposals(
904        &self,
905    ) -> impl Iterator<Item = QueuedAppEphemeralProposal<'_>> {
906        self.staged_proposal_queue.app_ephemeral_proposals()
907    }
908    // NOTE: this is not a default proposal type
909    #[cfg(feature = "extensions-draft")]
910    /// Returns the AppDataUpdate proposals that are covered by the Commit message as an iterator
911    /// over [`QueuedAppDataUpdateProposal`].
912    pub fn app_data_update_proposals(
913        &self,
914    ) -> impl Iterator<Item = QueuedAppDataUpdateProposal<'_>> {
915        self.staged_proposal_queue.app_data_update_proposals()
916    }
917
918    /// Returns an iterator over all [`QueuedProposal`]s.
919    pub fn queued_proposals(&self) -> impl Iterator<Item = &QueuedProposal> {
920        self.staged_proposal_queue.queued_proposals()
921    }
922
923    /// Returns the leaf node of the (optional) update path.
924    pub fn update_path_leaf_node(&self) -> Option<&LeafNode> {
925        match self.state {
926            StagedCommitState::PublicState(ref public_state) => {
927                public_state.update_path_leaf_node()
928            }
929            StagedCommitState::GroupMember(ref group_member_state) => {
930                group_member_state.update_path_leaf_node.as_ref()
931            }
932        }
933    }
934
935    /// Returns the credentials that the caller needs to verify are valid.
936    pub fn credentials_to_verify(&self) -> impl Iterator<Item = &Credential> {
937        let update_path_leaf_node_cred = if let Some(node) = self.update_path_leaf_node() {
938            vec![node.credential()]
939        } else {
940            vec![]
941        };
942
943        update_path_leaf_node_cred
944            .into_iter()
945            .chain(
946                self.queued_proposals()
947                    .flat_map(|proposal: &QueuedProposal| match proposal.proposal() {
948                        Proposal::Update(update_proposal) => {
949                            vec![update_proposal.leaf_node().credential()].into_iter()
950                        }
951                        Proposal::Add(add_proposal) => {
952                            vec![add_proposal.key_package().leaf_node().credential()].into_iter()
953                        }
954                        Proposal::GroupContextExtensions(gce_proposal) => gce_proposal
955                            .extensions()
956                            .iter()
957                            .flat_map(|extension| {
958                                match extension {
959                                    Extension::ExternalSenders(external_senders) => {
960                                        external_senders
961                                            .iter()
962                                            .map(|external_sender| external_sender.credential())
963                                            .collect()
964                                    }
965                                    _ => vec![],
966                                }
967                                .into_iter()
968                            })
969                            // TODO: ideally we wouldn't collect in between here, but the match arms
970                            //       have to all return the same type. We solve this by having them all
971                            //       be vec::IntoIter, but it would be nice if we just didn't have to
972                            //       do this.
973                            //       It might be possible to solve this by letting all match arms
974                            //       evaluate to a dyn Iterator.
975                            .collect::<Vec<_>>()
976                            .into_iter(),
977                        _ => vec![].into_iter(),
978                    }),
979            )
980    }
981
982    /// Returns `true` if the member was removed through a proposal covered by this Commit message
983    /// and `false` otherwise.
984    //
985    // Sibling-resync external commits intentionally land in `GroupMember`
986    // rather than `PublicState`, even though the proposal queue contains a
987    // Remove of our own leaf. The new leaf carries our state forward, so
988    // `self_removed()` returns `false` and `merge_staged_commit` keeps the
989    // group active.
990    pub fn self_removed(&self) -> bool {
991        matches!(self.state, StagedCommitState::PublicState(_))
992    }
993
994    /// Returns the [`GroupContext`] of the staged commit state.
995    pub fn group_context(&self) -> &GroupContext {
996        match self.state {
997            StagedCommitState::PublicState(ref ps) => ps.staged_diff().group_context(),
998            StagedCommitState::GroupMember(ref gm) => gm.group_context(),
999        }
1000    }
1001    /// Consume this [`StagedCommit`] and return the internal [`StagedCommitState`].
1002    pub(crate) fn into_state(self) -> StagedCommitState {
1003        self.state
1004    }
1005
1006    /// Returns the [`EpochAuthenticator`] of the staged commit state if the
1007    /// owner of the originating group state is a member of the group. Returns
1008    /// `None` otherwise.
1009    pub fn epoch_authenticator(&self) -> Option<&EpochAuthenticator> {
1010        if let StagedCommitState::GroupMember(ref gm) = self.state {
1011            Some(gm.group_epoch_secrets.epoch_authenticator())
1012        } else {
1013            None
1014        }
1015    }
1016
1017    /// Returns the [`ResumptionPskSecret`] of the staged commit state if the
1018    /// owner of the originating group state is a member of the group. Returns
1019    /// `None` otherwise.
1020    pub fn resumption_psk_secret(&self) -> Option<&ResumptionPskSecret> {
1021        if let StagedCommitState::GroupMember(ref gm) = self.state {
1022            Some(gm.group_epoch_secrets.resumption_psk())
1023        } else {
1024            None
1025        }
1026    }
1027
1028    /// Safely exports a secret for the given `component_id` from the epoch the
1029    /// staged commit moves to, before the commit is merged.
1030    ///
1031    /// This is needed by components that feed a secret exported from one
1032    /// commit into the processing of a related commit, e.g. a PSK derived
1033    /// from one group's staged commit and consumed by another group's key
1034    /// schedule.
1035    #[cfg(feature = "extensions-draft")]
1036    pub fn safe_export_secret(
1037        &mut self,
1038        crypto: &impl OpenMlsCrypto,
1039        component_id: ComponentId,
1040    ) -> Result<Vec<u8>, StagedSafeExportSecretError> {
1041        let ciphersuite = self.group_context().ciphersuite();
1042        let StagedCommitState::GroupMember(ref mut staged_commit) = self.state else {
1043            return Err(StagedSafeExportSecretError::NotGroupMember);
1044        };
1045        let Some(application_export_tree) = staged_commit.application_export_tree.as_mut() else {
1046            return Err(StagedSafeExportSecretError::Unsupported);
1047        };
1048        let secret =
1049            application_export_tree.safe_export_secret(crypto, ciphersuite, component_id)?;
1050        Ok(secret.as_slice().to_vec())
1051    }
1052
1053    /// Exports a secret from the epoch that the staged commit moves to.
1054    /// Returns [`ExportSecretError::KeyLengthTooLong`] if the requested
1055    /// key length is too long.
1056    /// Returns [`ExportSecretError::GroupStateError(MlsGroupStateError::UseAfterEviction)`]
1057    /// if the commit removed us from the group.
1058    ///
1059    /// [`ExportSecretError::GroupStateError(MlsGroupStateError::UseAfterEviction)`]: MlsGroupStateError::UseAfterEviction
1060    pub fn export_secret<CryptoProvider: OpenMlsCrypto>(
1061        &self,
1062        crypto: &CryptoProvider,
1063        label: &str,
1064        context: &[u8],
1065        key_length: usize,
1066    ) -> Result<Vec<u8>, ExportSecretError> {
1067        if key_length > u16::MAX as usize {
1068            log::error!("Got a key that is larger than u16::MAX");
1069            return Err(ExportSecretError::KeyLengthTooLong);
1070        }
1071
1072        match &self.state {
1073            StagedCommitState::PublicState(_public_staged_commit_state) => Err(
1074                ExportSecretError::GroupStateError(MlsGroupStateError::UseAfterEviction),
1075            ),
1076            StagedCommitState::GroupMember(member_staged_commit_state) => {
1077                Ok(member_staged_commit_state
1078                    .group_epoch_secrets
1079                    .exporter_secret()
1080                    .derive_exported_secret(
1081                        self.group_context().ciphersuite(),
1082                        crypto,
1083                        label,
1084                        context,
1085                        key_length,
1086                    )
1087                    .map_err(LibraryError::unexpected_crypto_error)?)
1088            }
1089        }
1090    }
1091}
1092
1093/// This struct is used internally by [`StagedCommit`] to encapsulate all the modified group state.
1094#[derive(Debug, Serialize, Deserialize)]
1095#[cfg_attr(any(test, feature = "test-utils"), derive(Clone, PartialEq))]
1096pub(crate) struct MemberStagedCommitState {
1097    group_epoch_secrets: GroupEpochSecrets,
1098    message_secrets: MessageSecrets,
1099    staged_diff: StagedPublicGroupDiff,
1100    new_keypairs: Vec<EncryptionKeyPair>,
1101    new_leaf_keypair_option: Option<EncryptionKeyPair>,
1102    update_path_leaf_node: Option<LeafNode>,
1103    #[cfg(feature = "extensions-draft")]
1104    #[serde(default)]
1105    // This is `None` only if the group was stored using an older version of
1106    // OpenMLS that did not support the application exporter.
1107    application_export_tree: Option<ApplicationExportTree>,
1108    // The new leaf index to install on the receiving group at merge time
1109    // when this staged commit is a sibling-resync external commit (a VC
1110    // external commit from a sibling emulator that inline-removes the
1111    // receiver's existing leaf). `None` for all other commit kinds.
1112    #[cfg(feature = "virtual-clients-draft")]
1113    #[serde(default)]
1114    new_own_leaf_index: Option<LeafNodeIndex>,
1115}
1116
1117impl MemberStagedCommitState {
1118    #[allow(clippy::too_many_arguments)]
1119    pub(crate) fn new(
1120        group_epoch_secrets: GroupEpochSecrets,
1121        message_secrets: MessageSecrets,
1122        staged_diff: StagedPublicGroupDiff,
1123        new_keypairs: Vec<EncryptionKeyPair>,
1124        new_leaf_keypair_option: Option<EncryptionKeyPair>,
1125        update_path_leaf_node: Option<LeafNode>,
1126        #[cfg(feature = "extensions-draft")] application_export_tree: ApplicationExportTree,
1127        #[cfg(feature = "virtual-clients-draft")] new_own_leaf_index: Option<LeafNodeIndex>,
1128    ) -> Self {
1129        Self {
1130            group_epoch_secrets,
1131            message_secrets,
1132            staged_diff,
1133            new_keypairs,
1134            new_leaf_keypair_option,
1135            update_path_leaf_node,
1136            #[cfg(feature = "extensions-draft")]
1137            application_export_tree: Some(application_export_tree),
1138            #[cfg(feature = "virtual-clients-draft")]
1139            new_own_leaf_index,
1140        }
1141    }
1142
1143    /// Get the staged [`GroupContext`].
1144    pub(crate) fn group_context(&self) -> &GroupContext {
1145        self.staged_diff.group_context()
1146    }
1147}
1148
1149#[cfg(all(test, feature = "virtual-clients-draft"))]
1150mod tests {
1151    use super::validate_vc_external_init_secret;
1152    use crate::{
1153        components::vc_derivation_info::VirtualClientsError, group::errors::StageCommitError,
1154    };
1155
1156    /// The two spec MUSTs behind `validate_vc_external_init_secret`: a
1157    /// sibling external commit whose derivation info omits the external init
1158    /// secret is rejected, and a carried init secret on a commit without an
1159    /// ExternalInit proposal is rejected. The conforming combinations pass.
1160    #[test]
1161    fn external_init_secret_presence_is_validated() {
1162        let malformed: Result<(), StageCommitError> =
1163            Err(VirtualClientsError::DerivationInfoMalformed.into());
1164
1165        // Sibling external commit without a carried init secret.
1166        assert_eq!(
1167            validate_vc_external_init_secret(true, true, false),
1168            malformed
1169        );
1170        // Carried init secret on a commit without an ExternalInit proposal.
1171        assert_eq!(
1172            validate_vc_external_init_secret(false, false, true),
1173            malformed
1174        );
1175        assert_eq!(
1176            validate_vc_external_init_secret(true, false, true),
1177            malformed
1178        );
1179
1180        // Conforming: external commit carrying the secret, and a regular
1181        // commit carrying none.
1182        assert_eq!(validate_vc_external_init_secret(true, true, true), Ok(()));
1183        assert_eq!(validate_vc_external_init_secret(false, true, false), Ok(()));
1184        assert_eq!(
1185            validate_vc_external_init_secret(false, false, false),
1186            Ok(())
1187        );
1188    }
1189}