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