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}