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-08")]
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-08")]
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 that was sent by another group member.
256    /// This function does the following:
257    ///  - Applies the proposals covered by the commit to the tree
258    ///  - Applies the (optional) update path to the tree
259    ///  - Updates the [`GroupContext`]
260    ///  - Decrypts and derives the path secrets
261    ///  - Initializes the key schedule for epoch rollover
262    ///  - Verifies the confirmation tag
263    ///
264    /// Returns a [`StagedCommit`] that can be inspected and later merged into
265    /// the group state either with [`MlsGroup::merge_commit()`] or
266    /// [`PublicGroup::merge_diff()`] This function does the following checks:
267    ///  - ValSem101
268    ///  - ValSem102
269    ///  - ValSem104
270    ///  - ValSem105
271    ///  - ValSem106
272    ///  - ValSem107
273    ///  - ValSem108
274    ///  - ValSem110
275    ///  - ValSem111
276    ///  - ValSem112
277    ///  - ValSem200
278    ///  - ValSem201
279    ///  - ValSem202: Path must be the right length
280    ///  - ValSem203: Path secrets must decrypt correctly
281    ///  - ValSem204: Public keys from Path must be verified and match the
282    ///    private keys from the direct path
283    ///  - ValSem205
284    ///  - ValSem240
285    ///  - ValSem241
286    ///  - ValSem242
287    ///  - ValSem244
288    ///
289    /// Returns an error if the given commit was sent by the owner of this
290    /// group.
291    pub(crate) fn stage_commit(
292        &self,
293        mls_content: &AuthenticatedContent,
294        crypto: &impl OpenMlsCrypto,
295    ) -> Result<StagedCommit, StageCommitError> {
296        let (commit, proposal_queue, sender_index) = self.validate_commit(mls_content, crypto)?;
297
298        let staged_diff = self.stage_diff(mls_content, &proposal_queue, sender_index, crypto)?;
299        let staged_state = PublicStagedCommitState {
300            staged_diff,
301            update_path_leaf_node: commit.path.as_ref().map(|p| p.leaf_node().clone()),
302        };
303
304        let staged_commit_state = StagedCommitState::PublicState(Box::new(staged_state));
305
306        Ok(StagedCommit::new(
307            proposal_queue,
308            staged_commit_state,
309            #[cfg(feature = "virtual-clients-draft")]
310            None,
311        ))
312    }
313
314    #[cfg(feature = "extensions-draft-08")]
315    pub(crate) fn stage_commit_with_app_data_updates(
316        &self,
317        mls_content: &AuthenticatedContent,
318        crypto: &impl OpenMlsCrypto,
319        app_data_dict_updates: Option<AppDataUpdates>,
320    ) -> Result<StagedCommit, StageCommitError> {
321        let (commit, proposal_queue, sender_index) = self.validate_commit(mls_content, crypto)?;
322
323        let staged_diff = self.stage_diff_with_app_data_updates(
324            mls_content,
325            &proposal_queue,
326            sender_index,
327            crypto,
328            app_data_dict_updates,
329        )?;
330        let staged_state = PublicStagedCommitState {
331            staged_diff,
332            update_path_leaf_node: commit.path.as_ref().map(|p| p.leaf_node().clone()),
333        };
334
335        let staged_commit_state = StagedCommitState::PublicState(Box::new(staged_state));
336
337        Ok(StagedCommit::new(
338            proposal_queue,
339            staged_commit_state,
340            #[cfg(feature = "virtual-clients-draft")]
341            None,
342        ))
343    }
344
345    fn stage_diff(
346        &self,
347        mls_content: &AuthenticatedContent,
348        proposal_queue: &ProposalQueue,
349        sender_index: LeafNodeIndex,
350        crypto: &impl OpenMlsCrypto,
351    ) -> Result<StagedPublicGroupDiff, StageCommitError> {
352        let mut diff = self.empty_diff();
353
354        let apply_proposals_values = diff.apply_proposals(proposal_queue, None)?;
355
356        self.stage_diff_internal(
357            mls_content,
358            apply_proposals_values,
359            diff,
360            sender_index,
361            crypto,
362        )
363    }
364
365    #[cfg(feature = "extensions-draft-08")]
366    fn stage_diff_with_app_data_updates(
367        &self,
368        mls_content: &AuthenticatedContent,
369        proposal_queue: &ProposalQueue,
370        sender_index: LeafNodeIndex,
371        crypto: &impl OpenMlsCrypto,
372        app_data_dict_updates: Option<AppDataUpdates>,
373    ) -> Result<StagedPublicGroupDiff, StageCommitError> {
374        let mut diff = self.empty_diff();
375
376        let apply_proposals_values = diff.apply_proposals_with_app_data_updates(
377            proposal_queue,
378            None,
379            app_data_dict_updates,
380        )?;
381
382        self.stage_diff_internal(
383            mls_content,
384            apply_proposals_values,
385            diff,
386            sender_index,
387            crypto,
388        )
389    }
390
391    fn stage_diff_internal(
392        &self,
393        mls_content: &AuthenticatedContent,
394        apply_proposals_values: ApplyProposalsValues,
395        mut diff: PublicGroupDiff,
396        sender_index: LeafNodeIndex,
397        crypto: &impl OpenMlsCrypto,
398    ) -> Result<StagedPublicGroupDiff, StageCommitError> {
399        let ciphersuite = self.ciphersuite();
400
401        let commit = match mls_content.content() {
402            FramedContentBody::Commit(commit) => commit,
403            _ => return Err(StageCommitError::WrongPlaintextContentType),
404        };
405
406        // Determine if Commit has a path
407        if let Some(update_path) = &commit.path {
408            // Update the public group
409            // ValSem202: Path must be the right length
410            diff.apply_received_update_path(crypto, ciphersuite, sender_index, update_path)?;
411        } else if apply_proposals_values.path_required {
412            // ValSem201
413            // https://validation.openmls.tech/#valn1206
414            return Err(StageCommitError::RequiredPathNotFound);
415        };
416
417        // Update group context
418        diff.update_group_context(crypto, apply_proposals_values.extensions)?;
419
420        // Update the confirmed transcript hash before we compute the confirmation tag.
421        diff.update_confirmed_transcript_hash(crypto, mls_content)?;
422
423        let received_confirmation_tag = mls_content
424            .confirmation_tag()
425            .ok_or(StageCommitError::ConfirmationTagMissing)?;
426
427        // If we have private key material, derive the secrets for the next
428        // epoch and check the confirmation tag.
429        diff.update_interim_transcript_hash(
430            ciphersuite,
431            crypto,
432            received_confirmation_tag.clone(),
433        )?;
434
435        let staged_diff = diff.into_staged_diff(crypto, ciphersuite)?;
436
437        Ok(staged_diff)
438    }
439
440    /// Merges a [StagedCommit] into the public group state.
441    pub fn merge_commit<Storage: PublicStorageProvider>(
442        &mut self,
443        storage: &Storage,
444        staged_commit: StagedCommit,
445    ) -> Result<(), MergeCommitError<Storage::Error>> {
446        match staged_commit.into_state() {
447            StagedCommitState::PublicState(staged_state) => {
448                self.merge_diff(staged_state.staged_diff);
449            }
450            StagedCommitState::GroupMember(_) => (),
451        }
452
453        self.proposal_store.empty();
454        storage
455            .clear_proposal_queue::<GroupId, ProposalRef>(self.group_id())
456            .map_err(MergeCommitError::StorageError)?;
457        self.store(storage).map_err(MergeCommitError::StorageError)
458    }
459}