openmls/group/mls_group/processing.rs
1//! Processing functions of an [`MlsGroup`] for incoming messages.
2
3use std::mem;
4
5#[cfg(any(not(feature = "virtual-clients-draft"), feature = "test-utils", test))]
6use errors::CommitToPendingProposalsError;
7use errors::MergePendingCommitError;
8#[cfg(feature = "extensions-draft")]
9use errors::ResolveAppDataCommitError;
10use openmls_traits::crypto::OpenMlsCrypto;
11#[cfg(any(not(feature = "virtual-clients-draft"), feature = "test-utils", test))]
12use openmls_traits::signatures::Signer;
13
14#[cfg(any(not(feature = "virtual-clients-draft"), feature = "test-utils", test))]
15use crate::messages::group_info::GroupInfo;
16use crate::{
17 framing::mls_content::FramedContentBody,
18 group::{errors::MergeCommitError, StageCommitError, ValidationError},
19 storage::OpenMlsProvider,
20 tree::sender_ratchet::SenderRatchetConfiguration,
21};
22
23// `virtual-clients-draft` implies `extensions-draft`, so this gate covers
24// both the sibling-commit detection and the AppDataUpdate handling below.
25#[cfg(feature = "extensions-draft")]
26use crate::messages::Commit;
27
28#[cfg(feature = "extensions-draft")]
29use crate::{
30 component::{ComponentData, ComponentId},
31 extensions::AppDataDictionary,
32 messages::proposals::AppDataUpdateProposal,
33};
34
35#[cfg(feature = "extensions-draft")]
36use std::collections::BTreeMap;
37
38use super::{errors::ProcessMessageError, *};
39
40/// Result of unprotecting an inbound message.
41pub(crate) enum UnprotectedMessage {
42 /// A message from another sender that has been unprotected and is ready
43 /// for signature verification and content parsing.
44 Unverified(Box<UnverifiedMessage>),
45 /// A PrivateMessage whose sender data claims this client's own leaf. The
46 /// content cannot be decrypted; callers should surface
47 /// [`ProcessedMessageContent::OwnPrivateMessage`] and skip further
48 /// processing.
49 OwnPrivateMessage {
50 epoch: GroupEpoch,
51 authenticated_data: Vec<u8>,
52 },
53}
54
55#[cfg(feature = "extensions-draft")]
56/// Keeps the old dictionary as well as the values that are being overwritten
57pub struct AppDataDictionaryUpdater<'a> {
58 old_dict: Option<&'a AppDataDictionary>,
59 new_entries: Option<AppDataUpdates>,
60}
61
62/// A diff of update values that can be provided to [`MlsGroup::stage_app_data_commit`] or [`CommitBuilder::with_app_data_dictionary_updates`]
63///
64/// [`CommitBuilder::with_app_data_dictionary_updates`]: crate::group::CommitBuilder::with_app_data_dictionary_updates
65#[cfg(feature = "extensions-draft")]
66#[derive(Default, Debug)]
67pub struct AppDataUpdates(BTreeMap<ComponentId, Option<Vec<u8>>>);
68
69#[cfg(feature = "extensions-draft")]
70impl IntoIterator for AppDataUpdates {
71 type Item = (ComponentId, Option<Vec<u8>>);
72
73 type IntoIter = <BTreeMap<ComponentId, Option<Vec<u8>>> as IntoIterator>::IntoIter;
74
75 fn into_iter(self) -> Self::IntoIter {
76 self.0.into_iter()
77 }
78}
79
80#[cfg(feature = "extensions-draft")]
81impl AppDataUpdates {
82 /// Returns the number of changes.
83 pub fn len(&self) -> usize {
84 self.0.len()
85 }
86
87 /// Returns whether there are changes.
88 pub fn is_empty(&self) -> bool {
89 self.0.is_empty()
90 }
91}
92
93#[cfg(feature = "extensions-draft")]
94impl<'a> AppDataDictionaryUpdater<'a> {
95 /// Creates a new [`AppDataDictionaryUpdater`].
96 pub fn new(old_dict: Option<&'a AppDataDictionary>) -> Self {
97 Self {
98 old_dict,
99 new_entries: None,
100 }
101 }
102
103 /// Looks up the old value for a component.
104 pub fn old_value(&self, component_id: ComponentId) -> Option<&[u8]> {
105 self.old_dict?.get(&component_id)
106 }
107
108 /// Helper method that returns a mutable reference to the
109 /// [`AppDataUpdates`], creating the struct if it does not exist.
110 fn new_entries_mut(&mut self) -> &mut AppDataUpdates {
111 self.new_entries
112 .get_or_insert_with(|| AppDataUpdates(BTreeMap::new()))
113 }
114
115 /// Sets a value in the new_entries. if we already have data for that component id, overwrite
116 /// it. Else add it in the right position.
117 pub fn set(&mut self, component_data: ComponentData) {
118 let (id, data) = component_data.into_parts();
119
120 self.new_entries_mut().0.insert(id, Some(data.into()));
121 }
122
123 /// Flags an entry in the dictionary for removal
124 pub fn remove(&mut self, id: &ComponentId) {
125 self.new_entries_mut().0.insert(*id, None);
126 }
127
128 /// Consumes the updater and returns just the changes, so we can pass them into
129 /// [`MlsGroup::stage_app_data_commit`] or
130 /// [`CommitBuilder::with_app_data_dictionary_updates`].
131 /// Only returns Some if we actually called set.
132 ///
133 /// [`CommitBuilder::with_app_data_dictionary_updates`]: crate::group::CommitBuilder::with_app_data_dictionary_updates
134 pub fn changes(self) -> Option<AppDataUpdates> {
135 self.new_entries
136 }
137}
138
139/// A verified Commit covering AppDataUpdate proposals that cannot be staged
140/// yet.
141///
142/// The AppDataUpdate proposals carry diffs in an application-defined format,
143/// so the application has to interpret them and compute the resulting
144/// [`AppDataUpdates`] before the commit can be staged: the updated
145/// [`AppDataDictionary`] becomes part of the new epoch's GroupContext and
146/// feeds into the key schedule.
147///
148/// Returned by [`MlsGroup::process_message()`] and
149/// [`PublicGroup::process_message()`] as
150/// [`ProcessedMessageContent::UnresolvedAppDataCommit`]. Inspect the proposals
151/// via [`Self::app_data_update_proposals()`], compute the updates with the
152/// help of [`MlsGroup::app_data_dictionary_updater()`] (or
153/// [`PublicGroup::app_data_dictionary_updater()`]) and resume staging via
154/// [`MlsGroup::stage_app_data_commit()`] (or
155/// [`PublicGroup::stage_app_data_commit()`]).
156///
157/// The message signature has already been verified at this point. Dropping
158/// this value discards the commit.
159///
160/// [`PublicGroup::process_message()`]: crate::group::public_group::PublicGroup::process_message
161/// [`PublicGroup::app_data_dictionary_updater()`]: crate::group::public_group::PublicGroup::app_data_dictionary_updater
162/// [`PublicGroup::stage_app_data_commit()`]: crate::group::public_group::PublicGroup::stage_app_data_commit
163#[cfg(feature = "extensions-draft")]
164pub struct UnresolvedAppDataCommit {
165 content: AuthenticatedContent,
166 /// The AppDataUpdate proposals covered by the commit, with proposals sent
167 /// by reference already resolved from the proposal store, sorted by
168 /// component id.
169 proposals: Vec<AppDataUpdateProposal>,
170 #[cfg(feature = "virtual-clients-draft")]
171 vc_commit_material: Option<crate::components::vc_derivation_info::VcCommitMaterial>,
172}
173
174#[cfg(feature = "extensions-draft")]
175impl UnresolvedAppDataCommit {
176 /// Constructs an [`UnresolvedAppDataCommit`] from verified content and the
177 /// covered AppDataUpdate proposals. Used by public-group processing, which
178 /// carries no virtual-clients material.
179 pub(crate) fn new(
180 content: AuthenticatedContent,
181 proposals: Vec<AppDataUpdateProposal>,
182 ) -> Self {
183 Self {
184 content,
185 proposals,
186 #[cfg(feature = "virtual-clients-draft")]
187 vc_commit_material: None,
188 }
189 }
190
191 /// Consumes the commit and returns the verified [`AuthenticatedContent`],
192 /// so that [`PublicGroup::stage_app_data_commit`] can resume staging.
193 ///
194 /// [`PublicGroup::stage_app_data_commit`]: crate::group::public_group::PublicGroup::stage_app_data_commit
195 pub(crate) fn into_content(self) -> AuthenticatedContent {
196 self.content
197 }
198
199 /// Returns the AppDataUpdate proposals covered by the commit, sorted by
200 /// component id. Proposals that were committed by reference have already
201 /// been resolved from the proposal store.
202 pub fn app_data_update_proposals(&self) -> impl Iterator<Item = &AppDataUpdateProposal> {
203 self.proposals.iter()
204 }
205}
206
207#[cfg(feature = "extensions-draft")]
208impl core::fmt::Debug for UnresolvedAppDataCommit {
209 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
210 let mut debug_struct = f.debug_struct("UnresolvedAppDataCommit");
211 debug_struct
212 .field("content", &self.content)
213 .field("proposals", &self.proposals);
214 // vc_commit_material holds secret key material, so only the epoch id
215 // is printed.
216 #[cfg(feature = "virtual-clients-draft")]
217 debug_struct.field(
218 "vc_derivation_epoch_id",
219 &self
220 .vc_commit_material
221 .as_ref()
222 .map(|material| &material.epoch_id),
223 );
224 debug_struct.finish_non_exhaustive()
225 }
226}
227
228impl MlsGroup {
229 /// Parses incoming messages from the DS. Checks for syntactic errors and
230 /// makes some semantic checks as well. If the input is an encrypted
231 /// message, it will be decrypted. This processing function does syntactic
232 /// and semantic validation of the message. It returns a [ProcessedMessage]
233 /// enum.
234 ///
235 #[cfg_attr(
236 feature = "extensions-draft",
237 doc = "A commit covering AppDataUpdate proposals is returned as\n\
238 [`ProcessedMessageContent::UnresolvedAppDataCommit`], since the\n\
239 application has to interpret the proposals before the commit can be\n\
240 staged via [`MlsGroup::stage_app_data_commit()`].\n"
241 )]
242 /// # Errors:
243 /// Returns an [`ProcessMessageError`] when the validation checks fail
244 /// with the exact reason of the failure.
245 pub fn process_message<Provider: OpenMlsProvider>(
246 &mut self,
247 provider: &Provider,
248 message: impl Into<ProtocolMessage>,
249 ) -> Result<ProcessedMessage, ProcessMessageError<Provider::StorageError>> {
250 match self.unprotect_message(provider, message)? {
251 UnprotectedMessage::Unverified(m) => self.process_unverified_message(provider, *m),
252 // The content cannot be decrypted and the sender claim is unauthenticated,
253 // so we surface OwnPrivateMessage and skip all further processing.
254 UnprotectedMessage::OwnPrivateMessage {
255 epoch,
256 authenticated_data,
257 } => {
258 let credential = self.credential()?.clone();
259 #[cfg_attr(not(feature = "extensions-draft"), allow(unused_mut))]
260 let mut processed = ProcessedMessage::new(
261 self.group_id().clone(),
262 epoch,
263 Sender::Member(self.own_leaf_index()),
264 authenticated_data,
265 ProcessedMessageContent::OwnPrivateMessage,
266 credential,
267 #[cfg(feature = "virtual-clients-draft")]
268 None,
269 );
270 #[cfg(feature = "extensions-draft")]
271 if self.context().safe_aad_required() {
272 processed
273 .try_attach_safe_aad()
274 .map_err(|_| ProcessMessageError::MalformedSafeAad)?;
275 }
276 Ok(processed)
277 }
278 }
279 }
280
281 #[cfg(feature = "extensions-draft")]
282 /// Returns a new helper struct for updating the app data
283 pub fn app_data_dictionary_updater<'a>(&'a self) -> AppDataDictionaryUpdater<'a> {
284 AppDataDictionaryUpdater::new(self.context().app_data_dict())
285 }
286
287 /// Parses and deprotects incoming messages from the DS. Checks for syntactic errors, but only
288 /// performs limited semantic checks.
289 pub(crate) fn unprotect_message<Provider: OpenMlsProvider>(
290 &mut self,
291 provider: &Provider,
292 message: impl Into<ProtocolMessage>,
293 ) -> Result<UnprotectedMessage, ProcessMessageError<Provider::StorageError>> {
294 // Make sure we are still a member of the group
295 if !self.is_active() {
296 return Err(ProcessMessageError::GroupStateError(
297 MlsGroupStateError::UseAfterEviction,
298 ));
299 }
300 let message = message.into();
301
302 // Check that handshake messages are compatible with the incoming wire format policy
303 if !message.is_external()
304 && message.is_handshake_message()
305 && !self
306 .configuration()
307 .wire_format_policy()
308 .incoming()
309 .is_compatible_with(message.wire_format())
310 {
311 return Err(ProcessMessageError::IncompatibleWireFormat);
312 }
313
314 // Parse the message
315 let sender_ratchet_configuration = *self.configuration().sender_ratchet_configuration();
316
317 // Check if this message will modify the secret tree when decrypting a
318 // private message
319 let will_modify_secret_tree = matches!(message, ProtocolMessage::PrivateMessage(_));
320
321 // Resolve the emulator reuse-guard context for `PrivateMessage`
322 // before calling `decrypt_message` so storage errors surface as
323 // `ProcessMessageError::StorageError`. `PublicMessage` carries no
324 // `reuse_guard`, so the lookup is skipped for it. The binding is
325 // looked up at the epoch the message was sent in: a delayed message
326 // from a past epoch must be deprotected with the derivation epoch state
327 // that was bound then, not the latest one.
328 #[cfg(feature = "virtual-clients-draft")]
329 let derivation_state = if let ProtocolMessage::PrivateMessage(private_message) = &message {
330 self.vc_derivation_state_at_epoch(provider.storage(), private_message.epoch())
331 .map_err(|e| match e {
332 super::VcDerivationStateError::Storage(e) => {
333 ProcessMessageError::StorageError(e)
334 }
335 super::VcDerivationStateError::MissingDerivationEpochState => {
336 ProcessMessageError::ValidationError(
337 crate::group::ValidationError::UnableToDecrypt(
338 crate::framing::errors::MessageDecryptionError::VirtualClientsError(
339 crate::components::vc_derivation_info::VirtualClientsError::MissingDerivationEpochState,
340 ),
341 ),
342 )
343 }
344 })?
345 } else {
346 None
347 };
348 #[cfg(feature = "virtual-clients-draft")]
349 let emulator_ctx: Option<crate::framing::EmulatorReuseGuardCtx<'_>> = derivation_state
350 .as_ref()
351 .map(|state| state.reuse_guard_inputs());
352
353 // Checks the following semantic validation:
354 // - ValSem002
355 // - ValSem003
356 // - ValSem006
357 // - ValSem007 MembershipTag presence
358 let decrypt_result = self.decrypt_message(
359 provider.crypto(),
360 message,
361 &sender_ratchet_configuration,
362 #[cfg(feature = "virtual-clients-draft")]
363 emulator_ctx.as_ref(),
364 )?;
365
366 // Persist the secret tree if it was modified to ensure forward secrecy
367 if will_modify_secret_tree {
368 provider
369 .storage()
370 .write_message_secrets(self.group_id(), &self.message_secrets_store)
371 .map_err(ProcessMessageError::StorageError)?;
372 }
373
374 let decrypted_message = match decrypt_result {
375 InboundDecryptionResult::Decrypted(decrypted_message) => decrypted_message,
376 // Own private messages short-circuit here: there is no content
377 // to parse or verify.
378 InboundDecryptionResult::OwnPrivateMessage {
379 epoch,
380 authenticated_data,
381 } => {
382 return Ok(UnprotectedMessage::OwnPrivateMessage {
383 epoch,
384 authenticated_data,
385 });
386 }
387 };
388
389 let unverified_message = self
390 .public_group
391 .parse_message(decrypted_message, &self.message_secrets_store)
392 .map_err(ProcessMessageError::from)?;
393
394 Ok(UnprotectedMessage::Unverified(Box::new(unverified_message)))
395 }
396
397 /// Stores a standalone proposal in the internal [ProposalStore]
398 pub fn store_pending_proposal<Storage: StorageProvider>(
399 &mut self,
400 storage: &Storage,
401 proposal: QueuedProposal,
402 ) -> Result<(), Storage::Error> {
403 storage.queue_proposal(self.group_id(), &proposal.proposal_reference(), &proposal)?;
404 // Store the proposal in in the internal ProposalStore
405 self.proposal_store_mut().add(proposal);
406
407 Ok(())
408 }
409
410 /// Returns true if there are pending proposals queued in the proposal store.
411 pub fn has_pending_proposals(&self) -> bool {
412 !self.proposal_store().is_empty()
413 }
414
415 /// Creates a Commit message that covers the pending proposals that are
416 /// currently stored in the group's [ProposalStore]. The Commit message is
417 /// created even if there are no valid pending proposals.
418 ///
419 /// Returns an error if there is a pending commit. Otherwise it returns a
420 /// tuple of `Commit, Option<Welcome>, Option<GroupInfo>`, where `Commit`
421 /// and [`Welcome`] are MlsMessages of the type [`MlsMessageOut`].
422 ///
423 /// Under the `virtual-clients-draft` feature this function is unavailable.
424 /// Use [`MlsGroup::commit_builder`], whose
425 /// [`CommitMessageBundle::confirmation`] surfaces the handshake confirmation
426 /// data.
427 ///
428 /// [`Welcome`]: crate::messages::Welcome
429 /// [`CommitMessageBundle::confirmation`]: crate::group::CommitMessageBundle::confirmation
430 // FIXME: #1217
431 #[cfg(any(not(feature = "virtual-clients-draft"), feature = "test-utils", test))]
432 #[allow(clippy::type_complexity)]
433 pub fn commit_to_pending_proposals<Provider: OpenMlsProvider>(
434 &mut self,
435 provider: &Provider,
436 signer: &impl Signer,
437 ) -> Result<
438 (MlsMessageOut, Option<MlsMessageOut>, Option<GroupInfo>),
439 CommitToPendingProposalsError<Provider::StorageError>,
440 > {
441 self.is_operational()?;
442
443 // Build and stage the commit using the commit builder
444 // TODO #751
445 let (commit, welcome, group_info) = self
446 .commit_builder()
447 // This forces committing to the proposals in the proposal store:
448 .consume_proposal_store(true)
449 .load_psks(provider.storage())?
450 .build(provider.rand(), provider.crypto(), signer, |_| true)?
451 .stage_commit(provider)?
452 .into_contents();
453
454 Ok((
455 commit,
456 // Turn the [`Welcome`] to an [`MlsMessageOut`], if there is one
457 welcome.map(|welcome| MlsMessageOut::from_welcome(welcome, self.version())),
458 group_info,
459 ))
460 }
461
462 /// Merge a [StagedCommit] into the group after inspection. As this advances
463 /// the epoch of the group, it also clears any pending commits.
464 pub fn merge_staged_commit<Provider: OpenMlsProvider>(
465 &mut self,
466 provider: &Provider,
467 staged_commit: StagedCommit,
468 ) -> Result<(), MergeCommitError<Provider::StorageError>> {
469 // Check if we were removed from the group
470 if staged_commit.self_removed() {
471 self.group_state = MlsGroupState::Inactive;
472 }
473 provider
474 .storage()
475 .write_group_state(self.group_id(), &self.group_state)
476 .map_err(MergeCommitError::StorageError)?;
477
478 // Update the per-epoch emulation bindings. Self-removal drops them,
479 // along with the group's own derivation-epoch log. Otherwise the epoch
480 // the commit moves the group into is bound to the derivation epoch of
481 // the commit's VC leaf, or, if the commit does not install a new VC
482 // leaf, to the binding of the current epoch, since the VC leaf stays
483 // active across commits by other members. Either way the derivation
484 // epochs that lost their last reference are released.
485 #[cfg(feature = "virtual-clients-draft")]
486 if staged_commit.self_removed() {
487 self.drop_all_vc_derivation_epoch_references(provider.storage())
488 .map_err(|e| {
489 log::error!("vc: drop derivation epoch references on self-removal: {e:?}");
490 MergeCommitError::StorageError(e)
491 })?;
492 } else {
493 use crate::components::vc_derivation_info::{EpochId, VcEmulationBinding};
494
495 let epoch_id = match staged_commit.vc_derivation_epoch_id.clone() {
496 Some(epoch_id) => Some(epoch_id),
497 None => provider
498 .storage()
499 .vc_emulation_binding(self.group_id(), &self.epoch())
500 .map_err(MergeCommitError::StorageError)?
501 .map(VcEmulationBinding::into_epoch_id),
502 };
503 if let Some(epoch_id) = epoch_id {
504 // Keep one binding per retained message-secrets epoch plus
505 // the new current one, so bindings age out in lockstep
506 // with the message secrets they are needed for.
507 let max_entries = self.message_secrets_store.max_epochs.saturating_add(1);
508 crate::components::vc_derivation_info::write_vc_emulation_binding_with_pruning(
509 provider.storage(),
510 self.group_id(),
511 staged_commit.epoch(),
512 epoch_id,
513 max_entries,
514 )
515 .map_err(|e| {
516 log::error!("vc: persist emulation binding at merge failed: {e:?}");
517 MergeCommitError::StorageError(e)
518 })?;
519 // The epochs whose bindings just aged out lost a reference, so
520 // sweep the ones that are now unreferenced.
521 provider
522 .storage()
523 .delete_unreferenced_vc_derivation_epoch_states::<EpochId>()
524 .map_err(|e| {
525 log::error!("vc: release unbound derivation epochs at merge: {e:?}");
526 MergeCommitError::StorageError(e)
527 })?;
528 }
529 }
530
531 // Merge staged commit
532 self.merge_commit(provider, staged_commit)?;
533
534 // Extract and store the resumption psk for the current epoch
535 let resumption_psk = self.group_epoch_secrets().resumption_psk();
536 self.resumption_psk_store
537 .add(self.context().epoch(), resumption_psk.clone());
538 provider
539 .storage()
540 .write_resumption_psk_store(self.group_id(), &self.resumption_psk_store)
541 .map_err(MergeCommitError::StorageError)?;
542
543 // Delete own KeyPackageBundles
544 self.own_leaf_nodes.clear();
545 provider
546 .storage()
547 .delete_own_leaf_nodes(self.group_id())
548 .map_err(MergeCommitError::StorageError)?;
549
550 // Delete a potential pending commit
551 self.clear_pending_commit(provider.storage())
552 .map_err(MergeCommitError::StorageError)?;
553
554 Ok(())
555 }
556
557 /// Merges the pending [`StagedCommit`] if there is one, and
558 /// clears the field by setting it to `None`.
559 pub fn merge_pending_commit<Provider: OpenMlsProvider>(
560 &mut self,
561 provider: &Provider,
562 ) -> Result<(), MergePendingCommitError<Provider::StorageError>> {
563 match &self.group_state {
564 MlsGroupState::PendingCommit(_) => {
565 let old_state = mem::replace(&mut self.group_state, MlsGroupState::Operational);
566 if let MlsGroupState::PendingCommit(pending_commit_state) = old_state {
567 self.merge_staged_commit(provider, (*pending_commit_state).into())?;
568 }
569 Ok(())
570 }
571 MlsGroupState::Inactive => Err(MlsGroupStateError::UseAfterEviction)?,
572 MlsGroupState::Operational => Ok(()),
573 }
574 }
575
576 /// Resolve a commit's virtual-clients derivation info to the per-commit
577 /// `OperationSecret` the receiver needs in order to recreate the path of a
578 /// commit sent by a sibling emulator client, plus the `EpochId` the
579 /// commit binds the group to on merge.
580 ///
581 /// See [`is_sibling_vc_commit`] for the precondition the caller must
582 /// check before invoking this helper. Sibling commits come in two
583 /// shapes:
584 ///
585 /// * own-leaf VC commits, where a sibling emulator committed through our
586 /// shared higher-level leaf
587 /// * sibling-resync external commits, where a sibling emulator joined this
588 /// higher-level group externally onto a leaf of their own,
589 /// inline-removing our previous leaf.
590 ///
591 /// Returns `Ok(None)` when the commit carries no virtual-clients
592 /// derivation-info entry on its update-path leaf (path-less commits, or
593 /// commits without an `app_data_dictionary`). Otherwise:
594 /// - looks up the per-epoch `VcDerivationEpochState` and operation secret
595 /// tree registered for the commit's derivation epoch,
596 /// - decrypts the wrapped `DerivationInfoTbe` with the AEAD key/nonce
597 /// derived from the epoch encryption key and the path leaf's
598 /// serialized encryption key,
599 /// - derives the operation secret positionally from the tree at the
600 /// sender's emulation-leaf coordinates and persists the advanced
601 /// tree,
602 /// - returns the resulting `OperationSecret` and `EpochId`.
603 ///
604 /// A generation the tree reports as already consumed fails with
605 /// `OperationGenerationConsumed`. Operation secrets are consume-once,
606 /// matching the semantics of regular PrivateMessage decryption.
607 #[cfg(feature = "virtual-clients-draft")]
608 pub(super) fn load_vc_commit_material<Provider: OpenMlsProvider>(
609 &self,
610 provider: &Provider,
611 commit: &Commit,
612 ) -> Result<Option<crate::components::vc_derivation_info::VcCommitMaterial>, StageCommitError>
613 {
614 use tls_codec::Serialize as _;
615
616 use crate::{
617 components::vc_derivation_info::{
618 VcDerivationEpochState, VirtualClientOperationType, VirtualClientsError,
619 },
620 components::vc_operation_tree::OperationSecretTree,
621 treesync::node::leaf_node::LeafNodeSource,
622 };
623
624 let Some(path) = commit.path.as_ref() else {
625 return Ok(None);
626 };
627 let Some(derivation_info) = path.leaf_node().vc_derivation_info()? else {
628 return Ok(None);
629 };
630
631 let epoch_id = derivation_info.epoch_id();
632 let storage = provider.storage();
633 let state: VcDerivationEpochState = storage
634 .vc_derivation_epoch_state(epoch_id)
635 .map_err(|e| {
636 log::error!("vc: load derivation epoch state failed: {e:?}");
637 VirtualClientsError::StorageError
638 })?
639 .ok_or(VirtualClientsError::MissingDerivationEpochState)?;
640 let mut operation_tree: OperationSecretTree = storage
641 .vc_operation_tree(epoch_id)
642 .map_err(|e| {
643 log::error!("vc: load operation tree failed: {e:?}");
644 VirtualClientsError::StorageError
645 })?
646 .ok_or(VirtualClientsError::MissingOperationTree)?;
647 // The receiver uses the derivation epoch's AEAD key and ciphersuite
648 // for `DerivationInfoTbe`. The sender's emulation leaf index travels
649 // on the wire, so it doesn't have to come from storage on this side.
650 let (_state_leaf_index, epoch_encryption_key, emulation_ciphersuite) = state.into_parts();
651
652 let crypto = provider.crypto();
653 let leaf_encryption_key = path
654 .leaf_node()
655 .encryption_key()
656 .tls_serialize_detached()
657 .map_err(VirtualClientsError::from)?;
658 // The operation type is not on the wire. It is inferred from the
659 // carrying leaf's source: key-package leaves map to `KeyPackage`,
660 // update and commit leaves map to `LeafNode`. Only `LeafNode` is
661 // wired up today, and an update-path leaf always has a commit
662 // source. It selects the tagless `DerivationInfoTbe` variant the
663 // plaintext decodes into.
664 let operation_type = match path.leaf_node().leaf_node_source() {
665 LeafNodeSource::KeyPackage(_) => {
666 log::error!("vc: key-package leaf on an update path");
667 return Err(VirtualClientsError::DerivationInfoMalformed.into());
668 }
669 LeafNodeSource::Update | LeafNodeSource::Commit(_) => {
670 VirtualClientOperationType::LeafNode
671 }
672 };
673 let tbe = derivation_info.decrypt(
674 crypto,
675 emulation_ciphersuite,
676 &epoch_encryption_key,
677 &leaf_encryption_key,
678 operation_type,
679 )?;
680 // Carried by an external commit's leaf only; `None` for own-leaf
681 // (regular) VC commits. A sibling uses it as the new epoch's external
682 // init secret instead of decapsulating from the previous epoch's
683 // `external_secret`.
684 let external_init_secret = tbe.external_init_secret().cloned();
685 // The operation context for `LeafNode` operations is the
686 // higher-level group's id.
687 let operation_context = self.group_id().as_slice().to_vec();
688
689 // An already-consumed generation propagates as a hard error here:
690 // operation secrets are consume-once, like the per-generation keys
691 // of regular PrivateMessage decryption.
692 let operation_secret = operation_tree.derive_operation_secret(
693 crypto,
694 emulation_ciphersuite,
695 epoch_id,
696 tbe.leaf_index(),
697 operation_type,
698 tbe.generation(),
699 &operation_context,
700 )?;
701 // Persist the advanced tree immediately, before any key material is
702 // derived from the secret.
703 storage
704 .write_vc_operation_tree(epoch_id, &operation_tree)
705 .map_err(|e| {
706 log::error!("vc: persist advanced operation tree failed: {e:?}");
707 VirtualClientsError::StorageError
708 })?;
709
710 Ok(Some(
711 crate::components::vc_derivation_info::VcCommitMaterial {
712 epoch_id: epoch_id.clone(),
713 operation_secret,
714 external_init_secret,
715 },
716 ))
717 }
718
719 /// Helper function to read decryption keypairs.
720 pub(super) fn read_decryption_keypairs(
721 &self,
722 provider: &impl OpenMlsProvider,
723 own_leaf_nodes: &[LeafNode],
724 ) -> Result<(Vec<EncryptionKeyPair>, Vec<EncryptionKeyPair>), StageCommitError> {
725 // All keys from the previous epoch are potential decryption keypairs.
726 let old_epoch_keypairs = self.read_epoch_keypairs(provider.storage()).map_err(|e| {
727 log::error!("Error reading epoch keypairs: {e:?}");
728 StageCommitError::MissingDecryptionKey
729 })?;
730
731 // If we are processing an update proposal that originally came from
732 // us, the keypair corresponding to the leaf in the update is also a
733 // potential decryption keypair.
734 let leaf_node_keypairs = own_leaf_nodes
735 .iter()
736 .map(|leaf_node| {
737 EncryptionKeyPair::read(provider, leaf_node.encryption_key())
738 .ok_or(StageCommitError::MissingDecryptionKey)
739 })
740 .collect::<Result<Vec<EncryptionKeyPair>, StageCommitError>>()?;
741
742 Ok((old_epoch_keypairs, leaf_node_keypairs))
743 }
744
745 /// Stages a Commit covering AppDataUpdate proposals, after the application
746 /// has interpreted the proposals and computed the resulting
747 /// [`AppDataUpdates`].
748 ///
749 /// The returned [`StagedCommit`] can be inspected and merged into the
750 /// group's state using [`MlsGroup::merge_staged_commit()`].
751 #[cfg(feature = "extensions-draft")]
752 pub fn stage_app_data_commit<Provider: OpenMlsProvider>(
753 &self,
754 provider: &Provider,
755 unresolved_commit: UnresolvedAppDataCommit,
756 app_data_dict_updates: Option<AppDataUpdates>,
757 ) -> Result<StagedCommit, StageCommitError> {
758 let content = unresolved_commit.content;
759 #[cfg(feature = "virtual-clients-draft")]
760 let vc_commit_material = unresolved_commit.vc_commit_material;
761
762 let (old_epoch_keypairs, leaf_node_keypairs) =
763 self.read_decryption_keypairs(provider, &self.own_leaf_nodes)?;
764
765 self.stage_commit_with_app_data_updates(
766 &content,
767 old_epoch_keypairs,
768 leaf_node_keypairs,
769 app_data_dict_updates,
770 provider,
771 #[cfg(feature = "virtual-clients-draft")]
772 vc_commit_material,
773 )
774 }
775
776 /// Resolves a [`ProcessedMessage`] carrying an
777 /// [`ProcessedMessageContent::UnresolvedAppDataCommit`]: stages the commit
778 /// with the application-computed [`AppDataUpdates`] and returns the same
779 /// message with the resulting [`StagedCommit`] as regular
780 /// [`ProcessedMessageContent::StagedCommitMessage`] content. All other
781 /// message fields (sender, credential, authenticated data) are preserved.
782 ///
783 /// Use this instead of [`MlsGroup::stage_app_data_commit()`] when the
784 /// caller needs the resolved commit in [`ProcessedMessage`] form, e.g. to
785 /// keep a single code path for commits with and without AppDataUpdate
786 /// proposals.
787 ///
788 /// Returns an error if the message content is not an unresolved app data
789 /// commit; the message is consumed either way.
790 #[cfg(feature = "extensions-draft")]
791 pub fn resolve_app_data_commit<Provider: OpenMlsProvider>(
792 &self,
793 provider: &Provider,
794 processed_message: ProcessedMessage,
795 app_data_dict_updates: Option<AppDataUpdates>,
796 ) -> Result<ProcessedMessage, ResolveAppDataCommitError> {
797 processed_message.resolve_app_data_commit(|unresolved_commit| {
798 self.stage_app_data_commit(provider, unresolved_commit, app_data_dict_updates)
799 })
800 }
801
802 /// This processing function does most of the semantic verifications.
803 /// It returns a [ProcessedMessage] enum.
804 ///
805 /// Checks the following semantic validation:
806 /// - ValSem008
807 /// - ValSem010
808 /// - ValSem101
809 /// - ValSem102
810 /// - ValSem104
811 /// - ValSem106
812 /// - ValSem107
813 /// - ValSem108
814 /// - ValSem110
815 /// - ValSem111
816 /// - ValSem112
817 /// - ValSem113: All Proposals: The proposal type must be supported by all
818 /// members of the group
819 /// - ValSem200
820 /// - ValSem201
821 /// - ValSem202: Path must be the right length
822 /// - ValSem203: Path secrets must decrypt correctly
823 /// - ValSem204: Public keys from Path must be verified and match the
824 /// private keys from the direct path
825 /// - ValSem205
826 pub(crate) fn process_unverified_message<Provider: OpenMlsProvider>(
827 &self,
828 provider: &Provider,
829 unverified_message: UnverifiedMessage,
830 ) -> Result<ProcessedMessage, ProcessMessageError<Provider::StorageError>> {
831 // Checks the following semantic validation:
832 // - ValSem010
833 // - ValSem246 (as part of ValSem010)
834 // - https://validation.openmls.tech/#valn1302
835 // - https://validation.openmls.tech/#valn1304
836 let verified =
837 unverified_message.verify(self.ciphersuite(), provider.crypto(), self.version())?;
838
839 #[cfg_attr(not(feature = "extensions-draft"), allow(unused_mut))]
840 let mut processed = match verified.content.sender() {
841 Sender::Member(_) | Sender::NewMemberProposal | Sender::NewMemberCommit => self
842 .process_internal_authenticated_content(
843 provider,
844 verified.content,
845 verified.credential,
846 #[cfg(feature = "virtual-clients-draft")]
847 verified.emulator_sender_leaf_index,
848 )?,
849 Sender::External(_) => self.process_external_authenticated_content(
850 provider,
851 verified.content,
852 verified.credential,
853 )?,
854 };
855 #[cfg(feature = "extensions-draft")]
856 if self.context().safe_aad_required() {
857 processed
858 .try_attach_safe_aad()
859 .map_err(|_| ProcessMessageError::MalformedSafeAad)?;
860 }
861 Ok(processed)
862 }
863
864 fn process_internal_authenticated_content<Provider: OpenMlsProvider>(
865 &self,
866 provider: &Provider,
867 content: AuthenticatedContent,
868 credential: Credential,
869 #[cfg(feature = "virtual-clients-draft")] emulator_sender_leaf_index: Option<LeafNodeIndex>,
870 ) -> Result<ProcessedMessage, ProcessMessageError<Provider::StorageError>> {
871 let sender = content.sender().clone();
872 let authenticated_data = content.authenticated_data().to_owned();
873 let epoch = content.epoch();
874
875 let content = match content.content() {
876 FramedContentBody::Application(application_message) => {
877 ProcessedMessageContent::ApplicationMessage(ApplicationMessage::new(
878 application_message.as_slice().to_owned(),
879 ))
880 }
881 FramedContentBody::Proposal(_) => {
882 let proposal = Box::new(QueuedProposal::from_authenticated_content_by_ref(
883 self.ciphersuite(),
884 provider.crypto(),
885 content,
886 )?);
887
888 if matches!(sender, Sender::NewMemberProposal) {
889 ProcessedMessageContent::ExternalJoinProposalMessage(proposal)
890 } else {
891 ProcessedMessageContent::ProposalMessage(proposal)
892 }
893 }
894 FramedContentBody::Commit(commit) => {
895 let is_own_commit =
896 matches!(&sender, Sender::Member(member) if member == &self.own_leaf_index());
897
898 if is_own_commit {
899 let received_tag = content
900 .confirmation_tag()
901 .ok_or(StageCommitError::ConfirmationTagMissing)?;
902 if self.matches_pending_commit(received_tag) {
903 // The Commit is our pending commit this client got
904 // fanned out by the delivery service: surface
905 // `OwnPendingCommit` so the caller merges the pending
906 // commit instead of staging the fanned-out Commit.
907 return Ok(ProcessedMessage::new(
908 self.group_id().clone(),
909 epoch,
910 sender,
911 authenticated_data,
912 ProcessedMessageContent::OwnPendingCommit,
913 credential,
914 #[cfg(feature = "virtual-clients-draft")]
915 emulator_sender_leaf_index,
916 ));
917 }
918 }
919
920 // Load virtual-client derivation info when this commit was
921 // authored by a sibling emulator through a leaf shared with us.
922 // The pending-commit match above already ran, so an own commit
923 // echoed back by the delivery service never reaches this load
924 // and no operation-secret generation is consumed for it. A
925 // sibling's commit never matches our pending commit's
926 // confirmation tag, so sibling commits still take this path.
927 // The receiver only loads the material when the commit shape
928 // lets it identify itself as a sibling:
929 //
930 // * `Sender::Member(idx)` with `idx == own_leaf_index`: the
931 // sender committed through our shared higher-level leaf, so
932 // we are a sibling.
933 // * `Sender::NewMemberCommit` with an inline `Remove(own_leaf)`:
934 // the sender is a sibling joining externally and the
935 // auto-Remove targets our previous leaf, so we are the
936 // sibling being resynced.
937 #[cfg(feature = "virtual-clients-draft")]
938 let (vc_commit_material, is_own_commit) = {
939 let vc_commit_material =
940 if is_sibling_vc_commit(commit, &sender, self.own_leaf_index()) {
941 self.load_vc_commit_material(provider, commit)?
942 } else {
943 None
944 };
945
946 let is_own_commit = is_own_commit && vc_commit_material.is_none();
947
948 (vc_commit_material, is_own_commit)
949 };
950
951 // An own Commit that did not match the pending commit above
952 // cannot be staged when it carries an UpdatePath: we cannot
953 // decrypt a path we encrypted to the other members. A Commit
954 // without an UpdatePath carries no author-private material and
955 // falls through to staging (a sibling's Commit without an
956 // UpdatePath, or our own commit replayed after the pending
957 // commit was cleared).
958 if is_own_commit && commit.path.is_some() {
959 return Err(StageCommitError::OwnCommitMismatch.into());
960 }
961
962 // A commit covering AppDataUpdate proposals cannot be staged
963 // immediately: the proposals contain diffs in an
964 // application-defined format, so the application has to
965 // interpret them and supply the resulting dictionary entries
966 // first. The verified content is handed back to the caller,
967 // who resumes staging via `MlsGroup::stage_app_data_commit`.
968 #[cfg(feature = "extensions-draft")]
969 {
970 let app_data_update_proposals =
971 committed_app_data_update_proposals(commit, self.proposal_store());
972 if !app_data_update_proposals.is_empty() {
973 let unresolved_commit = UnresolvedAppDataCommit {
974 content,
975 proposals: app_data_update_proposals,
976 #[cfg(feature = "virtual-clients-draft")]
977 vc_commit_material,
978 };
979 return Ok(ProcessedMessage::new(
980 self.group_id().clone(),
981 epoch,
982 sender,
983 authenticated_data,
984 ProcessedMessageContent::UnresolvedAppDataCommit(Box::new(
985 unresolved_commit,
986 )),
987 credential,
988 #[cfg(feature = "virtual-clients-draft")]
989 emulator_sender_leaf_index,
990 ));
991 }
992 }
993
994 // Since this is a commit, we need to load the private key material we need for decryption.
995 let (old_epoch_keypairs, leaf_node_keypairs) =
996 self.read_decryption_keypairs(provider, &self.own_leaf_nodes)?;
997
998 let staged_commit = self.stage_commit(
999 &content,
1000 old_epoch_keypairs,
1001 leaf_node_keypairs,
1002 provider,
1003 #[cfg(feature = "virtual-clients-draft")]
1004 vc_commit_material,
1005 )?;
1006
1007 ProcessedMessageContent::StagedCommitMessage(Box::new(staged_commit))
1008 }
1009 };
1010
1011 Ok(ProcessedMessage::new(
1012 self.group_id().clone(),
1013 epoch,
1014 sender,
1015 authenticated_data,
1016 content,
1017 credential,
1018 #[cfg(feature = "virtual-clients-draft")]
1019 emulator_sender_leaf_index,
1020 ))
1021 }
1022
1023 /// - ValSem240
1024 /// - ValSem241
1025 /// - ValSem242
1026 /// - ValSem244
1027 /// - ValSem246 (as part of ValSem010)
1028 fn process_external_authenticated_content<Provider: OpenMlsProvider>(
1029 &self,
1030 provider: &Provider,
1031 content: AuthenticatedContent,
1032 credential: Credential,
1033 ) -> Result<ProcessedMessage, ProcessMessageError<Provider::StorageError>> {
1034 #[cfg(feature = "virtual-clients-draft")]
1035 let emulator_sender_leaf_index: Option<crate::binary_tree::LeafNodeIndex> = None;
1036 let sender = content.sender().clone();
1037 let data = content.authenticated_data().to_owned();
1038
1039 debug_assert!(matches!(sender, Sender::External(_)));
1040
1041 // https://validation.openmls.tech/#valn1501
1042 match content.content() {
1043 FramedContentBody::Application(_) => {
1044 Err(ProcessMessageError::UnauthorizedExternalApplicationMessage)
1045 }
1046 // TODO: https://validation.openmls.tech/#valn1502
1047 FramedContentBody::Proposal(Proposal::GroupContextExtensions(_)) => {
1048 let content = ProcessedMessageContent::ProposalMessage(Box::new(
1049 QueuedProposal::from_authenticated_content_by_ref(
1050 self.ciphersuite(),
1051 provider.crypto(),
1052 content,
1053 )?,
1054 ));
1055 Ok(ProcessedMessage::new(
1056 self.group_id().clone(),
1057 self.context().epoch(),
1058 sender,
1059 data,
1060 content,
1061 credential,
1062 #[cfg(feature = "virtual-clients-draft")]
1063 emulator_sender_leaf_index,
1064 ))
1065 }
1066
1067 FramedContentBody::Proposal(Proposal::Remove(_)) => {
1068 let content = ProcessedMessageContent::ProposalMessage(Box::new(
1069 QueuedProposal::from_authenticated_content_by_ref(
1070 self.ciphersuite(),
1071 provider.crypto(),
1072 content,
1073 )?,
1074 ));
1075 Ok(ProcessedMessage::new(
1076 self.group_id().clone(),
1077 self.context().epoch(),
1078 sender,
1079 data,
1080 content,
1081 credential,
1082 #[cfg(feature = "virtual-clients-draft")]
1083 emulator_sender_leaf_index,
1084 ))
1085 }
1086 FramedContentBody::Proposal(Proposal::Add(_)) => {
1087 let content = ProcessedMessageContent::ProposalMessage(Box::new(
1088 QueuedProposal::from_authenticated_content_by_ref(
1089 self.ciphersuite(),
1090 provider.crypto(),
1091 content,
1092 )?,
1093 ));
1094 Ok(ProcessedMessage::new(
1095 self.group_id().clone(),
1096 self.context().epoch(),
1097 sender,
1098 data,
1099 content,
1100 credential,
1101 #[cfg(feature = "virtual-clients-draft")]
1102 emulator_sender_leaf_index,
1103 ))
1104 }
1105 // RFC 9420 ยง12.1.8 permits external senders to send PreSharedKey
1106 // proposals.
1107 FramedContentBody::Proposal(Proposal::PreSharedKey(_)) => {
1108 let content = ProcessedMessageContent::ProposalMessage(Box::new(
1109 QueuedProposal::from_authenticated_content_by_ref(
1110 self.ciphersuite(),
1111 provider.crypto(),
1112 content,
1113 )?,
1114 ));
1115 Ok(ProcessedMessage::new(
1116 self.group_id().clone(),
1117 self.context().epoch(),
1118 sender,
1119 data,
1120 content,
1121 credential,
1122 #[cfg(feature = "virtual-clients-draft")]
1123 emulator_sender_leaf_index,
1124 ))
1125 }
1126 // TODO #151/#106
1127 FramedContentBody::Proposal(_) => Err(ProcessMessageError::UnsupportedProposalType),
1128 FramedContentBody::Commit(_) => {
1129 Err(ProcessMessageError::UnauthorizedExternalCommitMessage)
1130 }
1131 }
1132 }
1133
1134 /// Performs framing validation and, if necessary, decrypts the given message.
1135 ///
1136 /// Returns the [`InboundDecryptionResult`] if processing is successful, or a
1137 /// [`ValidationError`] if it is not.
1138 ///
1139 /// Checks the following semantic validation:
1140 /// - ValSem002
1141 /// - ValSem003
1142 /// - ValSem006
1143 /// - ValSem007 MembershipTag presence
1144 /// - https://validation.openmls.tech/#valn1202
1145 pub(crate) fn decrypt_message(
1146 &mut self,
1147 crypto: &impl OpenMlsCrypto,
1148 message: ProtocolMessage,
1149 sender_ratchet_configuration: &SenderRatchetConfiguration,
1150 #[cfg(feature = "virtual-clients-draft")] emulator_ctx: Option<
1151 &crate::framing::EmulatorReuseGuardCtx<'_>,
1152 >,
1153 ) -> Result<InboundDecryptionResult, ValidationError> {
1154 // Checks the following semantic validation:
1155 // - ValSem002
1156 // - ValSem003
1157 self.public_group.validate_framing(&message)?;
1158
1159 let epoch = message.epoch();
1160
1161 // Checks the following semantic validation:
1162 // - ValSem006
1163 // - ValSem007 MembershipTag presence
1164 match message {
1165 ProtocolMessage::PublicMessage(public_message) => {
1166 // If the message is older than the current epoch, we need to fetch the correct secret tree first.
1167 let message_secrets =
1168 self.message_secrets_for_epoch(epoch).map_err(|e| match e {
1169 SecretTreeError::TooDistantInThePast => ValidationError::NoPastEpochData,
1170 _ => LibraryError::custom(
1171 "Unexpected error while retrieving message secrets for epoch.",
1172 )
1173 .into(),
1174 })?;
1175 DecryptedMessage::from_inbound_public_message(
1176 *public_message,
1177 message_secrets,
1178 message_secrets.serialized_context().to_vec(),
1179 crypto,
1180 self.ciphersuite(),
1181 )
1182 .map(InboundDecryptionResult::Decrypted)
1183 }
1184 ProtocolMessage::PrivateMessage(ciphertext) => {
1185 // If the message is older than the current epoch, we need to fetch the correct secret tree first
1186 DecryptedMessage::from_inbound_ciphertext(
1187 ciphertext,
1188 crypto,
1189 self,
1190 sender_ratchet_configuration,
1191 #[cfg(feature = "virtual-clients-draft")]
1192 emulator_ctx,
1193 )
1194 }
1195 }
1196 }
1197}
1198
1199/// Collects the AppDataUpdate proposals covered by a commit, sorted by
1200/// component id.
1201///
1202/// Proposals sent by reference are resolved from the proposal store. A
1203/// reference that cannot be resolved is skipped here: staging fails on it
1204/// later with the regular missing-proposal error, so it does not need to be
1205/// surfaced at detection time.
1206#[cfg(feature = "extensions-draft")]
1207pub(crate) fn committed_app_data_update_proposals(
1208 commit: &Commit,
1209 proposal_store: &ProposalStore,
1210) -> Vec<AppDataUpdateProposal> {
1211 use crate::messages::proposals::ProposalOrRef;
1212
1213 let mut proposals: Vec<AppDataUpdateProposal> = commit
1214 .proposals
1215 .iter()
1216 .filter_map(|proposal_or_ref| match proposal_or_ref {
1217 ProposalOrRef::Proposal(proposal) => match proposal.as_ref() {
1218 Proposal::AppDataUpdate(proposal) => Some(proposal.as_ref().clone()),
1219 _ => None,
1220 },
1221 ProposalOrRef::Reference(reference) => proposal_store
1222 .proposals()
1223 .find(|queued_proposal| {
1224 queued_proposal.proposal_reference_ref() == reference.as_ref()
1225 })
1226 .and_then(|queued_proposal| match queued_proposal.proposal() {
1227 Proposal::AppDataUpdate(proposal) => Some(proposal.as_ref().clone()),
1228 _ => None,
1229 }),
1230 })
1231 .collect();
1232
1233 proposals.sort_by_key(|proposal| proposal.component_id());
1234 proposals
1235}
1236
1237/// Determines from the commit's shape whether the receiver is a sibling virtual
1238/// client of the sender of a virtual-clients commit.
1239///
1240/// Returns `true` for:
1241/// * own-leaf commits (`Sender::Member(idx)` with `idx == own_leaf_index`),
1242/// where receiver and sender share the higher-level leaf
1243/// * sibling-resync external commits (`Sender::NewMemberCommit` whose
1244/// proposal list inlines a `Remove` of `own_leaf_index`.
1245///
1246/// `false` for everything else.
1247#[cfg(feature = "virtual-clients-draft")]
1248fn is_sibling_vc_commit(
1249 commit: &Commit,
1250 sender: &super::Sender,
1251 own_leaf_index: crate::binary_tree::LeafNodeIndex,
1252) -> bool {
1253 use crate::messages::proposals::{Proposal, ProposalOrRef};
1254
1255 match sender {
1256 super::Sender::Member(idx) => *idx == own_leaf_index,
1257 super::Sender::NewMemberCommit => commit.proposals.iter().any(|p| {
1258 matches!(
1259 p,
1260 ProposalOrRef::Proposal(boxed)
1261 if matches!(boxed.as_ref(), Proposal::Remove(r) if r.removed() == own_leaf_index)
1262 )
1263 }),
1264 _ => false,
1265 }
1266}