Skip to main content

openmls/components/
vc_commit_data.rs

1//! Virtual-clients commit data (mls-virtual-clients draft).
2//!
3//! A virtual client attaches this struct to a commit as a Safe AAD item under
4//! [`VC_COMPONENT_ID`]. It tells the sibling emulators which derivation epochs
5//! the author still uses and which actions the commit performs.
6//!
7//! ```tls
8//! enum {
9//!   reserved(0),
10//!   key_package_upload(1),
11//!   new_derivation_epoch(2),
12//!   (255)
13//! } ActionType;
14//!
15//! struct {
16//!   ActionType action_type;
17//!   select (VirtualClientAction.action_type) {
18//!     case key_package_upload:
19//!       KeyPackageUpload key_package_upload;
20//!     case new_derivation_epoch:
21//!       struct{};
22//!   };
23//! } VirtualClientAction;
24//!
25//! struct {
26//!   opaque epoch_id<V>;
27//! } VcEpochReference;
28//!
29//! struct {
30//!   VcEpochReference in_use_epochs<V>;
31//! } VcEpochUsage;
32//!
33//! struct {
34//!   optional<VcEpochUsage> epoch_usage;
35//!   VirtualClientAction actions<V>;
36//! } VirtualClientCommitData;
37//! ```
38//!
39//! Entries of a [`VcEpochUsage`] are unique and sorted, and a
40//! [`VirtualClientCommitData`] carries at most one `new_derivation_epoch`
41//! action. Both rules are enforced on construction and on deserialization.
42//!
43//! [`VC_COMPONENT_ID`]: crate::components::vc_derivation_info::VC_COMPONENT_ID
44//! [`VcEpochUsage`]: crate::components::vc_commit_data::VcEpochUsage
45//! [`VirtualClientCommitData`]: crate::components::vc_commit_data::VirtualClientCommitData
46
47use std::cmp::Ordering;
48
49use tls_codec::{
50    DeserializeBytes as TlsDeserializeBytesTrait, Serialize as TlsSerializeTrait,
51    TlsDeserializeBytes, TlsSerialize, TlsSize,
52};
53
54use crate::{
55    components::vc_derivation_info::{EpochId, KeyPackageUpload, VC_COMPONENT_ID},
56    framing::SafeAadItem,
57};
58
59/// Errors that can occur when building or parsing a [`VirtualClientCommitData`].
60#[derive(thiserror::Error, Debug, PartialEq, Eq, Clone)]
61pub enum VcCommitDataError {
62    /// Two [`VcEpochReference`] entries carry the same `epoch_id`.
63    #[error("duplicate epoch id in VcEpochUsage")]
64    DuplicateEpochId,
65    /// Entries are not sorted in strictly-increasing order by the TLS
66    /// serialization of `epoch_id`.
67    #[error("VcEpochUsage entries are not sorted by the serialized epoch id in increasing order")]
68    EpochsNotSortedAscending,
69    /// More than one `new_derivation_epoch` action is present.
70    #[error("VirtualClientCommitData carries more than one new_derivation_epoch action")]
71    MultipleNewDerivationEpochs,
72    /// Encoding or decoding failure.
73    #[error("codec error: {0}")]
74    Codec(String),
75}
76
77impl From<tls_codec::Error> for VcCommitDataError {
78    fn from(err: tls_codec::Error) -> Self {
79        Self::Codec(err.to_string())
80    }
81}
82
83/// One action a virtual client's commit performs.
84#[derive(Debug, PartialEq, TlsSerialize, TlsDeserializeBytes, TlsSize)]
85#[repr(u8)]
86pub enum VirtualClientAction {
87    /// The author published a batch of KeyPackages.
88    #[tls_codec(discriminant = 1)]
89    KeyPackageUpload(KeyPackageUpload),
90    /// The author asks the group to start a new derivation epoch.
91    #[tls_codec(discriminant = 2)]
92    NewDerivationEpoch,
93}
94
95/// A reference to a derivation epoch by its [`EpochId`].
96#[derive(Debug, Clone, PartialEq, Eq, TlsSerialize, TlsDeserializeBytes, TlsSize)]
97pub struct VcEpochReference {
98    epoch_id: EpochId,
99}
100
101impl VcEpochReference {
102    /// The referenced epoch.
103    pub fn epoch_id(&self) -> &EpochId {
104        &self.epoch_id
105    }
106}
107
108impl From<EpochId> for VcEpochReference {
109    fn from(epoch_id: EpochId) -> Self {
110        Self { epoch_id }
111    }
112}
113
114impl From<VcEpochReference> for EpochId {
115    fn from(reference: VcEpochReference) -> Self {
116        reference.epoch_id
117    }
118}
119
120/// The set of derivation epochs the author of a commit still uses.
121///
122/// Entries are unique and sorted by the TLS serialization of their `epoch_id`.
123/// That order is a canonical encoding rule only. It says nothing about the
124/// order in which the derivation epochs were created or should be used.
125#[derive(Debug, Clone, PartialEq, Eq, TlsSerialize, TlsSize)]
126pub struct VcEpochUsage {
127    in_use_epochs: Vec<VcEpochReference>,
128}
129
130impl VcEpochUsage {
131    /// Build a usage declaration covering `epoch_ids`.
132    ///
133    /// The epochs are a set, so the caller may pass them in any order and may
134    /// repeat them. Duplicates are dropped and the entries are put into the
135    /// canonical order.
136    pub fn new(epoch_ids: impl IntoIterator<Item = EpochId>) -> Result<Self, VcCommitDataError> {
137        let mut keyed = epoch_ids
138            .into_iter()
139            .map(|epoch_id| Ok((epoch_id.tls_serialize_detached()?, epoch_id)))
140            .collect::<Result<Vec<_>, tls_codec::Error>>()?;
141        keyed.sort_by(|(left, _), (right, _)| left.cmp(right));
142        keyed.dedup_by(|(left, _), (right, _)| left == right);
143        Ok(Self {
144            in_use_epochs: keyed
145                .into_iter()
146                .map(|(_, epoch_id)| VcEpochReference::from(epoch_id))
147                .collect(),
148        })
149    }
150
151    /// Build a usage declaration covering no epoch at all.
152    ///
153    /// This is not the same as omitting the declaration: it retires every epoch
154    /// the author declared before.
155    pub fn empty() -> Self {
156        Self {
157            in_use_epochs: Vec::new(),
158        }
159    }
160
161    /// The entries in canonical order.
162    pub fn in_use_epochs(&self) -> &[VcEpochReference] {
163        &self.in_use_epochs
164    }
165
166    /// The referenced epochs in canonical order.
167    pub fn epoch_ids(&self) -> impl Iterator<Item = &EpochId> {
168        self.in_use_epochs.iter().map(VcEpochReference::epoch_id)
169    }
170
171    /// Returns true if no epoch is declared as in use.
172    pub fn is_empty(&self) -> bool {
173        self.in_use_epochs.is_empty()
174    }
175
176    fn from_entries(entries: Vec<VcEpochReference>) -> Result<Self, VcCommitDataError> {
177        let mut previous: Option<Vec<u8>> = None;
178        for entry in &entries {
179            let current = entry.epoch_id.tls_serialize_detached()?;
180            if let Some(previous) = &previous {
181                match current.cmp(previous) {
182                    Ordering::Equal => return Err(VcCommitDataError::DuplicateEpochId),
183                    Ordering::Less => return Err(VcCommitDataError::EpochsNotSortedAscending),
184                    Ordering::Greater => {}
185                }
186            }
187            previous = Some(current);
188        }
189        Ok(Self {
190            in_use_epochs: entries,
191        })
192    }
193}
194
195impl TlsDeserializeBytesTrait for VcEpochUsage {
196    fn tls_deserialize_bytes(bytes: &[u8]) -> Result<(Self, &[u8]), tls_codec::Error> {
197        let (entries, rest) = Vec::<VcEpochReference>::tls_deserialize_bytes(bytes)?;
198        let usage = VcEpochUsage::from_entries(entries)
199            .map_err(|err| tls_codec::Error::DecodingError(err.to_string()))?;
200        Ok((usage, rest))
201    }
202}
203
204/// What a virtual client tells its siblings about a commit it authored.
205#[derive(Debug, PartialEq, TlsSerialize, TlsSize)]
206pub struct VirtualClientCommitData {
207    epoch_usage: Option<VcEpochUsage>,
208    actions: Vec<VirtualClientAction>,
209}
210
211impl VirtualClientCommitData {
212    /// Assemble the commit data.
213    ///
214    /// Pass `None` for `epoch_usage` to leave the author's previous declaration
215    /// in place, and `Some(VcEpochUsage::empty())` to replace it with the empty
216    /// set.
217    ///
218    /// Returns an error if `actions` holds more than one
219    /// [`VirtualClientAction::NewDerivationEpoch`].
220    pub fn new(
221        epoch_usage: Option<VcEpochUsage>,
222        actions: Vec<VirtualClientAction>,
223    ) -> Result<Self, VcCommitDataError> {
224        Self::validate(&actions)?;
225        Ok(Self {
226            epoch_usage,
227            actions,
228        })
229    }
230
231    /// The author's epoch usage declaration, or `None` if the commit does not
232    /// restate it.
233    pub fn epoch_usage(&self) -> Option<&VcEpochUsage> {
234        self.epoch_usage.as_ref()
235    }
236
237    /// All actions the commit performs.
238    pub fn actions(&self) -> &[VirtualClientAction] {
239        &self.actions
240    }
241
242    /// Returns true if the commit asks for a new derivation epoch.
243    pub fn creates_derivation_epoch(&self) -> bool {
244        self.actions
245            .iter()
246            .any(|action| matches!(action, VirtualClientAction::NewDerivationEpoch))
247    }
248
249    /// The KeyPackage batches the commit publishes, in wire order.
250    pub fn key_package_uploads(&self) -> impl Iterator<Item = &KeyPackageUpload> {
251        self.actions.iter().filter_map(|action| match action {
252            VirtualClientAction::KeyPackageUpload(upload) => Some(upload),
253            VirtualClientAction::NewDerivationEpoch => None,
254        })
255    }
256
257    /// Wrap this commit data in a Safe AAD item tagged with
258    /// [`VC_COMPONENT_ID`], ready to be attached to an outgoing commit.
259    pub fn to_safe_aad_item(&self) -> Result<SafeAadItem, VcCommitDataError> {
260        let data = self.tls_serialize_detached()?;
261        Ok(SafeAadItem::new(VC_COMPONENT_ID, data))
262    }
263
264    /// Parse commit data from the bytes of a Safe AAD item tagged with
265    /// [`VC_COMPONENT_ID`]. Rejects bytes left over after the struct.
266    pub fn from_safe_aad_item_data(data: &[u8]) -> Result<Self, VcCommitDataError> {
267        Ok(Self::tls_deserialize_exact_bytes(data)?)
268    }
269
270    fn validate(actions: &[VirtualClientAction]) -> Result<(), VcCommitDataError> {
271        let new_epoch_actions = actions
272            .iter()
273            .filter(|action| matches!(action, VirtualClientAction::NewDerivationEpoch))
274            .count();
275        if new_epoch_actions > 1 {
276            return Err(VcCommitDataError::MultipleNewDerivationEpochs);
277        }
278        Ok(())
279    }
280}
281
282impl TlsDeserializeBytesTrait for VirtualClientCommitData {
283    fn tls_deserialize_bytes(bytes: &[u8]) -> Result<(Self, &[u8]), tls_codec::Error> {
284        let (epoch_usage, rest) = Option::<VcEpochUsage>::tls_deserialize_bytes(bytes)?;
285        let (actions, rest) = Vec::<VirtualClientAction>::tls_deserialize_bytes(rest)?;
286        let commit_data = VirtualClientCommitData::new(epoch_usage, actions)
287            .map_err(|err| tls_codec::Error::DecodingError(err.to_string()))?;
288        Ok((commit_data, rest))
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use openmls_traits::types::Ciphersuite;
295
296    use super::*;
297    use crate::{
298        binary_tree::LeafNodeIndex, ciphersuite::hash_ref::KeyPackageRef,
299        components::vc_derivation_info::KeyPackageInfo,
300    };
301
302    const CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
303
304    fn epoch_id(bytes: &[u8]) -> EpochId {
305        EpochId::new(bytes.to_vec())
306    }
307
308    fn key_package_upload() -> KeyPackageUpload {
309        KeyPackageUpload {
310            epoch_id: epoch_id(b"epoch-for-upload"),
311            leaf_index: LeafNodeIndex::new(3),
312            generation: 7,
313            key_package_info: vec![
314                KeyPackageInfo {
315                    key_package_ref: KeyPackageRef::from_slice(b"first key package ref"),
316                    cipher_suite: CIPHERSUITE,
317                    key_package_index: 0,
318                },
319                KeyPackageInfo {
320                    key_package_ref: KeyPackageRef::from_slice(b"second key package ref"),
321                    cipher_suite: CIPHERSUITE,
322                    key_package_index: 1,
323                },
324            ],
325        }
326    }
327
328    fn full_commit_data() -> VirtualClientCommitData {
329        let usage = VcEpochUsage::new([epoch_id(b"aaa"), epoch_id(b"bb"), epoch_id(b"cccc")])
330            .expect("epoch ids must serialize");
331        VirtualClientCommitData::new(
332            Some(usage),
333            vec![
334                VirtualClientAction::KeyPackageUpload(key_package_upload()),
335                VirtualClientAction::NewDerivationEpoch,
336            ],
337        )
338        .expect("one new_derivation_epoch action is valid")
339    }
340
341    #[test]
342    fn vc_commit_data_roundtrip() {
343        let commit_data = full_commit_data();
344
345        let bytes = commit_data.tls_serialize_detached().unwrap();
346        let parsed = VirtualClientCommitData::tls_deserialize_exact_bytes(&bytes).unwrap();
347
348        assert_eq!(parsed, commit_data);
349        assert_eq!(parsed.tls_serialize_detached().unwrap(), bytes);
350        assert!(parsed.creates_derivation_epoch());
351        assert_eq!(
352            parsed.key_package_uploads().collect::<Vec<_>>(),
353            vec![&key_package_upload()]
354        );
355        assert_eq!(
356            parsed.epoch_usage().unwrap().in_use_epochs().len(),
357            3,
358            "all three epochs are distinct"
359        );
360    }
361
362    #[test]
363    fn vc_commit_data_absent_and_empty_epoch_usage_differ() {
364        let absent = VirtualClientCommitData::new(None, Vec::new()).unwrap();
365        let empty = VirtualClientCommitData::new(Some(VcEpochUsage::empty()), Vec::new()).unwrap();
366
367        let absent_bytes = absent.tls_serialize_detached().unwrap();
368        let empty_bytes = empty.tls_serialize_detached().unwrap();
369
370        // `optional<>` prefixes the value with 0 for absent and 1 for present.
371        // The present-but-empty case then adds the empty entry vector.
372        assert_eq!(absent_bytes, vec![0x00, 0x00]);
373        assert_eq!(empty_bytes, vec![0x01, 0x00, 0x00]);
374
375        let parsed_absent =
376            VirtualClientCommitData::tls_deserialize_exact_bytes(&absent_bytes).unwrap();
377        let parsed_empty =
378            VirtualClientCommitData::tls_deserialize_exact_bytes(&empty_bytes).unwrap();
379
380        assert_eq!(parsed_absent.epoch_usage(), None);
381        assert_eq!(parsed_empty.epoch_usage(), Some(&VcEpochUsage::empty()));
382        assert!(parsed_empty.epoch_usage().unwrap().is_empty());
383    }
384
385    #[test]
386    fn vc_commit_data_new_rejects_two_new_derivation_epochs() {
387        let err = VirtualClientCommitData::new(
388            None,
389            vec![
390                VirtualClientAction::NewDerivationEpoch,
391                VirtualClientAction::NewDerivationEpoch,
392            ],
393        )
394        .unwrap_err();
395
396        assert_eq!(err, VcCommitDataError::MultipleNewDerivationEpochs);
397    }
398
399    #[test]
400    fn vc_commit_data_deserialize_rejects_two_new_derivation_epochs() {
401        // Hand-craft bytes the constructor would refuse to produce: absent
402        // epoch usage followed by two `new_derivation_epoch` actions.
403        let raw_bytes = vec![0x00, 0x02, 0x02, 0x02];
404
405        let err = VirtualClientCommitData::tls_deserialize_exact_bytes(&raw_bytes).unwrap_err();
406        match err {
407            tls_codec::Error::DecodingError(message) => assert!(
408                message.contains("more than one new_derivation_epoch"),
409                "unexpected error message: {message}"
410            ),
411            other => panic!("unexpected error variant: {other:?}"),
412        }
413    }
414
415    #[test]
416    fn vc_epoch_usage_new_sorts_and_dedups() {
417        let usage = VcEpochUsage::new([
418            epoch_id(b"cccc"),
419            epoch_id(b"aaa"),
420            epoch_id(b"bb"),
421            epoch_id(b"aaa"),
422        ])
423        .unwrap();
424
425        let epochs: Vec<&[u8]> = usage.epoch_ids().map(EpochId::as_bytes).collect();
426        // Sorting compares the TLS serializations, which start with the length
427        // prefix, so the shorter id sorts first regardless of its content.
428        assert_eq!(epochs, vec![b"bb".as_slice(), b"aaa", b"cccc"]);
429    }
430
431    #[test]
432    fn vc_epoch_usage_deserialize_rejects_unsorted() {
433        let entries: Vec<VcEpochReference> = vec![
434            epoch_id(b"cccc").into(),
435            epoch_id(b"aaa").into(),
436            epoch_id(b"bb").into(),
437        ];
438        let raw_bytes = entries.tls_serialize_detached().unwrap();
439
440        let err = VcEpochUsage::tls_deserialize_exact_bytes(&raw_bytes).unwrap_err();
441        match err {
442            tls_codec::Error::DecodingError(message) => assert!(
443                message.contains("not sorted"),
444                "unexpected error message: {message}"
445            ),
446            other => panic!("unexpected error variant: {other:?}"),
447        }
448    }
449
450    #[test]
451    fn vc_epoch_usage_deserialize_rejects_duplicates() {
452        let entries: Vec<VcEpochReference> =
453            vec![epoch_id(b"same").into(), epoch_id(b"same").into()];
454        let raw_bytes = entries.tls_serialize_detached().unwrap();
455
456        let err = VcEpochUsage::tls_deserialize_exact_bytes(&raw_bytes).unwrap_err();
457        match err {
458            tls_codec::Error::DecodingError(message) => assert!(
459                message.contains("duplicate"),
460                "unexpected error message: {message}"
461            ),
462            other => panic!("unexpected error variant: {other:?}"),
463        }
464    }
465
466    #[test]
467    fn vc_epoch_usage_sorting_compares_serializations() {
468        // A 64-byte id needs a two-byte length prefix, a 63-byte id one byte,
469        // so the longer id sorts last even though its content byte is smaller.
470        let short = epoch_id(&[0xff; 63]);
471        let long = epoch_id(&[0x00; 64]);
472
473        let usage = VcEpochUsage::new([long.clone(), short.clone()]).unwrap();
474
475        let epochs: Vec<&EpochId> = usage.epoch_ids().collect();
476        assert_eq!(epochs, vec![&short, &long]);
477    }
478
479    #[test]
480    fn vc_action_deserialize_rejects_reserved_and_unknown_types() {
481        for discriminant in [0x00u8, 0x03, 0xff] {
482            let err = VirtualClientAction::tls_deserialize_exact_bytes(&[discriminant])
483                .expect_err("only key_package_upload and new_derivation_epoch are valid");
484            assert!(
485                matches!(err, tls_codec::Error::UnknownValue(value) if value == discriminant as u64),
486                "unexpected error variant for {discriminant}: {err:?}"
487            );
488        }
489    }
490
491    #[test]
492    fn vc_commit_data_safe_aad_item_roundtrip() {
493        let commit_data = full_commit_data();
494
495        let item = commit_data.to_safe_aad_item().unwrap();
496        assert_eq!(item.component_id(), VC_COMPONENT_ID);
497
498        let parsed = VirtualClientCommitData::from_safe_aad_item_data(item.data()).unwrap();
499        assert_eq!(parsed, commit_data);
500    }
501
502    #[test]
503    fn vc_commit_data_from_item_data_rejects_trailing_bytes() {
504        let mut data = full_commit_data()
505            .to_safe_aad_item()
506            .unwrap()
507            .data()
508            .to_vec();
509        data.push(0x00);
510
511        let err = VirtualClientCommitData::from_safe_aad_item_data(&data).unwrap_err();
512        assert_eq!(
513            err,
514            VcCommitDataError::Codec(tls_codec::Error::TrailingData.to_string())
515        );
516    }
517}