Skip to main content

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}