Skip to main content

offline_protocol/file_store/
mls.rs

1//! [`SealedFileMlsStorage`]: MLS material in files, sealed under a store key.
2
3use std::collections::{BTreeSet, HashSet};
4use std::path::{Path, PathBuf};
5use std::sync::Mutex;
6
7use chacha20poly1305::aead::{Aead, KeyInit, Payload};
8use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
9use hkdf::Hkdf;
10use hmac::{Hmac, Mac};
11use offline_protocol_mls::storage::{MlsStorage, StorageError, StorageResult};
12use rand_core::{OsRng, RngCore};
13use sha2::Sha256;
14use zeroize::Zeroizing;
15
16use super::key::{StoreKeyError, StoreKeyProvider, STORE_KEY_BYTES};
17use super::pair::{StoredPairing, PAIRING_ID_BYTES};
18use super::records::{self, BoundedRead, StoreLock, SweepMemo, MAX_FRAME_BYTES};
19use super::{account_directory, lock_directory, FileStoreError};
20
21/// Magic of a sealed record file. Distinct from the plain frame's `OPS1`, so
22/// neither store mistakes the other's file for its own.
23const SEALED_MAGIC: &[u8; 4] = b"OMS1";
24
25/// ChaCha20-Poly1305 nonce length.
26const NONCE_BYTES: usize = 12;
27
28/// Poly1305 tag length.
29const TAG_BYTES: usize = 16;
30
31/// Bytes a seal adds to its plaintext.
32const SEAL_OVERHEAD: usize = SEALED_MAGIC.len() + NONCE_BYTES + TAG_BYTES;
33
34/// Largest sealed file a read accepts: the largest frame, sealed.
35const MAX_SEALED_BYTES: usize = MAX_FRAME_BYTES + SEAL_OVERHEAD;
36
37/// HKDF salt: names this construction, so a store key reused elsewhere (it
38/// should not be) derives unrelated keys here.
39const HKDF_SALT: &[u8] = b"offline-protocol sealed file mls store v1";
40
41/// Prefix of a type directory: `c_` and the keyed digest of the key type.
42const TYPE_PREFIX: &str = "c_";
43
44/// Prefix of a record file: `e_` and the keyed digest of the type and id.
45const ENTRY_PREFIX: &str = "e_";
46
47/// The file that proves the store key before any record is read.
48const KEY_CHECK_FILE: &str = "store-key-check";
49
50/// What the key check seals.
51const KEY_CHECK_PLAINTEXT: &[u8] = b"offline-protocol sealed file mls store key check v1";
52
53/// Records the key check tries when the check file is missing or damaged.
54const KEY_CHECK_PROBE_LIMIT: usize = 64;
55
56/// The lock file this store holds in its account directory. Distinct from
57/// the protocol-state store's, so the two can share a root.
58const LOCK_FILE: &str = "mls-store.lock";
59
60/// The pairing id this store shares with the protocol-state store beside it,
61/// sealed like the key check. In the account directory, outside every type
62/// directory, so [`SealedFileMlsStorage::holds_records`] never takes it for an
63/// identity: it is written before the engine has minted one.
64pub(super) const PAIRING_FILE: &str = "mls-store.pair";
65
66/// MLS storage in files, every record sealed under a key derived from the
67/// operator's store key.
68///
69/// This is the headless host's stand-in for the platform keystore the mobile
70/// bindings use. What it protects against is someone who can read the
71/// directory but does not have the store key: a copied disk, a backup, another
72/// local user. Against that reader:
73///
74/// - **Values are sealed.** ChaCha20-Poly1305 under a key derived with HKDF
75///   from the store key and the account namespace, so a record copied into
76///   another account's directory does not open.
77/// - **Keys are sealed too.** The record's `(key_type, key_id)` travels inside
78///   the ciphertext, and file names are an HMAC under a second derived key. A
79///   listing reveals how many records of each kind exist, never which peers or
80///   groups they belong to. (The protocol-state store's names are an unsalted
81///   digest, a format shared with the bindings.)
82/// - **A record is bound to its slot.** The associated data is the file name,
83///   and the decrypted header must name the key asked for, so a record moved
84///   onto another's name does not open.
85///
86/// What it cannot protect against is the store key itself. It arrives from a
87/// [`StoreKeyProvider`], and on a host without a keystore that means in the
88/// clear from the operator's configuration (residual risk R18).
89///
90/// A record that does not authenticate is reported as
91/// [`StorageError::CorruptedData`], naming its file, and **left in place**.
92/// The store never deletes on a read, because the common cause is a wrong key
93/// and deleting on that would destroy the identity. [`Self::open`] checks the
94/// key against a sealed check file first and refuses a wrong one before any
95/// record is read, so a record that fails afterwards is damaged: the error
96/// names the file, and what deleting it costs depends on what it held (the
97/// engine regenerates a damaged protocol-state record key by itself).
98pub struct SealedFileMlsStorage {
99    directory: PathBuf,
100    cipher: ChaCha20Poly1305,
101    name_key: Zeroizing<[u8; 32]>,
102    lock: StoreLock,
103    swept: SweepMemo,
104    /// Records the listing has already warned about. The engine lists key
105    /// packages and groups often, and a damaged record stays damaged, so one
106    /// warning per file per store says everything without flooding the log.
107    listing_warned: Mutex<HashSet<PathBuf>>,
108}
109
110impl std::fmt::Debug for SealedFileMlsStorage {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        f.debug_struct("SealedFileMlsStorage")
113            .field("directory", &self.directory)
114            .finish_non_exhaustive()
115    }
116}
117
118/// What the key check found when its file was missing or did not open.
119enum Probe {
120    NoRecords,
121    Opened,
122    NoneOpened,
123}
124
125/// What one record file turned out to be.
126///
127/// A file that could not be read is not a file that did not open. Folding
128/// the two together sent a permission or disk error to the operator as "does
129/// not open under this store key", and counted it towards refusing the key.
130enum Opened {
131    Record(String, String),
132    /// Removed between the listing and the read.
133    Absent,
134    /// Read, and does not authenticate under this key and name.
135    DoesNotOpen,
136}
137
138impl SealedFileMlsStorage {
139    /// Opens (creating if needed) the store at `<root>/<namespace>/`, sealed
140    /// under the key `provider` supplies.
141    ///
142    /// The provider is asked once. The first open writes a sealed check file;
143    /// every later open must open it, and a key that does not is refused with
144    /// [`FileStoreError::WrongStoreKey`] before any record is read or changed.
145    /// A check file that is missing or damaged is rebuilt only when an
146    /// existing record opens under the key, which proves it. A check that
147    /// does not open over a store with no records is refused with
148    /// [`FileStoreError::KeyCheckUnverifiable`], which names the check file.
149    ///
150    /// A second store over the same directory is refused with
151    /// [`FileStoreError::InUse`] until this one is closed or dropped.
152    ///
153    /// `root` should be a directory that survives an upgrade: this store holds
154    /// the device's identity, and losing it gives the device a new address.
155    pub fn open(
156        root: impl AsRef<Path>,
157        namespace: &str,
158        provider: &dyn StoreKeyProvider,
159    ) -> Result<Self, FileStoreError> {
160        let directory = account_directory(root.as_ref(), namespace)?;
161        let directory_lock = lock_directory(&directory, LOCK_FILE)?;
162        let store_key = provider.store_key()?;
163        let hkdf = Hkdf::<Sha256>::new(Some(HKDF_SALT), store_key.as_slice());
164        let seal_key = derive(&hkdf, b"seal", namespace)?;
165        let name_key = derive(&hkdf, b"names", namespace)?;
166        let store = Self {
167            directory,
168            cipher: ChaCha20Poly1305::new(Key::from_slice(seal_key.as_slice())),
169            name_key,
170            lock: StoreLock::new(directory_lock),
171            swept: SweepMemo::default(),
172            listing_warned: Mutex::default(),
173        };
174        store.verify_store_key()?;
175        Ok(store)
176    }
177
178    /// The directory this store owns.
179    pub fn directory(&self) -> &Path {
180        &self.directory
181    }
182
183    /// Releases the directory and refuses every later operation.
184    ///
185    /// The lock goes at once, whoever still holds the store: an engine that
186    /// was handed it, a host object its runtime has not freed yet. An
187    /// operation in flight finishes first. Afterwards every operation is an
188    /// error naming the directory, and a new store may open over it. Closing
189    /// twice is closing once.
190    pub fn close(&self) {
191        self.lock.close();
192    }
193
194    /// Whether `<root>/<namespace>/` holds MLS records, without opening,
195    /// creating or locking anything.
196    ///
197    /// A record file, not the directory and not a type directory. An open
198    /// refused after the account directory was made (a wrong key, a
199    /// protocol-state root that could not be created) leaves the directory,
200    /// its lock file and its key check, and no identity. A first write that
201    /// failed (a full disk, a crash) leaves a type directory with nothing in
202    /// it but a temporary. A directory that cannot be read, at either level,
203    /// is an [`FileStoreError::Io`], never "no records".
204    pub fn holds_records(root: impl AsRef<Path>, namespace: &str) -> Result<bool, FileStoreError> {
205        super::account_holds(root.as_ref(), namespace, |path, name| {
206            if !name.starts_with(TYPE_PREFIX) {
207                return Ok(false);
208            }
209            // A file that merely carries the prefix holds no record. Asked
210            // of the entry, not left to the error a directory read gives for
211            // a file, which differs by platform. `metadata`, not `is_dir`:
212            // an account directory that can be listed but not searched
213            // fails here, and `is_dir` would answer "not a directory", which
214            // reads as "no records".
215            if !std::fs::metadata(path)?.is_dir() {
216                return Ok(false);
217            }
218            records::holds_entry(path, ENTRY_PREFIX)
219        })
220    }
221
222    /// The pairing id this store holds, read under the store key.
223    ///
224    /// `Damaged` is a file that is there and does not open: it names no
225    /// state root, and the pair falls back to asking the records.
226    pub(super) fn pairing(&self) -> Result<StoredPairing, FileStoreError> {
227        let _guard = self
228            .lock
229            .open()
230            .ok_or_else(|| FileStoreError::Closed(self.directory.clone()))?;
231        let path = self.directory.join(PAIRING_FILE);
232        let raw = match records::read_bounded(&path, PAIRING_ID_BYTES + SEAL_OVERHEAD).map_err(
233            |source| FileStoreError::Io {
234                path: path.clone(),
235                source,
236            },
237        )? {
238            BoundedRead::Bytes(raw) => raw,
239            BoundedRead::Absent => return Ok(StoredPairing::Absent),
240            BoundedRead::Oversized => return Ok(StoredPairing::Damaged),
241        };
242        Ok(match self.open_sealed(PAIRING_FILE, &raw) {
243            Some(plain) if plain.len() == PAIRING_ID_BYTES => {
244                let mut id = [0u8; PAIRING_ID_BYTES];
245                id.copy_from_slice(&plain);
246                StoredPairing::Present(id)
247            }
248            _ => StoredPairing::Damaged,
249        })
250    }
251
252    /// Records `id` as this store's pairing id, durably.
253    pub(super) fn set_pairing(&self, id: &[u8; PAIRING_ID_BYTES]) -> Result<(), FileStoreError> {
254        let _guard = self
255            .lock
256            .open()
257            .ok_or_else(|| FileStoreError::Closed(self.directory.clone()))?;
258        let sealed = self.seal(PAIRING_FILE, id).ok_or_else(|| {
259            FileStoreError::StoreKey(StoreKeyError::Unavailable(
260                "sealing the pairing id failed".to_string(),
261            ))
262        })?;
263        let path = self.directory.join(PAIRING_FILE);
264        records::write_atomic(&self.directory, &path, &sealed)
265            .map_err(|source| FileStoreError::Io { path, source })
266    }
267
268    /// What a closed store answers.
269    fn closed(&self) -> String {
270        format!("the store at {} is closed", self.directory.display())
271    }
272
273    fn keyed_name(&self, prefix: &str, parts: &[&[u8]]) -> String {
274        // Every part but the last is length-prefixed, so no two different
275        // (type, id) pairs feed the MAC the same bytes.
276        let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(self.name_key.as_slice())
277            .unwrap_or_else(|_| unreachable!("HMAC accepts a key of any length"));
278        if let Some((last, rest)) = parts.split_last() {
279            for part in rest {
280                mac.update(&(part.len() as u64).to_be_bytes());
281                mac.update(part);
282            }
283            mac.update(last);
284        }
285        format!("{prefix}{}", hex::encode(mac.finalize().into_bytes()))
286    }
287
288    fn type_directory(&self, key_type: &str) -> PathBuf {
289        self.directory
290            .join(self.keyed_name(TYPE_PREFIX, &[b"type", key_type.as_bytes()]))
291    }
292
293    fn entry_name(&self, key_type: &str, key_id: &str) -> String {
294        self.keyed_name(
295            ENTRY_PREFIX,
296            &[b"entry", key_type.as_bytes(), key_id.as_bytes()],
297        )
298    }
299
300    fn entry_path(&self, key_type: &str, key_id: &str) -> PathBuf {
301        self.type_directory(key_type)
302            .join(self.entry_name(key_type, key_id))
303    }
304
305    /// The file a record lives in, for tests outside this module that damage
306    /// one on purpose.
307    #[cfg(test)]
308    pub(crate) fn record_path(&self, key_type: &str, key_id: &str) -> PathBuf {
309        self.entry_path(key_type, key_id)
310    }
311
312    /// Reports a record that is present but cannot be read, and leaves it.
313    fn corrupt(path: &Path, reason: &str) -> StorageError {
314        tracing::warn!(path = %path.display(), reason, "sealed MLS record is unreadable; left in place");
315        StorageError::CorruptedData(format!("{reason}: {}; left in place", path.display()))
316    }
317
318    fn associated_data(name: &str) -> Vec<u8> {
319        let mut aad = Vec::with_capacity(SEALED_MAGIC.len() + name.len());
320        aad.extend_from_slice(SEALED_MAGIC);
321        aad.extend_from_slice(name.as_bytes());
322        aad
323    }
324
325    fn seal(&self, name: &str, plaintext: &[u8]) -> Option<Vec<u8>> {
326        let mut nonce = [0u8; NONCE_BYTES];
327        OsRng.fill_bytes(&mut nonce);
328        let ciphertext = self
329            .cipher
330            .encrypt(
331                Nonce::from_slice(&nonce),
332                Payload {
333                    msg: plaintext,
334                    aad: &Self::associated_data(name),
335                },
336            )
337            .ok()?;
338        let mut sealed = Vec::with_capacity(SEAL_OVERHEAD + plaintext.len());
339        sealed.extend_from_slice(SEALED_MAGIC);
340        sealed.extend_from_slice(&nonce);
341        sealed.extend_from_slice(&ciphertext);
342        Some(sealed)
343    }
344
345    fn open_sealed(&self, name: &str, sealed: &[u8]) -> Option<Zeroizing<Vec<u8>>> {
346        let header = SEALED_MAGIC.len() + NONCE_BYTES;
347        if sealed.len() < header + TAG_BYTES || &sealed[..SEALED_MAGIC.len()] != SEALED_MAGIC {
348            return None;
349        }
350        self.cipher
351            .decrypt(
352                Nonce::from_slice(&sealed[SEALED_MAGIC.len()..header]),
353                Payload {
354                    msg: &sealed[header..],
355                    aad: &Self::associated_data(name),
356                },
357            )
358            .ok()
359            .map(Zeroizing::new)
360    }
361
362    /// Opens one record file into its `(key_type, key_id)`. An error is a
363    /// read that failed, never a record that does not open. The value is not
364    /// copied: the only plaintext is the zeroizing buffer, dropped here.
365    fn open_record(&self, path: &Path, name: &str) -> std::io::Result<Opened> {
366        let raw = match records::read_bounded(path, MAX_SEALED_BYTES)? {
367            BoundedRead::Bytes(raw) => raw,
368            BoundedRead::Absent => return Ok(Opened::Absent),
369            BoundedRead::Oversized => return Ok(Opened::DoesNotOpen),
370        };
371        let opened = self
372            .open_sealed(name, &raw)
373            .and_then(|plain| records::parse_header(&plain))
374            .map_or(Opened::DoesNotOpen, |header| {
375                Opened::Record(header.key_type, header.key_id)
376            });
377        Ok(opened)
378    }
379
380    /// Whether this is the first listing to warn about `path` since the file
381    /// there was last written or removed.
382    fn first_listing_warning(&self, path: &Path) -> bool {
383        self.listing_warned
384            .lock()
385            .unwrap_or_else(std::sync::PoisonError::into_inner)
386            .insert(path.to_path_buf())
387    }
388
389    /// Forgets that a listing warned about `path`, once the file there has
390    /// been written or removed: a record damaged again later is news.
391    fn forget_listing_warning(&self, path: &Path) {
392        self.listing_warned
393            .lock()
394            .unwrap_or_else(std::sync::PoisonError::into_inner)
395            .remove(path);
396    }
397
398    /// Proves the store key against the check file, minting the file on a
399    /// fresh store and rebuilding it when a record proves the key.
400    fn verify_store_key(&self) -> Result<(), FileStoreError> {
401        let _guard = self
402            .lock
403            .open()
404            .ok_or_else(|| FileStoreError::Closed(self.directory.clone()))?;
405        // The check is the one file written into the account directory, so
406        // a crash mid-write orphans its temporary here, where no store write
407        // would ever sweep.
408        self.swept.sweep_once(&self.directory);
409        let path = self.directory.join(KEY_CHECK_FILE);
410        let io = |source| FileStoreError::Io {
411            path: path.clone(),
412            source,
413        };
414        let check_present =
415            match records::read_bounded(&path, KEY_CHECK_PLAINTEXT.len() + SEAL_OVERHEAD)
416                .map_err(io)?
417            {
418                BoundedRead::Bytes(raw) => match self.open_sealed(KEY_CHECK_FILE, &raw) {
419                    Some(plain) if plain.as_slice() == KEY_CHECK_PLAINTEXT => return Ok(()),
420                    _ => true,
421                },
422                BoundedRead::Oversized => true,
423                BoundedRead::Absent => false,
424            };
425        // The check is missing, or present and not opening. Records that
426        // open under this key prove it, whatever happened to the check: a
427        // crash before its first write finished, an operator's cleanup, or
428        // damage to the check alone. Records that exist and none of which
429        // opens mean the key is wrong, and minting a check for it would lock
430        // the right key out.
431        //
432        // Rebuilding a damaged check on a record's proof costs nothing: anyone
433        // who can damage the check can delete it, which already reaches this
434        // path. A check that does not open with no records to prove anything
435        // stays a refusal; there is nothing sealed to lose, and the operator
436        // can remove the check file to start the store afresh.
437        match self.probe_existing_records()? {
438            Probe::Opened => {
439                if check_present {
440                    tracing::warn!(
441                        path = %path.display(),
442                        "store key check does not open but a record does; rebuilding the check"
443                    );
444                }
445            }
446            Probe::NoneOpened => return Err(FileStoreError::WrongStoreKey(self.directory.clone())),
447            Probe::NoRecords if check_present => {
448                return Err(FileStoreError::KeyCheckUnverifiable(path.clone()))
449            }
450            Probe::NoRecords => {}
451        }
452        let sealed = self
453            .seal(KEY_CHECK_FILE, KEY_CHECK_PLAINTEXT)
454            .ok_or_else(|| {
455                FileStoreError::StoreKey(StoreKeyError::Unavailable(
456                    "sealing the key check failed".to_string(),
457                ))
458            })?;
459        records::write_atomic(&self.directory, &path, &sealed).map_err(io)
460    }
461
462    /// Tries up to [`KEY_CHECK_PROBE_LIMIT`] records against the store key.
463    ///
464    /// A record that cannot be read says nothing about the key, so it is
465    /// skipped rather than counted against it, and a readable record later in
466    /// the walk can still prove the key. Only when none opens is a read
467    /// failure the answer, naming the record: refusing the key as wrong over
468    /// a permission error would send the operator after the wrong fault.
469    fn probe_existing_records(&self) -> Result<Probe, FileStoreError> {
470        let directory_error = |source| FileStoreError::Io {
471            path: self.directory.clone(),
472            source,
473        };
474        let mut probe = Probe::NoRecords;
475        let mut tried = 0usize;
476        let mut unreadable: Option<FileStoreError> = None;
477        for type_entry in std::fs::read_dir(&self.directory).map_err(directory_error)? {
478            let type_entry = type_entry.map_err(directory_error)?;
479            let is_type_dir = type_entry
480                .file_name()
481                .to_str()
482                .is_some_and(|n| n.starts_with(TYPE_PREFIX))
483                && type_entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
484            if !is_type_dir {
485                continue;
486            }
487            let type_directory = type_entry.path();
488            records::for_each_entry(
489                &type_directory,
490                ENTRY_PREFIX,
491                KEY_CHECK_PROBE_LIMIT,
492                |path, name| {
493                    if matches!(probe, Probe::Opened) || tried >= KEY_CHECK_PROBE_LIMIT {
494                        return;
495                    }
496                    match self.open_record(path, name) {
497                        Ok(Opened::Record(..)) => {
498                            tried += 1;
499                            probe = Probe::Opened;
500                        }
501                        Ok(Opened::DoesNotOpen) => {
502                            tried += 1;
503                            probe = Probe::NoneOpened;
504                        }
505                        // Removed meanwhile: not a record.
506                        Ok(Opened::Absent) => {}
507                        Err(source) => {
508                            unreadable.get_or_insert(FileStoreError::Io {
509                                path: path.to_path_buf(),
510                                source,
511                            });
512                        }
513                    }
514                },
515            )
516            .map_err(|source| FileStoreError::Io {
517                path: type_directory.clone(),
518                source,
519            })?;
520            if matches!(probe, Probe::Opened) || tried >= KEY_CHECK_PROBE_LIMIT {
521                break;
522            }
523        }
524        match (probe, unreadable) {
525            (Probe::Opened, _) => Ok(Probe::Opened),
526            (_, Some(err)) => Err(err),
527            (probe, None) => Ok(probe),
528        }
529    }
530}
531
532fn derive(
533    hkdf: &Hkdf<Sha256>,
534    label: &[u8],
535    namespace: &str,
536) -> Result<Zeroizing<[u8; STORE_KEY_BYTES]>, FileStoreError> {
537    let mut info = Vec::with_capacity(label.len() + 1 + namespace.len());
538    info.extend_from_slice(label);
539    info.push(0);
540    info.extend_from_slice(namespace.as_bytes());
541    let mut out = Zeroizing::new([0u8; STORE_KEY_BYTES]);
542    hkdf.expand(&info, out.as_mut_slice()).map_err(|_| {
543        FileStoreError::StoreKey(StoreKeyError::Unavailable(
544            "key derivation failed".to_string(),
545        ))
546    })?;
547    Ok(out)
548}
549
550impl MlsStorage for SealedFileMlsStorage {
551    fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> StorageResult<()> {
552        let framed = Zeroizing::new(
553            records::frame(key_type, key_id, data)
554                .map_err(|err| StorageError::StoreFailed(err.to_string()))?,
555        );
556        let name = self.entry_name(key_type, key_id);
557        let sealed = self
558            .seal(&name, &framed)
559            .ok_or_else(|| StorageError::StoreFailed("sealing the record failed".to_string()))?;
560        let _guard = self
561            .lock
562            .open()
563            .ok_or_else(|| StorageError::StoreFailed(self.closed()))?;
564        let directory = self.type_directory(key_type);
565        records::private_mkdir(&directory)
566            .map_err(|err| StorageError::StoreFailed(err.to_string()))?;
567        self.swept.sweep_once(&directory);
568        let path = directory.join(&name);
569        records::write_atomic(&directory, &path, &sealed)
570            .map_err(|err| StorageError::StoreFailed(err.to_string()))?;
571        self.forget_listing_warning(&path);
572        Ok(())
573    }
574
575    fn load(&self, key_type: &str, key_id: &str) -> StorageResult<Option<Vec<u8>>> {
576        let _guard = self
577            .lock
578            .open()
579            .ok_or_else(|| StorageError::LoadFailed(self.closed()))?;
580        let name = self.entry_name(key_type, key_id);
581        let path = self.type_directory(key_type).join(&name);
582        let raw = match records::read_bounded(&path, MAX_SEALED_BYTES)
583            .map_err(|err| StorageError::LoadFailed(err.to_string()))?
584        {
585            BoundedRead::Absent => return Ok(None),
586            BoundedRead::Oversized => {
587                return Err(Self::corrupt(&path, "sealed record is over the ceiling"))
588            }
589            BoundedRead::Bytes(raw) => raw,
590        };
591        let plain = self.open_sealed(&name, &raw).ok_or_else(|| {
592            Self::corrupt(
593                &path,
594                "sealed record does not authenticate under this store key",
595            )
596        })?;
597        // The file name is in the associated data, so a record moved onto
598        // another's name has already failed to open. This check is the second
599        // lock: it holds even if two keys' names ever collided.
600        match records::parse_header(&plain) {
601            Some(header) if header.key_type == key_type && header.key_id == key_id => {
602                Ok(Some(plain[header.value_offset..].to_vec()))
603            }
604            _ => Err(Self::corrupt(&path, "sealed record names another key")),
605        }
606    }
607
608    fn delete(&self, key_type: &str, key_id: &str) -> StorageResult<()> {
609        let _guard = self
610            .lock
611            .open()
612            .ok_or_else(|| StorageError::DeleteFailed(self.closed()))?;
613        let path = self.entry_path(key_type, key_id);
614        records::remove(&path).map_err(|err| StorageError::DeleteFailed(err.to_string()))?;
615        self.forget_listing_warning(&path);
616        Ok(())
617    }
618
619    fn list_keys(&self, key_type: &str) -> StorageResult<Vec<String>> {
620        let _guard = self
621            .lock
622            .open()
623            .ok_or_else(|| StorageError::LoadFailed(self.closed()))?;
624        let mut keys = BTreeSet::new();
625        records::for_each_entry(
626            &self.type_directory(key_type),
627            ENTRY_PREFIX,
628            records::MAX_LISTED_ENTRIES,
629            |path, name| match self.open_record(path, name) {
630                Ok(Opened::Record(record_type, record_id)) if record_type == key_type => {
631                    keys.insert(record_id);
632                }
633                // Deleted since the directory was read: not a record.
634                Ok(Opened::Absent) => {}
635                // Left out of the listing, and named with the real cause. A
636                // listing that failed outright on one unreadable file would
637                // stop an engine from finding any of its groups.
638                Err(err) => {
639                    if self.first_listing_warning(path) {
640                        tracing::warn!(
641                            path = %path.display(),
642                            error = %err,
643                            "sealed MLS record could not be read; left out of the listing"
644                        );
645                    }
646                }
647                // The listing is how the engine finds its groups, so a record
648                // skipped here is one the engine never learns it had. Same
649                // level and same path as `load` reports: names are keyed
650                // digests, and the path is the only way to find the file.
651                // Once per file per store: listings are frequent.
652                Ok(Opened::Record(..) | Opened::DoesNotOpen) => {
653                    if self.first_listing_warning(path) {
654                        tracing::warn!(
655                            path = %path.display(),
656                            "sealed MLS record does not open under this store key; \
657                             left in place and left out of the listing"
658                        );
659                    }
660                }
661            },
662        )
663        .map_err(|err| StorageError::LoadFailed(err.to_string()))?;
664        Ok(keys.into_iter().collect())
665    }
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671    use crate::file_store::test_support::{files_under, TempRoot};
672    use crate::file_store::{account_storage_namespace, StaticStoreKey};
673
674    fn namespace() -> String {
675        account_storage_namespace("com.example.mls", "alice")
676    }
677
678    fn key(byte: u8) -> StaticStoreKey {
679        StaticStoreKey::new([byte; 32])
680    }
681
682    fn open(root: &TempRoot, byte: u8) -> SealedFileMlsStorage {
683        SealedFileMlsStorage::open(root.path(), &namespace(), &key(byte)).expect("open")
684    }
685
686    fn snapshot(dir: &Path) -> Vec<(PathBuf, Vec<u8>)> {
687        files_under(dir)
688            .into_iter()
689            .map(|p| {
690                let bytes = std::fs::read(&p).expect("read");
691                (p, bytes)
692            })
693            .collect()
694    }
695
696    /// An open leaves a lock file and a key check and no record, so the
697    /// directory existing says nothing about an identity. Only a stored
698    /// record counts, and the probe itself creates nothing.
699    #[test]
700    fn holds_records_counts_records_not_the_directory() {
701        let root = TempRoot::new("mls-holds");
702        let probe = || SealedFileMlsStorage::holds_records(root.path(), &namespace());
703        assert!(!probe().expect("absent"));
704        assert!(
705            files_under(root.path()).is_empty(),
706            "the probe creates nothing"
707        );
708        let store = open(&root, 1);
709        assert!(!probe().expect("lock and key check only"));
710
711        // What a first write that failed leaves: the type directory, and a
712        // temporary in it. Neither is an identity. Nor is a file that merely
713        // carries a type directory's prefix.
714        let type_directory = store.type_directory("identity");
715        std::fs::create_dir(&type_directory).expect("type directory");
716        assert!(!probe().expect("an empty type directory"));
717        std::fs::write(
718            type_directory.join(format!("{}crashed", records::TEMP_PREFIX)),
719            b"half a record",
720        )
721        .expect("temporary");
722        std::fs::write(store.directory().join("c_not-a-directory"), b"").expect("stray file");
723        assert!(!probe().expect("a temporary and a stray file"));
724
725        store.store("identity", "self", b"key").expect("store");
726        assert!(probe().expect("a record"));
727        assert!(matches!(
728            SealedFileMlsStorage::holds_records(root.path(), "../escape"),
729            Err(FileStoreError::InvalidNamespace)
730        ));
731    }
732
733    /// Closing releases the directory while the store is still held, which
734    /// is the point: a host whose runtime has not freed the object yet must
735    /// not keep the next store out. What the closed store then answers is a
736    /// failed operation, never a lost record, because the engine settles a
737    /// lost record as gone for good and regenerates a lost record key.
738    #[test]
739    fn a_closed_store_releases_the_directory_and_refuses_every_operation() {
740        let root = TempRoot::new("mls-close");
741        let store = open(&root, 1);
742        store.store("identity", "self", b"key").expect("store");
743        assert!(matches!(
744            SealedFileMlsStorage::open(root.path(), &namespace(), &key(1)),
745            Err(FileStoreError::InUse(_))
746        ));
747
748        store.close();
749        store.close();
750        let next = open(&root, 1);
751        assert_eq!(
752            next.load("identity", "self").expect("load"),
753            Some(b"key".to_vec()),
754            "the record written before the close is the next store's to read"
755        );
756
757        assert!(matches!(
758            store.store("identity", "self", b"other"),
759            Err(StorageError::StoreFailed(m)) if m.contains("closed")
760        ));
761        assert!(matches!(
762            store.load("identity", "self"),
763            Err(StorageError::LoadFailed(m)) if m.contains("closed")
764        ));
765        assert!(matches!(
766            store.delete("identity", "self"),
767            Err(StorageError::DeleteFailed(m)) if m.contains("closed")
768        ));
769        assert!(matches!(
770            store.list_keys("identity"),
771            Err(StorageError::LoadFailed(m)) if m.contains("closed")
772        ));
773        assert_eq!(
774            next.load("identity", "self").expect("load"),
775            Some(b"key".to_vec()),
776            "a closed store changes nothing in a directory it no longer holds"
777        );
778    }
779
780    #[test]
781    fn the_store_passes_the_conformance_suite() {
782        let root = TempRoot::new("mls-conformance");
783        let report = offline_protocol_mls::storage_conformance::run(&open(&root, 1));
784        assert!(report.is_green(), "{report:#?}");
785    }
786
787    #[test]
788    fn records_survive_a_reopen_under_the_same_key() {
789        let root = TempRoot::new("mls-reopen");
790        let large = vec![0x5au8; offline_protocol_mls::storage_conformance::LARGE_RECORD_BYTES];
791        {
792            let store = open(&root, 1);
793            store
794                .store("identity", "key_pair", b"identity")
795                .expect("store");
796            store.store("group_state", "team", &large).expect("store");
797        }
798        let store = open(&root, 1);
799        assert_eq!(
800            store.load("identity", "key_pair").expect("load"),
801            Some(b"identity".to_vec())
802        );
803        assert_eq!(
804            store.load("group_state", "team").expect("load"),
805            Some(large)
806        );
807        assert_eq!(store.list_keys("group_state").expect("list"), vec!["team"]);
808    }
809
810    #[test]
811    fn a_wrong_key_is_refused_at_open_and_nothing_changes() {
812        let root = TempRoot::new("mls-wrong-key");
813        {
814            let store = open(&root, 1);
815            store
816                .store("identity", "key_pair", b"identity")
817                .expect("store");
818        }
819        let before = snapshot(root.path());
820
821        let result = SealedFileMlsStorage::open(root.path(), &namespace(), &key(2));
822        assert!(matches!(result, Err(FileStoreError::WrongStoreKey(_))));
823        assert_eq!(snapshot(root.path()), before);
824    }
825
826    #[test]
827    fn a_lost_key_check_is_rebuilt_only_by_the_right_key() {
828        let root = TempRoot::new("mls-lost-check");
829        let check = {
830            let store = open(&root, 1);
831            store
832                .store("identity", "key_pair", b"identity")
833                .expect("store");
834            store.directory().join(KEY_CHECK_FILE)
835        };
836        std::fs::remove_file(&check).expect("remove check");
837        let before = snapshot(root.path());
838
839        // A wrong key must not mint a check for itself over the records.
840        assert!(matches!(
841            SealedFileMlsStorage::open(root.path(), &namespace(), &key(2)),
842            Err(FileStoreError::WrongStoreKey(_))
843        ));
844        assert_eq!(snapshot(root.path()), before);
845
846        let store = open(&root, 1);
847        assert!(check.exists(), "the right key rebuilds the check");
848        assert_eq!(
849            store.load("identity", "key_pair").expect("load"),
850            Some(b"identity".to_vec())
851        );
852    }
853
854    /// Damage to the check alone must not lock the right key out of its own
855    /// identity: a record that opens proves the key, and the check is
856    /// rebuilt. A wrong key still proves nothing and is still refused.
857    #[test]
858    fn a_damaged_key_check_is_rebuilt_only_by_the_right_key() {
859        let root = TempRoot::new("mls-damaged-check");
860        let check = {
861            let store = open(&root, 1);
862            store
863                .store("identity", "key_pair", b"identity")
864                .expect("store");
865            store.directory().join(KEY_CHECK_FILE)
866        };
867        let mut bytes = std::fs::read(&check).expect("read");
868        let last = bytes.len() - 1;
869        bytes[last] ^= 1;
870        std::fs::write(&check, &bytes).expect("write");
871        let before = snapshot(root.path());
872
873        assert!(matches!(
874            SealedFileMlsStorage::open(root.path(), &namespace(), &key(2)),
875            Err(FileStoreError::WrongStoreKey(_))
876        ));
877        assert_eq!(snapshot(root.path()), before, "a wrong key changed nothing");
878
879        let store = open(&root, 1);
880        assert_ne!(std::fs::read(&check).expect("read"), bytes, "check rebuilt");
881        assert_eq!(
882            store.load("identity", "key_pair").expect("load"),
883            Some(b"identity".to_vec())
884        );
885        drop(store);
886        // And the rebuilt check is a real one: the next open takes the fast path.
887        open(&root, 1);
888    }
889
890    /// With no record to prove anything, a check that does not open stays a
891    /// refusal, whichever key is offered. The error is not `WrongStoreKey`,
892    /// because the key may be right, and it names the check file, because
893    /// removing that file is the remedy when it is.
894    #[test]
895    fn a_damaged_key_check_over_an_empty_store_is_refused() {
896        let root = TempRoot::new("mls-damaged-empty");
897        let check = open(&root, 1).directory().join(KEY_CHECK_FILE);
898        let mut bytes = std::fs::read(&check).expect("read");
899        let last = bytes.len() - 1;
900        bytes[last] ^= 1;
901        std::fs::write(&check, &bytes).expect("write");
902        for byte in [1, 2] {
903            match SealedFileMlsStorage::open(root.path(), &namespace(), &key(byte)) {
904                Err(FileStoreError::KeyCheckUnverifiable(path)) => assert_eq!(path, check),
905                other => panic!("expected KeyCheckUnverifiable, got {other:?}"),
906            }
907        }
908        assert_eq!(
909            std::fs::read(&check).expect("read"),
910            bytes,
911            "nothing changed"
912        );
913
914        // The remedy the error names: remove the check and the store opens.
915        std::fs::remove_file(&check).expect("remove check");
916        open(&root, 1);
917    }
918
919    /// The probe keeps going past a record that does not open: one damaged
920    /// record must not hide the good one that proves the key.
921    #[test]
922    fn a_lost_key_check_is_rebuilt_past_an_unreadable_record() {
923        let _callsite = listing_warning_callsite();
924        let root = TempRoot::new("mls-lost-check-damaged");
925        let (check, damaged) = {
926            let store = open(&root, 1);
927            store.store("group_state", "a", b"one").expect("store");
928            store.store("group_state", "b", b"two").expect("store");
929            (
930                store.directory().join(KEY_CHECK_FILE),
931                store.entry_path("group_state", "a"),
932            )
933        };
934        std::fs::remove_file(&check).expect("remove check");
935        let mut bytes = std::fs::read(&damaged).expect("read");
936        bytes[SEALED_MAGIC.len() + NONCE_BYTES] ^= 1;
937        std::fs::write(&damaged, bytes).expect("write");
938
939        let store = open(&root, 1);
940        assert!(check.exists(), "the good record proved the key");
941        assert_eq!(store.list_keys("group_state").expect("list"), vec!["b"]);
942    }
943
944    /// Held by every test that lists a store holding a record that does not
945    /// open, which is every test that reaches the listing's warning.
946    ///
947    /// `tracing` caches whether a callsite is wanted process-wide, and the
948    /// first thread to reach a callsite fills that cache. A test thread with
949    /// no subscriber can compute "never" before the capturing test installs
950    /// its subscriber and store it after, which hides the warning from the
951    /// capture. A test holding this guard has finished any first
952    /// registration before it releases it, so the capturing test's rebuild
953    /// always comes after. Any new test that lists an unopenable record
954    /// takes it too.
955    static LISTING_WARNING_CALLSITE: std::sync::Mutex<()> = std::sync::Mutex::new(());
956
957    fn listing_warning_callsite() -> std::sync::MutexGuard<'static, ()> {
958        LISTING_WARNING_CALLSITE
959            .lock()
960            .unwrap_or_else(std::sync::PoisonError::into_inner)
961    }
962
963    /// Captures every warning raised on this thread, with its fields.
964    #[derive(Clone, Default)]
965    struct Warnings(std::sync::Arc<std::sync::Mutex<Vec<String>>>);
966
967    impl tracing::Subscriber for Warnings {
968        fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
969            *metadata.level() <= tracing::Level::WARN
970        }
971        fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
972            tracing::span::Id::from_u64(1)
973        }
974        fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
975        fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
976        fn event(&self, event: &tracing::Event<'_>) {
977            struct Fields(String);
978            impl tracing::field::Visit for Fields {
979                fn record_debug(
980                    &mut self,
981                    field: &tracing::field::Field,
982                    value: &dyn std::fmt::Debug,
983                ) {
984                    self.0.push_str(&format!("{}={value:?} ", field.name()));
985                }
986            }
987            let mut fields = Fields(String::new());
988            event.record(&mut fields);
989            self.0.lock().expect("warnings").push(fields.0);
990        }
991        fn enter(&self, _: &tracing::span::Id) {}
992        fn exit(&self, _: &tracing::span::Id) {}
993    }
994
995    /// The listing is how the engine enumerates its groups, so a record it
996    /// skips is one the engine never learns about. It must be reported as
997    /// loudly as `load` reports it, with the path, since names are digests.
998    #[test]
999    fn a_record_the_listing_skips_is_warned_about_with_its_path() {
1000        let root = TempRoot::new("mls-list-warn");
1001        let store = open(&root, 1);
1002        store.store("group_state", "a", b"one").expect("store");
1003        store.store("group_state", "b", b"two").expect("store");
1004        let damaged = store.entry_path("group_state", "a");
1005        let mut bytes = std::fs::read(&damaged).expect("read");
1006        bytes[SEALED_MAGIC.len() + NONCE_BYTES] ^= 1;
1007        std::fs::write(&damaged, bytes).expect("write");
1008
1009        // One subscriber for the whole test, installed while no other test
1010        // can be registering the callsite, and the interest cache rebuilt
1011        // once it is in place (see `LISTING_WARNING_CALLSITE`).
1012        let _callsite = listing_warning_callsite();
1013        let warnings = Warnings::default();
1014        let _default = tracing::dispatcher::set_default(&tracing::Dispatch::new(warnings.clone()));
1015        tracing::callsite::rebuild_interest_cache();
1016
1017        let path = damaged.display().to_string();
1018        let named = || {
1019            warnings
1020                .0
1021                .lock()
1022                .expect("warnings")
1023                .iter()
1024                .filter(|w| w.contains(&path))
1025                .count()
1026        };
1027
1028        assert_eq!(store.list_keys("group_state").expect("list"), vec!["b"]);
1029        assert_eq!(named(), 1, "one warning names {path}");
1030
1031        // Listings are frequent; a record that stays damaged is warned about
1032        // once per store, not on every listing.
1033        assert_eq!(store.list_keys("group_state").expect("list"), vec!["b"]);
1034        assert_eq!(named(), 1, "the second listing warned again");
1035        assert!(store
1036            .listing_warned
1037            .lock()
1038            .expect("memo")
1039            .contains(&damaged));
1040    }
1041
1042    /// A record written or removed after a listing warned about it is a new
1043    /// file: damaged again later, it is warned about again.
1044    #[test]
1045    fn a_record_rewritten_after_a_warning_is_warned_about_again() {
1046        let root = TempRoot::new("mls-list-rewarn");
1047        let store = open(&root, 1);
1048        store.store("group_state", "a", b"one").expect("store");
1049        let damaged = store.entry_path("group_state", "a");
1050        let damage = || {
1051            let mut bytes = std::fs::read(&damaged).expect("read");
1052            bytes[SEALED_MAGIC.len() + NONCE_BYTES] ^= 1;
1053            std::fs::write(&damaged, bytes).expect("write");
1054        };
1055        damage();
1056
1057        let _callsite = listing_warning_callsite();
1058        let warnings = Warnings::default();
1059        let _default = tracing::dispatcher::set_default(&tracing::Dispatch::new(warnings.clone()));
1060        tracing::callsite::rebuild_interest_cache();
1061        let path = damaged.display().to_string();
1062        let named = || {
1063            warnings
1064                .0
1065                .lock()
1066                .expect("warnings")
1067                .iter()
1068                .filter(|w| w.contains(&path))
1069                .count()
1070        };
1071
1072        assert!(store.list_keys("group_state").expect("list").is_empty());
1073        assert_eq!(named(), 1);
1074
1075        store.store("group_state", "a", b"again").expect("store");
1076        damage();
1077        assert!(store.list_keys("group_state").expect("list").is_empty());
1078        assert_eq!(
1079            named(),
1080            2,
1081            "a rewritten record damaged again was not reported"
1082        );
1083
1084        store.delete("group_state", "a").expect("delete");
1085        assert!(
1086            !store
1087                .listing_warned
1088                .lock()
1089                .expect("memo")
1090                .contains(&damaged),
1091            "a removed record's warning is still remembered"
1092        );
1093    }
1094
1095    /// Makes `path` unreadable, or answers false where the test runs with
1096    /// the power to read it anyway.
1097    #[cfg(unix)]
1098    fn make_unreadable(path: &Path) -> bool {
1099        use std::os::unix::fs::PermissionsExt;
1100        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o000)).expect("chmod");
1101        std::fs::read(path).is_err()
1102    }
1103
1104    #[cfg(unix)]
1105    fn make_readable(path: &Path) {
1106        use std::os::unix::fs::PermissionsExt;
1107        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).expect("chmod");
1108    }
1109
1110    /// A record that cannot be read is not evidence against the store key.
1111    /// Counted as one that does not open, a permission error on the only
1112    /// record refused the right key as a wrong one.
1113    #[cfg(unix)]
1114    #[test]
1115    fn an_unreadable_record_is_an_io_error_not_a_wrong_key() {
1116        let root = TempRoot::new("mls-unreadable-probe");
1117        let (check, record) = {
1118            let store = open(&root, 1);
1119            store.store("group_state", "a", b"one").expect("store");
1120            (
1121                store.directory().join(KEY_CHECK_FILE),
1122                store.entry_path("group_state", "a"),
1123            )
1124        };
1125        std::fs::remove_file(&check).expect("remove check");
1126        if !make_unreadable(&record) {
1127            return;
1128        }
1129        let outcome = SealedFileMlsStorage::open(root.path(), &namespace(), &key(1));
1130        make_readable(&record);
1131        match outcome {
1132            Err(FileStoreError::Io { path, .. }) => {
1133                assert_eq!(path, record, "the error does not name the record");
1134            }
1135            Err(other) => panic!("expected an I/O error naming the record, got {other}"),
1136            Ok(_) => panic!("an unreadable record proved the key"),
1137        }
1138        assert!(
1139            !check.exists(),
1140            "no key check was minted on an unread record"
1141        );
1142        open(&root, 1);
1143        assert!(check.exists(), "once readable, the record proves the key");
1144    }
1145
1146    /// An unreadable record does not stand in the way of a readable one that
1147    /// proves the key, whichever the directory lists first.
1148    ///
1149    /// File names are keyed digests, so the order the probe meets records in
1150    /// is not the test's to choose. Four of five are unreadable, so a probe
1151    /// that stopped at the first unreadable record fails here on most runs.
1152    #[cfg(unix)]
1153    #[test]
1154    fn an_unreadable_record_does_not_hide_one_that_proves_the_key() {
1155        let root = TempRoot::new("mls-unreadable-beside-good");
1156        let ids = ["a", "b", "c", "d", "e"];
1157        let (check, unreadable) = {
1158            let store = open(&root, 1);
1159            for id in ids {
1160                store.store("group_state", id, b"value").expect("store");
1161            }
1162            (
1163                store.directory().join(KEY_CHECK_FILE),
1164                ids[1..]
1165                    .iter()
1166                    .map(|id| store.entry_path("group_state", id))
1167                    .collect::<Vec<_>>(),
1168            )
1169        };
1170        std::fs::remove_file(&check).expect("remove check");
1171        if !unreadable.iter().all(|path| make_unreadable(path)) {
1172            unreadable.iter().for_each(|path| make_readable(path));
1173            return;
1174        }
1175        let outcome = SealedFileMlsStorage::open(root.path(), &namespace(), &key(1));
1176        unreadable.iter().for_each(|path| make_readable(path));
1177        outcome.expect("a readable record proves the key");
1178        assert!(check.exists(), "the key check is rebuilt");
1179    }
1180
1181    /// The listing names a read failure as one, not as a record sealed under
1182    /// another key, and still returns the records it could read.
1183    #[cfg(unix)]
1184    #[test]
1185    fn an_unreadable_record_is_listed_as_unreadable() {
1186        let root = TempRoot::new("mls-unreadable-list");
1187        let store = open(&root, 1);
1188        store.store("group_state", "a", b"one").expect("store");
1189        store.store("group_state", "b", b"two").expect("store");
1190        let record = store.entry_path("group_state", "a");
1191        if !make_unreadable(&record) {
1192            return;
1193        }
1194
1195        let _callsite = listing_warning_callsite();
1196        let warnings = Warnings::default();
1197        let _default = tracing::dispatcher::set_default(&tracing::Dispatch::new(warnings.clone()));
1198        tracing::callsite::rebuild_interest_cache();
1199
1200        let listed = store.list_keys("group_state");
1201        make_readable(&record);
1202        assert_eq!(listed.expect("list"), vec!["b"]);
1203        let path = record.display().to_string();
1204        let said: Vec<String> = warnings
1205            .0
1206            .lock()
1207            .expect("warnings")
1208            .iter()
1209            .filter(|w| w.contains(&path))
1210            .cloned()
1211            .collect();
1212        assert_eq!(said.len(), 1, "{said:?}");
1213        assert!(
1214            said[0].contains("could not be read") && !said[0].contains("store key"),
1215            "the warning blames the key for a read failure: {}",
1216            said[0]
1217        );
1218    }
1219
1220    /// Nothing to write down in a record's name, so the error must carry the
1221    /// path: file names are keyed digests, and an operator cannot find the
1222    /// damaged file any other way.
1223    #[test]
1224    fn a_corrupt_record_error_names_its_file() {
1225        let root = TempRoot::new("mls-corrupt-path");
1226        let store = open(&root, 1);
1227        store.store("identity", "key_pair", b"id").expect("store");
1228        let path = store.entry_path("identity", "key_pair");
1229        let mut bytes = std::fs::read(&path).expect("read");
1230        let last = bytes.len() - 1;
1231        bytes[last] ^= 1;
1232        std::fs::write(&path, bytes).expect("write");
1233
1234        match store.load("identity", "key_pair") {
1235            Err(StorageError::CorruptedData(message)) => assert!(
1236                message.contains(&path.display().to_string()),
1237                "{message} does not name {}",
1238                path.display()
1239            ),
1240            other => panic!("expected CorruptedData, got {other:?}"),
1241        }
1242    }
1243
1244    #[test]
1245    fn a_second_store_over_one_directory_is_refused() {
1246        let root = TempRoot::new("mls-in-use");
1247        let first = open(&root, 1);
1248        assert!(matches!(
1249            SealedFileMlsStorage::open(root.path(), &namespace(), &key(1)),
1250            Err(FileStoreError::InUse(_))
1251        ));
1252        drop(first);
1253        open(&root, 1);
1254    }
1255
1256    /// The point of sealing names as well as values: a copied directory must
1257    /// not say which peers or groups the device knows.
1258    #[test]
1259    fn neither_values_nor_keys_reach_the_disk_in_the_clear() {
1260        let root = TempRoot::new("mls-opaque");
1261        let store = open(&root, 1);
1262        let secret_value = b"the-secret-value-marker";
1263        let secret_id = "group-named-secret-team";
1264        let secret_type = "contact_key_package";
1265        store
1266            .store(secret_type, secret_id, secret_value)
1267            .expect("store");
1268
1269        for (path, bytes) in snapshot(root.path()) {
1270            let name = path.to_string_lossy();
1271            for needle in [
1272                &secret_value[..],
1273                secret_id.as_bytes(),
1274                secret_type.as_bytes(),
1275            ] {
1276                assert!(
1277                    !bytes.windows(needle.len()).any(|w| w == needle),
1278                    "{} holds {:?} in the clear",
1279                    name,
1280                    String::from_utf8_lossy(needle)
1281                );
1282                assert!(!name.contains(std::str::from_utf8(needle).expect("utf8")));
1283            }
1284        }
1285    }
1286
1287    #[test]
1288    fn a_record_moved_onto_another_name_does_not_open_and_is_kept() {
1289        let _callsite = listing_warning_callsite();
1290        let root = TempRoot::new("mls-moved");
1291        let store = open(&root, 1);
1292        store.store("group_state", "a", b"one").expect("store");
1293        store.store("group_state", "b", b"two").expect("store");
1294        let a = store.entry_path("group_state", "a");
1295        std::fs::copy(store.entry_path("group_state", "b"), &a).expect("copy");
1296
1297        assert!(matches!(
1298            store.load("group_state", "a"),
1299            Err(StorageError::CorruptedData(_))
1300        ));
1301        assert!(a.exists(), "the sealed store never deletes on a read");
1302        assert_eq!(store.list_keys("group_state").expect("list"), vec!["b"]);
1303        assert_eq!(
1304            store.load("group_state", "b").expect("load"),
1305            Some(b"two".to_vec())
1306        );
1307    }
1308
1309    #[test]
1310    fn a_tampered_or_oversized_record_is_corrupt_and_kept() {
1311        let _callsite = listing_warning_callsite();
1312        let root = TempRoot::new("mls-tampered");
1313        let store = open(&root, 1);
1314        store
1315            .store("identity", "key_pair", b"identity")
1316            .expect("store");
1317        store.store("identity", "other", b"other").expect("store");
1318        let path = store.entry_path("identity", "key_pair");
1319        let mut bytes = std::fs::read(&path).expect("read");
1320        bytes[SEALED_MAGIC.len() + NONCE_BYTES] ^= 1;
1321        std::fs::write(&path, &bytes).expect("write");
1322
1323        assert!(matches!(
1324            store.load("identity", "key_pair"),
1325            Err(StorageError::CorruptedData(_))
1326        ));
1327        assert!(path.exists());
1328        assert_eq!(store.list_keys("identity").expect("list"), vec!["other"]);
1329
1330        let handle = std::fs::OpenOptions::new()
1331            .write(true)
1332            .open(&path)
1333            .expect("open");
1334        handle
1335            .set_len((MAX_SEALED_BYTES + 1) as u64)
1336            .expect("sparse extend");
1337        drop(handle);
1338        assert!(matches!(
1339            store.load("identity", "key_pair"),
1340            Err(StorageError::CorruptedData(_))
1341        ));
1342        assert!(path.exists());
1343    }
1344
1345    /// The seal and name keys are derived per namespace, so one store key
1346    /// serving two accounts does not let one account's record open in the
1347    /// other's directory.
1348    #[test]
1349    fn a_record_copied_into_another_account_does_not_open() {
1350        let root = TempRoot::new("mls-accounts");
1351        let alice = open(&root, 1);
1352        let bob = SealedFileMlsStorage::open(
1353            root.path(),
1354            &account_storage_namespace("com.example.mls", "bob"),
1355            &key(1),
1356        )
1357        .expect("open");
1358        alice
1359            .store("identity", "key_pair", b"alice")
1360            .expect("store");
1361        bob.store("identity", "key_pair", b"bob").expect("store");
1362
1363        std::fs::copy(
1364            alice.entry_path("identity", "key_pair"),
1365            bob.entry_path("identity", "key_pair"),
1366        )
1367        .expect("copy");
1368        assert!(matches!(
1369            bob.load("identity", "key_pair"),
1370            Err(StorageError::CorruptedData(_))
1371        ));
1372        assert_ne!(
1373            alice.entry_name("identity", "key_pair"),
1374            bob.entry_name("identity", "key_pair")
1375        );
1376    }
1377
1378    #[test]
1379    fn ids_differing_only_in_case_are_distinct_records() {
1380        let root = TempRoot::new("mls-case");
1381        let store = open(&root, 1);
1382        store.store("group_state", "Team", b"upper").expect("store");
1383        store.store("group_state", "team", b"lower").expect("store");
1384        assert_eq!(
1385            store.load("group_state", "Team").expect("load"),
1386            Some(b"upper".to_vec())
1387        );
1388        assert_eq!(
1389            store.load("group_state", "team").expect("load"),
1390            Some(b"lower".to_vec())
1391        );
1392    }
1393
1394    /// Length-prefixing keeps `("ab", "c")` and `("a", "bc")` apart.
1395    #[test]
1396    fn a_type_and_id_split_differently_name_different_files() {
1397        let root = TempRoot::new("mls-split");
1398        let store = open(&root, 1);
1399        assert_ne!(store.entry_name("ab", "c"), store.entry_name("a", "bc"));
1400    }
1401}