1use 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
21const SEALED_MAGIC: &[u8; 4] = b"OMS1";
24
25const NONCE_BYTES: usize = 12;
27
28const TAG_BYTES: usize = 16;
30
31const SEAL_OVERHEAD: usize = SEALED_MAGIC.len() + NONCE_BYTES + TAG_BYTES;
33
34const MAX_SEALED_BYTES: usize = MAX_FRAME_BYTES + SEAL_OVERHEAD;
36
37const HKDF_SALT: &[u8] = b"offline-protocol sealed file mls store v1";
40
41const TYPE_PREFIX: &str = "c_";
43
44const ENTRY_PREFIX: &str = "e_";
46
47const KEY_CHECK_FILE: &str = "store-key-check";
49
50const KEY_CHECK_PLAINTEXT: &[u8] = b"offline-protocol sealed file mls store key check v1";
52
53const KEY_CHECK_PROBE_LIMIT: usize = 64;
55
56const LOCK_FILE: &str = "mls-store.lock";
59
60pub(super) const PAIRING_FILE: &str = "mls-store.pair";
65
66pub struct SealedFileMlsStorage {
99 directory: PathBuf,
100 cipher: ChaCha20Poly1305,
101 name_key: Zeroizing<[u8; 32]>,
102 lock: StoreLock,
103 swept: SweepMemo,
104 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
118enum Probe {
120 NoRecords,
121 Opened,
122 NoneOpened,
123}
124
125enum Opened {
131 Record(String, String),
132 Absent,
134 DoesNotOpen,
136}
137
138impl SealedFileMlsStorage {
139 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 pub fn directory(&self) -> &Path {
180 &self.directory
181 }
182
183 pub fn close(&self) {
191 self.lock.close();
192 }
193
194 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 if !std::fs::metadata(path)?.is_dir() {
216 return Ok(false);
217 }
218 records::holds_entry(path, ENTRY_PREFIX)
219 })
220 }
221
222 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 Ok(Opened::Absent) => {}
635 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 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 #[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 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 #[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 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 #[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 open(&root, 1);
888 }
889
890 #[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 std::fs::remove_file(&check).expect("remove check");
916 open(&root, 1);
917 }
918
919 #[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 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}