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, 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 // ValSem105: Check if ciphersuite and version of the group are correct:
419 // https://validation.openmls.tech/#valn0201
420 if key_package.ciphersuite() != self.ciphersuite()
421 || key_package.protocol_version() != self.version()
422 {
423 return Err(ProposalValidationError::InvalidAddProposalCiphersuiteOrVersion);
424 }
425
426 // Check that the leaf node of the added key package supports all extensions in the group
427 // context.
428 // https://validation.openmls.tech/#valn0502
429 let added_leaf_supports_all_group_context_extensions =
430 self.group_context().extensions().iter().all(|extension| {
431 key_package
432 .leaf_node()
433 .supports_extension(&extension.extension_type())
434 });
435 if !added_leaf_supports_all_group_context_extensions {
436 return Err(ProposalValidationError::InsufficientCapabilities);
437 }
438
439 // https://validation.openmls.tech/#valn0202
440 self.validate_leaf_node(key_package.leaf_node())?;
441
442 Ok(())
443 }
444
445 /// Validate Add proposals. This function implements the following checks:
446 /// - ValSem105: Add Proposal: Ciphersuite & protocol version must match the group
447 pub(crate) fn validate_add_proposals(
448 &self,
449 proposal_queue: &ProposalQueue,
450 ) -> Result<(), ProposalValidationError> {
451 let add_proposals = proposal_queue.add_proposals();
452
453 // We do the key package validation checks here inline
454 // https://validation.openmls.tech/#valn0501
455 for add_proposal in add_proposals {
456 self.validate_key_package_for_add(add_proposal.add_proposal().key_package())?;
457 }
458 Ok(())
459 }
460
461 /// Validate Remove proposals. This function implements the following checks:
462 /// - ValSem107: Remove Proposal: Removed member must be unique among proposals
463 /// - ValSem108: Remove Proposal: Removed member must be an existing group member
464 pub(crate) fn validate_remove_proposals(
465 &self,
466 proposal_queue: &ProposalQueue,
467 ) -> Result<(), ProposalValidationError> {
468 let updates_set: HashSet<_> = proposal_queue
469 .update_proposals()
470 .map(|proposal| {
471 if let Sender::Member(index) = proposal.sender() {
472 Ok(*index)
473 } else {
474 Err(ProposalValidationError::UpdateFromNonMember)
475 }
476 })
477 .collect::<Result<_, _>>()?;
478
479 let remove_proposals = proposal_queue.remove_proposals();
480
481 let mut removes_set = HashSet::new();
482
483 // https://validation.openmls.tech/#valn0701
484 for remove_proposal in remove_proposals {
485 let removed = remove_proposal.remove_proposal().removed();
486 // The node has to be a leaf in the tree
487 // ValSem108
488 if !self.treesync().is_leaf_in_tree(removed) {
489 return Err(ProposalValidationError::UnknownMemberRemoval);
490 }
491
492 // ValSem107
493 // https://validation.openmls.tech/#valn0304
494 if !removes_set.insert(removed) {
495 return Err(ProposalValidationError::DuplicateMemberRemoval);
496 }
497 if updates_set.contains(&removed) {
498 return Err(ProposalValidationError::DuplicateMemberRemoval);
499 }
500
501 // removed node can not be blank
502 if self.treesync().leaf(removed).is_none() {
503 return Err(ProposalValidationError::UnknownMemberRemoval);
504 }
505 }
506
507 Ok(())
508 }
509
510 /// Validate Update proposals. This function implements the following checks:
511 /// - ValSem111: Update Proposal: The sender of a full Commit must not include own update proposals
512 /// - ValSem112: Update Proposal: The sender of a standalone update proposal must be of type member
513 ///
514 /// TODO: #133 This validation must be updated according to Sec. 13.2
515 pub(crate) fn validate_update_proposals(
516 &self,
517 proposal_queue: &ProposalQueue,
518 committer: LeafNodeIndex,
519 ) -> Result<(), ProposalValidationError> {
520 // Check the update proposals from the proposal queue first
521 let update_proposals = proposal_queue.update_proposals();
522
523 for update_proposal in update_proposals {
524 // ValSem112
525 // The sender of a standalone update proposal must be of type member
526 if let Sender::Member(sender_index) = update_proposal.sender() {
527 // ValSem111
528 // https://validation.openmls.tech/#valn0302
529 // The sender of a full Commit must not include own update proposals
530 if committer == *sender_index {
531 return Err(ProposalValidationError::CommitterIncludedOwnUpdate);
532 }
533 } else {
534 return Err(ProposalValidationError::UpdateFromNonMember);
535 }
536
537 // https://validation.openmls.tech/#valn0601
538 self.validate_leaf_node(update_proposal.update_proposal().leaf_node())?;
539
540 // Check that the leaf node in the update proposal supports all group context extensions
541 // https://validation.openmls.tech/#valn0602
542 let leaf_node_supports_group_context_extensions =
543 self.group_context().extensions().iter().all(|extension| {
544 update_proposal
545 .update_proposal()
546 .leaf_node()
547 .supports_extension(&extension.extension_type())
548 });
549
550 if !leaf_node_supports_group_context_extensions {
551 return Err(ProposalValidationError::LeafNodeValidation(
552 LeafNodeValidationError::UnsupportedExtensions,
553 ));
554 }
555 }
556 Ok(())
557 }
558
559 /// Validate PreSharedKey proposals.
560 ///
561 /// This method implements the following checks:
562 ///
563 /// * ValSem401: The nonce of a PreSharedKeyID must have length KDF.Nh.
564 /// * ValSem402: PSK in proposal must be of type Resumption (with usage Application) or External.
565 pub(crate) fn validate_pre_shared_key_proposals(
566 &self,
567 proposal_queue: &ProposalQueue,
568 ) -> Result<(), ProposalValidationError> {
569 // ValSem403 (1/2)
570 // TODO(#1335): Duplicate proposals are (likely) filtered.
571 // Let's do this check here until we haven't made sure.
572 let mut visited_psk_ids = BTreeSet::new();
573
574 for proposal in proposal_queue.psk_proposals() {
575 let psk_id = proposal.psk_proposal().clone().into_psk_id();
576
577 // ValSem401
578 // ValSem402
579 // https://validation.openmls.tech/#valn0803
580 let psk_id = psk_id.validate_in_proposal(self.ciphersuite())?;
581 if let Psk::Resumption(psk) = psk_id.psk() {
582 if matches!(psk.usage(), ResumptionPskUsage::Branch) {
583 // https://validation.openmls.tech/#valn0802
584 // Branching PSKs must only be processed as part of the
585 // initial commit, adding the other members.
586 if self.group_context.epoch().as_u64() != 0 {
587 return Err(PskError::NotAllowed.into());
588 }
589 // Note: branch/reinit exclusivity (valn1401) is enforced for
590 // the Welcome PSK list in `PreSharedKeyId::validate_in_welcome`.
591 }
592 }
593
594 // ValSem403 (2/2)
595 if !visited_psk_ids.contains(&psk_id) {
596 visited_psk_ids.insert(psk_id);
597 } else {
598 return Err(PskError::Duplicate { first: psk_id }.into());
599 }
600 }
601
602 Ok(())
603 }
604
605 /// Validate constraints on an external commit. This function implements the following checks:
606 /// - ValSem240: External Commit, inline Proposals: There MUST be at least one ExternalInit proposal.
607 /// - ValSem241: External Commit, inline Proposals: There MUST be at most one ExternalInit proposal.
608 /// - ValSem242: External Commit must only cover inline proposal in allowlist (ExternalInit, Remove, PreSharedKey)
609 /// - When the `extensions-draft` feature is enabled, AppDataUpdate and AppEphemeral proposals are allowed additionally.
610 pub(crate) fn validate_external_commit(
611 &self,
612 proposal_queue: &ProposalQueue,
613 ) -> Result<(), ExternalCommitValidationError> {
614 // [valn0401](https://validation.openmls.tech/#valn0401)
615 let count_external_init_proposals = proposal_queue
616 .filtered_by_type(ProposalType::ExternalInit)
617 .count();
618 if count_external_init_proposals == 0 {
619 // ValSem240: External Commit, inline Proposals: There MUST be at least one ExternalInit proposal.
620 return Err(ExternalCommitValidationError::NoExternalInitProposals);
621 } else if count_external_init_proposals > 1 {
622 // ValSem241: External Commit, inline Proposals: There MUST be at most one ExternalInit proposal.
623 return Err(ExternalCommitValidationError::MultipleExternalInitProposals);
624 }
625
626 // ValSem242: External Commit must only cover inline proposal in allowlist (ExternalInit, Remove, PreSharedKey)
627 // [valn0404](https://validation.openmls.tech/#valn0404)
628 let contains_denied_proposal = proposal_queue.queued_proposals().any(|p| {
629 let is_inline = p.proposal_or_ref_type() == ProposalOrRefType::Proposal;
630 let is_allowed_type = match p.proposal() {
631 Proposal::ExternalInit(_)
632 | Proposal::Remove(_)
633 | Proposal::PreSharedKey(_)
634 | Proposal::Custom(_) => true,
635 #[cfg(feature = "extensions-draft")]
636 Proposal::AppDataUpdate(_) | Proposal::AppEphemeral(_) => true,
637 _ => false,
638 };
639 is_inline && !is_allowed_type
640 });
641 if contains_denied_proposal {
642 return Err(ExternalCommitValidationError::InvalidInlineProposals);
643 }
644
645 // If a Remove proposal is present,
646 // the credential in the LeafNode MUST present a set of
647 // identifiers that is acceptable to the application for
648 // the removed participant.
649 // This MUST be checked by the application.
650
651 Ok(())
652 }
653
654 /// Returns a [`LeafNodeValidationError`] if an [`ExtensionType`]
655 /// in `extensions` is not supported by a leaf in this tree.
656 /// Implements check [valn1001](https://validation.openmls.tech/#valn1001).
657 pub(crate) fn validate_group_context_extensions_proposal(
658 &self,
659 proposal_queue: &ProposalQueue,
660 ) -> Result<(), GroupContextExtensionsProposalValidationError> {
661 let iter = proposal_queue.filtered_by_type(ProposalType::GroupContextExtensions);
662
663 for (i, queued_proposal) in iter.enumerate() {
664 // There must at most be one group context extionsion proposal. Return an error if there are more
665 if i > 0 {
666 return Err(GroupContextExtensionsProposalValidationError::TooManyGCEProposals);
667 }
668
669 match queued_proposal.proposal() {
670 Proposal::GroupContextExtensions(extensions) => {
671 let required_capabilities_in_proposal =
672 extensions.extensions().required_capabilities();
673
674 // Prepare the empty required capabilities in case there is no
675 // RequiredCapabilitiesExtension in the proposal
676 let default_required_capabilities =
677 RequiredCapabilitiesExtension::new(&[], &[], &[]);
678
679 // If there is a RequiredCapabilitiesExtension in the proposal, validate it and
680 // use that. Otherwise, use the empty default one.
681 let required_capabilities = match required_capabilities_in_proposal {
682 Some(required_capabilities_new) => {
683 // If a group context extensions proposal updates the required capabilities, we
684 // need to check that these are satisfied for all existing members of the group.
685 self.check_extension_support(required_capabilities_new.extension_types()).map_err(|_| GroupContextExtensionsProposalValidationError::RequiredExtensionNotSupportedByAllMembers)?;
686 required_capabilities_new
687 }
688 None => &default_required_capabilities,
689 };
690
691 // Make sure that all other extensions are known to be supported, by checking
692 // that they are default extensions or included in the required capabilities.
693 let all_extensions_are_in_required_capabilities: bool = extensions
694 .extensions()
695 .iter()
696 .map(|ext| ext.extension_type())
697 .all(|ext_type| {
698 ext_type.is_default()
699 || required_capabilities.requires_extension_type_support(ext_type)
700 });
701
702 if !all_extensions_are_in_required_capabilities {
703 return Err(GroupContextExtensionsProposalValidationError::ExtensionNotInRequiredCapabilities);
704 }
705 }
706 _ => {
707 return Err(GroupContextExtensionsProposalValidationError::LibraryError(
708 LibraryError::custom(
709 "found non-gce proposal when filtered for gce proposals",
710 ),
711 ))
712 }
713 }
714 }
715
716 Ok(())
717 }
718
719 /// Returns an [`AppDataUpdateValidationError`] if:
720 /// - An [`AppDataUpdateProposal`] appears before a [`GroupContextExtensionProposal`]
721 /// - The [`GroupContextExtensionProposal`] updates the [`AppDataDictionary`] when the
722 /// required capabilities include AppDataUpdate proposal type
723 /// - For any [`ComponentId`], the list of [`AppDataUpdateProposal`]s includes both Updates
724 /// and Removes
725 /// - For any [`ComponentId`], the list of [`AppDataUpdateProposal`]s includes more than one
726 /// Remove
727 #[cfg(feature = "extensions-draft")]
728 pub(crate) fn validate_app_data_update_proposals_and_group_context(
729 &self,
730 proposal_queue: &ProposalQueue,
731 ) -> Result<(), AppDataUpdateValidationError> {
732 // retrieve the GroupContextExtensions proposal, if available
733 let group_context_extension_proposal = proposal_queue
734 .filtered_by_type(ProposalType::GroupContextExtensions)
735 .filter_map(|queued_proposal| match queued_proposal.proposal() {
736 Proposal::GroupContextExtensions(p) => Some(p),
737 _ => None,
738 })
739 .next();
740
741 // From https://datatracker.ietf.org/doc/html/draft-ietf-mls-extensions#section-4.7-6:
742 // When an MLS group contains the AppDataUpdate proposal type in the proposal_types list in
743 // the group's required_capabilities extension, a GroupContextExtensions proposal MUST NOT
744 // add, remove, or modify the app_data_dictionary GroupContext extension. In other words,
745 // when every member of the group supports the AppDataUpdate proposal, a
746 // GroupContextExtensions proposal could be sent to update some other extension(s), but the
747 // app_data_dictionary GroupContext extension, if it exists, is left as it was.
748 //
749 // This is checked *before* the "no AppDataUpdate proposals" early return below: the rule
750 // binds every commit that carries a GroupContextExtensions proposal, including one with no
751 // accompanying AppDataUpdate proposals. Skipping it in that case would let a bare
752 // GroupContextExtensions proposal rewrite the app_data_dictionary directly, bypassing the
753 // AppDataUpdate machinery.
754 //
755 // The rule is treated as active when the group requires AppDataUpdate either before or
756 // after the commit. Reading only the *proposed* required_capabilities would let a sender
757 // evade the check by dropping AppDataUpdate from required_capabilities in the same
758 // proposal; reading only the *current* required_capabilities would skip the migrating
759 // commit that both introduces the dictionary and adds AppDataUpdate.
760 if let Some(group_context_extension) = group_context_extension_proposal {
761 let current_requires_app_data_update = self
762 .group_context()
763 .extensions()
764 .required_capabilities()
765 .map(|rc| rc.proposal_types().contains(&ProposalType::AppDataUpdate))
766 .unwrap_or(false);
767 let proposed_requires_app_data_update = group_context_extension
768 .extensions()
769 .required_capabilities()
770 .map(|rc| rc.proposal_types().contains(&ProposalType::AppDataUpdate))
771 .unwrap_or(false);
772
773 if (current_requires_app_data_update || proposed_requires_app_data_update)
774 && group_context_extension.extensions().app_data_dictionary()
775 != self.group_context().extensions().app_data_dictionary()
776 {
777 return Err(AppDataUpdateValidationError::CannotUpdateDictionaryDirectly);
778 }
779 }
780
781 let no_app_data_updates = proposal_queue.app_data_update_proposals().next().is_none();
782 if no_app_data_updates {
783 return Ok(());
784 }
785
786 // check ordering
787 // return an error if an AppDataUpdate appears before a GroupContextExtensions proposal
788 //
789 // From the draft:
790 //
791 // A commit can contain a GroupContextExtensions proposal which modifies
792 // GroupContext extensions other than app_data_dictionary, and can be
793 // followed by zero or more AppDataUpdate proposals.
794 //
795 // https://datatracker.ietf.org/doc/html/draft-ietf-mls-extensions#section-4.7-7
796 //
797 // Note that it is a bit unclear if this is really needed; we already require that default
798 // proposals are processed first.
799 if proposal_queue
800 .queued_proposals()
801 .map(|proposal| proposal.proposal().proposal_type())
802 .skip_while(|proposal_type| *proposal_type != ProposalType::AppDataUpdate)
803 .any(|proposal_type| proposal_type == ProposalType::GroupContextExtensions)
804 {
805 return Err(AppDataUpdateValidationError::IncorrectOrder);
806 }
807
808 // From the draft:
809 //
810 // 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.
811 //
812 // https://datatracker.ietf.org/doc/html/draft-ietf-mls-extensions#section-4.7-4
813 // NOTE: We depend on the proposals being sorted by component id first and by
814 // type second - for a given ID, removes come first. The sorting is stable and
815 // won't mess with the ordering of updates within a component.
816 // This is ensured in ProposalQueue::app_data_update_proposals.
817 let mut latest = None;
818 for proposal in proposal_queue.app_data_update_proposals() {
819 let proposal = &proposal.app_data_update_proposal;
820 let component_id = proposal.component_id();
821 let operation_type = proposal.operation().operation_type();
822
823 if latest == Some(component_id) {
824 let app_data_update_validation_error = match operation_type {
825 AppDataUpdateOperationType::Update => {
826 AppDataUpdateValidationError::CombinedRemoveAndUpdateOperations
827 }
828 AppDataUpdateOperationType::Remove => {
829 AppDataUpdateValidationError::CombinedRemoveAndUpdateOperations
830 }
831 };
832
833 return Err(app_data_update_validation_error);
834 }
835
836 if proposal.operation().operation_type() == AppDataUpdateOperationType::Remove {
837 // From the draft:
838 //
839 // An AppDataUpdate proposal is invalid if [...] it specifies the removal of
840 // state for a component_id that has no state present.
841 //
842 // https://datatracker.ietf.org/doc/html/draft-ietf-mls-extensions#section-4.7-4
843 let Some(gce) = group_context_extension_proposal else {
844 // extension gets implicitly created in the group context, so absence is not an
845 // error condition
846 return Ok(());
847 };
848 let Some(app_data_dict) = gce.extensions().app_data_dictionary() else {
849 // extension gets implicitly created in the group context, so absence is not an
850 // error condition
851 return Ok(());
852 };
853
854 if app_data_dict.dictionary().get(&component_id).is_none() {
855 return Err(AppDataUpdateValidationError::CannotRemoveNonexistentComponent);
856 }
857
858 latest = Some(component_id)
859 }
860 }
861
862 Ok(())
863 }
864
865 fn validate_leaf_node_capabilities(
866 &self,
867 leaf_node: &LeafNode,
868 ) -> Result<(), LeafNodeValidationError> {
869 // Check that the data in the leaf node is self-consistent
870 // Check that the capabilities contain the leaf node's credential
871 // type (https://validation.openmls.tech/#valn0113)
872 // Check that all extension types are valid in leaf node
873 // (https://validation.openmls.tech/#valn1601)
874 leaf_node.validate_locally()?;
875
876 // Check if the ciphersuite and the version of the group are
877 // supported.
878 let capabilities = leaf_node.capabilities();
879 if !capabilities.contains_ciphersuite(VerifiableCiphersuite::from(self.ciphersuite()))
880 || !capabilities.contains_version(self.version())
881 {
882 return Err(LeafNodeValidationError::CiphersuiteNotInCapabilities);
883 }
884
885 // If there is a required capabilities extension, check if that one
886 // is supported (https://validation.openmls.tech/#valn0103).
887 if let Some(required_capabilities) =
888 self.group_context().extensions().required_capabilities()
889 {
890 // Check if all required capabilities are supported.
891 capabilities.supports_required_capabilities(required_capabilities)?;
892 }
893
894 // Check that the credential type is supported by all members of the group (https://validation.openmls.tech/#valn0104).
895 if !self.treesync().full_leaves().all(|(_, node)| {
896 node.capabilities()
897 .contains_credential(leaf_node.credential().credential_type())
898 }) {
899 return Err(LeafNodeValidationError::UnsupportedCredentials);
900 }
901
902 // Check that the capabilities field of this LeafNode indicates
903 // support for all the credential types currently in use by other
904 // members (https://validation.openmls.tech/#valn0104).
905 if !self
906 .treesync()
907 .full_leaves()
908 .all(|(_, node)| capabilities.contains_credential(node.credential().credential_type()))
909 {
910 return Err(LeafNodeValidationError::UnsupportedCredentials);
911 }
912
913 Ok(())
914 }
915
916 /// Validate a leaf node.
917 ///
918 /// This always validates the lifetime.
919 pub(crate) fn validate_leaf_node(
920 &self,
921 leaf_node: &crate::treesync::LeafNode,
922 ) -> Result<(), LeafNodeValidationError> {
923 // Call the validation function and validate the lifetime
924 self.validate_leaf_node_inner(leaf_node, LeafNodeLifetimePolicy::Verify)
925 }
926
927 /// Validate a leaf node.
928 ///
929 /// This may skip checking the lifetime when validating a ratchet tree.
930 pub(crate) fn validate_leaf_node_inner(
931 &self,
932 leaf_node: &crate::treesync::LeafNode,
933 validate_lifetimes: LeafNodeLifetimePolicy,
934 ) -> Result<(), LeafNodeValidationError> {
935 // https://validation.openmls.tech/#valn0103
936 // https://validation.openmls.tech/#valn0104
937 // https://validation.openmls.tech/#valn0107
938 self.validate_leaf_node_capabilities(leaf_node)?;
939
940 // https://validation.openmls.tech/#valn0105 is done when sending
941
942 // https://validation.openmls.tech/#valn0106
943 //
944 // Only leaf nodes in key packages contain lifetimes, so this will return None for other
945 // cases. Therefore we only check the lifetimes for leaf nodes in key packages.
946 //
947 // We may want to check these in ratchet trees as well.
948 // However, this may lead to errors when leaf nodes don't get updated
949 // after being added to the tree. RFC 9420 recommends checking the lifetime
950 // but acknowledges already that this may cause issues.
951 // https://www.rfc-editor.org/rfc/rfc9420.html#section-7.3-4.5.1
952 // See #1810 for more background.
953 // We therefore check the lifetime by default, but skip it if ...
954 //
955 // Some KATs use key packages that are expired by now. In order to run these tests, we
956 // provide a way to turn off this check.
957 if matches!(validate_lifetimes, LeafNodeLifetimePolicy::Verify)
958 && !crate::skip_validation::is_disabled::leaf_node_lifetime()
959 {
960 if let Some(lifetime) = leaf_node.life_time() {
961 lifetime.validate()?;
962 }
963 }
964
965 // These are done at the caller and we can't do them here:
966 //
967 // https://validation.openmls.tech/#valn0108
968 // https://validation.openmls.tech/#valn0109
969 // https://validation.openmls.tech/#valn0110
970
971 // These are done in validate_key_uniqueness, which is called in the context of changing
972 // this group:
973 //
974 // https://validation.openmls.tech/#valn0111
975 // https://validation.openmls.tech/#valn0112
976
977 Ok(())
978 }
979
980 /// Returns a [`LeafNodeValidationError`] if an [`ExtensionType`]
981 /// in `extensions` is not supported by a leaf in this tree.
982 pub(crate) fn check_extension_support(
983 &self,
984 extensions: &[crate::extensions::ExtensionType],
985 ) -> Result<(), LeafNodeValidationError> {
986 for (_, leaf) in self.treesync().full_leaves() {
987 leaf.check_extension_support(extensions)?;
988 }
989 Ok(())
990 }
991}