offline_protocol/protocol_state_storage.rs
1//! Install-scoped storage for protocol delivery state.
2//!
3//! This storage domain is deliberately separate from [`crate::MlsStorage`].
4//! MLS storage contains cryptographic identity and group material that may
5//! outlive an app-container incarnation. Protocol state contains restartable
6//! delivery machinery—outbox entries, pending messages, retry lifecycles, and
7//! peer snapshots—and must be removed with the app container.
8//!
9//! # Confidentiality
10//!
11//! Lifecycle separation is not confidentiality separation: some of this state
12//! (queued message plaintext, cloud-media `encryption_key`/`iv`) is as
13//! sensitive as anything in the credential store. Implementations are *not*
14//! trusted to protect it — the SDK seals those record values with an AEAD
15//! whose key lives in [`crate::MlsStorage`] before they ever reach this trait
16//! (see `protocol::state_crypto`). An implementation therefore only ever sees
17//! ciphertext for sensitive categories, and a stolen app container without the
18//! credential store yields nothing.
19//!
20//! Record *keys* (`key_type` / `key_id`) are not sealed: they are peer ids and
21//! message ids, and the storage layout needs them in the clear to list and
22//! address entries.
23
24use std::fmt;
25
26/// Failure of a protocol-state storage operation.
27///
28/// Deliberately defined here rather than reusing
29/// `offline_protocol_mls::StorageError`: the whole point of this trait is that
30/// install-scoped delivery state does not share a contract — or a crate — with
31/// cryptographic storage. Implementations map their platform errors onto these
32/// variants.
33// Adding a variant to a public error enum is a breaking change without
34// this attribute; downstream crates must carry a wildcard arm.
35#[non_exhaustive]
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ProtocolStateError {
38 /// The requested entry does not exist.
39 ///
40 /// `Ok(None)` from [`ProtocolStateStorage::load`] is the preferred way to
41 /// report absence; this variant exists for implementations whose platform
42 /// API cannot express it any other way. The SDK reads the two identically —
43 /// a `load` that fails with `NotFound` is absence, not a lost record — so
44 /// an implementation that reports absence this way does **not** cause
45 /// spurious `message_failed` settlements on restore.
46 NotFound(String),
47 /// The entry exists but can never be decoded by the backing store.
48 ///
49 /// This is the variant for a *permanent* loss the implementation detected
50 /// itself — a truncated or tampered record, a framing header that does not
51 /// parse, an entry over
52 /// [`MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES`]. The SDK reads it as
53 /// exactly that: the record is dropped and, for categories the application
54 /// holds message ids for, settled with a terminal `message_failed` (or a
55 /// `pending_state_lost` diagnostic) so the id cannot hang forever.
56 ///
57 /// Report it *only* when the record can never be read. Every other error
58 /// variant means "this read failed", which the SDK treats as recoverable:
59 /// the record is left in place for a later launch and nothing is settled.
60 /// Reporting a transient failure here would tell the application a message
61 /// failed terminally and then deliver it on the next launch.
62 ///
63 /// Returning `Ok(None)` for a record the implementation destroyed is
64 /// accepted — the built-in providers did that before this variant carried
65 /// meaning — but it costs the settlement: the SDK cannot distinguish it
66 /// from a record that was never written, so the application is told
67 /// nothing at all.
68 Corrupted(String),
69 /// The entry could not be written.
70 StoreFailed(String),
71 /// The entry could not be read.
72 LoadFailed(String),
73 /// The entry could not be removed.
74 DeleteFailed(String),
75}
76
77impl fmt::Display for ProtocolStateError {
78 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79 match self {
80 Self::NotFound(detail) => write!(f, "Protocol state entry not found: {}", detail),
81 Self::Corrupted(detail) => write!(f, "Protocol state entry corrupted: {}", detail),
82 Self::StoreFailed(detail) => write!(f, "Failed to store protocol state: {}", detail),
83 Self::LoadFailed(detail) => write!(f, "Failed to load protocol state: {}", detail),
84 Self::DeleteFailed(detail) => write!(f, "Failed to delete protocol state: {}", detail),
85 }
86 }
87}
88
89impl std::error::Error for ProtocolStateError {}
90
91/// Result alias for [`ProtocolStateStorage`] operations.
92pub type ProtocolStateResult<T> = Result<T, ProtocolStateError>;
93
94/// Largest record the SDK will ever hand a [`ProtocolStateStorage`], and the
95/// ceiling implementations must enforce on the way back.
96///
97/// Core refuses to write anything larger and refuses to deserialize anything
98/// larger on restore. But `load` returns an owned `Vec`, so by the time core
99/// can check a length the provider has already allocated it: a corrupt or
100/// tampered multi-gigabyte file would be read whole — and, across UniFFI,
101/// copied — before core ever sees it. Bounding it is therefore the provider's
102/// job, and this constant is the number to bound by.
103///
104/// A record over this ceiling cannot have been written by this SDK, so a
105/// provider should treat it as corrupt (drop it and report absence) rather than
106/// return it. See [`ProtocolStateStorage::load`].
107///
108/// The value is a deliberate superset of core's own record cap plus its seal
109/// envelope, so a provider enforcing it never rejects a record the SDK
110/// legitimately wrote. `bounded_load_ceiling_is_a_superset_of_the_record_cap`
111/// pins that relationship; the built-in Swift, Kotlin, and Python providers
112/// mirror the constant.
113pub const MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES: usize = 8 * 1024 * 1024;
114
115/// Storage for restartable protocol and message-plane state.
116///
117/// Implementations must be app-container scoped and must not use a credential
118/// store whose lifetime can exceed the app container. The operations retain
119/// the atomicity and durability contract defined by
120/// [`offline_protocol_mls::MlsStorage`], but this
121/// trait deliberately does not inherit from the crypto-storage contract:
122/// lifecycle separation should be structural, not a marker on the wrong
123/// abstraction.
124///
125/// Values for sensitive categories arrive already sealed (see the module
126/// docs), so an implementation must store and return the bytes it is given
127/// verbatim — it must not inspect, re-encode, or truncate them.
128pub trait ProtocolStateStorage: Send + Sync {
129 /// Atomically stores or replaces one protocol-state entry.
130 ///
131 /// Implementations may refuse a value over
132 /// [`MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES`]; core never sends one.
133 fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> ProtocolStateResult<()>;
134
135 /// Loads one protocol-state entry, returning `None` when absent.
136 ///
137 /// **Implementations must bound the read.** Check the stored entry's size
138 /// before materializing it and never return — or allocate — more than
139 /// [`MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES`]. An entry above the ceiling
140 /// is corrupt or tampered by construction; drop it and report
141 /// [`ProtocolStateError::Corrupted`], which is what lets core settle the
142 /// message ids the application is still holding for it. Returning the
143 /// oversized value instead defeats the size policy, because core can only
144 /// check a length it has already been handed.
145 ///
146 /// The same applies to a record whose framing does not parse. Reserve
147 /// `Corrupted` for losses that are permanent — anything transient belongs
148 /// in [`ProtocolStateError::LoadFailed`], which core leaves in place to
149 /// retry.
150 fn load(&self, key_type: &str, key_id: &str) -> ProtocolStateResult<Option<Vec<u8>>>;
151
152 /// Deletes one protocol-state entry and succeeds when it is already absent.
153 fn delete(&self, key_type: &str, key_id: &str) -> ProtocolStateResult<()>;
154
155 /// Lists entry IDs within one protocol-state category.
156 ///
157 /// Like [`Self::load`], enumeration should stay bounded: core caps every
158 /// category well below any sane ceiling, so a store holding vastly more
159 /// has been tampered with and an implementation may stop early.
160 ///
161 /// An empty category is `Ok(vec![])`. A backend that can only spell it as
162 /// [`ProtocolStateError::NotFound`] is read the same way, so honoring that
163 /// contract does not fail restore.
164 fn list_keys(&self, key_type: &str) -> ProtocolStateResult<Vec<String>>;
165}
166
167#[cfg(test)]
168mod tests {
169 use super::MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES;
170 use crate::protocol::state_crypto::SEALED_RECORD_OVERHEAD;
171 use crate::protocol::MAX_PROTOCOL_STATE_RECORD_BYTES;
172
173 /// The provider ceiling is what the built-in Swift, Kotlin, and Python
174 /// stores enforce before reading a file. If core's own cap ever grew past
175 /// it, those providers would start dropping records the SDK legitimately
176 /// wrote — silent data loss that only shows up on a device.
177 ///
178 /// This is one half of that guarantee.
179 /// [`built_in_providers_mirror_the_transfer_ceiling`] is the other: this
180 /// test relates core's cap to the constant, and that one relates the
181 /// constant to the three literals the providers actually enforce. Neither
182 /// is sufficient alone — the ceiling lives in four places, and only the
183 /// pair covers the path from "core writes it" to "a provider accepts it".
184 #[test]
185 fn bounded_load_ceiling_is_a_superset_of_the_record_cap() {
186 assert!(
187 MAX_PROTOCOL_STATE_RECORD_BYTES + SEALED_RECORD_OVERHEAD
188 < MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES,
189 "provider read ceiling ({}) must exceed the largest sealed record \
190 core can write ({} + {})",
191 MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES,
192 MAX_PROTOCOL_STATE_RECORD_BYTES,
193 SEALED_RECORD_OVERHEAD,
194 );
195 }
196
197 /// The ceiling is enforced *inside* each provider — it has to be, since
198 /// core can only check a length a provider has already handed it — so the
199 /// number exists in four places: this constant and three hand-written
200 /// literals. Nothing related them, and the drift is invisible: raise
201 /// [`MAX_PROTOCOL_STATE_RECORD_BYTES`] past a provider's literal and core
202 /// keeps writing records that provider then rejects as corrupt, which core
203 /// dutifully settles as a terminal `message_failed`. No CI job can see
204 /// that; it shows up as messages failing on a device.
205 ///
206 /// Same shape as the RN bridge fallback guard in
207 /// `offline_protocol_reliability::constants`: read the binding sources and
208 /// assert the literal, so changing the Rust constant fails here until all
209 /// three are updated with it.
210 #[test]
211 fn built_in_providers_mirror_the_transfer_ceiling() {
212 let mib = MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES / (1024 * 1024);
213 assert_eq!(
214 mib * 1024 * 1024,
215 MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES,
216 "all three providers spell this ceiling `N * 1024 * 1024`, so a \
217 value that is not a whole number of MiB cannot be matched here — \
218 respell the provider literals and this test together rather than \
219 letting the guard silently stop covering them"
220 );
221
222 let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
223 let sources = [
224 (
225 repo.join("bindings/react-native/ios/ProtocolStateStorage.swift"),
226 format!("static let maxValueBytes = {mib} * 1024 * 1024"),
227 ),
228 (
229 repo.join(
230 "bindings/react-native/android/src/main/java/com/offlineprotocol/\
231 ProtocolStateStorage.kt",
232 ),
233 format!("const val MAX_VALUE_BYTES = {mib} * 1024 * 1024"),
234 ),
235 (
236 repo.join("bindings/python/offline_protocol_sdk/state_storage.py"),
237 format!("MAX_VALUE_BYTES = {mib} * 1024 * 1024"),
238 ),
239 ];
240
241 for (path, expected) in &sources {
242 // The guard only applies in the repo checkout; skip when the
243 // bindings tree isn't present (e.g. a vendored crate).
244 let Ok(source) = std::fs::read_to_string(path) else {
245 eprintln!(
246 "bindings tree not present, skipping ceiling drift check for {}",
247 path.display()
248 );
249 continue;
250 };
251 assert!(
252 source.contains(expected.as_str()),
253 "built-in provider record ceiling drifted from \
254 MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES: expected `{expected}` in {}",
255 path.display()
256 );
257 }
258 }
259}