1use std::path::{Component, Path, PathBuf};
32use std::sync::Arc;
33
34use rand_core::{OsRng, RngCore};
35
36use super::key::StoreKeyProvider;
37use super::{FileProtocolStateStorage, FileStoreError, SealedFileMlsStorage, SealedState};
38
39pub(super) const PAIRING_ID_BYTES: usize = 32;
41
42pub(super) enum StoredPairing {
44 Absent,
46 Present([u8; PAIRING_ID_BYTES]),
48 Damaged,
51}
52
53#[derive(Clone)]
61pub struct FileStorePair {
62 secure: Arc<SealedFileMlsStorage>,
63 state: Arc<FileProtocolStateStorage>,
64}
65
66impl std::fmt::Debug for FileStorePair {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 f.debug_struct("FileStorePair")
69 .field("secure", &self.secure)
70 .field("state", &self.state)
71 .finish()
72 }
73}
74
75impl FileStorePair {
76 pub fn open(
108 mls_root: impl AsRef<Path>,
109 state_root: impl AsRef<Path>,
110 namespace: &str,
111 provider: &dyn StoreKeyProvider,
112 ) -> Result<Self, FileStoreError> {
113 let (mls_root, state_root) = (mls_root.as_ref(), state_root.as_ref());
114 let overlap = |on_this_volume| FileStoreError::RootsOverlap {
115 mls_root: mls_root.to_path_buf(),
116 state_root: state_root.to_path_buf(),
117 on_this_volume,
118 };
119 if roots_overlap(mls_root, state_root) {
120 return Err(overlap(false));
121 }
122 refuse_state_without_identity(mls_root, state_root, namespace)?;
123
124 let secure = SealedFileMlsStorage::open(mls_root, namespace, provider)?;
127 let state = FileProtocolStateStorage::open(state_root, namespace)?;
128
129 if roots_overlap(mls_root, state_root) {
133 return Err(overlap(true));
134 }
135
136 let pairing = secure.pairing()?;
137 if FileProtocolStateStorage::holds_records(state_root, namespace)? {
140 admit(
141 ownership(&secure, &state, &pairing)?,
142 state.directory(),
143 mls_root,
144 )?;
145 }
146 bind(&secure, &state, pairing)?;
147 Ok(Self {
148 secure: Arc::new(secure),
149 state: Arc::new(state),
150 })
151 }
152
153 pub fn secure_storage(&self) -> Arc<SealedFileMlsStorage> {
155 self.secure.clone()
156 }
157
158 pub fn state_storage(&self) -> Arc<FileProtocolStateStorage> {
160 self.state.clone()
161 }
162
163 pub fn close(&self) {
167 self.state.close();
168 self.secure.close();
169 }
170}
171
172fn ownership(
191 secure: &SealedFileMlsStorage,
192 state: &FileProtocolStateStorage,
193 pairing: &StoredPairing,
194) -> Result<SealedState, FileStoreError> {
195 match (state.pairing()?, pairing) {
196 (Some(bound), StoredPairing::Present(ours)) if bound == *ours => Ok(SealedState::Opens),
197 (Some(_), StoredPairing::Present(_)) => Ok(match state.sealed_state(secure)? {
201 SealedState::Opens => SealedState::Opens,
202 SealedState::Empty | SealedState::Foreign | SealedState::KeyDamaged => {
203 SealedState::Foreign
204 }
205 }),
206 (Some(_), StoredPairing::Absent | StoredPairing::Damaged) | (None, _) => {
207 state.sealed_state(secure)
208 }
209 }
210}
211
212fn admit(
217 verdict: SealedState,
218 state_directory: &Path,
219 mls_root: &Path,
220) -> Result<(), FileStoreError> {
221 match verdict {
222 SealedState::Empty | SealedState::Opens => Ok(()),
223 SealedState::KeyDamaged => {
224 tracing::warn!(
225 state = %state_directory.display(),
226 "the protocol-state record key is damaged: the sealed state beside it \
227 cannot be opened again, and the engine will settle it as failed"
228 );
229 Ok(())
230 }
231 SealedState::Foreign => Err(FileStoreError::ForeignState {
232 state: state_directory.to_path_buf(),
233 mls_root: mls_root.to_path_buf(),
234 }),
235 }
236}
237
238fn bind(
248 secure: &SealedFileMlsStorage,
249 state: &FileProtocolStateStorage,
250 pairing: StoredPairing,
251) -> Result<(), FileStoreError> {
252 match pairing {
253 StoredPairing::Present(id) => {
254 if state.pairing()? != Some(id) {
255 state.set_pairing(&id)?;
256 }
257 }
258 StoredPairing::Absent | StoredPairing::Damaged => {
259 let mut id = [0u8; PAIRING_ID_BYTES];
260 OsRng.fill_bytes(&mut id);
261 state.set_pairing(&id)?;
262 secure.set_pairing(&id)?;
263 }
264 }
265 Ok(())
266}
267
268fn refuse_state_without_identity(
285 mls_root: &Path,
286 state_root: &Path,
287 namespace: &str,
288) -> Result<(), FileStoreError> {
289 if SealedFileMlsStorage::holds_records(mls_root, namespace)? {
290 return Ok(());
291 }
292 if FileProtocolStateStorage::holds_records(state_root, namespace)? {
293 return Err(FileStoreError::StateWithoutIdentity {
294 state: state_root.join(namespace),
295 mls_root: mls_root.to_path_buf(),
296 });
297 }
298 Ok(())
299}
300
301fn roots_overlap(a: &Path, b: &Path) -> bool {
306 let (a, b) = (resolved_root(a), resolved_root(b));
307 a.starts_with(&b) || b.starts_with(&a)
308}
309
310fn resolved_root(path: &Path) -> PathBuf {
315 let absolute = if path.is_absolute() {
316 path.to_path_buf()
317 } else {
318 std::env::current_dir()
319 .map(|cwd| cwd.join(path))
320 .unwrap_or_else(|_| path.to_path_buf())
321 };
322 let mut existing = absolute.as_path();
323 let mut rest = Vec::new();
324 let base = loop {
325 if let Ok(canonical) = existing.canonicalize() {
326 break canonical;
327 }
328 match (existing.parent(), existing.file_name()) {
329 (Some(parent), Some(name)) => {
330 rest.push(Component::Normal(name));
331 existing = parent;
332 }
333 (Some(parent), None) => {
335 rest.extend(existing.components().next_back());
336 existing = parent;
337 }
338 (None, _) => break PathBuf::new(),
339 }
340 };
341 let mut resolved = base;
342 for component in rest.into_iter().rev() {
343 match component {
344 Component::ParentDir => {
345 resolved.pop();
346 }
347 Component::CurDir => {}
348 other => resolved.push(other),
349 }
350 }
351 resolved
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::file_store::account_storage_namespace;
358 use crate::file_store::mls::PAIRING_FILE;
359 use crate::file_store::test_support::{files_under, TempRoot};
360 use crate::file_store::StaticStoreKey;
361 use crate::protocol_state_storage::ProtocolStateStorage;
362 use offline_protocol_mls::storage::MlsStorage;
363
364 fn namespace() -> String {
365 account_storage_namespace("com.example.pair", "alice")
366 }
367
368 struct Roots {
369 keys: TempRoot,
370 state: TempRoot,
371 }
372
373 fn roots(label: &str) -> Roots {
374 Roots {
375 keys: TempRoot::new(&format!("{label}-keys")),
376 state: TempRoot::new(&format!("{label}-state")),
377 }
378 }
379
380 fn open(roots: &Roots) -> Result<FileStorePair, FileStoreError> {
381 FileStorePair::open(
382 roots.keys.path(),
383 roots.state.path(),
384 &namespace(),
385 &StaticStoreKey::new([4; 32]),
386 )
387 }
388
389 #[test]
394 fn only_state_another_identity_wrote_is_refused() {
395 let (state, keys) = (Path::new("/srv/state/account"), Path::new("/srv/keys"));
396 let err = admit(SealedState::Foreign, state, keys).expect_err("foreign");
397 let message = err.to_string();
398 assert!(
399 matches!(err, FileStoreError::ForeignState { .. })
400 && message.contains("another identity")
401 && message.contains("fresh directory")
402 && message.contains("/srv/state/account")
403 && message.contains("/srv/keys"),
404 "{message}"
405 );
406 for verdict in [
407 SealedState::Empty,
408 SealedState::Opens,
409 SealedState::KeyDamaged,
410 ] {
411 admit(verdict, state, keys).unwrap_or_else(|err| panic!("{verdict:?}: {err}"));
412 }
413 }
414
415 #[test]
419 fn the_first_open_binds_the_pair_and_writes_no_record() {
420 let roots = roots("pair-bind");
421 let pair = open(&roots).expect("open");
422 let StoredPairing::Present(id) = pair.secure.pairing().expect("mls pairing") else {
423 panic!("the MLS store is bound");
424 };
425 assert_eq!(pair.state.pairing().expect("state pairing"), Some(id));
426 assert!(!SealedFileMlsStorage::holds_records(roots.keys.path(), &namespace()).unwrap());
427 assert!(
428 !FileProtocolStateStorage::holds_records(roots.state.path(), &namespace()).unwrap()
429 );
430
431 pair.close();
432 let again = open(&roots).expect("reopen");
433 assert!(
434 matches!(again.secure.pairing().unwrap(), StoredPairing::Present(same) if same == id),
435 "a bound pair keeps its id"
436 );
437 }
438
439 #[test]
443 fn state_bound_to_another_mls_store_is_refused_and_kept() {
444 let ours = roots("pair-ours");
445 let pair = open(&ours).expect("open");
446 pair.secure
447 .store("identity", "self", b"ours")
448 .expect("an identity");
449 pair.state
450 .store("lamport_clock", "current", b"7")
451 .expect("a record");
452 pair.close();
453
454 let theirs = roots("pair-theirs");
455 let other = open(&theirs).expect("another identity's pair");
456 other
457 .secure
458 .store("identity", "self", b"theirs")
459 .expect("another identity");
460 other.close();
461 let before = files_under(ours.state.path());
462 let err = FileStorePair::open(
463 theirs.keys.path(),
464 ours.state.path(),
465 &namespace(),
466 &StaticStoreKey::new([4; 32]),
467 )
468 .expect_err("another MLS store");
469 assert!(matches!(err, FileStoreError::ForeignState { .. }), "{err}");
470 assert_eq!(files_under(ours.state.path()), before);
471
472 let sealed = crate::protocol::state_crypto::StateRecordCipher::new(&[9; 32])
476 .seal("outbox", "parked", b"another identity's")
477 .expect("seal");
478 let bound_state = FileProtocolStateStorage::open(ours.state.path(), &namespace())
479 .expect("open the state store");
480 bound_state
481 .store("outbox", "parked", &sealed)
482 .expect("a sealed record");
483 bound_state.close();
484 let before = files_under(ours.state.path());
485 let unbound = TempRoot::new("pair-unbound-keys");
486 let secure =
487 SealedFileMlsStorage::open(unbound.path(), &namespace(), &StaticStoreKey::new([4; 32]))
488 .expect("open");
489 secure
490 .store("identity", "self", b"key")
491 .expect("an identity");
492 secure.close();
493 let err = FileStorePair::open(
494 unbound.path(),
495 ours.state.path(),
496 &namespace(),
497 &StaticStoreKey::new([4; 32]),
498 )
499 .expect_err("records that open under no key it holds");
500 assert!(matches!(err, FileStoreError::ForeignState { .. }), "{err}");
501 assert_eq!(files_under(ours.state.path()), before);
502
503 open(&ours).expect("the pair that belongs together still opens");
504 }
505
506 #[test]
509 fn a_state_store_with_no_record_is_rebound_not_refused() {
510 let ours = roots("pair-empty-ours");
511 drop(open(&ours).expect("open"));
512 let theirs = roots("pair-empty-theirs");
513 let pair = FileStorePair::open(
514 theirs.keys.path(),
515 ours.state.path(),
516 &namespace(),
517 &StaticStoreKey::new([4; 32]),
518 )
519 .expect("no record to lose");
520 let StoredPairing::Present(id) = pair.secure.pairing().unwrap() else {
521 panic!("bound");
522 };
523 assert_eq!(pair.state.pairing().unwrap(), Some(id));
524 }
525
526 #[test]
530 fn a_missing_mls_pairing_is_judged_by_the_records_not_refused() {
531 let roots = roots("pair-missing");
532 let pair = open(&roots).expect("open");
533 pair.secure
534 .store("identity", "self", b"ours")
535 .expect("an identity");
536 pair.state
537 .store("lamport_clock", "current", b"7")
538 .expect("a record");
539 let path = pair.secure.directory().join(PAIRING_FILE);
540 pair.close();
541 std::fs::remove_file(&path).expect("remove the pairing file");
542
543 let pair = open(&roots).expect("the same identity over its own state");
544 let StoredPairing::Present(id) = pair.secure.pairing().unwrap() else {
545 panic!("bound again");
546 };
547 assert_eq!(pair.state.pairing().unwrap(), Some(id));
548 }
549
550 #[test]
556 fn a_rebind_that_fails_part_way_still_opens_next_time() {
557 let roots = roots("pair-cut-short");
558 let pair = open(&roots).expect("open");
559 pair.secure
560 .store("identity", "self", b"ours")
561 .expect("an identity");
562 pair.state
563 .store("lamport_clock", "current", b"7")
564 .expect("a record");
565 let path = pair.secure.directory().join(PAIRING_FILE);
566 pair.close();
567 std::fs::remove_file(&path).expect("unbind the MLS store");
568
569 crate::file_store::state::tests::FAIL_PAIRING_WRITE.with(|fail| fail.set(true));
570 let err = open(&roots).expect_err("the state store's pairing write fails");
571 assert!(matches!(err, FileStoreError::Io { .. }), "{err}");
572
573 open(&roots).expect("the next open admits the pair and binds it");
574 }
575
576 fn seal_one_record(pair: &FileStorePair, key: [u8; 32]) {
579 use crate::protocol::storage_keys::{STATE_RECORD_KEY, STATE_RECORD_KEY_ID};
580 pair.secure
581 .store(STATE_RECORD_KEY, STATE_RECORD_KEY_ID, &key)
582 .expect("a record key");
583 let key_type = crate::protocol::sealed_state_key_types()
584 .next()
585 .expect("a sealed category");
586 let sealed = crate::protocol::state_crypto::StateRecordCipher::new(&key)
587 .seal(key_type, "parked", b"ours")
588 .expect("seal");
589 pair.state
590 .store(key_type, "parked", &sealed)
591 .expect("a sealed record");
592 }
593
594 #[test]
599 fn a_state_root_whose_records_open_is_admitted_over_an_id_mismatch() {
600 let ours = roots("pair-mismatch-opens");
601 let pair = open(&ours).expect("open");
602 pair.secure
603 .store("identity", "self", b"ours")
604 .expect("an identity");
605 seal_one_record(&pair, [5; 32]);
606 let pairing_file = pair.secure.directory().join(PAIRING_FILE);
607 pair.close();
608 std::fs::remove_file(&pairing_file).expect("lose the MLS pairing file");
609
610 let elsewhere = TempRoot::new("pair-mismatch-elsewhere");
611 let rebound = FileStorePair::open(
612 ours.keys.path(),
613 elsewhere.path(),
614 &namespace(),
615 &StaticStoreKey::new([4; 32]),
616 )
617 .expect("bound beside a fresh state root");
618 let StoredPairing::Present(new_id) = rebound.secure.pairing().unwrap() else {
619 panic!("bound again");
620 };
621 rebound.close();
622 assert_ne!(
623 FileProtocolStateStorage::open(ours.state.path(), &namespace())
624 .and_then(|state| {
625 let id = state.pairing();
626 state.close();
627 id
628 })
629 .expect("the first state root"),
630 Some(new_id),
631 "the first state root still carries the old id"
632 );
633
634 let pair = open(&ours).expect("its own records open under its record key");
635 assert_eq!(pair.state.pairing().unwrap(), Some(new_id), "bound again");
636 }
637
638 #[test]
642 fn a_state_store_with_no_record_is_rebound_beside_another_bound_mls_store() {
643 let theirs = roots("pair-empty-bound-theirs");
644 open(&theirs).expect("their pair").close();
645 let ours = roots("pair-empty-bound-ours");
646 open(&ours).expect("our pair").close();
647
648 let pair = FileStorePair::open(
649 theirs.keys.path(),
650 ours.state.path(),
651 &namespace(),
652 &StaticStoreKey::new([4; 32]),
653 )
654 .expect("no record to lose");
655 let StoredPairing::Present(id) = pair.secure.pairing().unwrap() else {
656 panic!("bound");
657 };
658 assert_eq!(pair.state.pairing().unwrap(), Some(id));
659 }
660
661 #[test]
665 fn a_state_root_made_again_takes_the_bound_mls_id() {
666 let roots = roots("pair-reinstall");
667 let pair = open(&roots).expect("open");
668 pair.secure
669 .store("identity", "self", b"ours")
670 .expect("an identity");
671 let StoredPairing::Present(id) = pair.secure.pairing().unwrap() else {
672 panic!("bound");
673 };
674 pair.close();
675 std::fs::remove_dir_all(roots.state.path()).expect("remove the state root");
676
677 let pair = open(&roots).expect("reopen over a new state root");
678 assert_eq!(pair.state.pairing().unwrap(), Some(id));
679 }
680
681 #[test]
684 fn a_damaged_mls_pairing_falls_back_to_the_records() {
685 let roots = roots("pair-damaged");
686 let pair = open(&roots).expect("open");
687 let path = pair.secure.directory().join(PAIRING_FILE);
688 pair.secure
689 .store("identity", "self", b"ours")
690 .expect("an identity");
691 pair.state
692 .store("lamport_clock", "current", b"7")
693 .expect("an unsealed record");
694 pair.close();
695 let mut bytes = std::fs::read(&path).expect("pairing file");
696 let last = bytes.len() - 1;
697 bytes[last] ^= 1;
698 std::fs::write(&path, bytes).expect("damage");
699
700 let pair = open(&roots).expect("no sealed record, so the records say Empty");
701 let StoredPairing::Present(id) = pair.secure.pairing().unwrap() else {
702 panic!("bound again");
703 };
704 assert_eq!(pair.state.pairing().unwrap(), Some(id));
705 }
706}