Skip to content

Commit 1c58e55

Browse files
committed
Tolerantly deserialize fork-only trailing fields on persisted structs
Upstream carries several trailing fields with #[serde(default)], which bincode ignores (it is positional), so 0.7.x-shape data that lacks the field hits EOF and fails to load. Restore the fork's approach across the persisted types libxmtp stores through bincode: - MessageSecrets.added_at: skip on the struct (keeping it byte-identical to the upstream five-field shape, since MessageSecrets is not the last field of EpochTree) and carry it plus a parallel past_epoch_added_at array as trailing, EOF-tolerant fields on MessageSecretsStore, re-hydrated on deserialize. - MemberStagedCommitState: hand-written serde reading application_export_tree (extensions-draft) and new_own_leaf_index (virtual-clients-draft) as trailing, EOF-tolerant fields in declaration order, so builds/data without a feature still round-trip. The #[cfg]-gated FIELDS entries keep bincode's field-count cap correct per feature combination. Verified against libxmtp releases 1.9/1.10, whose pinned openmls has no added_at field at all (pure five-field shape), so no 6-field inline data exists to read.
1 parent 2a47331 commit 1c58e55

3 files changed

Lines changed: 422 additions & 9 deletions

File tree

openmls/src/group/mls_group/past_secrets.rs

Lines changed: 226 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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))]
3452
pub(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"))]
45180
impl 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

Comments
 (0)