Skip to main content

openmls/components/
vc_derivation_info.rs

1use openmls_traits::{
2    crypto::OpenMlsCrypto,
3    random::OpenMlsRand,
4    types::{Ciphersuite, CryptoError},
5};
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8use tls_codec::{DeserializeBytes, Serialize as _, TlsDeserializeBytes, TlsSerialize, TlsSize};
9
10use crate::{
11    binary_tree::{array_representation::TreeSize, LeafNodeIndex},
12    ciphersuite::Secret,
13    messages::PathSecret,
14    schedule::pprf::{Pprf, PprfError, Prefix256},
15    treesync::node::encryption_keys::EncryptionKeyPair,
16};
17
18/// Component ID under which the virtual-clients derivation info is carried in
19/// the leaf node's `app_data_dictionary` extension.
20///
21/// `0x000D` is a placeholder until the IETF draft is assigned an IANA value.
22pub const VC_COMPONENT_ID: u16 = 0x000D;
23
24// Operation-secret child labels. Each child is derived from the per-commit
25// operation secret produced by evaluating the per-epoch PPRF on the per-commit
26// input. Only `Encryption Key` and `Path Generation` are wired up at the
27// moment; the remaining ones are reserved so that `key_package` / `application`
28// derivation can be added without churning the constants.
29const ENCRYPTION_KEY_LABEL: &str = "Encryption Key";
30const PATH_GENERATION_LABEL: &str = "Path Generation";
31
32const EPOCH_ID_LABEL: &str = "Epoch ID";
33const EPOCH_ENCRYPTION_KEY_LABEL: &str = "Encryption Key";
34const EPOCH_SECRET_LABEL: &str = "Base Secret";
35/// `DeriveSecret` label for [`ReuseGuardSecret`].
36const REUSE_GUARD_LABEL: &str = "Reuse Guard";
37/// `ExpandWithLabel` label for the 16-byte FF1 PRP key derived from a
38/// [`ReuseGuardSecret`] (mls-virtual-clients draft, Reuse Guard section).
39const REUSE_GUARD_PRP_KEY_LABEL: &str = "reuse guard";
40/// FF1 PRP key length in bytes (AES-128).
41const PRP_KEY_LEN: usize = 16;
42
43/// PPRF instance keyed on a 32-byte input. One of these is registered per
44/// emulation-group epoch by
45/// [`MlsGroup::register_vc_emulation_epoch`](crate::group::MlsGroup::register_vc_emulation_epoch).
46pub(crate) type VcPprf = Pprf<Prefix256>;
47
48/// Per-commit virtual-clients material that the application supplies to
49/// [`CommitBuilder::vc_emulation`] when sending a commit on a virtual-clients
50/// group.
51///
52/// The PPRF, per-epoch AEAD key, and the registering client's
53/// emulation-group leaf index live in the storage provider — the
54/// application registers them once per emulation epoch via
55/// [`MlsGroup::register_vc_emulation_epoch`], which sources the
56/// per-emulation-epoch root secret from the emulation group's
57/// `safe_export_secret(VC_COMPONENT_ID)`. When creating a commit, the
58/// application supplies just the `epoch_id`; the library hashes
59/// `(stored leaf_index, fresh random)` to produce the PPRF input,
60/// evaluates the PPRF, and persists the punctured state.
61///
62/// The leaf carrying a VC commit must declare
63/// [`ExtensionType::AppDataDictionary`](crate::extensions::ExtensionType::AppDataDictionary)
64/// in its capabilities and must include an `AppComponents` entry (component
65/// id `1`) listing [`VC_COMPONENT_ID`] in its `AppDataDictionary` extension;
66/// otherwise the sender pre-check rejects the commit with
67/// `VirtualClientsError::AppDataDictionaryNotSupported` or
68/// `VirtualClientsError::VcComponentNotListed`.
69///
70/// [`CommitBuilder::vc_emulation`]: crate::group::CommitBuilder::vc_emulation
71/// [`MlsGroup::register_vc_emulation_epoch`]: crate::group::MlsGroup::register_vc_emulation_epoch
72#[derive(Debug)]
73pub struct VcEmulation {
74    /// Identifier of the emulation epoch whose registered PPRF + AEAD key
75    /// the library should use for this commit.
76    pub epoch_id: EpochId,
77}
78
79/// Errors that can occur while processing virtual-clients derivation info.
80#[derive(Error, Debug, PartialEq, Clone)]
81pub enum VirtualClientsError {
82    /// The derivation-info bytes failed to deserialize.
83    #[error("Failed to deserialize derivation info.")]
84    DerivationInfoMalformed,
85    /// AEAD decryption of the encrypted epoch info failed (wrong key,
86    /// tampered ciphertext, or mismatched AAD).
87    #[error("Failed to decrypt epoch info.")]
88    EpochInfoDecryptionFailed,
89    /// No virtual-clients epoch encryption key was registered for this epoch.
90    #[error("No virtual-clients epoch encryption key for this epoch.")]
91    MissingEpochEncryptionKey,
92    /// No virtual-clients PPRF was registered for this epoch.
93    #[error("No virtual-clients PPRF for this epoch.")]
94    MissingPprf,
95    /// No virtual-clients `EmulationEpochState` was registered for this
96    /// epoch, or it has been deleted.
97    #[error("No virtual-clients emulation-epoch state for this epoch.")]
98    MissingEmulationEpochState,
99    /// Loading or storing virtual-clients state via the storage provider
100    /// failed.
101    #[error("Virtual-clients storage error")]
102    StorageError,
103    /// The leaf encryption key in the path does not match the key derived
104    /// from the path secret.
105    #[error("Leaf encryption key from path does not match the derived key.")]
106    EncryptionKeyMismatch,
107    /// PPRF evaluation failed (e.g. the input was already punctured or
108    /// out of bounds).
109    #[error("PPRF evaluation failed: {0}")]
110    PprfError(#[from] PprfError),
111    /// A cryptographic operation failed during virtual-clients processing.
112    #[error("Cryptographic operation failed.")]
113    CryptoError(#[from] CryptoError),
114    /// Hash function produced output of unexpected length.
115    #[error(
116        "Hash function produced output of length {actual_length}, expected {expected_length}."
117    )]
118    HashOutputLengthMismatch {
119        /// The number of bytes in the hash output.
120        actual_length: usize,
121        /// The required number of bytes in the hash output.
122        expected_length: usize,
123    },
124    /// Random byte generation failed.
125    #[error("Random byte generation failed.")]
126    RandError,
127    /// TLS encoding/decoding of a virtual-clients structure failed. Covers
128    /// both serialization on the sender side and deserialization of the
129    /// decrypted `EpochInfoTbe` on the receiver side.
130    #[error("TLS codec error: {0}")]
131    Tls(#[from] tls_codec::Error),
132    /// The leaf carrying (or about to carry) a VC derivation-info entry
133    /// does not declare `AppDataDictionary` in its capabilities.
134    #[error("Leaf does not declare AppDataDictionary support in its capabilities.")]
135    AppDataDictionaryNotSupported,
136    /// The leaf's `AppDataDictionary` extension is missing the
137    /// `AppComponents` entry, or that entry does not list
138    /// [`VC_COMPONENT_ID`].
139    #[error("Leaf's AppComponents entry does not list the virtual-clients component id.")]
140    VcComponentNotListed,
141}
142
143/// Per-emulation-epoch root secret. Sourced internally by
144/// [`MlsGroup::register_vc_emulation_epoch`] from the emulation group's
145/// `safe_export_secret(VC_COMPONENT_ID)`.
146///
147/// [`MlsGroup::register_vc_emulation_epoch`]: crate::group::MlsGroup::register_vc_emulation_epoch
148#[derive(Serialize, Deserialize)]
149pub(crate) struct EmulatorEpochSecret(Secret);
150
151impl std::fmt::Debug for EmulatorEpochSecret {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        f.debug_struct("EmulatorEpochSecret")
154            .field("secret", &"<redacted>")
155            .finish()
156    }
157}
158
159impl EmulatorEpochSecret {
160    /// Construct an `EmulatorEpochSecret` from raw bytes. Bytes are
161    /// expected to be the output of the emulation group's
162    /// `safe_export_secret(VC_COMPONENT_ID)`.
163    pub(crate) fn new(bytes: &[u8]) -> Self {
164        Self(Secret::from_slice(bytes))
165    }
166
167    pub(crate) fn derive_epoch_id(
168        &self,
169        crypto: &impl OpenMlsCrypto,
170        ciphersuite: Ciphersuite,
171    ) -> Result<EpochId, VirtualClientsError> {
172        let secret = self.0.derive_secret(crypto, ciphersuite, EPOCH_ID_LABEL)?;
173        Ok(EpochId(secret.as_slice().to_vec()))
174    }
175
176    pub(crate) fn derive_epoch_encryption_key(
177        &self,
178        crypto: &impl OpenMlsCrypto,
179        ciphersuite: Ciphersuite,
180    ) -> Result<EpochEncryptionKey, VirtualClientsError> {
181        let secret = self.0.kdf_expand_label(
182            crypto,
183            ciphersuite,
184            EPOCH_ENCRYPTION_KEY_LABEL,
185            &[],
186            ciphersuite.aead_key_length(),
187        )?;
188        Ok(EpochEncryptionKey(secret))
189    }
190
191    pub(crate) fn derive_epoch_secret(
192        &self,
193        crypto: &impl OpenMlsCrypto,
194        ciphersuite: Ciphersuite,
195    ) -> Result<Secret, VirtualClientsError> {
196        Ok(self
197            .0
198            .derive_secret(crypto, ciphersuite, EPOCH_SECRET_LABEL)?)
199    }
200
201    /// Derive the per-emulation-epoch [`ReuseGuardSecret`].
202    pub(crate) fn derive_reuse_guard_secret(
203        &self,
204        crypto: &impl OpenMlsCrypto,
205        ciphersuite: Ciphersuite,
206    ) -> Result<ReuseGuardSecret, VirtualClientsError> {
207        let secret = self
208            .0
209            .derive_secret(crypto, ciphersuite, REUSE_GUARD_LABEL)?;
210        Ok(ReuseGuardSecret(secret))
211    }
212}
213
214/// Per-emulation-epoch secret used to derive the FF1 PRP key for
215/// `reuse_guard` values sent by this virtual client. Derived from
216/// [`EmulatorEpochSecret`] via [`EmulatorEpochSecret::derive_reuse_guard_secret`].
217#[derive(Serialize, Deserialize)]
218pub(crate) struct ReuseGuardSecret(Secret);
219
220impl std::fmt::Debug for ReuseGuardSecret {
221    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222        f.debug_struct("ReuseGuardSecret")
223            .field("secret", &"<redacted>")
224            .finish()
225    }
226}
227
228impl ReuseGuardSecret {
229    /// Test-only constructor from raw bytes.
230    #[cfg(test)]
231    pub(crate) fn from_secret_for_tests(secret: Secret) -> Self {
232        Self(secret)
233    }
234
235    /// Derive the 16-byte FF1 PRP key for a single private message:
236    ///
237    /// ```text
238    /// prp_key = ExpandWithLabel(reuse_guard_secret, "reuse guard",
239    ///                           key_schedule_nonce, 16)
240    /// ```
241    ///
242    /// `ciphersuite` is the emulation group's ciphersuite, stored on
243    /// [`EmulationEpochState`].
244    pub(crate) fn derive_prp_key(
245        &self,
246        crypto: &impl OpenMlsCrypto,
247        ciphersuite: Ciphersuite,
248        key_schedule_nonce: &[u8],
249    ) -> Result<[u8; PRP_KEY_LEN], VirtualClientsError> {
250        let key = self.0.kdf_expand_label(
251            crypto,
252            ciphersuite,
253            REUSE_GUARD_PRP_KEY_LABEL,
254            key_schedule_nonce,
255            PRP_KEY_LEN,
256        )?;
257        key.as_slice()
258            .try_into()
259            .map_err(|_| VirtualClientsError::HashOutputLengthMismatch {
260                actual_length: key.as_slice().len(),
261                expected_length: PRP_KEY_LEN,
262            })
263    }
264}
265
266/// Build the per-emulation-epoch [`VcPprf`] root from the emulator epoch
267/// secret. Used by [`MlsGroup::register_vc_emulation_epoch`] together with
268/// the derived [`EpochId`] / [`EpochEncryptionKey`].
269///
270/// The PPRF tree's logical capacity is `2^256` (set by `Prefix256`'s
271/// depth). `TreeSize` is informational metadata stored alongside the
272/// root and is capped at `u32`; the actual input space is determined by
273/// the prefix, not by `width`, so we pass a safely representable
274/// placeholder.
275///
276/// [`MlsGroup::register_vc_emulation_epoch`]: crate::group::MlsGroup::register_vc_emulation_epoch
277pub(crate) fn build_vc_pprf(epoch_secret: Secret) -> VcPprf {
278    VcPprf::new_with_size(epoch_secret, TreeSize::from_leaf_count(u16::MAX as u32))
279}
280
281#[derive(Debug, TlsSize, TlsSerialize, TlsDeserializeBytes)]
282pub(crate) struct DerivationInfo {
283    epoch_id: EpochId,
284    ciphertext: EncryptedEpochInfo,
285}
286
287impl DerivationInfo {
288    pub(crate) fn new(epoch_id: EpochId, ciphertext: EncryptedEpochInfo) -> Self {
289        Self {
290            epoch_id,
291            ciphertext,
292        }
293    }
294
295    pub(crate) fn epoch_id(&self) -> &EpochId {
296        &self.epoch_id
297    }
298
299    pub(crate) fn decrypt(
300        &self,
301        crypto: &impl OpenMlsCrypto,
302        ciphersuite: Ciphersuite,
303        key: &EpochEncryptionKey,
304    ) -> Result<EpochInfoTbe, VirtualClientsError> {
305        self.ciphertext
306            .decrypt(crypto, ciphersuite, key, &self.epoch_id)
307    }
308}
309
310/// Identifier of an emulation epoch's registered virtual-clients state.
311/// Derived deterministically from the emulation group's
312/// `safe_export_secret(VC_COMPONENT_ID)` by
313/// [`MlsGroup::register_vc_emulation_epoch`].
314///
315/// [`MlsGroup::register_vc_emulation_epoch`]: crate::group::MlsGroup::register_vc_emulation_epoch
316#[derive(
317    Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TlsSize, TlsSerialize, TlsDeserializeBytes,
318)]
319pub struct EpochId(Vec<u8>);
320
321/// Per-higher-level-group record of which emulation-group epoch produced the
322/// virtual-client LeafNode that was active at each recent epoch of that
323/// group.
324///
325/// Reuse guards must be resolved with the emulation epoch that was bound at
326/// the higher-level epoch a message was sent in, not the latest one: a
327/// delayed PrivateMessage from a past higher-level epoch has to be
328/// deprotected with the state that was active then. Entries are written at
329/// commit merge and retained for as many past epochs as the group's message
330/// secrets store keeps, since a binding is only useful while the matching
331/// message secrets still exist.
332#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
333pub struct VcEmulationBindings {
334    // In order of insertion, oldest at the front.
335    bindings: std::collections::VecDeque<(crate::group::GroupEpoch, EpochId)>,
336}
337
338impl VcEmulationBindings {
339    /// Look up the emulation epoch bound at the given higher-level epoch.
340    pub fn get(&self, epoch: crate::group::GroupEpoch) -> Option<&EpochId> {
341        for (bound_epoch, epoch_id) in &self.bindings {
342            if *bound_epoch == epoch {
343                return Some(epoch_id);
344            }
345        }
346        None
347    }
348
349    /// Record `epoch_id` as the binding for `epoch`, keeping at most
350    /// `max_entries` entries by dropping the oldest ones.
351    pub(crate) fn insert(
352        &mut self,
353        epoch: crate::group::GroupEpoch,
354        epoch_id: EpochId,
355        max_entries: usize,
356    ) {
357        self.bindings
358            .retain(|(bound_epoch, _)| *bound_epoch != epoch);
359        self.bindings.push_back((epoch, epoch_id));
360        while self.bindings.len() > max_entries {
361            self.bindings.pop_front();
362        }
363    }
364}
365
366/// AEAD key used by the sender to wrap the [`EpochInfoTbe`] in the leaf's
367/// `app_data_dictionary` entry, and by the receiver to unwrap it. Its
368/// length is exactly [`Ciphersuite::aead_key_length`] for the emulation
369/// group's ciphersuite. Derived from the emulation group's
370/// `safe_export_secret(VC_COMPONENT_ID)` by
371/// [`MlsGroup::register_vc_emulation_epoch`].
372///
373/// [`MlsGroup::register_vc_emulation_epoch`]: crate::group::MlsGroup::register_vc_emulation_epoch
374#[derive(Serialize, Deserialize)]
375pub(crate) struct EpochEncryptionKey(Secret);
376
377impl std::fmt::Debug for EpochEncryptionKey {
378    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379        f.debug_struct("EpochEncryptionKey")
380            .field("secret", &"<redacted>")
381            .finish()
382    }
383}
384
385/// Per-emulation-epoch state persisted by
386/// [`MlsGroup::register_vc_emulation_epoch`] alongside the per-epoch PPRF,
387/// keyed by [`EpochId`]. Bundles everything the library needs to emit a VC
388/// commit for this epoch and to XOR private-message nonces with
389/// deterministic reuse guards.
390///
391/// [`MlsGroup::register_vc_emulation_epoch`]:
392///     crate::group::MlsGroup::register_vc_emulation_epoch
393#[derive(Debug, Serialize, Deserialize)]
394pub struct EmulationEpochState {
395    /// The registering client's leaf index in the emulation group at
396    /// registration time. Hashed into `EpochInfoTbe` and used as the
397    /// sender's `leaf_index_e` in the reuse-guard derivation.
398    pub(crate) leaf_index: LeafNodeIndex,
399    pub(crate) epoch_encryption_key: EpochEncryptionKey,
400    pub(crate) reuse_guard_secret: ReuseGuardSecret,
401    /// Number of leaves `N_e` in the emulation group at registration time.
402    pub(crate) emulation_group_size: TreeSize,
403    /// Ciphersuite of the emulation group at registration time. Used by
404    /// the reuse-guard derivation.
405    pub(crate) emulation_ciphersuite: Ciphersuite,
406}
407
408impl EmulationEpochState {
409    pub(crate) fn new(
410        leaf_index: LeafNodeIndex,
411        epoch_encryption_key: EpochEncryptionKey,
412        reuse_guard_secret: ReuseGuardSecret,
413        emulation_group_size: TreeSize,
414        emulation_ciphersuite: Ciphersuite,
415    ) -> Self {
416        Self {
417            leaf_index,
418            epoch_encryption_key,
419            reuse_guard_secret,
420            emulation_group_size,
421            emulation_ciphersuite,
422        }
423    }
424
425    /// Consume the state and return the fields needed by the
426    /// commit-builder / commit-processing paths.
427    pub(crate) fn into_parts(self) -> (LeafNodeIndex, EpochEncryptionKey, Ciphersuite) {
428        (
429            self.leaf_index,
430            self.epoch_encryption_key,
431            self.emulation_ciphersuite,
432        )
433    }
434
435    /// Borrow the per-message inputs the framing layer needs to derive
436    /// the PRP key and pick `x` for a reuse guard.
437    pub(crate) fn reuse_guard_inputs(&self) -> crate::framing::EmulatorReuseGuardCtx<'_> {
438        crate::framing::EmulatorReuseGuardCtx {
439            reuse_guard_secret: &self.reuse_guard_secret,
440            emulation_ciphersuite: self.emulation_ciphersuite,
441            emulation_group_size: self.emulation_group_size,
442            emulation_leaf_index: self.leaf_index,
443        }
444    }
445}
446
447/// Per-commit secret produced by evaluating the per-epoch PPRF on the
448/// per-commit hash input. Sender and receiver derive the same value by
449/// evaluating the same registered PPRF on the same input.
450#[derive(Serialize, Deserialize)]
451pub(crate) struct OperationSecret(Secret);
452
453impl From<Secret> for OperationSecret {
454    fn from(secret: Secret) -> Self {
455        Self(secret)
456    }
457}
458
459impl OperationSecret {
460    pub(crate) fn derive_encryption_key_secret(
461        &self,
462        crypto: &impl OpenMlsCrypto,
463        ciphersuite: Ciphersuite,
464    ) -> Result<EncryptionKeySecret, VirtualClientsError> {
465        let encryption_key_secret =
466            self.0
467                .derive_secret(crypto, ciphersuite, ENCRYPTION_KEY_LABEL)?;
468        Ok(EncryptionKeySecret(encryption_key_secret))
469    }
470
471    pub(crate) fn derive_path_generation_secret(
472        &self,
473        crypto: &impl OpenMlsCrypto,
474        ciphersuite: Ciphersuite,
475    ) -> Result<PathGenerationSecret, VirtualClientsError> {
476        let path_generation_secret =
477            self.0
478                .derive_secret(crypto, ciphersuite, PATH_GENERATION_LABEL)?;
479        Ok(PathGenerationSecret(path_generation_secret))
480    }
481}
482
483pub(crate) struct EncryptionKeySecret(Secret);
484
485impl EncryptionKeySecret {
486    pub(crate) fn generate_encryption_key_pair(
487        &self,
488        crypto: &impl OpenMlsCrypto,
489        ciphersuite: Ciphersuite,
490    ) -> Result<EncryptionKeyPair, VirtualClientsError> {
491        let hpke_config = ciphersuite.hpke_config();
492        let key_pair = crypto.derive_hpke_keypair(hpke_config, self.0.as_slice())?;
493        Ok(EncryptionKeyPair::from(key_pair))
494    }
495}
496
497pub(crate) struct PathGenerationSecret(Secret);
498
499impl From<PathGenerationSecret> for PathSecret {
500    fn from(value: PathGenerationSecret) -> Self {
501        value.0.into()
502    }
503}
504
505#[derive(Debug, TlsSize, TlsSerialize, TlsDeserializeBytes)]
506pub(crate) struct EncryptedEpochInfo {
507    nonce: Vec<u8>,
508    ciphertext: Vec<u8>,
509}
510
511impl EncryptedEpochInfo {
512    pub fn decrypt(
513        &self,
514        crypto: &impl OpenMlsCrypto,
515        ciphersuite: Ciphersuite,
516        key: &EpochEncryptionKey,
517        epoch_id: &EpochId,
518    ) -> Result<EpochInfoTbe, VirtualClientsError> {
519        let plaintext = crypto
520            .aead_decrypt(
521                ciphersuite.aead_algorithm(),
522                key.0.as_slice(),
523                self.ciphertext.as_slice(),
524                self.nonce.as_slice(),
525                epoch_id.0.as_slice(),
526            )
527            .map_err(|e| {
528                log::error!("vc: aead decrypt epoch info failed: {e:?}");
529                VirtualClientsError::EpochInfoDecryptionFailed
530            })?;
531        Ok(EpochInfoTbe::tls_deserialize_exact_bytes(&plaintext)?)
532    }
533}
534
535/// What virtual-clients operation this per-commit input is being derived
536/// for, per draft PR #11. Mixed into the PPRF input via TLS-serialized
537/// [`EpochInfoTbe`] so that secrets derived for different operations
538/// cannot collide even if the other fields happen to match.
539///
540/// Only `LeafNode` is wired into a sender path today (see `apply_vc_emulation`
541/// in the commit builder); `KeyPackage` and `Application` are reserved
542/// variants that future code paths will emit.
543#[derive(Debug, Clone, Copy, PartialEq, Eq, TlsSize, TlsSerialize, TlsDeserializeBytes)]
544#[repr(u8)]
545pub(crate) enum VirtualClientOperationType {
546    LeafNode = 1,
547    #[allow(dead_code)] // reserved
548    KeyPackage = 2,
549    #[allow(dead_code)] // reserved
550    Application = 3,
551}
552
553/// Per-commit AEAD plaintext attached to the leaf via the VC component.
554/// Per the spec, the same struct is hashed (under the emulation group's
555/// ciphersuite) to produce the PPRF input. See [`pprf_input`].
556///
557/// `leaf_index` is the *emulation*-group leaf index of the sending virtual
558/// client, *not* the leaf index in the group that carries this commit.
559#[derive(Debug, PartialEq, Eq, TlsSize, TlsSerialize, TlsDeserializeBytes)]
560pub(crate) struct EpochInfoTbe {
561    pub operation_type: VirtualClientOperationType,
562    pub leaf_index: LeafNodeIndex,
563    pub random: Vec<u8>,
564}
565
566impl EpochInfoTbe {
567    pub fn encrypt(
568        &self,
569        crypto: &impl OpenMlsCrypto,
570        rand: &impl OpenMlsRand,
571        ciphersuite: Ciphersuite,
572        key: &EpochEncryptionKey,
573        epoch_id: &EpochId,
574    ) -> Result<EncryptedEpochInfo, VirtualClientsError> {
575        let nonce = rand
576            .random_vec(ciphersuite.aead_nonce_length())
577            .map_err(|e| {
578                log::error!("vc: aead nonce randomness failed: {e:?}");
579                VirtualClientsError::RandError
580            })?;
581        let payload = self.tls_serialize_detached()?;
582        let ciphertext = crypto.aead_encrypt(
583            ciphersuite.aead_algorithm(),
584            key.0.as_slice(),
585            payload.as_slice(),
586            nonce.as_slice(),
587            epoch_id.0.as_slice(),
588        )?;
589        Ok(EncryptedEpochInfo { nonce, ciphertext })
590    }
591}
592
593/// Compute the 32-byte PPRF input as `Hash(tls_serialize(epoch_info))`,
594/// truncating to 32 bytes when the ciphersuite's hash is wider (the
595/// PPRF's `Prefix256` indexes into the first 256 bits).
596pub(crate) fn pprf_input(
597    crypto: &impl OpenMlsCrypto,
598    ciphersuite: Ciphersuite,
599    epoch_info: &EpochInfoTbe,
600) -> Result<[u8; Prefix256::PPRF_INPUT_LEN], VirtualClientsError> {
601    let serialized = epoch_info.tls_serialize_detached()?;
602    let hash = crypto.hash(ciphersuite.hash_algorithm(), &serialized)?;
603    if hash.len() < Prefix256::PPRF_INPUT_LEN {
604        log::error!(
605            "vc: pprf input hash too short: got {} bytes, need {}",
606            hash.len(),
607            Prefix256::PPRF_INPUT_LEN
608        );
609        return Err(VirtualClientsError::HashOutputLengthMismatch {
610            actual_length: hash.len(),
611            expected_length: Prefix256::PPRF_INPUT_LEN,
612        });
613    }
614    let mut input = [0u8; Prefix256::PPRF_INPUT_LEN];
615    input.copy_from_slice(&hash[..Prefix256::PPRF_INPUT_LEN]);
616    Ok(input)
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use openmls_rust_crypto::OpenMlsRustCrypto;
623    use openmls_traits::OpenMlsProvider;
624
625    /// Round-trip an `EpochInfoTbe` through `encrypt` ↔ `decrypt`. Catches
626    /// any disagreement between the two methods on the AAD/key/nonce layout
627    /// and any silent regression in the AEAD wrapping.
628    #[test]
629    fn epoch_info_tbe_roundtrip() {
630        let provider = OpenMlsRustCrypto::default();
631        let ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
632        let emulator = EmulatorEpochSecret::new(
633            &provider
634                .rand()
635                .random_vec(ciphersuite.hash_length())
636                .expect("randomness"),
637        );
638        let key = emulator
639            .derive_epoch_encryption_key(provider.crypto(), ciphersuite)
640            .expect("derive ek");
641        let epoch_id = emulator
642            .derive_epoch_id(provider.crypto(), ciphersuite)
643            .expect("derive epoch id");
644        let original = EpochInfoTbe {
645            operation_type: VirtualClientOperationType::LeafNode,
646            leaf_index: LeafNodeIndex::new(7),
647            random: provider.rand().random_vec(32).expect("randomness"),
648        };
649        let encrypted = original
650            .encrypt(
651                provider.crypto(),
652                provider.rand(),
653                ciphersuite,
654                &key,
655                &epoch_id,
656            )
657            .expect("encrypt");
658        let decrypted = encrypted
659            .decrypt(provider.crypto(), ciphersuite, &key, &epoch_id)
660            .expect("decrypt");
661        assert_eq!(original, decrypted);
662    }
663
664    /// Two `EpochInfoTbe`s with identical `(leaf_index, random)` but
665    /// different `operation_type` must produce different PPRF inputs;
666    /// otherwise, sharing a PPRF across operation contexts would be
667    /// unsafe.
668    #[test]
669    fn pprf_input_changes_with_operation_type() {
670        let provider = OpenMlsRustCrypto::default();
671        let ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
672        let leaf_node = EpochInfoTbe {
673            operation_type: VirtualClientOperationType::LeafNode,
674            leaf_index: LeafNodeIndex::new(3),
675            random: vec![0xAA; 32],
676        };
677        let key_package = EpochInfoTbe {
678            operation_type: VirtualClientOperationType::KeyPackage,
679            leaf_index: LeafNodeIndex::new(3),
680            random: vec![0xAA; 32],
681        };
682        let application = EpochInfoTbe {
683            operation_type: VirtualClientOperationType::Application,
684            leaf_index: LeafNodeIndex::new(3),
685            random: vec![0xAA; 32],
686        };
687        let in_leaf = pprf_input(provider.crypto(), ciphersuite, &leaf_node).unwrap();
688        let in_kp = pprf_input(provider.crypto(), ciphersuite, &key_package).unwrap();
689        let in_app = pprf_input(provider.crypto(), ciphersuite, &application).unwrap();
690        assert_ne!(in_leaf, in_kp);
691        assert_ne!(in_leaf, in_app);
692        assert_ne!(in_kp, in_app);
693    }
694}