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(proposal_queue, staged_commit_state))
307    }
308
309    #[cfg(feature = "extensions-draft-08")]
310    pub(crate) fn stage_commit_with_app_data_updates(
311        &self,
312        mls_content: &AuthenticatedContent,
313        crypto: &impl OpenMlsCrypto,
314        app_data_dict_updates: Option<AppDataUpdates>,
315    ) -> Result<StagedCommit, StageCommitError> {
316        let (commit, proposal_queue, sender_index) = self.validate_commit(mls_content, crypto)?;
317
318        let staged_diff = self.stage_diff_with_app_data_updates(
319            mls_content,
320            &proposal_queue,
321            sender_index,
322            crypto,
323            app_data_dict_updates,
324        )?;
325        let staged_state = PublicStagedCommitState {
326            staged_diff,
327            update_path_leaf_node: commit.path.as_ref().map(|p| p.leaf_node().clone()),
328        };
329
330        let staged_commit_state = StagedCommitState::PublicState(Box::new(staged_state));
331
332        Ok(StagedCommit::new(proposal_queue, staged_commit_state))
333    }
334
335    fn stage_diff(
336        &self,
337        mls_content: &AuthenticatedContent,
338        proposal_queue: &ProposalQueue,
339        sender_index: LeafNodeIndex,
340        crypto: &impl OpenMlsCrypto,
341    ) -> Result<StagedPublicGroupDiff, StageCommitError> {
342        let mut diff = self.empty_diff();
343
344        let apply_proposals_values = diff.apply_proposals(proposal_queue, None)?;
345
346        self.stage_diff_internal(
347            mls_content,
348            apply_proposals_values,
349            diff,
350            sender_index,
351            crypto,
352        )
353    }
354
355    #[cfg(feature = "extensions-draft-08")]
356    fn stage_diff_with_app_data_updates(
357        &self,
358        mls_content: &AuthenticatedContent,
359        proposal_queue: &ProposalQueue,
360        sender_index: LeafNodeIndex,
361        crypto: &impl OpenMlsCrypto,
362        app_data_dict_updates: Option<AppDataUpdates>,
363    ) -> Result<StagedPublicGroupDiff, StageCommitError> {
364        let mut diff = self.empty_diff();
365
366        let apply_proposals_values = diff.apply_proposals_with_app_data_updates(
367            proposal_queue,
368            None,
369            app_data_dict_updates,
370        )?;
371
372        self.stage_diff_internal(
373            mls_content,
374            apply_proposals_values,
375            diff,
376            sender_index,
377            crypto,
378        )
379    }
380
381    fn stage_diff_internal(
382        &self,
383        mls_content: &AuthenticatedContent,
384        apply_proposals_values: ApplyProposalsValues,
385        mut diff: PublicGroupDiff,
386        sender_index: LeafNodeIndex,
387        crypto: &impl OpenMlsCrypto,
388    ) -> Result<StagedPublicGroupDiff, StageCommitError> {
389        let ciphersuite = self.ciphersuite();
390
391        let commit = match mls_content.content() {
392            FramedContentBody::Commit(commit) => commit,
393            _ => return Err(StageCommitError::WrongPlaintextContentType),
394        };
395
396        // Determine if Commit has a path
397        if let Some(update_path) = &commit.path {
398            // Update the public group
399            // ValSem202: Path must be the right length
400            diff.apply_received_update_path(crypto, ciphersuite, sender_index, update_path)?;
401        } else if apply_proposals_values.path_required {
402            // ValSem201
403            // https://validation.openmls.tech/#valn1206
404            return Err(StageCommitError::RequiredPathNotFound);
405        };
406
407        // Update group context
408        diff.update_group_context(crypto, apply_proposals_values.extensions)?;
409
410        // Update the confirmed transcript hash before we compute the confirmation tag.
411        diff.update_confirmed_transcript_hash(crypto, mls_content)?;
412
413        let received_confirmation_tag = mls_content
414            .confirmation_tag()
415            .ok_or(StageCommitError::ConfirmationTagMissing)?;
416
417        // If we have private key material, derive the secrets for the next
418        // epoch and check the confirmation tag.
419        diff.update_interim_transcript_hash(
420            ciphersuite,
421            crypto,
422            received_confirmation_tag.clone(),
423        )?;
424
425        let staged_diff = diff.into_staged_diff(crypto, ciphersuite)?;
426
427        Ok(staged_diff)
428    }
429
430    /// Merges a [StagedCommit] into the public group state.
431    pub fn merge_commit<Storage: PublicStorageProvider>(
432        &mut self,
433        storage: &Storage,
434        staged_commit: StagedCommit,
435    ) -> Result<(), MergeCommitError<Storage::Error>> {
436        match staged_commit.into_state() {
437            StagedCommitState::PublicState(staged_state) => {
438                self.merge_diff(staged_state.staged_diff);
439            }
440            StagedCommitState::GroupMember(_) => (),
441        }
442
443        self.proposal_store.empty();
444        storage
445            .clear_proposal_queue::<GroupId, ProposalRef>(self.group_id())
446            .map_err(MergeCommitError::StorageError)?;
447        self.store(storage).map_err(MergeCommitError::StorageError)
448    }
449}