#![forbid(unsafe_code)] mod support; use std::{ collections::{BTreeMap, BTreeSet}, fs, }; use ironstorage::{ crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, mobile_totp::{ MobileTotpDiscoveryPhase, MobileTotpError, MobileTotpOperation, MobileTotpService, 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() ); 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 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(()) }