openmls/components/vc_operation_tree.rs
1//! Virtual Client Operation Secret Tree (mls-virtual-clients draft).
2//!
3//! A tree of secrets with the same structure as the RFC 9420 secret tree
4//! (Section 9): it has the same set of nodes and edges as the emulation
5//! group's ratchet tree at the corresponding epoch, parent-to-child node
6//! derivation uses the same `"tree"` label with `"left"` / `"right"`
7//! context, and each leaf is expanded once it is first used. It differs
8//! from the RFC 9420 secret tree in two ways: the root is the
9//! per-emulation-epoch `epoch_base_secret` rather than a secret derived
10//! from `encryption_secret`, and each leaf expands into one operation
11//! ratchet per `VirtualClientOperationType` instead of a handshake and an
12//! application sender ratchet.
13//!
14//! Each ratchet hands out one `OperationSecret` per generation, bound to
15//! the spec's `OperationContext`
16//! `(epoch_id, leaf_index, generation, operation_type, operation_context)`.
17//!
18//! Forward secrecy mirrors the RFC 9420 secret tree: parent node secrets
19//! are deleted once their children are derived, a leaf secret is deleted as
20//! soon as the initial ratchet secrets for all operation types have been
21//! derived, and ratchet heads plus generation secrets are deleted as soon
22//! as the operation secret of a generation has been derived. Deriving for a
23//! generation ahead of the ratchet head retains only the
24//! context-independent `operation_generation_secret` of each skipped
25//! generation, since the final operation secret also binds the (then still
26//! unknown) operation context. The retained entry is deleted when the
27//! operation for that generation arrives. Retention is bounded by
28//! `MAXIMUM_FORWARD_DISTANCE` and `OUT_OF_ORDER_TOLERANCE`.
29
30use std::collections::BTreeMap;
31
32use openmls_traits::{crypto::OpenMlsCrypto, types::Ciphersuite};
33use serde::{Deserialize, Serialize};
34use tls_codec::{Serialize as _, TlsSerialize, TlsSize, VLByteVec};
35
36use crate::{
37 binary_tree::{
38 array_representation::{
39 direct_path, left, right, root, ParentNodeIndex, TreeNodeIndex, TreeSize,
40 },
41 LeafNodeIndex,
42 },
43 ciphersuite::Secret,
44 components::vc_derivation_info::{
45 EpochId, OperationSecret, VirtualClientOperationType, VirtualClientsError,
46 },
47 tree::secret_tree::derive_child_secrets,
48 utils::vector_converter,
49};
50
51/// `ExpandWithLabel` label for the initial operation ratchet secret of each
52/// operation type, expanded from a leaf secret.
53const OPERATION_RATCHET_INIT_LABEL: &str = "vc operation init";
54/// `DeriveSecret` label for the per-generation `operation_generation_secret`.
55const OPERATION_GENERATION_LABEL: &str = "VC Operation Secret";
56/// `DeriveSecret` label for advancing an operation ratchet to the next
57/// generation.
58const OPERATION_RATCHET_ADVANCE_LABEL: &str = "VC Operation Ratchet";
59/// `ExpandWithLabel` label for the final operation secret, expanded from an
60/// `operation_generation_secret` with the TLS-serialized [`OperationContext`].
61const OPERATION_SECRET_LABEL: &str = "vc operation";
62
63/// How far beyond the current ratchet head a requested generation may lie.
64/// Requests further out fail with
65/// [`VirtualClientsError::OperationGenerationTooDistant`], which also stops a
66/// malicious sibling from forcing up to `u32::MAX` KDF steps through a
67/// fabricated `generation` in a commit's `DerivationInfo`.
68///
69/// Operation ratchets advance once per virtual-client operation rather than
70/// once per message, so legitimate gaps stay small: commits within one
71/// higher-level group are DS-ordered, but one tree spans all higher-level
72/// groups of the virtual client, so operations from different groups can be
73/// processed out of allocation order, and DS-rejected commits leave burned
74/// generations that receivers skip. This bound and
75/// [`OUT_OF_ORDER_TOLERANCE`] can become configurable later alongside the
76/// planned emulation-group configuration.
77const MAXIMUM_FORWARD_DISTANCE: u32 = 1024;
78
79/// How many skipped `operation_generation_secret`s a ratchet retains.
80/// Skipping past more than this many unconsumed generations evicts the oldest
81/// retained entries first, after which they fail with
82/// [`VirtualClientsError::OperationGenerationConsumed`]. The draft says
83/// implementations SHOULD bound how many skipped generations they retain,
84/// since every retained secret weakens forward secrecy within the emulation
85/// epoch. See [`MAXIMUM_FORWARD_DISTANCE`] for why legitimate gaps stay
86/// small and for the plan to make both bounds configurable.
87const OUT_OF_ORDER_TOLERANCE: usize = 32;
88
89/// Per-emulation-epoch Virtual Client Operation Secret Tree.
90///
91/// Rooted at the epoch's `epoch_base_secret` and shaped like the emulation
92/// group's ratchet tree at the corresponding epoch (sized by the leaf
93/// count, including blank leaves). Node secrets and per-leaf operation
94/// ratchets are derived lazily on first use, and consumed material is deleted
95/// as it is used (see the module documentation for the forward-secrecy
96/// rules).
97///
98/// # Concurrency
99///
100/// One tree is shared by all higher-level groups the virtual client is a
101/// member of. Every derivation mutates it, so a load-derive-store cycle
102/// against the storage provider must be atomic per emulation epoch.
103/// Applications that process messages for multiple higher-level groups in
104/// parallel must serialize these cycles. Two concurrent cycles on separate
105/// copies of the tree can allocate the same generation for two different
106/// operations (the key reuse the draft forbids) and last-write-wins
107/// persistence loses the other copy's punctured nodes and retained skipped
108/// generations.
109#[derive(Debug, Serialize, Deserialize)]
110pub struct OperationSecretTree {
111 leaf_nodes: Vec<Option<Secret>>,
112 parent_nodes: Vec<Option<Secret>>,
113 operation_ratchets: Vec<Option<LeafOperationRatchets>>,
114 size: TreeSize,
115}
116
117impl OperationSecretTree {
118 /// Create a tree rooted at `epoch_base_secret` with the given `size`.
119 /// The inner node secrets and the operation ratchets only get derived
120 /// when operation secrets are requested.
121 ///
122 /// `Secret` and `TreeSize` are crate-internal, so unlike the derivation
123 /// methods this constructor cannot be `pub`.
124 pub(crate) fn new(epoch_base_secret: Secret, size: TreeSize) -> Self {
125 let leaf_count = size.leaf_count() as usize;
126 let mut tree = Self {
127 leaf_nodes: std::iter::repeat_with(|| None).take(leaf_count).collect(),
128 parent_nodes: std::iter::repeat_with(|| None).take(leaf_count).collect(),
129 operation_ratchets: std::iter::repeat_with(|| None).take(leaf_count).collect(),
130 size,
131 };
132 // Set the epoch base secret in the root node. We ignore the Result
133 // here, since we rely on the tree math to be correct, i.e.
134 // root(size) < size.
135 let _ = tree.set_node(root(size), Some(epoch_base_secret));
136 tree
137 }
138
139 /// Derive the operation secret for the given coordinates, advancing the
140 /// per-leaf, per-operation-type ratchet as necessary.
141 ///
142 /// Deriving for a generation ahead of the ratchet head advances the head
143 /// past it while retaining the context-independent generation secrets of
144 /// the skipped generations (at most [`OUT_OF_ORDER_TOLERANCE`], oldest
145 /// evicted first), so the corresponding operations can still be processed
146 /// when they arrive. Asking for a generation whose operation secret was
147 /// already derived or evicted fails with
148 /// [`VirtualClientsError::OperationGenerationConsumed`], a generation
149 /// more than [`MAXIMUM_FORWARD_DISTANCE`] beyond the head fails with
150 /// [`VirtualClientsError::OperationGenerationTooDistant`], and a leaf
151 /// index outside the tree fails with
152 /// [`VirtualClientsError::IndexOutOfBounds`].
153 ///
154 /// # Warning
155 ///
156 /// This mutates the tree: it advances and punctures ratchets. The caller
157 /// MUST persist the mutated tree before any other cycle reads it, as a
158 /// single atomic load-derive-store per emulation epoch. Deriving against
159 /// a stale copy, or not persisting before the next derive, re-serves a
160 /// consumed generation and reuses key material, the reuse the draft
161 /// forbids. See the type-level `# Concurrency` note.
162 #[allow(clippy::too_many_arguments)]
163 pub(crate) fn derive_operation_secret(
164 &mut self,
165 crypto: &impl OpenMlsCrypto,
166 ciphersuite: Ciphersuite,
167 epoch_id: &EpochId,
168 leaf_index: LeafNodeIndex,
169 operation_type: VirtualClientOperationType,
170 generation: u32,
171 operation_context: &[u8],
172 ) -> Result<OperationSecret, VirtualClientsError> {
173 let ratchet = self.ratchet_mut(crypto, ciphersuite, leaf_index, operation_type)?;
174 let operation_generation_secret =
175 ratchet.generation_secret(crypto, ciphersuite, generation)?;
176 let context = OperationContext {
177 epoch_id: epoch_id.clone(),
178 leaf_index,
179 generation,
180 operation_type,
181 operation_context: operation_context.to_vec().into(),
182 };
183 // `operation_generation_secret` is dropped when this function
184 // returns, deleting it as required by the spec.
185 context.expand_operation_secret(crypto, ciphersuite, &operation_generation_secret)
186 }
187
188 /// Advance the caller's own ratchet for `operation_type` at
189 /// `own_leaf_index` and return the generation it was at together with the
190 /// operation secret for that generation. Use this when sending an
191 /// operation. Receivers re-derive the same secret positionally via
192 /// [`OperationSecretTree::derive_operation_secret`].
193 ///
194 /// # Warning
195 ///
196 /// This mutates the tree: it advances the own ratchet by one generation.
197 /// The caller MUST persist the mutated tree in the same atomic
198 /// load-derive-store cycle, before the allocated generation can be
199 /// observed on the wire. Allocating from a stale copy, or persisting
200 /// late, lets two operations share a generation and reuse key material,
201 /// the reuse the draft forbids. See the type-level `# Concurrency` note.
202 pub(crate) fn next_operation_secret(
203 &mut self,
204 crypto: &impl OpenMlsCrypto,
205 ciphersuite: Ciphersuite,
206 epoch_id: &EpochId,
207 own_leaf_index: LeafNodeIndex,
208 operation_type: VirtualClientOperationType,
209 operation_context: &[u8],
210 ) -> Result<(u32, OperationSecret), VirtualClientsError> {
211 let generation = self
212 .ratchet_mut(crypto, ciphersuite, own_leaf_index, operation_type)?
213 .head_generation();
214 let operation_secret = self.derive_operation_secret(
215 crypto,
216 ciphersuite,
217 epoch_id,
218 own_leaf_index,
219 operation_type,
220 generation,
221 operation_context,
222 )?;
223 Ok((generation, operation_secret))
224 }
225
226 /// Return the ratchet for `(leaf_index, operation_type)`, initializing
227 /// the leaf's operation ratchets first if necessary.
228 fn ratchet_mut(
229 &mut self,
230 crypto: &impl OpenMlsCrypto,
231 ciphersuite: Ciphersuite,
232 leaf_index: LeafNodeIndex,
233 operation_type: VirtualClientOperationType,
234 ) -> Result<&mut OperationRatchet, VirtualClientsError> {
235 if leaf_index.u32() >= self.size.leaf_count() {
236 log::error!("vc: leaf index is larger than the operation secret tree size.");
237 return Err(VirtualClientsError::IndexOutOfBounds);
238 }
239 if self
240 .operation_ratchets
241 .get(leaf_index.usize())
242 .ok_or(VirtualClientsError::IndexOutOfBounds)?
243 .is_none()
244 {
245 self.initialize_leaf_ratchets(crypto, ciphersuite, leaf_index)?;
246 }
247 let ratchets = self
248 .operation_ratchets
249 .get_mut(leaf_index.usize())
250 .and_then(|ratchets| ratchets.as_mut())
251 // We just initialized the ratchets, so this should not happen.
252 .ok_or(VirtualClientsError::LibraryError)?;
253 Ok(ratchets.ratchet_mut(operation_type))
254 }
255
256 /// Derive the node secrets down to `leaf_index`, expand the leaf secret
257 /// into one initial ratchet secret per operation type and delete it.
258 fn initialize_leaf_ratchets(
259 &mut self,
260 crypto: &impl OpenMlsCrypto,
261 ciphersuite: Ciphersuite,
262 leaf_index: LeafNodeIndex,
263 ) -> Result<(), VirtualClientsError> {
264 // If we don't have a secret in the leaf node, we derive it from the
265 // closest populated ancestor.
266 if self.get_node(leaf_index.into())?.is_none() {
267 // Collect empty nodes in the direct path until a non-empty node
268 // is found.
269 let mut empty_nodes: Vec<ParentNodeIndex> = Vec::new();
270 for parent_node in direct_path(leaf_index, self.size) {
271 empty_nodes.push(parent_node);
272 if self.get_node(parent_node.into())?.is_some() {
273 break;
274 }
275 }
276 // Derive the secrets down all the way to the leaf node, deleting
277 // each parent secret once its children are populated.
278 empty_nodes.reverse();
279 for parent_node in empty_nodes {
280 self.derive_down(crypto, ciphersuite, parent_node)?;
281 }
282 }
283
284 // Take the leaf secret out of the tree: the spec requires deleting
285 // it as soon as the initial ratchet secrets for all operation types
286 // have been derived. `initialize` consumes and drops it.
287 let leaf_secret = self
288 .leaf_nodes
289 .get_mut(leaf_index.usize())
290 .ok_or(VirtualClientsError::IndexOutOfBounds)?
291 .take()
292 // We just derived all necessary nodes, so this should not happen.
293 .ok_or(VirtualClientsError::LibraryError)?;
294 let ratchets = LeafOperationRatchets::initialize(crypto, ciphersuite, leaf_secret)?;
295 *self
296 .operation_ratchets
297 .get_mut(leaf_index.usize())
298 .ok_or(VirtualClientsError::IndexOutOfBounds)? = Some(ratchets);
299 Ok(())
300 }
301
302 /// Derive the secrets for the child nodes of a parent node, deleting the
303 /// parent node's secret.
304 fn derive_down(
305 &mut self,
306 crypto: &impl OpenMlsCrypto,
307 ciphersuite: Ciphersuite,
308 parent_index: ParentNodeIndex,
309 ) -> Result<(), VirtualClientsError> {
310 let parent_secret = self
311 .parent_nodes
312 .get_mut(parent_index.usize())
313 .ok_or(VirtualClientsError::IndexOutOfBounds)?
314 // Taking the secret deletes the parent node.
315 .take()
316 // This function only gets called top to bottom, so this should
317 // not happen.
318 .ok_or(VirtualClientsError::LibraryError)?;
319 let (left_secret, right_secret) =
320 derive_child_secrets(&parent_secret, crypto, ciphersuite)?;
321 self.set_node(left(parent_index), Some(left_secret))?;
322 self.set_node(right(parent_index), Some(right_secret))?;
323 Ok(())
324 }
325
326 fn get_node(&self, index: TreeNodeIndex) -> Result<Option<&Secret>, VirtualClientsError> {
327 match index {
328 TreeNodeIndex::Leaf(leaf_index) => Ok(self
329 .leaf_nodes
330 .get(leaf_index.usize())
331 .ok_or(VirtualClientsError::IndexOutOfBounds)?
332 .as_ref()),
333 TreeNodeIndex::Parent(parent_index) => Ok(self
334 .parent_nodes
335 .get(parent_index.usize())
336 .ok_or(VirtualClientsError::IndexOutOfBounds)?
337 .as_ref()),
338 }
339 }
340
341 fn set_node(
342 &mut self,
343 index: TreeNodeIndex,
344 secret: Option<Secret>,
345 ) -> Result<(), VirtualClientsError> {
346 match index {
347 TreeNodeIndex::Leaf(leaf_index) => {
348 *self
349 .leaf_nodes
350 .get_mut(leaf_index.usize())
351 .ok_or(VirtualClientsError::IndexOutOfBounds)? = secret;
352 }
353 TreeNodeIndex::Parent(parent_index) => {
354 *self
355 .parent_nodes
356 .get_mut(parent_index.usize())
357 .ok_or(VirtualClientsError::IndexOutOfBounds)? = secret;
358 }
359 }
360 Ok(())
361 }
362}
363
364/// The per-operation-type ratchets expanded from one leaf secret, one per
365/// non-reserved [`VirtualClientOperationType`].
366#[derive(Debug, Serialize, Deserialize)]
367struct LeafOperationRatchets {
368 key_package: OperationRatchet,
369 leaf_node: OperationRatchet,
370 application: OperationRatchet,
371}
372
373impl LeafOperationRatchets {
374 /// Expand `leaf_secret` into the initial ratchet secret for every
375 /// non-reserved operation type:
376 ///
377 /// ```text
378 /// operation_ratchet_secret[operation_type][0] =
379 /// ExpandWithLabel(leaf_secret, "vc operation init", operation_type, Kdf.Nh)
380 /// ```
381 ///
382 /// where `operation_type` is the TLS-encoded
383 /// [`VirtualClientOperationType`] value. `leaf_secret` is consumed and
384 /// dropped here, per the spec requirement to delete it as soon as the
385 /// initial ratchet secrets for all operation types have been derived.
386 fn initialize(
387 crypto: &impl OpenMlsCrypto,
388 ciphersuite: Ciphersuite,
389 leaf_secret: Secret,
390 ) -> Result<Self, VirtualClientsError> {
391 let initial_ratchet_secret =
392 |operation_type: VirtualClientOperationType| -> Result<Secret, VirtualClientsError> {
393 let context = operation_type.tls_serialize_detached()?;
394 Ok(leaf_secret.kdf_expand_label(
395 crypto,
396 ciphersuite,
397 OPERATION_RATCHET_INIT_LABEL,
398 &context,
399 ciphersuite.hash_length(),
400 )?)
401 };
402 Ok(Self {
403 key_package: OperationRatchet::new(initial_ratchet_secret(
404 VirtualClientOperationType::KeyPackage,
405 )?),
406 leaf_node: OperationRatchet::new(initial_ratchet_secret(
407 VirtualClientOperationType::LeafNode,
408 )?),
409 application: OperationRatchet::new(initial_ratchet_secret(
410 VirtualClientOperationType::Application,
411 )?),
412 })
413 }
414
415 fn ratchet_mut(&mut self, operation_type: VirtualClientOperationType) -> &mut OperationRatchet {
416 match operation_type {
417 VirtualClientOperationType::KeyPackage => &mut self.key_package,
418 VirtualClientOperationType::LeafNode => &mut self.leaf_node,
419 VirtualClientOperationType::Application => &mut self.application,
420 }
421 }
422}
423
424/// A single operation ratchet: the current ratchet head plus the
425/// context-independent `operation_generation_secret`s retained for
426/// generations that were skipped over.
427#[derive(Debug, Serialize, Deserialize)]
428struct OperationRatchet {
429 /// The `operation_ratchet_secret` for `next_generation`.
430 ratchet_secret: Secret,
431 next_generation: u32,
432 /// Retained `operation_generation_secret`s of skipped generations,
433 /// deleted when the operation for the generation arrives. A generation
434 /// below `next_generation` without an entry here was already consumed.
435 #[serde(with = "vector_converter")]
436 retained_generation_secrets: BTreeMap<u32, Secret>,
437}
438
439impl OperationRatchet {
440 fn new(initial_ratchet_secret: Secret) -> Self {
441 Self {
442 ratchet_secret: initial_ratchet_secret,
443 next_generation: 0,
444 retained_generation_secrets: BTreeMap::new(),
445 }
446 }
447
448 /// The generation the ratchet head is currently at, i.e. the generation
449 /// the next own operation would use.
450 fn head_generation(&self) -> u32 {
451 self.next_generation
452 }
453
454 /// Return the `operation_generation_secret` for `generation`, advancing
455 /// the ratchet head past it if necessary. Skipped generations retain
456 /// their generation secret, keeping at most [`OUT_OF_ORDER_TOLERANCE`]
457 /// entries by evicting the oldest first. Asking for a generation that was
458 /// already consumed or evicted fails with
459 /// [`VirtualClientsError::OperationGenerationConsumed`], and a generation
460 /// more than [`MAXIMUM_FORWARD_DISTANCE`] beyond the head fails with
461 /// [`VirtualClientsError::OperationGenerationTooDistant`] without
462 /// advancing the head.
463 fn generation_secret(
464 &mut self,
465 crypto: &impl OpenMlsCrypto,
466 ciphersuite: Ciphersuite,
467 generation: u32,
468 ) -> Result<Secret, VirtualClientsError> {
469 if generation < self.next_generation {
470 // Removing the entry deletes the retained secret once the caller
471 // drops it.
472 return self
473 .retained_generation_secrets
474 .remove(&generation)
475 .ok_or(VirtualClientsError::OperationGenerationConsumed);
476 }
477 if self.next_generation < u32::MAX - MAXIMUM_FORWARD_DISTANCE
478 && generation > self.next_generation + MAXIMUM_FORWARD_DISTANCE
479 {
480 log::error!(
481 "vc: requested operation generation {generation} is more than \
482 {MAXIMUM_FORWARD_DISTANCE} beyond the ratchet head {}.",
483 self.next_generation
484 );
485 return Err(VirtualClientsError::OperationGenerationTooDistant);
486 }
487 while self.next_generation < generation {
488 let skipped_generation = self.next_generation;
489 let skipped_secret = self.advance(crypto, ciphersuite)?;
490 self.retained_generation_secrets
491 .insert(skipped_generation, skipped_secret);
492 }
493 // Evict the oldest retained entries first, dropping their secrets.
494 while self.retained_generation_secrets.len() > OUT_OF_ORDER_TOLERANCE {
495 self.retained_generation_secrets.pop_first();
496 }
497 self.advance(crypto, ciphersuite)
498 }
499
500 /// One ratchet step:
501 ///
502 /// ```text
503 /// operation_generation_secret =
504 /// DeriveSecret(operation_ratchet_secret, "VC Operation Secret")
505 /// next_operation_ratchet_secret =
506 /// DeriveSecret(operation_ratchet_secret, "VC Operation Ratchet")
507 /// ```
508 ///
509 /// Returns the `operation_generation_secret` for the head generation and
510 /// advances the head, deleting the consumed `operation_ratchet_secret`.
511 fn advance(
512 &mut self,
513 crypto: &impl OpenMlsCrypto,
514 ciphersuite: Ciphersuite,
515 ) -> Result<Secret, VirtualClientsError> {
516 if self.next_generation == u32::MAX {
517 return Err(VirtualClientsError::OperationRatchetTooLong);
518 }
519 let operation_generation_secret =
520 self.ratchet_secret
521 .derive_secret(crypto, ciphersuite, OPERATION_GENERATION_LABEL)?;
522 // Overwriting the head deletes the consumed ratchet secret.
523 self.ratchet_secret = self.ratchet_secret.derive_secret(
524 crypto,
525 ciphersuite,
526 OPERATION_RATCHET_ADVANCE_LABEL,
527 )?;
528 self.next_generation += 1;
529 Ok(operation_generation_secret)
530 }
531}
532
533/// Context bound into each operation secret (mls-virtual-clients draft
534/// `OperationContext`):
535///
536/// ```text
537/// struct {
538/// opaque epoch_id<V>;
539/// uint32 leaf_index;
540/// uint32 generation;
541/// VirtualClientOperationType operation_type;
542/// opaque operation_context<V>;
543/// } OperationContext
544/// ```
545#[derive(Debug, TlsSize, TlsSerialize)]
546struct OperationContext {
547 epoch_id: EpochId,
548 leaf_index: LeafNodeIndex,
549 generation: u32,
550 operation_type: VirtualClientOperationType,
551 operation_context: VLByteVec,
552}
553
554impl OperationContext {
555 /// Expand the final operation secret:
556 ///
557 /// ```text
558 /// operation_secret =
559 /// ExpandWithLabel(operation_generation_secret, "vc operation",
560 /// OperationContext, Kdf.Nh)
561 /// ```
562 fn expand_operation_secret(
563 &self,
564 crypto: &impl OpenMlsCrypto,
565 ciphersuite: Ciphersuite,
566 operation_generation_secret: &Secret,
567 ) -> Result<OperationSecret, VirtualClientsError> {
568 let context = self.tls_serialize_detached()?;
569 let operation_secret = operation_generation_secret.kdf_expand_label(
570 crypto,
571 ciphersuite,
572 OPERATION_SECRET_LABEL,
573 &context,
574 ciphersuite.hash_length(),
575 )?;
576 Ok(OperationSecret::from(operation_secret))
577 }
578}
579
580#[cfg(test)]
581mod tests {
582 use openmls_rust_crypto::OpenMlsRustCrypto;
583 use openmls_traits::{random::OpenMlsRand, OpenMlsProvider};
584
585 use super::*;
586 use crate::components::vc_derivation_info::EmulatorEpochSecret;
587
588 const CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
589
590 /// Build two trees from the same `epoch_base_secret` (one sender-side,
591 /// one receiver-side instance), going through the real per-epoch
592 /// derivation chain.
593 fn setup(
594 leaf_count: u32,
595 ) -> (
596 OpenMlsRustCrypto,
597 EpochId,
598 OperationSecretTree,
599 OperationSecretTree,
600 ) {
601 let provider = OpenMlsRustCrypto::default();
602 let emulator = EmulatorEpochSecret::new(
603 &provider
604 .rand()
605 .random_vec(CIPHERSUITE.hash_length())
606 .expect("randomness"),
607 );
608 let epoch_id = emulator
609 .derive_epoch_id(provider.crypto(), CIPHERSUITE)
610 .expect("derive epoch id");
611 let epoch_base_secret = emulator
612 .derive_epoch_base_secret(provider.crypto(), CIPHERSUITE)
613 .expect("derive epoch base secret");
614 let size = TreeSize::from_leaf_count(leaf_count);
615 let tree_a = OperationSecretTree::new(epoch_base_secret.clone(), size);
616 let tree_b = OperationSecretTree::new(epoch_base_secret, size);
617 (provider, epoch_id, tree_a, tree_b)
618 }
619
620 /// Two instances built from the same `epoch_base_secret` and size must
621 /// agree on the operation secret for the same coordinates and context,
622 /// regardless of the order in which they derive.
623 #[test]
624 fn cross_instance_agreement() {
625 let (provider, epoch_id, mut tree_a, mut tree_b) = setup(8);
626 let secret_a = tree_a
627 .derive_operation_secret(
628 provider.crypto(),
629 CIPHERSUITE,
630 &epoch_id,
631 LeafNodeIndex::new(2),
632 VirtualClientOperationType::LeafNode,
633 3,
634 b"commit context",
635 )
636 .expect("derive on tree a");
637 // Tree b derives generations 0..=3 in order before reaching the same
638 // coordinates.
639 for generation in 0..3 {
640 tree_b
641 .derive_operation_secret(
642 provider.crypto(),
643 CIPHERSUITE,
644 &epoch_id,
645 LeafNodeIndex::new(2),
646 VirtualClientOperationType::LeafNode,
647 generation,
648 b"earlier context",
649 )
650 .expect("derive earlier generation on tree b");
651 }
652 let secret_b = tree_b
653 .derive_operation_secret(
654 provider.crypto(),
655 CIPHERSUITE,
656 &epoch_id,
657 LeafNodeIndex::new(2),
658 VirtualClientOperationType::LeafNode,
659 3,
660 b"commit context",
661 )
662 .expect("derive on tree b");
663 assert_eq!(secret_a.as_slice(), secret_b.as_slice());
664 }
665
666 /// Different generations, leaves, operation types, and contexts must all
667 /// yield different operation secrets.
668 #[test]
669 fn coordinates_and_context_bind_the_secret() {
670 let (provider, epoch_id, mut tree_a, mut tree_b) = setup(8);
671 let derive = |tree: &mut OperationSecretTree,
672 leaf: u32,
673 operation_type: VirtualClientOperationType,
674 generation: u32,
675 context: &[u8]| {
676 tree.derive_operation_secret(
677 provider.crypto(),
678 CIPHERSUITE,
679 &epoch_id,
680 LeafNodeIndex::new(leaf),
681 operation_type,
682 generation,
683 context,
684 )
685 .expect("derive operation secret")
686 };
687 let leaf_node = VirtualClientOperationType::LeafNode;
688 let baseline = derive(&mut tree_a, 0, leaf_node, 0, b"ctx");
689 let other_generation = derive(&mut tree_a, 0, leaf_node, 1, b"ctx");
690 let other_leaf = derive(&mut tree_a, 1, leaf_node, 0, b"ctx");
691 let other_type = derive(
692 &mut tree_a,
693 0,
694 VirtualClientOperationType::KeyPackage,
695 0,
696 b"ctx",
697 );
698 // Same coordinates as the baseline, but a different context. Derived
699 // on the second instance because the baseline consumed generation 0.
700 let other_context = derive(&mut tree_b, 0, leaf_node, 0, b"other ctx");
701 let secrets = [
702 baseline.as_slice(),
703 other_generation.as_slice(),
704 other_leaf.as_slice(),
705 other_type.as_slice(),
706 other_context.as_slice(),
707 ];
708 for (i, secret) in secrets.iter().enumerate() {
709 for other in &secrets[i + 1..] {
710 assert_ne!(secret, other);
711 }
712 }
713 }
714
715 /// Skipping ahead retains the skipped generations: deriving generation 5
716 /// first, generations 0 through 4 each still succeed exactly once, and
717 /// re-asking for any consumed generation fails. Skipped derivations match
718 /// an instance that derives in order.
719 #[test]
720 fn out_of_order_derivation_and_consumption() {
721 let (provider, epoch_id, mut tree_a, mut tree_b) = setup(4);
722 let leaf = LeafNodeIndex::new(0);
723 let operation_type = VirtualClientOperationType::Application;
724 let context_for = |generation: u32| format!("operation {generation}").into_bytes();
725 let in_order: Vec<_> = (0..=5)
726 .map(|generation| {
727 tree_b
728 .derive_operation_secret(
729 provider.crypto(),
730 CIPHERSUITE,
731 &epoch_id,
732 leaf,
733 operation_type,
734 generation,
735 &context_for(generation),
736 )
737 .expect("in-order derivation")
738 })
739 .collect();
740
741 // Generation 5 first, skipping 0..=4.
742 let skipped_ahead = tree_a
743 .derive_operation_secret(
744 provider.crypto(),
745 CIPHERSUITE,
746 &epoch_id,
747 leaf,
748 operation_type,
749 5,
750 &context_for(5),
751 )
752 .expect("derive generation 5");
753 assert_eq!(skipped_ahead.as_slice(), in_order[5].as_slice());
754
755 // Generations 0 through 4 still succeed exactly once.
756 for generation in 0..5 {
757 let retained = tree_a
758 .derive_operation_secret(
759 provider.crypto(),
760 CIPHERSUITE,
761 &epoch_id,
762 leaf,
763 operation_type,
764 generation,
765 &context_for(generation),
766 )
767 .expect("derive retained generation");
768 assert_eq!(
769 retained.as_slice(),
770 in_order[generation as usize].as_slice()
771 );
772 }
773
774 // Re-asking for any consumed generation fails, including generation 5.
775 for generation in 0..=5 {
776 let err = tree_a
777 .derive_operation_secret(
778 provider.crypto(),
779 CIPHERSUITE,
780 &epoch_id,
781 leaf,
782 operation_type,
783 generation,
784 &context_for(generation),
785 )
786 .expect_err("consumed generation must fail");
787 assert_eq!(err, VirtualClientsError::OperationGenerationConsumed);
788 }
789
790 // A leaf index outside the tree is rejected.
791 let out_of_bounds = LeafNodeIndex::new(TreeSize::from_leaf_count(4).leaf_count());
792 let err = tree_a
793 .derive_operation_secret(
794 provider.crypto(),
795 CIPHERSUITE,
796 &epoch_id,
797 out_of_bounds,
798 operation_type,
799 0,
800 b"ctx",
801 )
802 .expect_err("out-of-bounds leaf index must fail");
803 assert_eq!(err, VirtualClientsError::IndexOutOfBounds);
804 }
805
806 /// The "next own operation" method yields sequential generations whose
807 /// secrets match what a second instance derives positionally.
808 #[test]
809 fn next_operation_secret_advances_sequentially() {
810 let (provider, epoch_id, mut tree_a, mut tree_b) = setup(4);
811 let leaf = LeafNodeIndex::new(1);
812 let operation_type = VirtualClientOperationType::KeyPackage;
813 for expected_generation in 0..3 {
814 let context = format!("key package {expected_generation}").into_bytes();
815 let (generation, own_secret) = tree_a
816 .next_operation_secret(
817 provider.crypto(),
818 CIPHERSUITE,
819 &epoch_id,
820 leaf,
821 operation_type,
822 &context,
823 )
824 .expect("next operation secret");
825 assert_eq!(generation, expected_generation);
826 let positional = tree_b
827 .derive_operation_secret(
828 provider.crypto(),
829 CIPHERSUITE,
830 &epoch_id,
831 leaf,
832 operation_type,
833 generation,
834 &context,
835 )
836 .expect("positional derivation");
837 assert_eq!(own_secret.as_slice(), positional.as_slice());
838 }
839 }
840
841 /// A serde round-trip of a tree mid-state (some generations consumed,
842 /// some skipped) preserves behavior: the round-tripped tree still serves
843 /// retained skipped generations, still refuses consumed ones, and
844 /// continues at the right head generation.
845 #[test]
846 fn serde_roundtrip_preserves_ratchet_state() {
847 let (provider, epoch_id, mut tree_a, mut tree_b) = setup(4);
848 let leaf = LeafNodeIndex::new(2);
849 let operation_type = VirtualClientOperationType::LeafNode;
850 // Skip ahead to generation 4 (retaining 0..=3), then consume
851 // generation 1 from the retained entries.
852 tree_a
853 .derive_operation_secret(
854 provider.crypto(),
855 CIPHERSUITE,
856 &epoch_id,
857 leaf,
858 operation_type,
859 4,
860 b"four",
861 )
862 .expect("derive generation 4");
863 tree_a
864 .derive_operation_secret(
865 provider.crypto(),
866 CIPHERSUITE,
867 &epoch_id,
868 leaf,
869 operation_type,
870 1,
871 b"one",
872 )
873 .expect("derive retained generation 1");
874
875 let serialized = serde_json::to_vec(&tree_a).expect("serialize tree");
876 let mut restored: OperationSecretTree =
877 serde_json::from_slice(&serialized).expect("deserialize tree");
878
879 // A retained skipped generation is still served and agrees with a
880 // second instance.
881 let restored_secret = restored
882 .derive_operation_secret(
883 provider.crypto(),
884 CIPHERSUITE,
885 &epoch_id,
886 leaf,
887 operation_type,
888 2,
889 b"two",
890 )
891 .expect("derive retained generation after round-trip");
892 let positional = tree_b
893 .derive_operation_secret(
894 provider.crypto(),
895 CIPHERSUITE,
896 &epoch_id,
897 leaf,
898 operation_type,
899 2,
900 b"two",
901 )
902 .expect("positional derivation");
903 assert_eq!(restored_secret.as_slice(), positional.as_slice());
904
905 // Consumed generations are still refused.
906 for (generation, context) in [(1, b"one".as_slice()), (4, b"four".as_slice())] {
907 let err = restored
908 .derive_operation_secret(
909 provider.crypto(),
910 CIPHERSUITE,
911 &epoch_id,
912 leaf,
913 operation_type,
914 generation,
915 context,
916 )
917 .expect_err("consumed generation must fail after round-trip");
918 assert_eq!(err, VirtualClientsError::OperationGenerationConsumed);
919 }
920
921 // The ratchet head continues right after the skipped-ahead
922 // generation.
923 let (generation, _secret) = restored
924 .next_operation_secret(
925 provider.crypto(),
926 CIPHERSUITE,
927 &epoch_id,
928 leaf,
929 operation_type,
930 b"five",
931 )
932 .expect("next operation secret after round-trip");
933 assert_eq!(generation, 5);
934 }
935
936 /// A generation more than [`MAXIMUM_FORWARD_DISTANCE`] beyond the head is
937 /// rejected without advancing the head: the next own operation still
938 /// allocates generation 0.
939 #[test]
940 fn forward_distance_bound_rejects_without_advancing() {
941 let (provider, epoch_id, mut tree_a, _tree_b) = setup(4);
942 let leaf = LeafNodeIndex::new(0);
943 let operation_type = VirtualClientOperationType::LeafNode;
944 let err = tree_a
945 .derive_operation_secret(
946 provider.crypto(),
947 CIPHERSUITE,
948 &epoch_id,
949 leaf,
950 operation_type,
951 MAXIMUM_FORWARD_DISTANCE + 1,
952 b"ctx",
953 )
954 .expect_err("generation beyond the forward distance must fail");
955 assert_eq!(err, VirtualClientsError::OperationGenerationTooDistant);
956
957 // The head is unchanged: the next in-window derivation is still
958 // generation 0.
959 let (generation, _secret) = tree_a
960 .next_operation_secret(
961 provider.crypto(),
962 CIPHERSUITE,
963 &epoch_id,
964 leaf,
965 operation_type,
966 b"ctx",
967 )
968 .expect("next operation secret after rejected request");
969 assert_eq!(generation, 0);
970 }
971
972 /// A generation exactly at the forward-distance limit succeeds.
973 #[test]
974 fn forward_distance_boundary_succeeds() {
975 let (provider, epoch_id, mut tree_a, _tree_b) = setup(4);
976 let leaf = LeafNodeIndex::new(0);
977 let operation_type = VirtualClientOperationType::LeafNode;
978 tree_a
979 .derive_operation_secret(
980 provider.crypto(),
981 CIPHERSUITE,
982 &epoch_id,
983 leaf,
984 operation_type,
985 MAXIMUM_FORWARD_DISTANCE,
986 b"ctx",
987 )
988 .expect("generation at the forward-distance limit must succeed");
989 // The head sits right behind the consumed generation.
990 let (generation, _secret) = tree_a
991 .next_operation_secret(
992 provider.crypto(),
993 CIPHERSUITE,
994 &epoch_id,
995 leaf,
996 operation_type,
997 b"ctx",
998 )
999 .expect("next operation secret after skipping to the limit");
1000 assert_eq!(generation, MAXIMUM_FORWARD_DISTANCE + 1);
1001 }
1002
1003 /// Skipping more unconsumed generations than [`OUT_OF_ORDER_TOLERANCE`]
1004 /// evicts the oldest retained entries first: the evicted generation fails
1005 /// as consumed, while the oldest generation still within the window is
1006 /// served exactly once and agrees with an in-order instance.
1007 #[test]
1008 fn skipping_beyond_tolerance_evicts_oldest() {
1009 let (provider, epoch_id, mut tree_a, mut tree_b) = setup(4);
1010 let leaf = LeafNodeIndex::new(0);
1011 let operation_type = VirtualClientOperationType::Application;
1012 let tolerance = OUT_OF_ORDER_TOLERANCE as u32;
1013 // Skipping 0..=tolerance retains one entry more than the tolerance,
1014 // evicting generation 0.
1015 let skip_to = tolerance + 1;
1016 tree_a
1017 .derive_operation_secret(
1018 provider.crypto(),
1019 CIPHERSUITE,
1020 &epoch_id,
1021 leaf,
1022 operation_type,
1023 skip_to,
1024 b"ctx",
1025 )
1026 .expect("skipping derivation");
1027
1028 // The evicted generation 0 reports as consumed.
1029 let err = tree_a
1030 .derive_operation_secret(
1031 provider.crypto(),
1032 CIPHERSUITE,
1033 &epoch_id,
1034 leaf,
1035 operation_type,
1036 0,
1037 b"ctx",
1038 )
1039 .expect_err("evicted generation must fail");
1040 assert_eq!(err, VirtualClientsError::OperationGenerationConsumed);
1041
1042 // Reference values from an instance that derives strictly in order.
1043 let mut in_order = Vec::new();
1044 for generation in 0..=tolerance {
1045 let secret = tree_b
1046 .derive_operation_secret(
1047 provider.crypto(),
1048 CIPHERSUITE,
1049 &epoch_id,
1050 leaf,
1051 operation_type,
1052 generation,
1053 b"ctx",
1054 )
1055 .expect("in-order derivation");
1056 in_order.push(secret);
1057 }
1058
1059 // The oldest and newest generations within the window are still
1060 // served, agree with the in-order instance, and are served exactly
1061 // once.
1062 for generation in [1, tolerance] {
1063 let retained = tree_a
1064 .derive_operation_secret(
1065 provider.crypto(),
1066 CIPHERSUITE,
1067 &epoch_id,
1068 leaf,
1069 operation_type,
1070 generation,
1071 b"ctx",
1072 )
1073 .expect("retained generation within the window");
1074 assert_eq!(
1075 retained.as_slice(),
1076 in_order[generation as usize].as_slice()
1077 );
1078 let err = tree_a
1079 .derive_operation_secret(
1080 provider.crypto(),
1081 CIPHERSUITE,
1082 &epoch_id,
1083 leaf,
1084 operation_type,
1085 generation,
1086 b"ctx",
1087 )
1088 .expect_err("second request for the same generation must fail");
1089 assert_eq!(err, VirtualClientsError::OperationGenerationConsumed);
1090 }
1091 }
1092}