Skip to main content

openmls/group/public_group/
staged_commit.rs

1use super::{super::errors::*, diff::apply_proposals::ApplyProposalsValues, *};
2use crate::{
3    framing::{mls_auth_content::AuthenticatedContent, mls_content::FramedContentBody, Sender},
4    group::{
5        mls_group::staged_commit::StagedCommitState, proposal_store::ProposalQueue, StagedCommit,
6    },
7    messages::{
8        proposals::{ProposalOrRef, ProposalType},
9        Commit,
10    },
11    treesync::errors::LeafNodeValidationError,
12};
13
14#[cfg(feature = "extensions-draft")]
15use crate::prelude::processing::AppDataUpdates;
16
17#[derive(Debug, Serialize, Deserialize)]
18#[cfg_attr(any(test, feature = "test-utils"), derive(Clone, PartialEq))]
19pub struct PublicStagedCommitState {
20    pub(crate) staged_diff: StagedPublicGroupDiff,
21    pub(super) update_path_leaf_node: Option<LeafNode>,
22}
23
24impl PublicStagedCommitState {
25    pub fn new(
26        staged_diff: StagedPublicGroupDiff,
27        update_path_leaf_node: Option<LeafNode>,
28    ) -> Self {
29        Self {
30            staged_diff,
31            update_path_leaf_node,
32        }
33    }
34
35    pub(crate) fn into_staged_diff(self) -> StagedPublicGroupDiff {
36        self.staged_diff
37    }
38
39    pub fn update_path_leaf_node(&self) -> Option<&LeafNode> {
40        self.update_path_leaf_node.as_ref()
41    }
42
43    pub fn staged_diff(&self) -> &StagedPublicGroupDiff {
44        &self.staged_diff
45    }
46}
47
48impl PublicGroup {
49    pub(crate) fn validate_commit<'a>(
50        &self,
51        mls_content: &'a AuthenticatedContent,
52        crypto: &impl OpenMlsCrypto,
53        lifetime_policy: LeafNodeLifetimePolicy,
54    ) -> Result<(&'a Commit, ProposalQueue, LeafNodeIndex), StageCommitError> {
55        let ciphersuite = self.ciphersuite();
56
57        // Verify epoch
58        // https://validation.openmls.tech/#valn1201
59        if mls_content.epoch() != self.group_context().epoch() {
60            log::error!(
61                "Epoch mismatch. Got {:?}, expected {:?}",
62                mls_content.epoch(),
63                self.group_context().epoch()
64            );
65            return Err(StageCommitError::EpochMismatch);
66        }
67
68        // Extract Commit & Confirmation Tag from PublicMessage
69        let commit = match mls_content.content() {
70            FramedContentBody::Commit(commit) => commit,
71            _ => return Err(StageCommitError::WrongPlaintextContentType),
72        };
73
74        let sender = mls_content.sender();
75
76        if sender == &Sender::NewMemberCommit {
77            // External commit, there MUST be a path
78            // https://validation.openmls.tech/#valn0405
79            let Some(path) = &commit.path else {
80                return Err(ExternalCommitValidationError::NoPath.into());
81            };
82
83            // External Commit, The capabilities of the leaf node in the path MUST support all
84            // group context extensions.
85            // https://validation.openmls.tech/#valn1210
86            let leaf_nodes_supports_group_context_extensions = path
87                .leaf_node()
88                .capabilities()
89                .contains_extensions(self.group_context().extensions());
90
91            if !leaf_nodes_supports_group_context_extensions {
92                return Err(
93                    ExternalCommitValidationError::UnsupportedGroupContextExtensions.into(),
94                );
95            }
96
97            // ValSem244: External Commit, There MUST NOT be any referenced proposals.
98            // https://validation.openmls.tech/#valn0406
99            // Only SelfRemove proposals are allowed
100            if commit.proposals.iter().any(|proposal| {
101                let ProposalOrRef::Reference(proposal_ref) = proposal else {
102                    return false;
103                };
104                // Proposal references are only allowed if they refer to a
105                // SelfRemove proposal in our store
106                !self.proposal_store.proposals().any(|p| {
107                    p.proposal_reference_ref() == proposal_ref.as_ref()
108                        && p.proposal().is_type(ProposalType::SelfRemove)
109                })
110            }) {
111                return Err(ExternalCommitValidationError::ReferencedProposal.into());
112            }
113
114            let number_of_remove_proposals = commit
115                .proposals
116                .iter()
117                .filter(|prop| prop.as_proposal().filter(|p| p.is_remove()).is_some())
118                .count();
119
120            // https://validation.openmls.tech/#valn0402
121            if number_of_remove_proposals > 1 {
122                return Err(ExternalCommitValidationError::MultipleExternalInitProposals.into());
123            }
124        }
125
126        // Build a queue with all proposals from the Commit and check that we have all
127        // of the proposals by reference locally
128        // ValSem240: Commit must not cover inline self Remove proposal
129        let proposal_queue = ProposalQueue::from_committed_proposals(
130            ciphersuite,
131            crypto,
132            commit.proposals.as_slice().to_vec(),
133            self.proposal_store(),
134            sender,
135        )
136        .map_err(|e| {
137            log::error!("Error building the proposal queue for the commit ({e:?})");
138            match e {
139                FromCommittedProposalsError::LibraryError(e) => StageCommitError::LibraryError(e),
140                FromCommittedProposalsError::ProposalNotFound => StageCommitError::MissingProposal,
141                FromCommittedProposalsError::SelfRemoval => StageCommitError::AttemptedSelfRemoval,
142                FromCommittedProposalsError::DuplicatePskId(psk_id) => {
143                    StageCommitError::DuplicatePskId(psk_id)
144                }
145            }
146        })?;
147
148        // https://validation.openmls.tech/#valn1207
149        if let Some(update_path) = &commit.path {
150            // The leaf node of a Commit carries no lifetime.
151            self.validate_leaf_node(update_path.leaf_node(), LeafNodeLifetimePolicy::Verify)?;
152
153            // The capabilities of the leaf node in the path MUST support all
154            // group context extensions.
155            // https://validation.openmls.tech/#valn1210
156            let leaf_node_supports_group_context_extensions = update_path
157                .leaf_node()
158                .capabilities()
159                .contains_extensions(self.group_context().extensions());
160
161            if !leaf_node_supports_group_context_extensions {
162                return Err(LeafNodeValidationError::UnsupportedExtensions.into());
163            }
164        }
165
166        // Validate the staged proposals. This implements
167        // - https://validation.openmls.tech/#valn0301
168        // - https://validation.openmls.tech/#valn1204
169        //
170        // This is done by doing the following checks:
171
172        // ValSem101
173        // ValSem102
174        // ValSem103
175        // ValSem104
176        self.validate_key_uniqueness(
177            &proposal_queue,
178            Some(commit),
179            sender,
180            commit
181                .path
182                .as_ref()
183                .map(|path| path.leaf_node().signature_key()),
184        )?;
185        // ValSem105
186        self.validate_add_proposals(&proposal_queue, lifetime_policy)?;
187        // ValSem106
188        // ValSem109
189        self.validate_capabilities(&proposal_queue)?;
190        // ValSem107
191        // ValSem108
192        self.validate_remove_proposals(&proposal_queue)?;
193        // ValSem113: All Proposals: The proposal type must be supported by all
194        // members of the group
195        self.validate_proposal_type_support(&proposal_queue)?;
196        // ValSem208
197        // ValSem209
198        self.validate_group_context_extensions_proposal(&proposal_queue)?;
199
200        #[cfg(feature = "extensions-draft")]
201        self.validate_app_data_update_proposals_and_group_context(&proposal_queue)?;
202
203        // ValSem401
204        // ValSem402
205        // ValSem403
206        self.validate_pre_shared_key_proposals(&proposal_queue)?;
207
208        match sender {
209            Sender::Member(committer_leaf_index) => {
210                // ValSem110
211                // ValSem111
212                // ValSem112
213                self.validate_update_proposals(&proposal_queue, *committer_leaf_index)?;
214
215                self.validate_no_external_init_proposals(&proposal_queue)?;
216
217                // A Commit referencing a ReInit proposal must contain no other
218                // proposals (RFC 9420 ยง12.2). ReInit is not part of the external
219                // commit allowlist, so this only applies to member commits.
220                self.validate_reinit_proposals(&proposal_queue)?;
221            }
222            Sender::External(_) => {
223                // A commit cannot be issued by a pre-configured sender.
224                return Err(StageCommitError::SenderTypeExternal);
225            }
226            Sender::NewMemberProposal => {
227                // A commit cannot be issued by a `NewMemberProposal` sender.
228                return Err(StageCommitError::SenderTypeNewMemberProposal);
229            }
230            Sender::NewMemberCommit => {
231                // ValSem240: External Commit, inline Proposals: There MUST be at least one ExternalInit proposal.
232                // ValSem241: External Commit, inline Proposals: There MUST be at most one ExternalInit proposal.
233                // ValSem242: External Commit must only cover inline proposal in allowlist (ExternalInit, Remove, PreSharedKey)
234                self.validate_external_commit(&proposal_queue)?;
235            }
236        }
237
238        // Now we can actually look at the public keys as they might have changed.
239        let sender_index = match sender {
240            Sender::Member(leaf_index) => *leaf_index,
241            Sender::NewMemberCommit => {
242                self.leftmost_free_index(proposal_queue.queued_proposals())?
243            }
244            _ => {
245                return Err(StageCommitError::SenderTypeExternal);
246            }
247        };
248
249        Ok((commit, proposal_queue, sender_index))
250    }
251
252    // Check that no external init proposal occurs. Needed only for regular commits.
253    // [valn0310](https://validation.openmls.tech/#valn0310)
254    fn validate_no_external_init_proposals(
255        &self,
256        proposal_queue: &ProposalQueue,
257    ) -> Result<(), ProposalValidationError> {
258        for proposal in proposal_queue.queued_proposals() {
259            if matches!(
260                proposal.proposal().proposal_type(),
261                ProposalType::ExternalInit
262            ) {
263                return Err(ProposalValidationError::ExternalInitProposalInRegularCommit);
264            }
265        }
266
267        Ok(())
268    }
269
270    /// Validate a Commit that references a ReInit proposal:
271    ///
272    /// * it must contain no other proposals
273    ///   ([valn0309](https://validation.openmls.tech/#valn0309)), and
274    /// * the ReInit's protocol version must not be lower than the current
275    ///   group's ([valn0901](https://validation.openmls.tech/#valn0901)).
276    pub(crate) fn validate_reinit_proposals(
277        &self,
278        proposal_queue: &ProposalQueue,
279    ) -> Result<(), ProposalValidationError> {
280        let Some(reinit) = proposal_queue.reinit_proposal() else {
281            return Ok(());
282        };
283
284        // https://validation.openmls.tech/#valn0309
285        if proposal_queue.queued_proposals().count() != 1 {
286            return Err(ProposalValidationError::ReInitProposalNotAlone);
287        }
288
289        // https://validation.openmls.tech/#valn0901
290        // Match positively on supported versions.
291        if !matches!(reinit.version(), ProtocolVersion::Mls10) {
292            return Err(ProposalValidationError::ReInitUnsupportedVersion);
293        }
294        if reinit.version() < self.group_context.protocol_version() {
295            return Err(ProposalValidationError::ReInitDowngrade);
296        }
297
298        Ok(())
299    }
300
301    /// Stages a commit message. The commit may have been sent by another group
302    /// member or be our own Commit without an UpdatePath.
303    /// This function does the following:
304    ///  - Applies the proposals covered by the commit to the tree
305    ///  - Applies the (optional) update path to the tree
306    ///  - Updates the [`GroupContext`]
307    ///  - Decrypts and derives the path secrets
308    ///  - Initializes the key schedule for epoch rollover
309    ///  - Verifies the confirmation tag
310    ///
311    /// Returns a [`StagedCommit`] that can be inspected and later merged into
312    /// the group state either with [`MlsGroup::merge_commit()`] or
313    /// [`PublicGroup::merge_diff()`] This function does the following checks:
314    ///  - ValSem101
315    ///  - ValSem102
316    ///  - ValSem104
317    ///  - ValSem105
318    ///  - ValSem106
319    ///  - ValSem107
320    ///  - ValSem108
321    ///  - ValSem110
322    ///  - ValSem111
323    ///  - ValSem112
324    ///  - ValSem200
325    ///  - ValSem201
326    ///  - ValSem202: Path must be the right length
327    ///  - ValSem203: Path secrets must decrypt correctly
328    ///  - ValSem204: Public keys from Path must be verified and match the
329    ///    private keys from the direct path
330    ///  - ValSem205
331    ///  - ValSem240
332    ///  - ValSem241
333    ///  - ValSem242
334    ///  - ValSem244
335    pub(crate) fn stage_commit(
336        &self,
337        mls_content: &AuthenticatedContent,
338        crypto: &impl OpenMlsCrypto,
339        lifetime_policy: LeafNodeLifetimePolicy,
340    ) -> Result<StagedCommit, StageCommitError> {
341        let (commit, proposal_queue, sender_index) =
342            self.validate_commit(mls_content, crypto, lifetime_policy)?;
343
344        let staged_diff = self.stage_diff(mls_content, &proposal_queue, sender_index, crypto)?;
345        let staged_state = PublicStagedCommitState {
346            staged_diff,
347            update_path_leaf_node: commit.path.as_ref().map(|p| p.leaf_node().clone()),
348        };
349
350        let staged_commit_state = StagedCommitState::PublicState(Box::new(staged_state));
351
352        Ok(StagedCommit::new(
353            proposal_queue,
354            staged_commit_state,
355            #[cfg(feature = "virtual-clients-draft")]
356            None,
357        ))
358    }
359
360    #[cfg(feature = "extensions-draft")]
361    pub(crate) fn stage_commit_with_app_data_updates(
362        &self,
363        mls_content: &AuthenticatedContent,
364        crypto: &impl OpenMlsCrypto,
365        app_data_dict_updates: Option<AppDataUpdates>,
366        lifetime_policy: LeafNodeLifetimePolicy,
367    ) -> Result<StagedCommit, StageCommitError> {
368        let (commit, proposal_queue, sender_index) =
369            self.validate_commit(mls_content, crypto, lifetime_policy)?;
370
371        let staged_diff = self.stage_diff_with_app_data_updates(
372            mls_content,
373            &proposal_queue,
374            sender_index,
375            crypto,
376            app_data_dict_updates,
377        )?;
378        let staged_state = PublicStagedCommitState {
379            staged_diff,
380            update_path_leaf_node: commit.path.as_ref().map(|p| p.leaf_node().clone()),
381        };
382
383        let staged_commit_state = StagedCommitState::PublicState(Box::new(staged_state));
384
385        Ok(StagedCommit::new(
386            proposal_queue,
387            staged_commit_state,
388            #[cfg(feature = "virtual-clients-draft")]
389            None,
390        ))
391    }
392
393    fn stage_diff(
394        &self,
395        mls_content: &AuthenticatedContent,
396        proposal_queue: &ProposalQueue,
397        sender_index: LeafNodeIndex,
398        crypto: &impl OpenMlsCrypto,
399    ) -> Result<StagedPublicGroupDiff, StageCommitError> {
400        let mut diff = self.empty_diff();
401
402        let apply_proposals_values = diff.apply_proposals(proposal_queue, None)?;
403
404        self.stage_diff_internal(
405            mls_content,
406            apply_proposals_values,
407            diff,
408            sender_index,
409            crypto,
410        )
411    }
412
413    #[cfg(feature = "extensions-draft")]
414    fn stage_diff_with_app_data_updates(
415        &self,
416        mls_content: &AuthenticatedContent,
417        proposal_queue: &ProposalQueue,
418        sender_index: LeafNodeIndex,
419        crypto: &impl OpenMlsCrypto,
420        app_data_dict_updates: Option<AppDataUpdates>,
421    ) -> Result<StagedPublicGroupDiff, StageCommitError> {
422        let mut diff = self.empty_diff();
423
424        let apply_proposals_values = diff.apply_proposals_with_app_data_updates(
425            proposal_queue,
426            None,
427            app_data_dict_updates,
428        )?;
429
430        self.stage_diff_internal(
431            mls_content,
432            apply_proposals_values,
433            diff,
434            sender_index,
435            crypto,
436        )
437    }
438
439    fn stage_diff_internal(
440        &self,
441        mls_content: &AuthenticatedContent,
442        apply_proposals_values: ApplyProposalsValues,
443        mut diff: PublicGroupDiff,
444        sender_index: LeafNodeIndex,
445        crypto: &impl OpenMlsCrypto,
446    ) -> Result<StagedPublicGroupDiff, StageCommitError> {
447        let ciphersuite = self.ciphersuite();
448
449        let commit = match mls_content.content() {
450            FramedContentBody::Commit(commit) => commit,
451            _ => return Err(StageCommitError::WrongPlaintextContentType),
452        };
453
454        // Determine if Commit has a path
455        if let Some(update_path) = &commit.path {
456            // Update the public group
457            // ValSem202: Path must be the right length
458            diff.apply_received_update_path(crypto, ciphersuite, sender_index, update_path)?;
459        } else if apply_proposals_values.path_required {
460            // ValSem201
461            // https://validation.openmls.tech/#valn1206
462            return Err(StageCommitError::RequiredPathNotFound);
463        };
464
465        // Update group context
466        diff.update_group_context(crypto, apply_proposals_values.extensions)?;
467
468        // Update the confirmed transcript hash before we compute the confirmation tag.
469        diff.update_confirmed_transcript_hash(crypto, mls_content)?;
470
471        let received_confirmation_tag = mls_content
472            .confirmation_tag()
473            .ok_or(StageCommitError::ConfirmationTagMissing)?;
474
475        // If we have private key material, derive the secrets for the next
476        // epoch and check the confirmation tag.
477        diff.update_interim_transcript_hash(
478            ciphersuite,
479            crypto,
480            received_confirmation_tag.clone(),
481        )?;
482
483        let staged_diff = diff.into_staged_diff(crypto, ciphersuite)?;
484
485        Ok(staged_diff)
486    }
487
488    /// Merges a [StagedCommit] into the public group state.
489    #[openmls_traits::maybe_async]
490    pub async fn merge_commit<Storage: PublicStorageProvider>(
491        &mut self,
492        storage: &Storage,
493        staged_commit: StagedCommit,
494    ) -> Result<(), MergeCommitError<Storage::Error>> {
495        match staged_commit.into_state() {
496            StagedCommitState::PublicState(staged_state) => {
497                self.merge_diff(staged_state.staged_diff);
498            }
499            StagedCommitState::GroupMember(_) => (),
500        }
501
502        self.proposal_store.empty();
503        storage
504            .clear_proposal_queue::<GroupId, ProposalRef>(self.group_id())
505            .await
506            .map_err(MergeCommitError::StorageError)?;
507        self.store(storage)
508            .await
509            .map_err(MergeCommitError::StorageError)
510    }
511}