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
74pub use crate::storage_namespace::{account_storage_namespace, is_account_storage_namespace};
75
76use std::path::PathBuf;
77
78/// Why a built-in file store could not be opened, or could not answer a
79/// question about its directory.
80///
81/// Opening and the probes are the only steps with their own error type: a
82/// record operation reports through the error type of the trait the store
83/// implements.
84#[non_exhaustive]
85#[derive(Debug, thiserror::Error)]
86pub enum FileStoreError {
87    /// The namespace is not the output of [`account_storage_namespace`].
88    #[error("invalid storage namespace: expected `account-` and 64 lowercase hex digits")]
89    InvalidNamespace,
90    /// The store directory could not be created or read.
91    #[error("store directory {path} is unusable: {source}")]
92    Io {
93        /// The directory the store tried to use.
94        path: PathBuf,
95        /// The underlying failure.
96        source: std::io::Error,
97    },
98    /// The store key provider did not produce a key.
99    #[error("store key unavailable: {0}")]
100    StoreKey(#[from] StoreKeyError),
101    /// The store key does not open this store's records.
102    ///
103    /// Nothing was read, changed or deleted. The usual cause is the operator
104    /// supplying a different key than the one the store was created with.
105    #[error("the store key does not open the store at {0}; nothing was changed")]
106    WrongStoreKey(PathBuf),
107    /// The key check file does not open, and the store holds no record that
108    /// could prove the key either way.
109    ///
110    /// Nothing was read, changed or deleted. Either the key is wrong or the
111    /// check file is damaged, and an empty store cannot tell the two apart.
112    /// With nothing sealed to lose, removing the check file named here lets
113    /// the next open start the store afresh under the key offered.
114    #[error(
115        "the store key check at {0} does not open and the store has no record to prove \
116         the key; nothing was changed. If this is the right key, remove that file to \
117         start the store afresh"
118    )]
119    KeyCheckUnverifiable(PathBuf),
120    /// Another store already has this directory open, in this process or in
121    /// another one.
122    ///
123    /// Nothing was read or changed. Two stores over one directory would
124    /// diverge the MLS state, so the second is refused rather than guarded.
125    #[error(
126        "the store at {0} is already open elsewhere; two stores over one directory are refused"
127    )]
128    InUse(PathBuf),
129    /// The store was closed, and holds its directory no longer.
130    #[error("the store at {0} is closed")]
131    Closed(PathBuf),
132    /// The MLS store could not say whether it holds the key that seals
133    /// protocol state.
134    ///
135    /// Nothing was changed. This is a read that failed, not a key that is
136    /// missing or damaged: those are answers, and are reported as
137    /// [`SealedState`].
138    #[error("the protocol-state record key could not be read: {0}; nothing was changed")]
139    RecordKeyUnreadable(String),
140    /// [`FileStorePair::open`] was given one directory for both roots, or
141    /// one inside the other.
142    ///
143    /// The MLS root holds the identity and must survive the removal of the
144    /// state root, which is scoped to the installation. When
145    /// `on_this_volume` is set, the roots are spelled apart and are one
146    /// directory only on this volume (one that folds case), which shows only
147    /// once both exist: the stores' directories were created then, and no
148    /// record was written.
149    #[error("{}", roots_overlap_message(.mls_root, .state_root, .on_this_volume))]
150    RootsOverlap {
151        /// The MLS root as given.
152        mls_root: PathBuf,
153        /// The protocol-state root as given.
154        state_root: PathBuf,
155        /// Found only once both directories existed.
156        on_this_volume: bool,
157    },
158    /// The protocol-state account directory holds records and the MLS root
159    /// holds no identity for the account.
160    ///
161    /// Nothing was created. Opening would mint a new identity (a new
162    /// address) whose record key cannot unseal those records, and the first
163    /// restore would delete the ones it read: a deployment moving onto the
164    /// file stores with a kept state root, or one whose MLS root was lost.
165    #[error(
166        "{} holds protocol state, but {} holds no identity for this account: the file \
167         stores would start a new identity (a new address) and delete the state it cannot \
168         unseal. Point state_root at a fresh directory, or restore the mls_root that wrote \
169         this state",
170        .state.display(),
171        .mls_root.display()
172    )]
173    StateWithoutIdentity {
174        /// The protocol-state account directory.
175        state: PathBuf,
176        /// The MLS root.
177        mls_root: PathBuf,
178    },
179    /// The protocol-state account directory belongs to another MLS store:
180    /// it is bound to another one, or its sealed records open under no
181    /// record key this one holds.
182    ///
183    /// No record was changed. Handing the pair to an engine would delete the
184    /// queued and parked messages its restore reads.
185    #[error(
186        "{} holds protocol state that another identity wrote, not the one in {}: starting \
187         would delete the queued and parked messages in it. Use the mls_root that wrote this \
188         state, or point state_root at a fresh directory. No record was changed",
189        .state.display(),
190        .mls_root.display()
191    )]
192    ForeignState {
193        /// The protocol-state account directory.
194        state: PathBuf,
195        /// The MLS root.
196        mls_root: PathBuf,
197    },
198}
199
200fn roots_overlap_message(
201    mls_root: &std::path::Path,
202    state_root: &std::path::Path,
203    on_this_volume: &bool,
204) -> String {
205    if *on_this_volume {
206        format!(
207            "mls_root and state_root must be different directories, neither inside the \
208             other: {} and {} are spelled apart, and on this volume one is the other or lies \
209             inside it. The stores' directories were created; no record was written or changed",
210            mls_root.display(),
211            state_root.display()
212        )
213    } else {
214        "mls_root and state_root must be different directories, neither inside the other: \
215         mls_root holds the identity and must survive the removal of state_root"
216            .to_string()
217    }
218}
219
220fn account_directory(root: &std::path::Path, namespace: &str) -> Result<PathBuf, FileStoreError> {
221    if !is_account_storage_namespace(namespace) {
222        return Err(FileStoreError::InvalidNamespace);
223    }
224    let directory = root.join(namespace);
225    records::private_mkdir(&directory).map_err(|source| FileStoreError::Io {
226        path: directory.clone(),
227        source,
228    })?;
229    Ok(directory)
230}
231
232/// Whether the account directory `<root>/<namespace>/` holds an entry that
233/// `counts` accepts, without creating, locking or changing anything.
234///
235/// A directory that is not there holds nothing. One that cannot be read is
236/// an error, never "empty": a caller deciding whether it may start a new
237/// identity over it must not take a permission error for a fresh install.
238/// `counts` is handed the entry's path and name, and may look inside it; an
239/// error it returns is reported against the path it was given.
240fn account_holds(
241    root: &std::path::Path,
242    namespace: &str,
243    counts: impl Fn(&std::path::Path, &str) -> std::io::Result<bool>,
244) -> Result<bool, FileStoreError> {
245    if !is_account_storage_namespace(namespace) {
246        return Err(FileStoreError::InvalidNamespace);
247    }
248    let directory = root.join(namespace);
249    let unusable = |path: &std::path::Path, source: std::io::Error| FileStoreError::Io {
250        path: path.to_path_buf(),
251        source,
252    };
253    let entries = match std::fs::read_dir(&directory) {
254        Ok(entries) => entries,
255        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
256        Err(err) => return Err(unusable(&directory, err)),
257    };
258    for entry in entries {
259        let entry = entry.map_err(|err| unusable(&directory, err))?;
260        let path = entry.path();
261        if counts(&path, &entry.file_name().to_string_lossy())
262            .map_err(|err| unusable(&path, err))?
263        {
264            return Ok(true);
265        }
266    }
267    Ok(false)
268}
269
270/// Takes the store's exclusive lock on `directory`.
271fn lock_directory(
272    directory: &std::path::Path,
273    lock_name: &str,
274) -> Result<records::DirectoryLock, FileStoreError> {
275    records::DirectoryLock::acquire(directory, lock_name).map_err(|err| match err {
276        records::LockError::Held => FileStoreError::InUse(directory.to_path_buf()),
277        records::LockError::Io(source) => FileStoreError::Io {
278            path: directory.join(lock_name),
279            source,
280        },
281    })
282}
283
284#[cfg(test)]
285pub(crate) mod test_support {
286    use std::path::{Path, PathBuf};
287
288    use rand_core::{OsRng, RngCore};
289
290    /// A unique directory under the system temp dir, removed on drop.
291    pub(crate) struct TempRoot(PathBuf);
292
293    impl TempRoot {
294        pub(crate) fn new(label: &str) -> Self {
295            let mut suffix = [0u8; 8];
296            OsRng.fill_bytes(&mut suffix);
297            let path = std::env::temp_dir()
298                .join(format!("offline-protocol-{label}-{}", hex::encode(suffix)));
299            std::fs::create_dir_all(&path).expect("create temp root");
300            Self(path)
301        }
302
303        pub(crate) fn path(&self) -> &Path {
304            &self.0
305        }
306    }
307
308    impl Drop for TempRoot {
309        fn drop(&mut self) {
310            let _ = std::fs::remove_dir_all(&self.0);
311        }
312    }
313
314    /// Every regular file under `dir`, recursively.
315    pub(crate) fn files_under(dir: &Path) -> Vec<PathBuf> {
316        let mut out = Vec::new();
317        let mut stack = vec![dir.to_path_buf()];
318        while let Some(next) = stack.pop() {
319            let Ok(entries) = std::fs::read_dir(&next) else {
320                continue;
321            };
322            for entry in entries.flatten() {
323                let path = entry.path();
324                if path.is_dir() {
325                    stack.push(path);
326                } else {
327                    out.push(path);
328                }
329            }
330        }
331        out.sort();
332        out
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    /// The vector the Python binding pins in `test_storage_namespace.py`; the
341    /// Swift and Kotlin helpers share the derivation. A Rust store that
342    /// derived anything else would open an empty directory for an account a
343    /// binding had already written.
344    #[test]
345    fn the_namespace_matches_the_bindings() {
346        assert_eq!(
347            account_storage_namespace("test-app", "test-user-1"),
348            "account-814873e0cbdb2a1f25f14b31625e7f904cf9923e55b415b91ca4b29b210c12a1"
349        );
350        assert_ne!(
351            account_storage_namespace("chat", "alice"),
352            account_storage_namespace("chat", "bob")
353        );
354        assert_ne!(
355            account_storage_namespace("chat", "alice"),
356            account_storage_namespace("other-chat", "alice")
357        );
358    }
359
360    #[test]
361    fn only_the_derived_shape_is_a_namespace() {
362        assert!(is_account_storage_namespace(&account_storage_namespace(
363            "chat", "alice"
364        )));
365        let a64 = "a".repeat(64);
366        for bad in [
367            String::new(),
368            "account-".to_string(),
369            "../../other-account".to_string(),
370            format!("account-{}", "a".repeat(63)),
371            format!("account-{}", "a".repeat(65)),
372            format!("account-{}", "A".repeat(64)),
373            format!("account-{}", "g".repeat(64)),
374            format!("account-{a64}/.."),
375            format!("Account-{a64}"),
376            format!("account-{}\u{0}", "a".repeat(63)),
377        ] {
378            assert!(!is_account_storage_namespace(&bad), "{bad:?} accepted");
379        }
380    }
381
382    #[test]
383    fn a_bad_namespace_is_refused_before_anything_is_created() {
384        let root = test_support::TempRoot::new("namespace");
385        let result = FileProtocolStateStorage::open(root.path(), "../escape");
386        assert!(matches!(result, Err(FileStoreError::InvalidNamespace)));
387        assert!(test_support::files_under(root.path()).is_empty());
388        assert_eq!(
389            std::fs::read_dir(root.path()).expect("read root").count(),
390            0,
391            "a refused namespace must not create a directory"
392        );
393    }
394}