@@ -28,7 +28,25 @@ pub(crate) struct EpochTree {
2828
2929/// Can store message secrets for up to `max_epochs`. The trees are added with [`self::add()`] and can be queried
3030/// with [`Self::get_epoch()`].
31- #[ derive( Serialize , Deserialize ) ]
31+ ///
32+ /// Persisted on-disk format. The bincode/postcard positional wire layout is:
33+ ///
34+ /// ```text
35+ /// max_epochs: usize
36+ /// past_epoch_trees: VecDeque<EpochTree>
37+ /// message_secrets: MessageSecrets (five upstream-0.7.x fields)
38+ /// added_at: Option<SystemTime> <- fork-only trailing field
39+ /// past_epoch_added_at: Vec<Option<SystemTime>> <- fork-only trailing field
40+ /// ```
41+ ///
42+ /// The two fork-only fields are hand-serialized here rather than as an inline
43+ /// `MessageSecrets.added_at`, because `MessageSecrets` is not the last field of
44+ /// `EpochTree`, so an inline trailing field would corrupt the byte stream. They
45+ /// are read *tolerantly*: EOF (or any deserialize error) past the upstream shape
46+ /// is treated as "absent -> `None`", so data written by openmls-0.7.x — which
47+ /// lacks them — still loads. On deserialize the timestamps are re-hydrated onto
48+ /// the corresponding `MessageSecrets.added_at` fields. Both are at the end of
49+ /// the struct, so a tolerant misread cannot consume a sibling's bytes.
3250#[ cfg_attr( any( test, feature = "test-utils" ) , derive( Clone , PartialEq ) ) ]
3351#[ cfg_attr( feature = "crypto-debug" , derive( Debug ) ) ]
3452pub ( crate ) struct MessageSecretsStore {
@@ -41,6 +59,123 @@ pub(crate) struct MessageSecretsStore {
4159 message_secrets : MessageSecrets ,
4260}
4361
62+ impl Serialize for MessageSecretsStore {
63+ fn serialize < S : serde:: Serializer > ( & self , serializer : S ) -> Result < S :: Ok , S :: Error > {
64+ use serde:: ser:: SerializeStruct ;
65+ let mut s = serializer. serialize_struct ( "MessageSecretsStore" , 5 ) ?;
66+ s. serialize_field ( "max_epochs" , & self . max_epochs ) ?;
67+ s. serialize_field ( "past_epoch_trees" , & self . past_epoch_trees ) ?;
68+ s. serialize_field ( "message_secrets" , & self . message_secrets ) ?;
69+ s. serialize_field ( "added_at" , & self . message_secrets . added_at ) ?;
70+ let past_added_at: Vec < Option < SystemTime > > = self
71+ . past_epoch_trees
72+ . iter ( )
73+ . map ( |t| t. message_secrets . added_at )
74+ . collect ( ) ;
75+ s. serialize_field ( "past_epoch_added_at" , & past_added_at) ?;
76+ s. end ( )
77+ }
78+ }
79+
80+ impl < ' de > Deserialize < ' de > for MessageSecretsStore {
81+ fn deserialize < D : serde:: Deserializer < ' de > > ( deserializer : D ) -> Result < Self , D :: Error > {
82+ const FIELDS : & [ & str ] = & [
83+ "max_epochs" ,
84+ "past_epoch_trees" ,
85+ "message_secrets" ,
86+ "added_at" ,
87+ "past_epoch_added_at" ,
88+ ] ;
89+ deserializer. deserialize_struct ( "MessageSecretsStore" , FIELDS , MessageSecretsStoreVisitor )
90+ }
91+ }
92+
93+ struct MessageSecretsStoreVisitor ;
94+
95+ impl < ' de > serde:: de:: Visitor < ' de > for MessageSecretsStoreVisitor {
96+ type Value = MessageSecretsStore ;
97+
98+ fn expecting ( & self , f : & mut core:: fmt:: Formatter < ' _ > ) -> core:: fmt:: Result {
99+ f. write_str ( "struct MessageSecretsStore" )
100+ }
101+
102+ fn visit_seq < A : serde:: de:: SeqAccess < ' de > > (
103+ self ,
104+ mut seq : A ,
105+ ) -> Result < MessageSecretsStore , A :: Error > {
106+ use serde:: de:: Error ;
107+ let max_epochs = seq
108+ . next_element :: < usize > ( ) ?
109+ . ok_or_else ( || Error :: missing_field ( "max_epochs" ) ) ?;
110+ let mut past_epoch_trees = seq
111+ . next_element :: < VecDeque < EpochTree > > ( ) ?
112+ . ok_or_else ( || Error :: missing_field ( "past_epoch_trees" ) ) ?;
113+ let mut message_secrets = seq
114+ . next_element :: < MessageSecrets > ( ) ?
115+ . ok_or_else ( || Error :: missing_field ( "message_secrets" ) ) ?;
116+ // Tolerant tail #1: `added_at` for the current `message_secrets`.
117+ let added_at = seq
118+ . next_element :: < Option < SystemTime > > ( )
119+ . unwrap_or ( None )
120+ . flatten ( ) ;
121+ message_secrets. added_at = added_at;
122+ // Tolerant tail #2: per-past-epoch timestamps, re-hydrated by index.
123+ let past_added_at: Vec < Option < SystemTime > > = seq
124+ . next_element :: < Vec < Option < SystemTime > > > ( )
125+ . unwrap_or ( None )
126+ . unwrap_or_default ( ) ;
127+ for ( i, tree) in past_epoch_trees. iter_mut ( ) . enumerate ( ) {
128+ tree. message_secrets . added_at = past_added_at. get ( i) . copied ( ) . unwrap_or ( None ) ;
129+ }
130+ Ok ( MessageSecretsStore {
131+ max_epochs,
132+ past_epoch_trees,
133+ message_secrets,
134+ } )
135+ }
136+
137+ fn visit_map < A : serde:: de:: MapAccess < ' de > > (
138+ self ,
139+ mut map : A ,
140+ ) -> Result < MessageSecretsStore , A :: Error > {
141+ use serde:: de:: Error ;
142+ let mut max_epochs: Option < usize > = None ;
143+ let mut past_epoch_trees: Option < VecDeque < EpochTree > > = None ;
144+ let mut message_secrets: Option < MessageSecrets > = None ;
145+ let mut added_at: Option < Option < SystemTime > > = None ;
146+ let mut past_epoch_added_at: Option < Vec < Option < SystemTime > > > = None ;
147+
148+ while let Some ( key) = map. next_key :: < String > ( ) ? {
149+ match key. as_str ( ) {
150+ "max_epochs" => max_epochs = Some ( map. next_value ( ) ?) ,
151+ "past_epoch_trees" => past_epoch_trees = Some ( map. next_value ( ) ?) ,
152+ "message_secrets" => message_secrets = Some ( map. next_value ( ) ?) ,
153+ "added_at" => added_at = Some ( map. next_value ( ) ?) ,
154+ "past_epoch_added_at" => past_epoch_added_at = Some ( map. next_value ( ) ?) ,
155+ _ => {
156+ let _: serde:: de:: IgnoredAny = map. next_value ( ) ?;
157+ }
158+ }
159+ }
160+
161+ let mut message_secrets =
162+ message_secrets. ok_or_else ( || Error :: missing_field ( "message_secrets" ) ) ?;
163+ message_secrets. added_at = added_at. unwrap_or ( None ) ;
164+ let mut past_epoch_trees =
165+ past_epoch_trees. ok_or_else ( || Error :: missing_field ( "past_epoch_trees" ) ) ?;
166+ if let Some ( past_added_at) = past_epoch_added_at {
167+ for ( i, tree) in past_epoch_trees. iter_mut ( ) . enumerate ( ) {
168+ tree. message_secrets . added_at = past_added_at. get ( i) . copied ( ) . unwrap_or ( None ) ;
169+ }
170+ }
171+ Ok ( MessageSecretsStore {
172+ max_epochs : max_epochs. ok_or_else ( || Error :: missing_field ( "max_epochs" ) ) ?,
173+ past_epoch_trees,
174+ message_secrets,
175+ } )
176+ }
177+ }
178+
44179#[ cfg( not( feature = "crypto-debug" ) ) ]
45180impl core:: fmt:: Debug for MessageSecretsStore {
46181 fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
@@ -340,3 +475,93 @@ impl MessageSecretsStore {
340475 self . past_epoch_trees . len ( )
341476 }
342477}
478+
479+ // Storage-compatibility tests for the fork-only `added_at` tail, exercised
480+ // through *bincode* — the non-self-describing codec libxmtp actually persists
481+ // with. The existing `past_secrets_storage_compatibility` KAT uses `serde_json`
482+ // (self-describing), so it only covers the `visit_map` path; these cover the
483+ // `visit_seq` / EOF-tolerant path that motivates the hand-written impl.
484+ #[ cfg( all( test, not( target_arch = "wasm32" ) ) ) ]
485+ mod bincode_storage_compat_tests {
486+ use super :: { EpochTree , MessageSecretsStore } ;
487+ use crate :: binary_tree:: array_representation:: LeafNodeIndex ;
488+ use crate :: group:: mls_group:: config:: PastEpochDeletionPolicy ;
489+ use crate :: schedule:: message_secrets:: MessageSecrets ;
490+ use openmls_rust_crypto:: RustCrypto ;
491+ use openmls_traits:: types:: Ciphersuite ;
492+ use serde:: Serialize ;
493+ use std:: collections:: VecDeque ;
494+ use std:: time:: SystemTime ;
495+
496+ const CIPHERSUITE : Ciphersuite = Ciphersuite :: MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 ;
497+
498+ fn secrets ( rng : & RustCrypto , added_at : Option < SystemTime > ) -> MessageSecrets {
499+ MessageSecrets :: random ( CIPHERSUITE , rng, LeafNodeIndex :: new ( 0 ) ) . with_timestamp ( added_at)
500+ }
501+
502+ /// A new-format `MessageSecretsStore` round-trips through bincode, preserving
503+ /// the fork-only `added_at` timestamps carried on the trailing tail fields —
504+ /// both the current secrets and each past epoch.
505+ #[ test]
506+ fn bincode_roundtrip_preserves_added_at ( ) {
507+ let rng = RustCrypto :: default ( ) ;
508+ let mut store = MessageSecretsStore :: new_with_secret (
509+ & PastEpochDeletionPolicy :: KeepAll ,
510+ secrets ( & rng, None ) ,
511+ ) ;
512+ store. add_past_epoch_tree ( 0u64 , secrets ( & rng, None ) , Vec :: new ( ) ) ;
513+ store. add_past_epoch_tree ( 1u64 , secrets ( & rng, None ) , Vec :: new ( ) ) ;
514+
515+ // Set known timestamps directly, bypassing the `SystemTime::now()` the
516+ // constructors stamp. Mix in a `None` so the parallel
517+ // `past_epoch_added_at` array is exercised on both a present and an
518+ // absent entry.
519+ let current = SystemTime :: UNIX_EPOCH + std:: time:: Duration :: from_secs ( 1_000 ) ;
520+ let past0 = SystemTime :: UNIX_EPOCH + std:: time:: Duration :: from_secs ( 2_000 ) ;
521+ store. message_secrets . added_at = Some ( current) ;
522+ store. past_epoch_trees [ 0 ] . message_secrets . added_at = Some ( past0) ;
523+ store. past_epoch_trees [ 1 ] . message_secrets . added_at = None ;
524+
525+ let bytes = bincode:: serialize ( & store) . expect ( "serialize" ) ;
526+ let back: MessageSecretsStore = bincode:: deserialize ( & bytes) . expect ( "deserialize" ) ;
527+
528+ assert_eq ! ( back. message_secrets. added_at, Some ( current) ) ;
529+ assert_eq ! (
530+ back. past_epoch_trees[ 0 ] . message_secrets. added_at,
531+ Some ( past0)
532+ ) ;
533+ assert_eq ! ( back. past_epoch_trees[ 1 ] . message_secrets. added_at, None ) ;
534+ }
535+
536+ /// Data written before `added_at` existed (openmls-0.7.x, as shipped in
537+ /// libxmtp 1.9/1.10) has only the three base fields under bincode. The
538+ /// trailing tail fields must read tolerantly — EOF -> `None`, not an error.
539+ #[ test]
540+ fn bincode_tolerates_pre_added_at_layout ( ) {
541+ let rng = RustCrypto :: default ( ) ;
542+ let store = MessageSecretsStore :: new_with_secret (
543+ & PastEpochDeletionPolicy :: KeepAll ,
544+ secrets ( & rng, Some ( SystemTime :: now ( ) ) ) ,
545+ ) ;
546+
547+ // The openmls-0.7.x on-disk shape: the three base fields only, no
548+ // trailing `added_at` / `past_epoch_added_at`. bincode is positional, so
549+ // this is byte-identical to what an older openmls wrote.
550+ #[ derive( Serialize ) ]
551+ struct PreAddedAtLayout < ' a > {
552+ max_epochs : usize ,
553+ past_epoch_trees : & ' a VecDeque < EpochTree > ,
554+ message_secrets : & ' a MessageSecrets ,
555+ }
556+ let old_bytes = bincode:: serialize ( & PreAddedAtLayout {
557+ max_epochs : store. max_epochs ,
558+ past_epoch_trees : & store. past_epoch_trees ,
559+ message_secrets : & store. message_secrets ,
560+ } )
561+ . expect ( "serialize old layout" ) ;
562+
563+ let back: MessageSecretsStore =
564+ bincode:: deserialize ( & old_bytes) . expect ( "deserialize old layout" ) ;
565+ assert ! ( back. message_secrets. added_at. is_none( ) ) ;
566+ }
567+ }
0 commit comments