#![forbid(unsafe_code)] mod support; use std::{ collections::{BTreeMap, BTreeSet}, fs, }; use ironstorage::{ crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, document::EntryDocumentService, mobile_totp::{ MobileTotpDiscoveryPhase, MobileTotpError, MobileTotpOperation, MobileTotpService, }, mobile_watch::MobileWatchSnapshotState, otp::OtpError, recipient::RecipientPolicyManager, repository::{EncryptedEntry, EntryPath, Repository, SecretBytes}, }; use support::compatibility::{FixtureSet, TestResult}; struct FixtureSecrets(BTreeMap>); impl FixtureSecrets { fn all(fixture: &FixtureSet) -> Self { Self( fixture .generated .keys .iter() .map(|key| { ( key.primary_fingerprint.clone(), key.passphrase.as_bytes().to_vec(), ) }) .collect(), ) } } impl SecretProvider for FixtureSecrets { fn secret_for(&mut self, key: &KeyInfo) -> Result { self.0 .get(key.fingerprint().as_str()) .cloned() .map(SecretBytes::new) .ok_or(SecretProviderError::Unavailable) } } struct CountingSecrets { inner: FixtureSecrets, requests: usize, } impl CountingSecrets { fn all(fixture: &FixtureSet) -> Self { Self { inner: FixtureSecrets::all(fixture), requests: 0, } } } impl SecretProvider for CountingSecrets { fn secret_for(&mut self, key: &KeyInfo) -> Result { self.requests += 1; self.inner.secret_for(key) } } struct CancellingSecrets<'a> { inner: FixtureSecrets, operation: &'a MobileTotpOperation, requests: usize, } struct MutatingSecrets<'a> { inner: FixtureSecrets, repository: &'a Repository, path: EntryPath, replacement: Option, } impl SecretProvider for MutatingSecrets<'_> { fn secret_for(&mut self, key: &KeyInfo) -> Result { let secret = self.inner.secret_for(key)?; if let Some(replacement) = self.replacement.take() { self.repository .write_entry(&self.path, &replacement) .expect("fixture mutation succeeds"); } Ok(secret) } } impl SecretProvider for CancellingSecrets<'_> { fn secret_for(&mut self, key: &KeyInfo) -> Result { self.requests += 1; let secret = self.inner.secret_for(key)?; if self.requests == 1 { self.operation.cancel(); } Ok(secret) } } #[test] fn mobile_totp_catalog_details_and_watch_selection_are_storage_owned() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; write_plaintext( &repository, &keys, "otp/alice", b"password\notpauth://totp/Acme:alice@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Acme&digits=8&period=30\n", )?; write_plaintext( &repository, &keys, "otp/counter", b"otpauth://hotp/Counter?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=0\n", )?; write_plaintext(&repository, &keys, "ordinary", b"password\nlogin: alice\n")?; let service = MobileTotpService::new(&repository, &keys); let mut secrets = FixtureSecrets::all(&fixture); let page = service.page(&BTreeSet::new(), &mut secrets)?; let alice = page .rows() .iter() .find(|row| row.path() == "otp/alice") .expect("inserted TOTP row"); assert_eq!(alice.issuer(), Some("Acme")); assert_eq!(alice.account(), "alice@example.com"); assert!(!alice.shared_with_watch()); assert!(page.rows().iter().all(|row| row.path() != "otp/counter")); assert_eq!(page.watch().state(), MobileWatchSnapshotState::Unavailable); assert!(!format!("{page:?}").contains("94287082")); let detail = service.detail("otp/alice", 59, &BTreeSet::new(), &mut secrets)?; assert_eq!(detail.code().expose(), b"94287082"); assert_eq!(detail.valid_until(), 60); assert_eq!(detail.period(), 30); assert!(!format!("{detail:?}").contains("94287082")); let selected = BTreeSet::from([EntryPath::parse("otp/alice")?]); let page = service.page(&selected, &mut secrets)?; assert!( page.rows() .iter() .find(|row| row.path() == "otp/alice") .expect("selected TOTP row") .shared_with_watch() ); assert_eq!(page.watch().state(), MobileWatchSnapshotState::Pending); let detail = service.detail("otp/alice", 60, &selected, &mut secrets)?; assert!(detail.shared_with_watch()); assert_eq!(detail.valid_until(), 90); Ok(()) } #[test] fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; write_plaintext( &repository, &keys, "otp/alice", b"password\notpauth://totp/Acme:alice@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Acme\n", )?; for path in ["team-a", "team-a/shared", "team-b/shared"] { write_plaintext( &repository, &keys, path, b"otpauth://totp/Shared?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ\n", )?; } write_plaintext(&repository, &keys, "ordinary", b"password\nlogin: alice\n")?; let cache_directory = tempfile::tempdir()?; let cache = cache_directory.path().join("totp-catalog.toml"); let service = MobileTotpService::new(&repository, &keys); let cold = MobileTotpOperation::default(); let mut cold_secrets = CountingSecrets::all(&fixture); let page = service.discover(&BTreeSet::new(), &mut cold_secrets, &cache, &cold)?; assert!(page.rows().iter().any(|row| row.path() == "otp/alice")); assert!(page.rows().iter().any(|row| row.path() == "team-a")); assert!(page.rows().iter().any(|row| row.path() == "team-a/shared")); assert!(page.rows().iter().any(|row| row.path() == "team-b/shared")); assert_eq!(cold.progress().phase(), MobileTotpDiscoveryPhase::Complete); assert_eq!(cold.progress().inspected(), cold.progress().total()); assert!(cold_secrets.requests > 0); let encoded = fs::read_to_string(&cache)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; assert_eq!(fs::metadata(&cache)?.permissions().mode() & 0o777, 0o600); } assert!(encoded.contains("is_totp = true")); assert!(encoded.contains("ciphertext_hash =")); assert!(encoded.contains("otp/alice")); for secret in [ "otpauth://", "secret=", "alice@example.com", "Acme", "password", ] { assert!(!encoded.contains(secret), "cache leaked {secret}"); } let warm = MobileTotpOperation::default(); let mut warm_secrets = CountingSecrets::all(&fixture); let warm_page = service.discover(&BTreeSet::new(), &mut warm_secrets, &cache, &warm)?; assert_eq!(warm_secrets.requests, 0); assert_eq!(warm.progress().cache_hits(), warm.progress().total()); assert_eq!(warm_page.rows(), page.rows()); assert_eq!( service .cached_page(&BTreeSet::new(), &cache) .expect("created cache") .rows(), page.rows() ); assert_eq!( service .search_cached_page(&BTreeSet::new(), &cache, " TEAM-A ") .expect("created cache") .rows() .iter() .map(|row| row.path()) .collect::>(), ["team-a/shared", "team-a"] ); assert!( service .search_cached_page(&BTreeSet::new(), &cache, "missing") .expect("created cache") .rows() .is_empty() ); write_plaintext( &repository, &keys, "ordinary", b"otpauth://totp/New:new@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=New\n", )?; let changed = MobileTotpOperation::default(); let mut changed_secrets = CountingSecrets::all(&fixture); let changed_page = service.discover(&BTreeSet::new(), &mut changed_secrets, &cache, &changed)?; assert_eq!(changed_secrets.requests, 1); assert_eq!( changed.progress().cache_hits() + 1, changed.progress().total() ); assert!( changed_page .rows() .iter() .any(|row| row.path() == "ordinary") ); repository.remove_entry(&EntryPath::parse("otp/alice")?)?; let deleted = MobileTotpOperation::default(); let mut deleted_secrets = CountingSecrets::all(&fixture); let deleted_page = service.discover(&BTreeSet::new(), &mut deleted_secrets, &cache, &deleted)?; assert_eq!(deleted_secrets.requests, 0); assert!( deleted_page .rows() .iter() .all(|row| row.path() != "otp/alice") ); assert!( service .cached_page(&BTreeSet::new(), &cache) .expect("updated cache") .rows() .iter() .all(|row| row.path() != "otp/alice") ); let cancelled = MobileTotpOperation::default(); cancelled.cancel(); let mut cancelled_secrets = CountingSecrets::all(&fixture); assert!(matches!( service.discover(&BTreeSet::new(), &mut cancelled_secrets, &cache, &cancelled,), Err(MobileTotpError::Otp(OtpError::Cancelled)) )); assert_eq!( cancelled.progress().phase(), MobileTotpDiscoveryPhase::Cancelled ); fs::write(&cache, "not a TOTP catalog")?; let recovery = MobileTotpOperation::default(); let mut recovery_secrets = CountingSecrets::all(&fixture); let recovered = service.discover(&BTreeSet::new(), &mut recovery_secrets, &cache, &recovery)?; assert!(recovered.cache_notice().is_some()); assert!(service.cached_page(&BTreeSet::new(), &cache).is_some()); let other_store = fixture.materialize_store("nested")?; let other_repository = Repository::open(other_store.path())?; assert!( MobileTotpService::new(&other_repository, &keys) .cached_page(&BTreeSet::new(), &cache) .is_none() ); Ok(()) } #[test] fn totp_cache_survives_application_container_relocation() -> TestResult { let fixture = FixtureSet::load()?; let application = tempfile::tempdir()?; let installed = application.path().join("installed"); fs::create_dir(&installed)?; let vault = installed.join("vault"); fs::rename(fixture.materialize_store("basic")?.keep(), &vault)?; let cache = installed.join("totp-catalog.toml"); let keys = KeyStore::load(fixture.path("keys"))?; let expected = { let repository = Repository::open(&vault)?; let service = MobileTotpService::new(&repository, &keys); let operation = MobileTotpOperation::default(); let mut secrets = CountingSecrets::all(&fixture); service.discover(&BTreeSet::new(), &mut secrets, &cache, &operation)? }; let updated = application.path().join("updated"); fs::rename(&installed, &updated)?; let repository = Repository::open(updated.join("vault"))?; let service = MobileTotpService::new(&repository, &keys); let relocated_cache = updated.join("totp-catalog.toml"); assert_eq!( service .cached_page(&BTreeSet::new(), &relocated_cache) .expect("relocated cache remains valid") .rows(), expected.rows() ); let operation = MobileTotpOperation::default(); let mut secrets = CountingSecrets::all(&fixture); service.discover(&BTreeSet::new(), &mut secrets, &relocated_cache, &operation)?; assert_eq!(secrets.requests, 0); assert_eq!( operation.progress().cache_hits(), operation.progress().total() ); Ok(()) } #[test] fn decrypted_entry_details_reconcile_totp_cache_without_persisting_secrets() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; let path = EntryPath::parse("otp/detail")?; write_plaintext( &repository, &keys, "otp/detail", b"password\notpauth://totp/Acme:detail@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Acme&digits=8&period=30\n", )?; let cache_directory = tempfile::tempdir()?; let cache = cache_directory.path().join("totp-catalog.toml"); let service = MobileTotpService::new(&repository, &keys); let mut secrets = FixtureSecrets::all(&fixture); let document = EntryDocumentService::new(&repository, &keys).open("otp/detail", &mut secrets)?; let ciphertext = repository.read_entry(&path)?; let reconciliation = service.reconcile_document(&document, &ciphertext, Some(59), &BTreeSet::new(), &cache)?; let detail = reconciliation.detail().expect("valid TOTP detail"); assert_eq!(detail.code().expose(), b"94287082"); assert_eq!(detail.valid_until(), 60); assert!( service .cached_page(&BTreeSet::new(), &cache) .expect("detail created cache") .rows() .iter() .any(|row| row.path() == "otp/detail") ); let encoded = fs::read_to_string(&cache)?; assert!(encoded.contains("otp/detail")); assert!(encoded.contains("is_totp = true")); for secret in ["otpauth://", "secret=", "detail@example.com", "password"] { assert!(!encoded.contains(secret), "cache leaked {secret}"); } write_plaintext( &repository, &keys, "otp/detail", b"password\nlogin: detail\n", )?; let mut secrets = FixtureSecrets::all(&fixture); let document = EntryDocumentService::new(&repository, &keys).open("otp/detail", &mut secrets)?; let ciphertext = repository.read_entry(&path)?; let reconciliation = service.reconcile_document(&document, &ciphertext, Some(59), &BTreeSet::new(), &cache)?; assert!(reconciliation.detail().is_none()); assert!( service .cached_page(&BTreeSet::new(), &cache) .expect("detail updated cache") .rows() .iter() .all(|row| row.path() != "otp/detail") ); assert!(fs::read_to_string(cache)?.contains("is_totp = false")); Ok(()) } #[test] fn cancelled_discovery_checkpoints_completed_entries() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; let cache_directory = tempfile::tempdir()?; let cache = cache_directory.path().join("totp-catalog.toml"); let service = MobileTotpService::new(&repository, &keys); let interrupted = MobileTotpOperation::default(); let mut secrets = CancellingSecrets { inner: FixtureSecrets::all(&fixture), operation: &interrupted, requests: 0, }; assert!(matches!( service.discover(&BTreeSet::new(), &mut secrets, &cache, &interrupted,), Err(MobileTotpError::Otp(OtpError::Cancelled)) )); assert!(cache.is_file()); let resumed = MobileTotpOperation::default(); let mut resumed_secrets = CountingSecrets::all(&fixture); service.discover(&BTreeSet::new(), &mut resumed_secrets, &cache, &resumed)?; assert!(resumed.progress().cache_hits() >= 1); assert!(resumed_secrets.requests < resumed.progress().total() as usize); Ok(()) } #[test] fn discovery_rejects_a_result_when_ciphertext_changes_mid_scan() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; let path = EntryPath::parse("ordinary")?; write_plaintext(&repository, &keys, "ordinary", b"password\n")?; let recipients = RecipientPolicyManager::new(&repository, &keys).resolve_for_entry(&path, None)?; let replacement = keys.encrypt( SecretBytes::new(b"changed password\n".to_vec()), recipients.recipients(), )?; let cache_directory = tempfile::tempdir()?; let cache = cache_directory.path().join("totp-catalog.toml"); let operation = MobileTotpOperation::default(); let mut secrets = MutatingSecrets { inner: FixtureSecrets::all(&fixture), repository: &repository, path, replacement: Some(replacement), }; assert!(matches!( MobileTotpService::new(&repository, &keys).discover( &BTreeSet::new(), &mut secrets, &cache, &operation, ), Err(MobileTotpError::ConcurrentModification) )); assert_ne!( operation.progress().phase(), MobileTotpDiscoveryPhase::Complete ); Ok(()) } fn write_plaintext( repository: &Repository, keys: &KeyStore, path: &str, plaintext: &[u8], ) -> TestResult { let path = EntryPath::parse(path)?; let recipients = RecipientPolicyManager::new(repository, keys).resolve_for_entry(&path, None)?; let encrypted = keys.encrypt( SecretBytes::new(plaintext.to_vec()), recipients.recipients(), )?; repository.write_entry(&path, &encrypted)?; Ok(()) }