Skip to main content

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}