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