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