offline_protocol/file_store/mod.rs
1//! Built-in file-backed stores, for a host with a filesystem and no platform
2//! keystore.
3//!
4//! The engine takes its two stores from the host
5//! ([`crate::OfflineProtocol::initialize_mls`]). The mobile bindings hand in the
6//! Keychain or Keystore and an app-container directory; a headless host on
7//! Linux has neither a keystore the SDK can assume nor a binding that writes
8//! files for it. These two stores are that host's defaults:
9//!
10//! - [`FileProtocolStateStorage`] keeps restartable delivery state. It writes
11//! the same on-disk format as the Python, Swift and Kotlin providers, byte
12//! for byte, so a state directory is portable across bindings. Its values
13//! arrive already sealed by the engine for every sensitive category, exactly
14//! as they do for those providers.
15//! - [`SealedFileMlsStorage`] keeps identity keys, group state and the record
16//! key that seals the other store. It seals every record under a key derived
17//! from a *store key* the operator supplies through a [`StoreKeyProvider`],
18//! and names its files with a keyed digest.
19//!
20//! # Invariants
21//!
22//! - **A write the store acknowledged survives a crash.** Every store writes a
23//! temporary file, flushes it, renames it into place and flushes the
24//! directory. The engine writes state that depends on a returned `Ok`
25//! immediately (the record key is the sharp case), so an acknowledged write
26//! that is only staged is a data-loss bug, not a performance choice.
27//! - **A load never allocates more than the ceiling.** The file size is
28//! checked before the read and the read itself is capped, so a planted
29//! multi-gigabyte file costs a `stat`.
30//! - **File names are fixed-length digests, never an encoding of the key.**
31//! Case-insensitive volumes (APFS, NTFS by default) fold `group:Team` and
32//! `group:team` onto one file, and group ids are chosen by peers. A digest
33//! is lowercase hex whatever the key, so the key is compared byte for byte.
34//! - **The sealed store never deletes on a read.** A record that does not
35//! authenticate is reported as corrupt, with its path, and left in place.
36//! The common cause is the operator supplying the wrong store key, and
37//! deleting on that would destroy the identity.
38//! [`SealedFileMlsStorage::open`] refuses a wrong key before any record is
39//! read, so a record that fails afterwards is damaged, and the error names
40//! the file so the operator can decide.
41//! - **One store per directory.** Each store holds an exclusive lock on its
42//! account directory until it is closed or dropped, and a second store over
43//! the same directory, in this process or another, is refused with
44//! [`FileStoreError::InUse`]. Two stores over one MLS directory would each
45//! advance ratchet state the other never sees, and the groups would diverge
46//! without an error.
47//! - **Releasing a directory never depends on who still holds the store.**
48//! `close` releases the lock at once and makes every later operation an
49//! error. A host whose runtime decides when an object is freed (a garbage
50//! collector, a reference cycle through a callback) would otherwise hold
51//! the directory for as long as anything, anywhere, kept a reference.
52//!
53//! # Layout
54//!
55//! Each store owns `<root>/<namespace>/`, where the namespace is
56//! [`account_storage_namespace`] of the application id and profile, the same
57//! derivation every binding uses. The two stores use different directory
58//! prefixes and different lock files, so they do not collide if given one
59//! root, but they should not share one: protocol state is scoped to an
60//! installation and is meant to be removed with it, while MLS material may
61//! outlive it.
62
63mod key;
64mod mls;
65mod pair;
66mod records;
67mod state;
68
69pub use key::{EnvStoreKey, StaticStoreKey, StoreKeyError, StoreKeyProvider, STORE_KEY_BYTES};
70pub use mls::SealedFileMlsStorage;
71pub use pair::FileStorePair;
72pub use state::{FileProtocolStateStorage, SealedState};
73
74use sha2::{Digest, Sha256};
75use std::path::PathBuf;
76
77/// Domain separator of the account namespace digest. Shared with the Python,
78/// Swift and Kotlin `StorageNamespace` helpers; changing it strands every
79/// existing store.
80const NAMESPACE_DOMAIN: &[u8] = b"offline-protocol-storage-v1";
81
82/// Prefix of every account namespace.
83const NAMESPACE_PREFIX: &str = "account-";
84
85/// Returns the opaque, filesystem-safe namespace for one protocol account.
86///
87/// `account-` followed by the lowercase hex SHA-256 of
88/// `"offline-protocol-storage-v1" || 0x00 || app_id || 0x00 || profile`, the
89/// derivation every binding uses, so a directory written by one binding opens
90/// under another for the same account.
91pub fn account_storage_namespace(app_id: &str, profile: &str) -> String {
92 let mut sha = Sha256::new();
93 sha.update(NAMESPACE_DOMAIN);
94 sha.update([0u8]);
95 sha.update(app_id.as_bytes());
96 sha.update([0u8]);
97 sha.update(profile.as_bytes());
98 format!("{NAMESPACE_PREFIX}{}", hex::encode(sha.finalize()))
99}
100
101/// Whether `value` is a well-formed account namespace.
102///
103/// The namespace becomes a path component, so an arbitrary string could
104/// escape its own account (`..`) or collide with another's. Only the exact
105/// shape [`account_storage_namespace`] produces is accepted: `account-` and
106/// 64 lowercase hex digits.
107pub fn is_account_storage_namespace(value: &str) -> bool {
108 value.strip_prefix(NAMESPACE_PREFIX).is_some_and(|digest| {
109 digest.len() == 64
110 && digest
111 .bytes()
112 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
113 })
114}
115
116/// Why a built-in file store could not be opened, or could not answer a
117/// question about its directory.
118///
119/// Opening and the probes are the only steps with their own error type: a
120/// record operation reports through the error type of the trait the store
121/// implements.
122#[non_exhaustive]
123#[derive(Debug, thiserror::Error)]
124pub enum FileStoreError {
125 /// The namespace is not the output of [`account_storage_namespace`].
126 #[error("invalid storage namespace: expected `account-` and 64 lowercase hex digits")]
127 InvalidNamespace,
128 /// The store directory could not be created or read.
129 #[error("store directory {path} is unusable: {source}")]
130 Io {
131 /// The directory the store tried to use.
132 path: PathBuf,
133 /// The underlying failure.
134 source: std::io::Error,
135 },
136 /// The store key provider did not produce a key.
137 #[error("store key unavailable: {0}")]
138 StoreKey(#[from] StoreKeyError),
139 /// The store key does not open this store's records.
140 ///
141 /// Nothing was read, changed or deleted. The usual cause is the operator
142 /// supplying a different key than the one the store was created with.
143 #[error("the store key does not open the store at {0}; nothing was changed")]
144 WrongStoreKey(PathBuf),
145 /// The key check file does not open, and the store holds no record that
146 /// could prove the key either way.
147 ///
148 /// Nothing was read, changed or deleted. Either the key is wrong or the
149 /// check file is damaged, and an empty store cannot tell the two apart.
150 /// With nothing sealed to lose, removing the check file named here lets
151 /// the next open start the store afresh under the key offered.
152 #[error(
153 "the store key check at {0} does not open and the store has no record to prove \
154 the key; nothing was changed. If this is the right key, remove that file to \
155 start the store afresh"
156 )]
157 KeyCheckUnverifiable(PathBuf),
158 /// Another store already has this directory open, in this process or in
159 /// another one.
160 ///
161 /// Nothing was read or changed. Two stores over one directory would
162 /// diverge the MLS state, so the second is refused rather than guarded.
163 #[error(
164 "the store at {0} is already open elsewhere; two stores over one directory are refused"
165 )]
166 InUse(PathBuf),
167 /// The store was closed, and holds its directory no longer.
168 #[error("the store at {0} is closed")]
169 Closed(PathBuf),
170 /// The MLS store could not say whether it holds the key that seals
171 /// protocol state.
172 ///
173 /// Nothing was changed. This is a read that failed, not a key that is
174 /// missing or damaged: those are answers, and are reported as
175 /// [`SealedState`].
176 #[error("the protocol-state record key could not be read: {0}; nothing was changed")]
177 RecordKeyUnreadable(String),
178 /// [`FileStorePair::open`] was given one directory for both roots, or
179 /// one inside the other.
180 ///
181 /// The MLS root holds the identity and must survive the removal of the
182 /// state root, which is scoped to the installation. When
183 /// `on_this_volume` is set, the roots are spelled apart and are one
184 /// directory only on this volume (one that folds case), which shows only
185 /// once both exist: the stores' directories were created then, and no
186 /// record was written.
187 #[error("{}", roots_overlap_message(.mls_root, .state_root, .on_this_volume))]
188 RootsOverlap {
189 /// The MLS root as given.
190 mls_root: PathBuf,
191 /// The protocol-state root as given.
192 state_root: PathBuf,
193 /// Found only once both directories existed.
194 on_this_volume: bool,
195 },
196 /// The protocol-state account directory holds records and the MLS root
197 /// holds no identity for the account.
198 ///
199 /// Nothing was created. Opening would mint a new identity (a new
200 /// address) whose record key cannot unseal those records, and the first
201 /// restore would delete the ones it read: a deployment moving onto the
202 /// file stores with a kept state root, or one whose MLS root was lost.
203 #[error(
204 "{} holds protocol state, but {} holds no identity for this account: the file \
205 stores would start a new identity (a new address) and delete the state it cannot \
206 unseal. Point state_root at a fresh directory, or restore the mls_root that wrote \
207 this state",
208 .state.display(),
209 .mls_root.display()
210 )]
211 StateWithoutIdentity {
212 /// The protocol-state account directory.
213 state: PathBuf,
214 /// The MLS root.
215 mls_root: PathBuf,
216 },
217 /// The protocol-state account directory belongs to another MLS store:
218 /// it is bound to another one, or its sealed records open under no
219 /// record key this one holds.
220 ///
221 /// No record was changed. Handing the pair to an engine would delete the
222 /// queued and parked messages its restore reads.
223 #[error(
224 "{} holds protocol state that another identity wrote, not the one in {}: starting \
225 would delete the queued and parked messages in it. Use the mls_root that wrote this \
226 state, or point state_root at a fresh directory. No record was changed",
227 .state.display(),
228 .mls_root.display()
229 )]
230 ForeignState {
231 /// The protocol-state account directory.
232 state: PathBuf,
233 /// The MLS root.
234 mls_root: PathBuf,
235 },
236}
237
238fn roots_overlap_message(
239 mls_root: &std::path::Path,
240 state_root: &std::path::Path,
241 on_this_volume: &bool,
242) -> String {
243 if *on_this_volume {
244 format!(
245 "mls_root and state_root must be different directories, neither inside the \
246 other: {} and {} are spelled apart, and on this volume one is the other or lies \
247 inside it. The stores' directories were created; no record was written or changed",
248 mls_root.display(),
249 state_root.display()
250 )
251 } else {
252 "mls_root and state_root must be different directories, neither inside the other: \
253 mls_root holds the identity and must survive the removal of state_root"
254 .to_string()
255 }
256}
257
258fn account_directory(root: &std::path::Path, namespace: &str) -> Result<PathBuf, FileStoreError> {
259 if !is_account_storage_namespace(namespace) {
260 return Err(FileStoreError::InvalidNamespace);
261 }
262 let directory = root.join(namespace);
263 records::private_mkdir(&directory).map_err(|source| FileStoreError::Io {
264 path: directory.clone(),
265 source,
266 })?;
267 Ok(directory)
268}
269
270/// Whether the account directory `<root>/<namespace>/` holds an entry that
271/// `counts` accepts, without creating, locking or changing anything.
272///
273/// A directory that is not there holds nothing. One that cannot be read is
274/// an error, never "empty": a caller deciding whether it may start a new
275/// identity over it must not take a permission error for a fresh install.
276/// `counts` is handed the entry's path and name, and may look inside it; an
277/// error it returns is reported against the path it was given.
278fn account_holds(
279 root: &std::path::Path,
280 namespace: &str,
281 counts: impl Fn(&std::path::Path, &str) -> std::io::Result<bool>,
282) -> Result<bool, FileStoreError> {
283 if !is_account_storage_namespace(namespace) {
284 return Err(FileStoreError::InvalidNamespace);
285 }
286 let directory = root.join(namespace);
287 let unusable = |path: &std::path::Path, source: std::io::Error| FileStoreError::Io {
288 path: path.to_path_buf(),
289 source,
290 };
291 let entries = match std::fs::read_dir(&directory) {
292 Ok(entries) => entries,
293 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
294 Err(err) => return Err(unusable(&directory, err)),
295 };
296 for entry in entries {
297 let entry = entry.map_err(|err| unusable(&directory, err))?;
298 let path = entry.path();
299 if counts(&path, &entry.file_name().to_string_lossy())
300 .map_err(|err| unusable(&path, err))?
301 {
302 return Ok(true);
303 }
304 }
305 Ok(false)
306}
307
308/// Takes the store's exclusive lock on `directory`.
309fn lock_directory(
310 directory: &std::path::Path,
311 lock_name: &str,
312) -> Result<records::DirectoryLock, FileStoreError> {
313 records::DirectoryLock::acquire(directory, lock_name).map_err(|err| match err {
314 records::LockError::Held => FileStoreError::InUse(directory.to_path_buf()),
315 records::LockError::Io(source) => FileStoreError::Io {
316 path: directory.join(lock_name),
317 source,
318 },
319 })
320}
321
322#[cfg(test)]
323pub(crate) mod test_support {
324 use std::path::{Path, PathBuf};
325
326 use rand_core::{OsRng, RngCore};
327
328 /// A unique directory under the system temp dir, removed on drop.
329 pub(crate) struct TempRoot(PathBuf);
330
331 impl TempRoot {
332 pub(crate) fn new(label: &str) -> Self {
333 let mut suffix = [0u8; 8];
334 OsRng.fill_bytes(&mut suffix);
335 let path = std::env::temp_dir()
336 .join(format!("offline-protocol-{label}-{}", hex::encode(suffix)));
337 std::fs::create_dir_all(&path).expect("create temp root");
338 Self(path)
339 }
340
341 pub(crate) fn path(&self) -> &Path {
342 &self.0
343 }
344 }
345
346 impl Drop for TempRoot {
347 fn drop(&mut self) {
348 let _ = std::fs::remove_dir_all(&self.0);
349 }
350 }
351
352 /// Every regular file under `dir`, recursively.
353 pub(crate) fn files_under(dir: &Path) -> Vec<PathBuf> {
354 let mut out = Vec::new();
355 let mut stack = vec![dir.to_path_buf()];
356 while let Some(next) = stack.pop() {
357 let Ok(entries) = std::fs::read_dir(&next) else {
358 continue;
359 };
360 for entry in entries.flatten() {
361 let path = entry.path();
362 if path.is_dir() {
363 stack.push(path);
364 } else {
365 out.push(path);
366 }
367 }
368 }
369 out.sort();
370 out
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 /// The vector the Python binding pins in `test_storage_namespace.py`; the
379 /// Swift and Kotlin helpers share the derivation. A Rust store that
380 /// derived anything else would open an empty directory for an account a
381 /// binding had already written.
382 #[test]
383 fn the_namespace_matches_the_bindings() {
384 assert_eq!(
385 account_storage_namespace("test-app", "test-user-1"),
386 "account-814873e0cbdb2a1f25f14b31625e7f904cf9923e55b415b91ca4b29b210c12a1"
387 );
388 assert_ne!(
389 account_storage_namespace("chat", "alice"),
390 account_storage_namespace("chat", "bob")
391 );
392 assert_ne!(
393 account_storage_namespace("chat", "alice"),
394 account_storage_namespace("other-chat", "alice")
395 );
396 }
397
398 #[test]
399 fn only_the_derived_shape_is_a_namespace() {
400 assert!(is_account_storage_namespace(&account_storage_namespace(
401 "chat", "alice"
402 )));
403 let a64 = "a".repeat(64);
404 for bad in [
405 String::new(),
406 "account-".to_string(),
407 "../../other-account".to_string(),
408 format!("account-{}", "a".repeat(63)),
409 format!("account-{}", "a".repeat(65)),
410 format!("account-{}", "A".repeat(64)),
411 format!("account-{}", "g".repeat(64)),
412 format!("account-{a64}/.."),
413 format!("Account-{a64}"),
414 format!("account-{}\u{0}", "a".repeat(63)),
415 ] {
416 assert!(!is_account_storage_namespace(&bad), "{bad:?} accepted");
417 }
418 }
419
420 #[test]
421 fn a_bad_namespace_is_refused_before_anything_is_created() {
422 let root = test_support::TempRoot::new("namespace");
423 let result = FileProtocolStateStorage::open(root.path(), "../escape");
424 assert!(matches!(result, Err(FileStoreError::InvalidNamespace)));
425 assert!(test_support::files_under(root.path()).is_empty());
426 assert_eq!(
427 std::fs::read_dir(root.path()).expect("read root").count(),
428 0,
429 "a refused namespace must not create a directory"
430 );
431 }
432}