openmls/group/public_group/
staged_commit.rs

1use super::{super::errors::*, *};
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};
12
13#[derive(Debug, Serialize, Deserialize)]
14#[cfg_attr(any(test, feature = "test-utils"), derive(Clone, PartialEq))]
15pub struct PublicStagedCommitState {
16    pub(super) staged_diff: StagedPublicGroupDiff,
17    pub(super) update_path_leaf_node: Option<LeafNode>,
18}
19
20impl PublicStagedCommitState {
21    pub fn new(
22        staged_diff: StagedPublicGroupDiff,
23        update_path_leaf_node: Option<LeafNode>,
24    ) -> Self {
25        Self {
26            staged_diff,
27            update_path_leaf_node,
28        }
29    }
30
31    pub(crate) fn into_staged_diff(self) -> StagedPublicGroupDiff {
32        self.staged_diff
33    }
34
35    pub fn update_path_leaf_node(&self) -> Option<&LeafNode> {
36        self.update_path_leaf_node.as_ref()
37    }
38
39    pub fn staged_diff(&self) -> &StagedPublicGroupDiff {
40        &self.staged_diff
41    }
42}
43
44impl PublicGroup {
45    pub(crate) fn validate_commit<'a>(
46        &self,
47        mls_content: &'a AuthenticatedContent,
48        crypto: &impl OpenMlsCrypto,
49    ) -> Result<(&'a Commit, ProposalQueue, LeafNodeIndex), StageCommitError> {
50        let ciphersuite = self.ciphersuite();
51
52        // Verify epoch
53        // https://validation.openmls.tech/#valn1201
54        if mls_content.epoch() != self.group_context().epoch() {
55            log::error!(
56                "Epoch mismatch. Got {:?}, expected {:?}",
57                mls_content.epoch(),
58                self.group_context().epoch()
59            );
60            return Err(StageCommitError::EpochMismatch);
61        }
62
63        // Extract Commit & Confirmation Tag from PublicMessage
64        let commit = match mls_content.content() {
65            FramedContentBody::Commit(commit) => commit,
66            _ => return Err(StageCommitError::WrongPlaintextContentType),
67        };
68
69        let sender = mls_content.sender();
70
71        if sender == &Sender::NewMemberCommit {
72            // External commit, there MUST be a path
73            // https://validation.openmls.tech/#valn0405
74            if commit.path.is_none() {
75                return Err(ExternalCommitValidationError::NoPath.into());
76            }
77
78            // ValSem244: External Commit, There MUST NOT be any referenced proposals.
79            // https://validation.openmls.tech/#valn0406
80            if commit
81                .proposals
82                .iter()
83                .any(|proposal| matches!(proposal, ProposalOrRef::Reference(_)))
84            {
85                return Err(ExternalCommitValidationError::ReferencedProposal.into());
86            }
87
88            let number_of_remove_proposals = commit
89                .proposals
90                .iter()
91                .filter(|prop| matches!(prop, ProposalOrRef::Proposal(Proposal::Remove(_))))
92                .count();
93
94            // https://validation.openmls.tech/#valn0402
95            if number_of_remove_proposals > 1 {
96                return Err(ExternalCommitValidationError::MultipleExternalInitProposals.into());
97            }
98        }
99
100        // Build a queue with all proposals from the Commit and check that we have all
101        // of the proposals by reference locally
102        // ValSem240: Commit must not cover inline self Remove proposal
103        let proposal_queue = ProposalQueue::from_committed_proposals(
104            ciphersuite,
105            crypto,
106            commit.proposals.as_slice().to_vec(),
107            self.proposal_store(),
108            sender,
109        )
110        .map_err(|e| {
111            log::error!("Error building the proposal queue for the commit ({e:?})");
112            match e {
113                FromCommittedProposalsError::LibraryError(e) => StageCommitError::LibraryError(e),
114                FromCommittedProposalsError::ProposalNotFound => StageCommitError::MissingProposal,
115                FromCommittedProposalsError::SelfRemoval => StageCommitError::AttemptedSelfRemoval,
116            }
117        })?;
118
119        // https://validation.openmls.tech/#valn1207
120        if let Some(update_path) = &commit.path {
121            self.validate_leaf_node(update_path.leaf_node())?;
122        }
123
124        // Validate the staged proposals. This implements https://validation.openmls.tech/#valn1204.
125        // This is done by doing the following checks:
126
127        // ValSem101
128        // ValSem102
129        // ValSem103
130        // ValSem104
131        self.validate_key_uniqueness(&proposal_queue, Some(commit))?;
132        // ValSem105
133        self.validate_add_proposals(&proposal_queue)?;
134        // ValSem106
135        // ValSem109
136        self.validate_capabilities(&proposal_queue)?;
137        // ValSem107
138        // ValSem108
139        self.validate_remove_proposals(&proposal_queue)?;
140        // ValSem113: All Proposals: The proposal type must be supported by all
141        // members of the group
142        self.validate_proposal_type_support(&proposal_queue)?;
143        // ValSem208
144        // ValSem209
145        self.validate_group_context_extensions_proposal(&proposal_queue)?;
146        // ValSem401
147        // ValSem402
148        // ValSem403
149        self.validate_pre_shared_key_proposals(&proposal_queue)?;
150
151        match sender {
152            Sender::Member(leaf_index) => {
153                // ValSem110
154                // ValSem111
155                // ValSem112
156                self.validate_update_proposals(&proposal_queue, *leaf_index)?;
157
158                self.validate_no_external_init_proposals(&proposal_queue)?;
159            }
160            Sender::External(_) => {
161                // A commit cannot be issued by a pre-configured sender.
162                return Err(StageCommitError::SenderTypeExternal);
163            }
164            Sender::NewMemberProposal => {
165                // A commit cannot be issued by a `NewMemberProposal` sender.
166                return Err(StageCommitError::SenderTypeNewMemberProposal);
167            }
168            Sender::NewMemberCommit => {
169                // ValSem240: External Commit, inline Proposals: There MUST be at least one ExternalInit proposal.
170                // ValSem241: External Commit, inline Proposals: There MUST be at most one ExternalInit proposal.
171                // ValSem242: External Commit must only cover inline proposal in allowlist (ExternalInit, Remove, PreSharedKey)
172                self.validate_external_commit(&proposal_queue)?;
173            }
174        }
175
176        // Now we can actually look at the public keys as they might have changed.
177        let sender_index = match sender {
178            Sender::Member(leaf_index) => *leaf_index,
179            Sender::NewMemberCommit => {
180                self.leftmost_free_index(iter::empty(), proposal_queue.queued_proposals())?
181            }
182            _ => {
183                return Err(StageCommitError::SenderTypeExternal);
184            }
185        };
186
187        Ok((commit, proposal_queue, sender_index))
188    }
189
190    // Check that no external init proposal occurs. Needed only for regular commits.
191    // [valn0310](https://validation.openmls.tech/#valn0310)
192    fn validate_no_external_init_proposals(
193        &self,
194        proposal_queue: &ProposalQueue,
195    ) -> Result<(), ProposalValidationError> {
196        for proposal in proposal_queue.queued_proposals() {
197            if matches!(
198                proposal.proposal().proposal_type(),
199                ProposalType::ExternalInit
200            ) {
201                return Err(ProposalValidationError::ExternalInitProposalInRegularCommit);
202            }
203        }
204
205        Ok(())
206    }
207
208    /// Stages a commit message that was sent by another group member.
209    /// This function does the following:
210    ///  - Applies the proposals covered by the commit to the tree
211    ///  - Applies the (optional) update path to the tree
212    ///  - Updates the [`GroupContext`]
213    ///  - Decrypts and derives the path secrets
214    ///  - Initializes the key schedule for epoch rollover
215    ///  - Verifies the confirmation tag
216    ///
217    /// Returns a [`StagedCommit`] that can be inspected and later merged into
218    /// the group state either with [`MlsGroup::merge_commit()`] or
219    /// [`PublicGroup::merge_diff()`] This function does the following checks:
220    ///  - ValSem101
221    ///  - ValSem102
222    ///  - ValSem104
223    ///  - ValSem105
224    ///  - ValSem106
225    ///  - ValSem107
226    ///  - ValSem108
227    ///  - ValSem110
228    ///  - ValSem111
229    ///  - ValSem112
230    ///  - ValSem200
231    ///  - ValSem201
232    ///  - ValSem202: Path must be the right length
233    ///  - ValSem203: Path secrets must decrypt correctly
234    ///  - ValSem204: Public keys from Path must be verified and match the
235    ///    private keys from the direct path
236    ///  - ValSem205
237    ///  - ValSem240
238    ///  - ValSem241
239    ///  - ValSem242
240    ///  - ValSem244
241    ///
242    /// Returns an error if the given commit was sent by the owner of this
243    /// group.
244    pub(crate) fn stage_commit(
245        &self,
246        mls_content: &AuthenticatedContent,
247        crypto: &impl OpenMlsCrypto,
248    ) -> Result<StagedCommit, StageCommitError> {
249        let (commit, proposal_queue, sender_index) = self.validate_commit(mls_content, crypto)?;
250
251        let staged_diff = self.stage_diff(mls_content, &proposal_queue, sender_index, crypto)?;
252        let staged_state = PublicStagedCommitState {
253            staged_diff,
254            update_path_leaf_node: commit.path.as_ref().map(|p| p.leaf_node().clone()),
255        };
256
257        let staged_commit_state = StagedCommitState::PublicState(Box::new(staged_state));
258
259        Ok(StagedCommit::new(proposal_queue, staged_commit_state))
260    }
261
262    fn stage_diff(
263        &self,
264        mls_content: &AuthenticatedContent,
265        proposal_queue: &ProposalQueue,
266        sender_index: LeafNodeIndex,
267        crypto: &impl OpenMlsCrypto,
268    ) -> Result<StagedPublicGroupDiff, StageCommitError> {
269        let ciphersuite = self.ciphersuite();
270        let mut diff = self.empty_diff();
271
272        let apply_proposals_values = diff.apply_proposals(proposal_queue, None)?;
273
274        let commit = match mls_content.content() {
275            FramedContentBody::Commit(commit) => commit,
276            _ => return Err(StageCommitError::WrongPlaintextContentType),
277        };
278
279        // Determine if Commit has a path
280        if let Some(update_path) = &commit.path {
281            // Update the public group
282            // ValSem202: Path must be the right length
283            diff.apply_received_update_path(crypto, ciphersuite, sender_index, update_path)?;
284        } else if apply_proposals_values.path_required {
285            // ValSem201
286            // https://validation.openmls.tech/#valn1206
287            return Err(StageCommitError::RequiredPathNotFound);
288        };
289
290        // Update group context
291        diff.update_group_context(crypto, apply_proposals_values.extensions.clone())?;
292
293        // Update the confirmed transcript hash before we compute the confirmation tag.
294        diff.update_confirmed_transcript_hash(crypto, mls_content)?;
295
296        let received_confirmation_tag = mls_content
297            .confirmation_tag()
298            .ok_or(StageCommitError::ConfirmationTagMissing)?;
299
300        // If we have private key material, derive the secrets for the next
301        // epoch and check the confirmation tag.
302        diff.update_interim_transcript_hash(
303            ciphersuite,
304            crypto,
305            received_confirmation_tag.clone(),
306        )?;
307
308        let staged_diff = diff.into_staged_diff(crypto, ciphersuite)?;
309
310        Ok(staged_diff)
311    }
312
313    /// Merges a [StagedCommit] into the public group state.
314    pub fn merge_commit<Storage: PublicStorageProvider>(
315        &mut self,
316        storage: &Storage,
317        staged_commit: StagedCommit,
318    ) -> Result<(), MergeCommitError<Storage::Error>> {
319        match staged_commit.into_state() {
320            StagedCommitState::PublicState(staged_state) => {
321                self.merge_diff(staged_state.staged_diff);
322            }
323            StagedCommitState::GroupMember(_) => (),
324        }
325
326        self.proposal_store.empty();
327        storage
328            .clear_proposal_queue::<GroupId, ProposalRef>(self.group_id())
329            .map_err(MergeCommitError::StorageError)?;
330        self.store(storage).map_err(MergeCommitError::StorageError)
331    }
332}