Skip to main content

openmls/group/public_group/
validation.rs

1//! This module contains validation functions for incoming messages
2//! as defined in <https://github.com/openmls/openmls/wiki/Message-validation>
3
4use std::collections::{BTreeSet, HashSet};
5
6use openmls_traits::types::VerifiableCiphersuite;
7
8use super::PublicGroup;
9use crate::{
10    binary_tree::array_representation::LeafNodeIndex,
11    ciphersuite::signature::SignaturePublicKey,
12    extensions::RequiredCapabilitiesExtension,
13    framing::{
14        mls_auth_content_in::VerifiableAuthenticatedContentIn, ContentType, ProtocolMessage,
15        Sender, WireFormat,
16    },
17    group::{
18        creation::LeafNodeLifetimePolicy,
19        errors::{ExternalCommitValidationError, ProposalValidationError, ValidationError},
20        past_secrets::MessageSecretsStore,
21        proposal_store::ProposalQueue,
22        GroupContextExtensionsProposalValidationError, Member,
23    },
24    key_packages::KeyPackage,
25    messages::{
26        proposals::{Proposal, ProposalOrRefType, ProposalType},
27        Commit,
28    },
29    prelude::LibraryError,
30    schedule::{errors::PskError, psk::ResumptionPskUsage, Psk},
31    treesync::{errors::LeafNodeValidationError, node::leaf_node::LeafNodeConstraints, LeafNode},
32};
33
34#[cfg(feature = "extensions-draft")]
35use crate::{
36    group::errors::AppDataUpdateValidationError, messages::proposals::AppDataUpdateOperationType,
37};
38
39impl PublicGroup {
40    // === Messages ===
41
42    /// Checks the following semantic validation:
43    ///  - ValSem002
44    ///  - ValSem003
45    ///  - [valn1307](https://validation.openmls.tech/#valn1307)
46    pub(crate) fn validate_framing(
47        &self,
48        message: &ProtocolMessage,
49    ) -> Result<(), ValidationError> {
50        // ValSem002
51        if message.group_id() != self.group_id() {
52            return Err(ValidationError::WrongGroupId);
53        }
54
55        // ValSem003: Check boundaries for the epoch
56        // We differentiate depending on the content type
57        match message.content_type() {
58            // For application messages we allow messages for older epochs as well
59            ContentType::Application => {
60                if message.epoch() > self.group_context().epoch() {
61                    log::error!(
62                        "Wrong Epoch: message.epoch() {} > {} self.group_context().epoch()",
63                        message.epoch(),
64                        self.group_context().epoch()
65                    );
66                    return Err(ValidationError::WrongEpoch);
67                }
68            }
69            // For all other messages we only only accept the current epoch
70            _ => {
71                // https://validation.openmls.tech/#valn1307
72                if message.epoch() != self.group_context().epoch() {
73                    log::error!(
74                        "Wrong Epoch: message.epoch() {} != {} self.group_context().epoch()",
75                        message.epoch(),
76                        self.group_context().epoch()
77                    );
78                    return Err(ValidationError::WrongEpoch);
79                }
80            }
81        }
82
83        Ok(())
84    }
85
86    /// Checks the following semantic validation:
87    ///  - ValSem004
88    ///  - ValSem005
89    ///  - ValSem009
90    pub(super) fn validate_verifiable_content(
91        &self,
92        verifiable_content: &VerifiableAuthenticatedContentIn,
93        message_secrets_store_option: Option<&MessageSecretsStore>,
94    ) -> Result<(), ValidationError> {
95        // ValSem004
96        let sender = verifiable_content.sender();
97        if let Sender::Member(leaf_index) = sender {
98            // If the sender is a member, it has to be in the tree, except if
99            // it's an application message. Then it might be okay if it's in an
100            // old secret tree instance.
101            let is_in_secrets_store = if let Some(mss) = message_secrets_store_option {
102                mss.epoch_has_leaf(verifiable_content.epoch(), *leaf_index)
103            } else {
104                false
105            };
106            if !self.treesync().is_leaf_in_tree(*leaf_index) && !is_in_secrets_store {
107                return Err(ValidationError::UnknownMember);
108            }
109        }
110
111        // ValSem005
112        // Application messages must always be encrypted
113        if verifiable_content.content_type() == ContentType::Application {
114            if verifiable_content.wire_format() != WireFormat::PrivateMessage {
115                return Err(ValidationError::UnencryptedApplicationMessage);
116            } else if !verifiable_content.sender().is_member() {
117                return Err(ValidationError::NonMemberApplicationMessage);
118            }
119        }
120
121        // ValSem009
122        if verifiable_content.content_type() == ContentType::Commit
123            && verifiable_content.confirmation_tag().is_none()
124        {
125            return Err(ValidationError::MissingConfirmationTag);
126        }
127
128        Ok(())
129    }
130
131    // === Proposals ===
132
133    /// Validate that all group members support the types of all proposals.
134    /// Implements check [valn0311](https://validation.openmls.tech/#valn0311)
135    pub(crate) fn validate_proposal_type_support(
136        &self,
137        proposal_queue: &ProposalQueue,
138    ) -> Result<(), ProposalValidationError> {
139        // Collect signature keys of removed members s.t. we can skip them
140        // when checking capabilities.
141        let signature_keys: HashSet<_> = proposal_queue
142            .remove_proposals()
143            .filter_map(|p| {
144                let removed_index = p.remove_proposal().removed();
145                self.treesync()
146                    .leaf(removed_index)
147                    .map(|leaf_node| leaf_node.signature_key())
148            })
149            .collect();
150
151        // Iterate over all leaf nodes except the removed ones
152        let mut leaves = self
153            .treesync()
154            .full_leaves()
155            .filter(|(_, leaf_node)| !signature_keys.contains(leaf_node.signature_key()));
156        let Some((_, first_leaf)) = leaves.next() else {
157            return Ok(());
158        };
159
160        // Initialize the capabilities intersection with the capabilities of the
161        // first leaf node.
162        let mut capabilities_intersection = first_leaf
163            .capabilities()
164            .proposals()
165            .iter()
166            .collect::<HashSet<_>>();
167        // Iterate over the remaining leaf nodes and intersect their capabilities
168        for (_, leaf_node) in leaves {
169            let leaf_capabilities_set = leaf_node.capabilities().proposals().iter().collect();
170            capabilities_intersection = capabilities_intersection
171                .intersection(&leaf_capabilities_set)
172                .cloned()
173                .collect();
174        }
175
176        // Check that the types of all non-default proposals are supported by all members
177        for proposal in proposal_queue.queued_proposals() {
178            let proposal_type = proposal.proposal().proposal_type();
179            if !proposal_type.is_default() && !capabilities_intersection.contains(&proposal_type) {
180                return Err(ProposalValidationError::UnsupportedProposalType);
181            }
182        }
183        Ok(())
184    }
185
186    /// Validate key uniqueness. This function implements the following checks:
187    ///  - ValSem101: Add Proposal: Signature public key in proposals must be unique among proposals & members
188    ///  - ValSem102: Add Proposal: Init key in proposals must be unique among proposals
189    ///  - ValSem103: Add Proposal: Encryption key in proposals must be unique among proposals & members
190    ///  - ValSem104: Add Proposal: Init key and encryption key must be different
191    ///  - ValSem110: Update Proposal: Encryption key must be unique among proposals & members
192    ///  - ValSem206: Commit: Path leaf node encryption key must be unique among proposals & members
193    ///  - ValSem207: Commit: Path encryption keys must be unique among proposals & members
194    ///  - [valn0111]: Verify that the following fields are unique among the members of the group: `signature_key`
195    ///  - [valn0112]: Verify that the following fields are unique among the members of the group: `encryption_key`
196    ///
197    /// `path_leaf_signature_key` is the signature key of the leaf node the
198    /// commit's path installs for `sender`. It is passed separately from
199    /// `commit` because the path of a commit under construction does not exist
200    /// yet. The signature key of its leaf node is already fixed, though.
201    ///
202    /// [valn0111]: https://validation.openmls.tech/#valn0111
203    /// [valn0112]: https://validation.openmls.tech/#valn0112
204    /// [valn1208]: https://validation.openmls.tech/#valn1208
205    pub(crate) fn validate_key_uniqueness(
206        &self,
207        proposal_queue: &ProposalQueue,
208        commit: Option<&Commit>,
209        sender: &Sender,
210        path_leaf_signature_key: Option<&SignaturePublicKey>,
211    ) -> Result<(), ProposalValidationError> {
212        let mut signature_key_set = HashSet::new();
213        let mut init_key_set = HashSet::new();
214        let mut encryption_key_set = HashSet::new();
215
216        // Handle the exceptions needed for https://validation.openmls.tech/#valn0306
217        let removed_members = proposal_queue
218            .remove_proposals()
219            .map(|remove_proposal| remove_proposal.remove_proposal().removed)
220            .chain(
221                proposal_queue
222                    .filtered_by_type(ProposalType::SelfRemove)
223                    .filter_map(|self_remove| self_remove.sender().as_member()),
224            )
225            .collect::<HashSet<LeafNodeIndex>>();
226
227        // The leaf node in the path replaces the committer's leaf, so the
228        // committer's current signature key is not compared with it.
229        let replaced_leaf = path_leaf_signature_key.and_then(|_| sender.as_member());
230
231        // Initialize the sets with the current members, filtered by the
232        // remove proposals.
233        for Member {
234            index,
235            encryption_key,
236            signature_key,
237            ..
238        } in self.treesync().full_leaf_members()
239        {
240            if removed_members.contains(&index) {
241                continue;
242            }
243            encryption_key_set.insert(encryption_key);
244            if replaced_leaf != Some(index) {
245                signature_key_set.insert(signature_key);
246            }
247        }
248
249        // Collect signature keys from add proposals and the commit path leaf
250        // node
251        let signature_keys = proposal_queue
252            .add_proposals()
253            .map(|add_proposal| {
254                add_proposal
255                    .add_proposal()
256                    .key_package()
257                    .leaf_node()
258                    .signature_key()
259                    .as_slice()
260                    .to_vec()
261            })
262            .chain(path_leaf_signature_key.map(|signature_key| signature_key.as_slice().to_vec()));
263
264        // Collect encryption keys from add proposals, update proposals, the
265        // commit leaf node and path keys
266        let encryption_keys = proposal_queue
267            .add_proposals()
268            .map(|add_proposal| {
269                add_proposal
270                    .add_proposal()
271                    .key_package()
272                    .leaf_node()
273                    .encryption_key()
274                    .key()
275                    .as_slice()
276                    .to_vec()
277            })
278            .chain(proposal_queue.update_proposals().map(|update_proposal| {
279                update_proposal
280                    .update_proposal()
281                    .leaf_node()
282                    .encryption_key()
283                    .key()
284                    .as_slice()
285                    .to_vec()
286            }))
287            .chain(commit.and_then(|commit| {
288                commit
289                    .path
290                    .as_ref()
291                    .map(|path| path.leaf_node().encryption_key().as_slice().to_vec())
292            }))
293            .chain(
294                commit
295                    .iter()
296                    .filter_map(|commit| {
297                        commit.path.as_ref().map(|path| {
298                            path.nodes()
299                                .iter()
300                                .map(|node| node.encryption_key().as_slice().to_vec())
301                        })
302                    })
303                    .flatten(),
304            );
305
306        // Collect init keys from add proposals
307        let init_keys = proposal_queue.add_proposals().map(|add_proposal| {
308            add_proposal
309                .add_proposal()
310                .key_package()
311                .hpke_init_key()
312                .as_slice()
313                .to_vec()
314        });
315
316        // Validate uniqueness of signature keys
317        //  - ValSem101
318        //  - https://validation.openmls.tech/#valn0111
319        //  - https://validation.openmls.tech/#valn0305
320        //  - https://validation.openmls.tech/#valn0306
321        //  - https://validation.openmls.tech/#valn1207
322        for signature_key in signature_keys {
323            if !signature_key_set.insert(signature_key) {
324                return Err(ProposalValidationError::DuplicateSignatureKey);
325            }
326        }
327
328        // Validate uniqueness of encryption keys
329        //  - ValSem103
330        //  - ValSem104
331        //  - ValSem110
332        //  - ValSem206
333        //  - ValSem207
334        //  - https://validation.openmls.tech/#valn0112
335        //  - https://validation.openmls.tech/#valn1209
336        for encryption_key in encryption_keys {
337            if init_key_set.contains(&encryption_key) {
338                return Err(ProposalValidationError::InitEncryptionKeyCollision);
339            }
340            if !encryption_key_set.insert(encryption_key) {
341                return Err(ProposalValidationError::DuplicateEncryptionKey);
342            }
343        }
344
345        // Validate uniqueness of init keys
346        //  - ValSem102
347        //  - ValSem104
348        for init_key in init_keys {
349            if encryption_key_set.contains(&init_key) {
350                return Err(ProposalValidationError::InitEncryptionKeyCollision);
351            }
352            if !init_key_set.insert(init_key) {
353                return Err(ProposalValidationError::DuplicateInitKey);
354            }
355        }
356
357        Ok(())
358    }
359
360    /// Validate capabilities. This function implements the following checks:
361    /// - ValSem106: Add Proposal: required capabilities
362    /// - ValSem109: Update Proposal: required capabilities
363    /// - [valn0113](https://validation.openmls.tech/#valn0113).
364    pub(crate) fn validate_capabilities(
365        &self,
366        proposal_queue: &ProposalQueue,
367    ) -> Result<(), ProposalValidationError> {
368        // ValSem106/ValSem109: Check the required capabilities of the add & update
369        // proposals This includes the following checks:
370        // - Are ciphersuite & version listed in the `Capabilities` Extension?
371        // - If a `RequiredCapabilitiesExtension` is present in the group: Is
372        //   this supported by the node?
373        // - Check that all extensions are contained in the capabilities.
374        // - Check that the capabilities contain the leaf node's credential
375        //   type (https://validation.openmls.tech/#valn0113).
376        // - Check that the credential type is supported by all members of the
377        //   group.
378        // - Check that the capabilities field of this LeafNode indicates
379        //   support for all the credential types currently in use by other
380        //   members.
381
382        // Extract the leaf nodes from the add & update proposals and validate them
383        proposal_queue
384            .queued_proposals()
385            .filter_map(|p| match p.proposal() {
386                Proposal::Add(add_proposal) => Some(add_proposal.key_package().leaf_node()),
387                Proposal::Update(update_proposal) => Some(update_proposal.leaf_node()),
388                _ => None,
389            })
390            .try_for_each(|leaf_node| {
391                self.validate_leaf_node_capabilities(leaf_node)
392                    .map_err(|_| ProposalValidationError::InsufficientCapabilities)
393            })
394    }
395
396    /// Checks whether `key_package` is eligible to be added to this group
397    ///
398    /// This runs the checks that a commit with an Add proposal for
399    /// `key_package` performs on that key package alone:
400    ///
401    /// - the ciphersuite and the protocol version match the group,
402    /// - the leaf node supports all extensions in the group context,
403    /// - the leaf node is valid for this group, which covers its capabilities,
404    ///   the required capabilities of the group, mutual support of the
405    ///   credential types in use with the existing members, and the lifetime of
406    ///   the leaf node.
407    ///
408    /// Checks that concern a set of proposals as a whole are not covered. In
409    /// particular the signature key, the init key and the encryption key of the
410    /// added member have to be unique among the group members and the other
411    /// proposals in the commit, which can only be decided once all proposals are
412    /// known. Passing this check therefore does not guarantee that a commit
413    /// adding `key_package` can be built.
414    pub fn validate_key_package_for_add(
415        &self,
416        key_package: &KeyPackage,
417    ) -> Result<(), ProposalValidationError> {
418        self.validate_key_package_for_add_with_lifetime_policy(
419            key_package,
420            LeafNodeLifetimePolicy::Verify,
421        )
422    }
423
424    /// Like [`Self::validate_key_package_for_add`], but checks the lifetime of
425    /// the leaf node under `lifetime_policy`.
426    pub fn validate_key_package_for_add_with_lifetime_policy(
427        &self,
428        key_package: &KeyPackage,
429        lifetime_policy: LeafNodeLifetimePolicy,
430    ) -> Result<(), ProposalValidationError> {
431        // ValSem105: Check if ciphersuite and version of the group are correct:
432        // https://validation.openmls.tech/#valn0201
433        if key_package.ciphersuite() != self.ciphersuite()
434            || key_package.protocol_version() != self.version()
435        {
436            return Err(ProposalValidationError::InvalidAddProposalCiphersuiteOrVersion);
437        }
438
439        // Check that the leaf node of the added key package supports all extensions in the group
440        // context.
441        // https://validation.openmls.tech/#valn0502
442        let added_leaf_supports_all_group_context_extensions =
443            self.group_context().extensions().iter().all(|extension| {
444                key_package
445                    .leaf_node()
446                    .supports_extension(&extension.extension_type())
447            });
448        if !added_leaf_supports_all_group_context_extensions {
449            return Err(ProposalValidationError::InsufficientCapabilities);
450        }
451
452        // https://validation.openmls.tech/#valn0202
453        self.validate_leaf_node(key_package.leaf_node(), lifetime_policy)?;
454
455        Ok(())
456    }
457
458    /// Validate Add proposals. This function implements the following checks:
459    ///  - ValSem105: Add Proposal: Ciphersuite & protocol version must match the group
460    pub(crate) fn validate_add_proposals(
461        &self,
462        proposal_queue: &ProposalQueue,
463        lifetime_policy: LeafNodeLifetimePolicy,
464    ) -> Result<(), ProposalValidationError> {
465        let add_proposals = proposal_queue.add_proposals();
466
467        // We do the key package validation checks here inline
468        // https://validation.openmls.tech/#valn0501
469        for add_proposal in add_proposals {
470            self.validate_key_package_for_add_with_lifetime_policy(
471                add_proposal.add_proposal().key_package(),
472                lifetime_policy,
473            )?;
474        }
475        Ok(())
476    }
477
478    /// Validate Remove proposals. This function implements the following checks:
479    ///  - ValSem107: Remove Proposal: Removed member must be unique among proposals
480    ///  - ValSem108: Remove Proposal: Removed member must be an existing group member
481    pub(crate) fn validate_remove_proposals(
482        &self,
483        proposal_queue: &ProposalQueue,
484    ) -> Result<(), ProposalValidationError> {
485        let updates_set: HashSet<_> = proposal_queue
486            .update_proposals()
487            .map(|proposal| {
488                if let Sender::Member(index) = proposal.sender() {
489                    Ok(*index)
490                } else {
491                    Err(ProposalValidationError::UpdateFromNonMember)
492                }
493            })
494            .collect::<Result<_, _>>()?;
495
496        let remove_proposals = proposal_queue.remove_proposals();
497
498        let mut removes_set = HashSet::new();
499
500        // https://validation.openmls.tech/#valn0701
501        for remove_proposal in remove_proposals {
502            let removed = remove_proposal.remove_proposal().removed();
503            // The node has to be a leaf in the tree
504            // ValSem108
505            if !self.treesync().is_leaf_in_tree(removed) {
506                return Err(ProposalValidationError::UnknownMemberRemoval);
507            }
508
509            // ValSem107
510            // https://validation.openmls.tech/#valn0304
511            if !removes_set.insert(removed) {
512                return Err(ProposalValidationError::DuplicateMemberRemoval);
513            }
514            if updates_set.contains(&removed) {
515                return Err(ProposalValidationError::DuplicateMemberRemoval);
516            }
517
518            // removed node can not be blank
519            if self.treesync().leaf(removed).is_none() {
520                return Err(ProposalValidationError::UnknownMemberRemoval);
521            }
522        }
523
524        Ok(())
525    }
526
527    /// Validate Update proposals. This function implements the following checks:
528    ///  - ValSem111: Update Proposal: The sender of a full Commit must not include own update proposals
529    ///  - ValSem112: Update Proposal: The sender of a standalone update proposal must be of type member
530    ///
531    /// TODO: #133 This validation must be updated according to Sec. 13.2
532    pub(crate) fn validate_update_proposals(
533        &self,
534        proposal_queue: &ProposalQueue,
535        committer: LeafNodeIndex,
536    ) -> Result<(), ProposalValidationError> {
537        // Check the update proposals from the proposal queue first
538        let update_proposals = proposal_queue.update_proposals();
539
540        for update_proposal in update_proposals {
541            // ValSem112
542            // The sender of a standalone update proposal must be of type member
543            if let Sender::Member(sender_index) = update_proposal.sender() {
544                // ValSem111
545                // https://validation.openmls.tech/#valn0302
546                // The sender of a full Commit must not include own update proposals
547                if committer == *sender_index {
548                    return Err(ProposalValidationError::CommitterIncludedOwnUpdate);
549                }
550            } else {
551                return Err(ProposalValidationError::UpdateFromNonMember);
552            }
553
554            // https://validation.openmls.tech/#valn0601
555            // The leaf node of an Update carries no lifetime.
556            self.validate_leaf_node(
557                update_proposal.update_proposal().leaf_node(),
558                LeafNodeLifetimePolicy::Verify,
559            )?;
560
561            // Check that the leaf node in the update proposal supports all group context extensions
562            // https://validation.openmls.tech/#valn0602
563            let leaf_node_supports_group_context_extensions =
564                self.group_context().extensions().iter().all(|extension| {
565                    update_proposal
566                        .update_proposal()
567                        .leaf_node()
568                        .supports_extension(&extension.extension_type())
569                });
570
571            if !leaf_node_supports_group_context_extensions {
572                return Err(ProposalValidationError::LeafNodeValidation(
573                    LeafNodeValidationError::UnsupportedExtensions,
574                ));
575            }
576        }
577        Ok(())
578    }
579
580    /// Validate PreSharedKey proposals.
581    ///
582    /// This method implements the following checks:
583    ///
584    /// * ValSem401: The nonce of a PreSharedKeyID must have length KDF.Nh.
585    /// * ValSem402: PSK in proposal must be of type Resumption (with usage Application) or External.
586    /// * ValSem403: Proposal list must not contain multiple PreSharedKey proposals that reference the same PreSharedKeyID.
587    pub(crate) fn validate_pre_shared_key_proposals(
588        &self,
589        proposal_queue: &ProposalQueue,
590    ) -> Result<(), ProposalValidationError> {
591        // ValSem403 (1/2)
592        // TODO(#1335): Duplicate proposals are (likely) filtered.
593        //              Let's do this check here until we haven't made sure.
594        let mut visited_psk_ids = BTreeSet::new();
595
596        for proposal in proposal_queue.psk_proposals() {
597            let psk_id = proposal.psk_proposal().clone().into_psk_id();
598
599            // ValSem401
600            // https://validation.openmls.tech/#valn0803
601            psk_id.validate_nonce(self.ciphersuite())?;
602
603            // ValSem402
604            // https://validation.openmls.tech/#valn0801
605            // https://validation.openmls.tech/#valn0802
606            if let Psk::Resumption(psk) = psk_id.psk() {
607                match psk.usage() {
608                    ResumptionPskUsage::Branch | ResumptionPskUsage::Reinit => {
609                        // https://validation.openmls.tech/#valn0801
610                        // https://validation.openmls.tech/#valn0802
611                        // Branching and reinit PSKs must only be processed as part
612                        // of the initial commit of the new sub-group resp. successor
613                        // group, adding the other members.
614                        if self.group_context.epoch().as_u64() != 0 {
615                            return Err(PskError::NotAllowed.into());
616                        }
617                        // Note: branch/reinit exclusivity (valn1401) is enforced for
618                        // the Welcome PSK list in `PreSharedKeyId::validate_in_welcome`.
619                    }
620                    ResumptionPskUsage::Application => {
621                        if self.group_id() != psk.psk_group_id() {
622                            return Err(PskError::DifferentGroupId {
623                                expected: self.group_id().to_owned(),
624                                got: psk.psk_group_id().to_owned(),
625                            }
626                            .into());
627                        }
628                    }
629                }
630            }
631
632            // ValSem403 (2/2)
633            if !visited_psk_ids.contains(&psk_id) {
634                visited_psk_ids.insert(psk_id);
635            } else {
636                return Err(PskError::Duplicate { first: psk_id }.into());
637            }
638        }
639
640        Ok(())
641    }
642
643    /// Validate constraints on an external commit. This function implements the following checks:
644    ///  - ValSem240: External Commit, inline Proposals: There MUST be at least one ExternalInit proposal.
645    ///  - ValSem241: External Commit, inline Proposals: There MUST be at most one ExternalInit proposal.
646    ///  - ValSem242: External Commit must only cover inline proposal in allowlist (ExternalInit, Remove, PreSharedKey)
647    ///  - When the `extensions-draft` feature is enabled, AppDataUpdate and AppEphemeral proposals are allowed additionally.
648    pub(crate) fn validate_external_commit(
649        &self,
650        proposal_queue: &ProposalQueue,
651    ) -> Result<(), ExternalCommitValidationError> {
652        // [valn0401](https://validation.openmls.tech/#valn0401)
653        let count_external_init_proposals = proposal_queue
654            .filtered_by_type(ProposalType::ExternalInit)
655            .count();
656        if count_external_init_proposals == 0 {
657            // ValSem240: External Commit, inline Proposals: There MUST be at least one ExternalInit proposal.
658            return Err(ExternalCommitValidationError::NoExternalInitProposals);
659        } else if count_external_init_proposals > 1 {
660            // ValSem241: External Commit, inline Proposals: There MUST be at most one ExternalInit proposal.
661            return Err(ExternalCommitValidationError::MultipleExternalInitProposals);
662        }
663
664        // ValSem242: External Commit must only cover inline proposal in allowlist (ExternalInit, Remove, PreSharedKey)
665        // [valn0404](https://validation.openmls.tech/#valn0404)
666        let contains_denied_proposal = proposal_queue.queued_proposals().any(|p| {
667            let is_inline = p.proposal_or_ref_type() == ProposalOrRefType::Proposal;
668            let is_allowed_type = match p.proposal() {
669                Proposal::ExternalInit(_)
670                | Proposal::Remove(_)
671                | Proposal::PreSharedKey(_)
672                | Proposal::Custom(_) => true,
673                #[cfg(feature = "extensions-draft")]
674                Proposal::AppDataUpdate(_) | Proposal::AppEphemeral(_) => true,
675                _ => false,
676            };
677            is_inline && !is_allowed_type
678        });
679        if contains_denied_proposal {
680            return Err(ExternalCommitValidationError::InvalidInlineProposals);
681        }
682
683        // If a Remove proposal is present,
684        // the credential in the LeafNode MUST present a set of
685        // identifiers that is acceptable to the application for
686        // the removed participant.
687        // This MUST be checked by the application.
688
689        Ok(())
690    }
691
692    /// Returns a [`LeafNodeValidationError`] if an [`ExtensionType`]
693    /// in `extensions` is not supported by a leaf in this tree.
694    /// Implements check [valn1001](https://validation.openmls.tech/#valn1001).
695    pub(crate) fn validate_group_context_extensions_proposal(
696        &self,
697        proposal_queue: &ProposalQueue,
698    ) -> Result<(), GroupContextExtensionsProposalValidationError> {
699        let iter = proposal_queue.filtered_by_type(ProposalType::GroupContextExtensions);
700
701        for (i, queued_proposal) in iter.enumerate() {
702            // There must at most be one group context extionsion proposal. Return an error if there are more
703            if i > 0 {
704                return Err(GroupContextExtensionsProposalValidationError::TooManyGCEProposals);
705            }
706
707            match queued_proposal.proposal() {
708                Proposal::GroupContextExtensions(extensions) => {
709                    let required_capabilities_in_proposal =
710                        extensions.extensions().required_capabilities();
711
712                    // Prepare the empty required capabilities in case there is no
713                    // RequiredCapabilitiesExtension in the proposal
714                    let default_required_capabilities =
715                        RequiredCapabilitiesExtension::new(&[], &[], &[]);
716
717                    // If there is a RequiredCapabilitiesExtension in the proposal, validate it and
718                    // use that. Otherwise, use the empty default one.
719                    let required_capabilities = match required_capabilities_in_proposal {
720                        Some(required_capabilities_new) => {
721                            // If a group context extensions proposal updates the required capabilities, we
722                            // need to check that these are satisfied for all existing members of the group.
723                            self.check_extension_support(required_capabilities_new.extension_types()).map_err(|_| GroupContextExtensionsProposalValidationError::RequiredExtensionNotSupportedByAllMembers)?;
724                            required_capabilities_new
725                        }
726                        None => &default_required_capabilities,
727                    };
728
729                    // Make sure that all other extensions are known to be supported, by checking
730                    // that they are default extensions or included in the required capabilities.
731                    let all_extensions_are_in_required_capabilities: bool = extensions
732                        .extensions()
733                        .iter()
734                        .map(|ext| ext.extension_type())
735                        .all(|ext_type| {
736                            ext_type.is_default()
737                                || required_capabilities.requires_extension_type_support(ext_type)
738                        });
739
740                    if !all_extensions_are_in_required_capabilities {
741                        return Err(GroupContextExtensionsProposalValidationError::ExtensionNotInRequiredCapabilities);
742                    }
743                }
744                _ => {
745                    return Err(GroupContextExtensionsProposalValidationError::LibraryError(
746                        LibraryError::custom(
747                            "found non-gce proposal when filtered for gce proposals",
748                        ),
749                    ))
750                }
751            }
752        }
753
754        Ok(())
755    }
756
757    /// Returns an [`AppDataUpdateValidationError`] if:
758    ///   - An [`AppDataUpdateProposal`] appears before a [`GroupContextExtensionProposal`]
759    ///   - The [`GroupContextExtensionProposal`] updates the [`AppDataDictionary`] when the
760    ///     required capabilities include AppDataUpdate proposal type
761    ///   - For any [`ComponentId`], the list of [`AppDataUpdateProposal`]s includes both Updates
762    ///     and Removes
763    ///   - For any [`ComponentId`], the list of [`AppDataUpdateProposal`]s includes more than one
764    ///     Remove
765    #[cfg(feature = "extensions-draft")]
766    pub(crate) fn validate_app_data_update_proposals_and_group_context(
767        &self,
768        proposal_queue: &ProposalQueue,
769    ) -> Result<(), AppDataUpdateValidationError> {
770        // retrieve the GroupContextExtensions proposal, if available
771        let group_context_extension_proposal = proposal_queue
772            .filtered_by_type(ProposalType::GroupContextExtensions)
773            .filter_map(|queued_proposal| match queued_proposal.proposal() {
774                Proposal::GroupContextExtensions(p) => Some(p),
775                _ => None,
776            })
777            .next();
778
779        // From https://datatracker.ietf.org/doc/html/draft-ietf-mls-extensions#section-4.7-6:
780        // When an MLS group contains the AppDataUpdate proposal type in the proposal_types list in
781        // the group's required_capabilities extension, a GroupContextExtensions proposal MUST NOT
782        // add, remove, or modify the app_data_dictionary GroupContext extension. In other words,
783        // when every member of the group supports the AppDataUpdate proposal, a
784        // GroupContextExtensions proposal could be sent to update some other extension(s), but the
785        // app_data_dictionary GroupContext extension, if it exists, is left as it was.
786        //
787        // This is checked *before* the "no AppDataUpdate proposals" early return below: the rule
788        // binds every commit that carries a GroupContextExtensions proposal, including one with no
789        // accompanying AppDataUpdate proposals. Skipping it in that case would let a bare
790        // GroupContextExtensions proposal rewrite the app_data_dictionary directly, bypassing the
791        // AppDataUpdate machinery.
792        //
793        // The rule is treated as active when the group requires AppDataUpdate either before or
794        // after the commit. Reading only the *proposed* required_capabilities would let a sender
795        // evade the check by dropping AppDataUpdate from required_capabilities in the same
796        // proposal; reading only the *current* required_capabilities would skip the migrating
797        // commit that both introduces the dictionary and adds AppDataUpdate.
798        if let Some(group_context_extension) = group_context_extension_proposal {
799            let current_requires_app_data_update = self
800                .group_context()
801                .extensions()
802                .required_capabilities()
803                .map(|rc| rc.proposal_types().contains(&ProposalType::AppDataUpdate))
804                .unwrap_or(false);
805            let proposed_requires_app_data_update = group_context_extension
806                .extensions()
807                .required_capabilities()
808                .map(|rc| rc.proposal_types().contains(&ProposalType::AppDataUpdate))
809                .unwrap_or(false);
810
811            if (current_requires_app_data_update || proposed_requires_app_data_update)
812                && group_context_extension.extensions().app_data_dictionary()
813                    != self.group_context().extensions().app_data_dictionary()
814            {
815                return Err(AppDataUpdateValidationError::CannotUpdateDictionaryDirectly);
816            }
817        }
818
819        let no_app_data_updates = proposal_queue.app_data_update_proposals().next().is_none();
820        if no_app_data_updates {
821            return Ok(());
822        }
823
824        // check ordering
825        // return an error if an AppDataUpdate appears before a GroupContextExtensions proposal
826        //
827        // From the draft:
828        //
829        // A commit can contain a GroupContextExtensions proposal which modifies
830        // GroupContext extensions other than app_data_dictionary, and can be
831        // followed by zero or more AppDataUpdate proposals.
832        //
833        // https://datatracker.ietf.org/doc/html/draft-ietf-mls-extensions#section-4.7-7
834        //
835        // Note that it is a bit unclear if this is really needed; we already require that default
836        // proposals are processed first.
837        if proposal_queue
838            .queued_proposals()
839            .map(|proposal| proposal.proposal().proposal_type())
840            .skip_while(|proposal_type| *proposal_type != ProposalType::AppDataUpdate)
841            .any(|proposal_type| proposal_type == ProposalType::GroupContextExtensions)
842        {
843            return Err(AppDataUpdateValidationError::IncorrectOrder);
844        }
845
846        // From the draft:
847        //
848        // A proposal list is invalid if it includes multiple AppDataUpdate proposals that remove state for the same component_id, or proposals that both update and remove state for the same component_id.
849        //
850        // https://datatracker.ietf.org/doc/html/draft-ietf-mls-extensions#section-4.7-4
851        // NOTE: We depend on the proposals being sorted by component id first and by
852        // type second - for a given ID, removes come first. The sorting is stable and
853        // won't mess with the ordering of updates within a component.
854        // This is ensured in ProposalQueue::app_data_update_proposals.
855        let mut latest = None;
856        for proposal in proposal_queue.app_data_update_proposals() {
857            let proposal = &proposal.app_data_update_proposal;
858            let component_id = proposal.component_id();
859            let operation_type = proposal.operation().operation_type();
860
861            if latest == Some(component_id) {
862                let app_data_update_validation_error = match operation_type {
863                    AppDataUpdateOperationType::Update => {
864                        AppDataUpdateValidationError::CombinedRemoveAndUpdateOperations
865                    }
866                    AppDataUpdateOperationType::Remove => {
867                        AppDataUpdateValidationError::CombinedRemoveAndUpdateOperations
868                    }
869                };
870
871                return Err(app_data_update_validation_error);
872            }
873
874            if proposal.operation().operation_type() == AppDataUpdateOperationType::Remove {
875                // From the draft:
876                //
877                // An AppDataUpdate proposal is invalid if [...] it specifies the removal of
878                // state for a component_id that has no state present.
879                //
880                // https://datatracker.ietf.org/doc/html/draft-ietf-mls-extensions#section-4.7-4
881                let Some(gce) = group_context_extension_proposal else {
882                    // extension gets implicitly created in the group context, so absence is not an
883                    // error condition
884                    return Ok(());
885                };
886                let Some(app_data_dict) = gce.extensions().app_data_dictionary() else {
887                    // extension gets implicitly created in the group context, so absence is not an
888                    // error condition
889                    return Ok(());
890                };
891
892                if app_data_dict.dictionary().get(&component_id).is_none() {
893                    return Err(AppDataUpdateValidationError::CannotRemoveNonexistentComponent);
894                }
895
896                latest = Some(component_id)
897            }
898        }
899
900        Ok(())
901    }
902
903    fn validate_leaf_node_capabilities(
904        &self,
905        leaf_node: &LeafNode,
906    ) -> Result<(), LeafNodeValidationError> {
907        // Check that the data in the leaf node is self-consistent
908        // Check that the capabilities contain the leaf node's credential
909        // type (https://validation.openmls.tech/#valn0113)
910        // Check that all extension types are valid in leaf node
911        // (https://validation.openmls.tech/#valn1601)
912        leaf_node.validate_locally()?;
913
914        // Check if the ciphersuite and the version of the group are
915        // supported.
916        let capabilities = leaf_node.capabilities();
917        if !capabilities.contains_ciphersuite(VerifiableCiphersuite::from(self.ciphersuite()))
918            || !capabilities.contains_version(self.version())
919        {
920            return Err(LeafNodeValidationError::CiphersuiteNotInCapabilities);
921        }
922
923        // If there is a required capabilities extension, check if that one
924        // is supported (https://validation.openmls.tech/#valn0103).
925        if let Some(required_capabilities) =
926            self.group_context().extensions().required_capabilities()
927        {
928            // Check if all required capabilities are supported.
929            capabilities.supports_required_capabilities(required_capabilities)?;
930        }
931
932        // Check that the credential type is supported by all members of the group (https://validation.openmls.tech/#valn0104).
933        if !self.treesync().full_leaves().all(|(_, node)| {
934            node.capabilities()
935                .contains_credential(leaf_node.credential().credential_type())
936        }) {
937            return Err(LeafNodeValidationError::UnsupportedCredentials);
938        }
939
940        // Check that the capabilities field of this LeafNode indicates
941        // support for all the credential types currently in use by other
942        // members (https://validation.openmls.tech/#valn0104).
943        if !self
944            .treesync()
945            .full_leaves()
946            .all(|(_, node)| capabilities.contains_credential(node.credential().credential_type()))
947        {
948            return Err(LeafNodeValidationError::UnsupportedCredentials);
949        }
950
951        Ok(())
952    }
953
954    /// Compile the leaf node constraints for this group.
955    /// This is built from the group context and every group member.
956    pub(crate) fn leaf_node_constraints(&self) -> LeafNodeConstraints {
957        LeafNodeConstraints::new(
958            self.group_context().extensions(),
959            self.treesync().full_leaves().map(|(_, member)| member),
960        )
961    }
962
963    /// Validate a leaf node, checking its lifetime under `lifetime_policy`.
964    pub(crate) fn validate_leaf_node(
965        &self,
966        leaf_node: &crate::treesync::LeafNode,
967        lifetime_policy: LeafNodeLifetimePolicy,
968    ) -> Result<(), LeafNodeValidationError> {
969        // https://validation.openmls.tech/#valn0103
970        // https://validation.openmls.tech/#valn0104
971        // https://validation.openmls.tech/#valn0107
972        self.validate_leaf_node_capabilities(leaf_node)?;
973
974        // https://validation.openmls.tech/#valn0105 is done when sending
975
976        // https://validation.openmls.tech/#valn0106
977        //
978        // Only leaf nodes in key packages contain lifetimes, so this will return None for other
979        // cases. Therefore we only check the lifetimes for leaf nodes in key packages.
980        //
981        // We may want to check these in ratchet trees as well.
982        // However, this may lead to errors when leaf nodes don't get updated
983        // after being added to the tree. RFC 9420 recommends checking the lifetime
984        // but acknowledges already that this may cause issues.
985        // https://www.rfc-editor.org/rfc/rfc9420.html#section-7.3-4.5.1
986        // See #1810 for more background.
987        // We therefore check the lifetime by default, but skip it if ...
988        //
989        // Some KATs use key packages that are expired by now. In order to run these tests, we
990        // provide a way to turn off this check.
991        if !crate::skip_validation::is_disabled::leaf_node_lifetime() {
992            if let Some(lifetime) = leaf_node.life_time() {
993                lifetime.validate_with_policy(lifetime_policy)?;
994            }
995        }
996
997        // These are done at the caller and we can't do them here:
998        //
999        // https://validation.openmls.tech/#valn0108
1000        // https://validation.openmls.tech/#valn0109
1001        // https://validation.openmls.tech/#valn0110
1002
1003        // These are done in validate_key_uniqueness, which is called in the context of changing
1004        // this group:
1005        //
1006        // https://validation.openmls.tech/#valn0111
1007        // https://validation.openmls.tech/#valn0112
1008
1009        Ok(())
1010    }
1011
1012    /// Returns a [`LeafNodeValidationError`] if an [`ExtensionType`]
1013    /// in `extensions` is not supported by a leaf in this tree.
1014    pub(crate) fn check_extension_support(
1015        &self,
1016        extensions: &[crate::extensions::ExtensionType],
1017    ) -> Result<(), LeafNodeValidationError> {
1018        for (_, leaf) in self.treesync().full_leaves() {
1019            leaf.check_extension_support(extensions)?;
1020        }
1021        Ok(())
1022    }
1023}