From e45ad07c8934de20f996b8bbec54f77f739c6881 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Wed, 12 Aug 2026 18:40:54 +0200 Subject: [PATCH] Keep iPhone TOTP cache across installs (#78) --- crates/storage/src/mobile_totp.rs | 17 ++++++++---- crates/storage/tests/mobile_totp.rs | 41 +++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/crates/storage/src/mobile_totp.rs b/crates/storage/src/mobile_totp.rs index 886f6b7..cac1bc6 100644 --- a/crates/storage/src/mobile_totp.rs +++ b/crates/storage/src/mobile_totp.rs @@ -348,7 +348,7 @@ impl<'a> MobileTotpService<'a> { progress.total = u32::try_from(initial_inventory.len()).unwrap_or(u32::MAX); }); - let store = store_identity(self.repository.root_path()); + let store = store_identity(self.repository.root_path(), cache_path); let (cached, mut cache_notice) = load_cache(cache_path, &store); let mut records = BTreeMap::new(); let mut rows = Vec::new(); @@ -424,7 +424,7 @@ impl<'a> MobileTotpService<'a> { shared: &BTreeSet, cache_path: &Path, ) -> Option { - let store = store_identity(self.repository.root_path()); + let store = store_identity(self.repository.root_path(), cache_path); let (catalog, _) = load_cache(cache_path, &store); let mut rows: Vec<_> = catalog? .entries @@ -460,7 +460,7 @@ impl<'a> MobileTotpService<'a> { ciphertext_hash: digest(ciphertext.as_bytes()), is_totp: uri.is_some(), }; - let store = store_identity(self.repository.root_path()); + let store = store_identity(self.repository.root_path(), cache_path); let cache_notice = upsert_cache_record(cache_path, &store, document.path().to_string(), record); let detail = match (uri, unix_seconds) { @@ -667,8 +667,15 @@ fn set_private_permissions(_temporary: &TempFile<'_>) -> Result<(), MobileTotpCa Ok(()) } -fn store_identity(root: &Path) -> String { - digest(root.to_string_lossy().as_bytes()) +fn store_identity(root: &Path, cache_path: &Path) -> String { + let cache_parent = cache_path + .parent() + .and_then(|parent| fs::canonicalize(parent).ok()); + let stable_root = cache_parent + .as_deref() + .and_then(|parent| root.strip_prefix(parent).ok()) + .unwrap_or(root); + digest(stable_root.to_string_lossy().as_bytes()) } fn digest(bytes: &[u8]) -> String { diff --git a/crates/storage/tests/mobile_totp.rs b/crates/storage/tests/mobile_totp.rs index 3a1e1a9..f2f493f 100644 --- a/crates/storage/tests/mobile_totp.rs +++ b/crates/storage/tests/mobile_totp.rs @@ -306,6 +306,47 @@ fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResu 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()?;