From 6e6bbc40b59fd6aee43d0c2240bb8d19d6a63dd5 Mon Sep 17 00:00:00 2001 From: dzejkop Date: Wed, 9 Sep 2026 15:35:38 +0200 Subject: [PATCH 1/6] Allow constructing credential stores with supplied storage keys --- .../walletkit-core/src/authenticator/mod.rs | 26 +- .../src/authenticator/with_storage.rs | 6 +- .../src/issuers/recovery_bindings_manager.rs | 4 +- ...of_request_credential_constraints_check.rs | 6 +- .../src/storage/credential_storage.rs | 381 +++++++++--------- crates/walletkit-core/src/storage/keys.rs | 126 +++++- crates/walletkit-core/src/storage/mod.rs | 20 +- .../walletkit-core/src/storage/tests_utils.rs | 7 + crates/walletkit-core/src/storage/traits.rs | 10 +- crates/walletkit-core/tests/common.rs | 14 +- .../tests/credential_storage_integration.rs | 3 +- crates/walletkit-testkit/src/storage.rs | 14 +- 12 files changed, 383 insertions(+), 234 deletions(-) diff --git a/crates/walletkit-core/src/authenticator/mod.rs b/crates/walletkit-core/src/authenticator/mod.rs index 3c0baf0d..5c1d234d 100644 --- a/crates/walletkit-core/src/authenticator/mod.rs +++ b/crates/walletkit-core/src/authenticator/mod.rs @@ -1071,7 +1071,7 @@ mod tests { async fn test_authenticator( server: &mut mockito::Server, ) -> (Authenticator, std::path::PathBuf) { - use crate::storage::tests_utils::{temp_root_path, InMemoryStorageProvider}; + use crate::storage::{tests_utils::temp_root_path, StorageKeys, StoragePaths}; use alloy::primitives::address; use world_id_core::primitives::ServiceEndpoint; use world_id_proof::artifacts::dummy::DummyZkArtifactSource; @@ -1096,9 +1096,11 @@ mod tests { ) .expect("valid config"); let root = temp_root_path(); - let provider = InMemoryStorageProvider::new(&root); + let keys = + StorageKeys::from_bytes(vec![0x51; 32]).expect("resolved database key"); let store = - CredentialStore::from_provider(&provider).expect("credential store"); + CredentialStore::new(Arc::new(StoragePaths::new(&root)), Arc::new(keys)) + .expect("credential store"); let authenticator = Authenticator::init_with_config( &TEST_SEED, config, @@ -1209,6 +1211,22 @@ mod tests { )); } + #[tokio::test] + async fn test_init_with_resolved_database_keys() { + let mut server = mockito::Server::new_async().await; + let (authenticator, root) = test_authenticator(&mut server).await; + authenticator + .init_storage(1000) + .expect("initialize direct-key storage"); + assert!(authenticator + .store + .list_credentials(None, 1000) + .expect("read storage") + .is_empty()); + drop(authenticator); + crate::storage::tests_utils::cleanup_test_storage(&root); + } + #[tokio::test] async fn test_poll_status_normalizes_request_id() { use crate::storage::tests_utils::cleanup_test_storage; @@ -1489,7 +1507,7 @@ mod tests { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("store"); + let store = provider.open_store().expect("store"); store.init(42, 100).expect("init storage"); let artifacts = diff --git a/crates/walletkit-core/src/authenticator/with_storage.rs b/crates/walletkit-core/src/authenticator/with_storage.rs index 8f96218e..7dbb1c53 100644 --- a/crates/walletkit-core/src/authenticator/with_storage.rs +++ b/crates/walletkit-core/src/authenticator/with_storage.rs @@ -22,7 +22,8 @@ impl Authenticator { /// Permanently destroys all credential storage data. /// - /// Removes the encryption keys, vault database, and cache database. + /// Releases the store's key reference and removes the vault and cache databases. + /// The host must separately delete any key envelope it owns. /// After this call the authenticator can no longer generate proofs or /// access stored credentials. Intended for logout or account deletion. /// @@ -78,7 +79,6 @@ mod tests { use crate::storage::tests_utils::{ cleanup_test_storage, temp_root_path, InMemoryStorageProvider, }; - use crate::storage::CredentialStore; use world_id_core::primitives::merkle::MerkleInclusionProof; use world_id_core::primitives::AuthenticatorPublicKeySet; use world_id_core::FieldElement; @@ -87,7 +87,7 @@ mod tests { fn test_cached_inclusion_round_trip() { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("store"); + let store = provider.open_store().expect("store"); store.init(42, 100).expect("init storage"); let siblings = [FieldElement::from(0u64); TREE_DEPTH]; diff --git a/crates/walletkit-core/src/issuers/recovery_bindings_manager.rs b/crates/walletkit-core/src/issuers/recovery_bindings_manager.rs index 29460628..88345bea 100644 --- a/crates/walletkit-core/src/issuers/recovery_bindings_manager.rs +++ b/crates/walletkit-core/src/issuers/recovery_bindings_manager.rs @@ -398,9 +398,7 @@ mod tests { fn create_test_credential_store() -> Arc { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - Arc::new( - CredentialStore::from_provider(&provider).expect("create credential store"), - ) + Arc::new(provider.open_store().expect("create credential store")) } async fn create_mock_eth_server() -> (ServerGuard, mockito::Mock) { diff --git a/crates/walletkit-core/src/proof_request_credential_constraints_check.rs b/crates/walletkit-core/src/proof_request_credential_constraints_check.rs index 8667f611..aa808127 100644 --- a/crates/walletkit-core/src/proof_request_credential_constraints_check.rs +++ b/crates/walletkit-core/src/proof_request_credential_constraints_check.rs @@ -231,7 +231,7 @@ mod tests { ) -> (CredentialStore, std::path::PathBuf) { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); + let store = provider.open_store().expect("create store"); store.init(42, now).expect("init"); for &id in issuer_ids { @@ -296,7 +296,7 @@ mod tests { let now = 5000; let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); + let store = provider.open_store().expect("create store"); store.init(42, 1000).expect("init"); let cred: Credential = CoreCredential::new() @@ -634,7 +634,7 @@ mod tests { ) -> (CredentialStore, std::path::PathBuf) { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); + let store = provider.open_store().expect("create store"); store.init(42, now).expect("init"); let cred: Credential = CoreCredential::new() .issuer_schema_id(issuer_id) diff --git a/crates/walletkit-core/src/storage/credential_storage.rs b/crates/walletkit-core/src/storage/credential_storage.rs index cd98a13b..1adb7e31 100644 --- a/crates/walletkit-core/src/storage/credential_storage.rs +++ b/crates/walletkit-core/src/storage/credential_storage.rs @@ -9,12 +9,9 @@ use world_id_core::FieldElement as CoreFieldElement; use super::error::{StorageError, StorageResult}; use super::keys::StorageKeys; use super::paths::StoragePaths; -use super::traits::StorageProvider; #[cfg(not(target_arch = "wasm32"))] use super::traits::{ActivityChangedListener, VaultChangedListener}; -use super::traits::{AtomicBlobStore, DeviceKeystore}; use super::types::{ActivityEntry, ActivityMetadata, ActivityQuery, CredentialRecord}; -use super::ACCOUNT_KEYS_FILENAME; use super::{CacheDb, CredentialVault}; use super::{StorageLock, StorageLockGuard}; use crate::{Credential, FieldElement}; @@ -66,50 +63,23 @@ impl std::fmt::Debug for CredentialStore { struct CredentialStoreInner { lock: StorageLock, - keystore: Arc, - blob_store: Arc, + keys: Option>, paths: StoragePaths, state: Option, } struct StorageState { - #[allow(dead_code)] - keys: StorageKeys, vault: CredentialVault, cache: CacheDb, leaf_index: u64, } impl CredentialStoreInner { - /// Creates a new storage handle from a platform provider. - /// - /// # Errors - /// - /// Returns an error if the storage lock cannot be opened. - pub fn from_provider(provider: &dyn StorageProvider) -> StorageResult { - let paths = provider.paths(); - Self::new( - paths.as_ref().clone(), - provider.keystore(), - provider.blob_store(), - ) - } - - /// Creates a new storage handle from explicit components. - /// - /// # Errors - /// - /// Returns an error if the storage lock cannot be opened. - pub fn new( - paths: StoragePaths, - keystore: Arc, - blob_store: Arc, - ) -> StorageResult { + fn new(paths: StoragePaths, keys: Arc) -> StorageResult { let lock = StorageLock::open(&paths.lock_path())?; Ok(Self { lock, - keystore, - blob_store, + keys: Some(keys), paths, state: None, }) @@ -130,39 +100,21 @@ impl CredentialStoreInner { #[uniffi::export] impl CredentialStore { - /// Creates a new storage handle from explicit components. + /// Creates storage from paths and an already-resolved database key. /// - /// # Errors + /// The store retains the keys through initialization retries and releases + /// its reference on destruction. Callers should release their own key handles + /// once construction succeeds. /// + /// # Errors /// Returns an error if the storage lock cannot be opened. #[uniffi::constructor] - pub fn new_with_components( + pub fn new( paths: Arc, - keystore: Arc, - blob_store: Arc, + keys: Arc, ) -> StorageResult { let paths = Arc::try_unwrap(paths).unwrap_or_else(|arc| (*arc).clone()); - let inner = CredentialStoreInner::new(paths, keystore, blob_store)?; - Ok(Self { - inner: Mutex::new(inner), - #[cfg(not(target_arch = "wasm32"))] - vault_changed_tx: Mutex::new(None), - #[cfg(not(target_arch = "wasm32"))] - activity_changed_tx: Mutex::new(None), - }) - } - - /// Creates a new storage handle from a platform provider. - /// - /// # Errors - /// - /// Returns an error if the storage lock cannot be opened. - #[uniffi::constructor] - #[allow(clippy::needless_pass_by_value)] - pub fn from_provider_arc( - provider: Arc, - ) -> StorageResult { - let inner = CredentialStoreInner::from_provider(provider.as_ref())?; + let inner = CredentialStoreInner::new(paths, keys)?; Ok(Self { inner: Mutex::new(inner), #[cfg(not(target_arch = "wasm32"))] @@ -346,41 +298,16 @@ impl CredentialStore { } } -#[cfg(all(target_arch = "wasm32", feature = "uniffi-wasm"))] #[uniffi::export] impl CredentialStore { - /// Creates process-local credential storage for browser demos and tests. + /// Closes the databases, releases this store's key reference, and deletes its files. /// - /// The store is discarded when the page is refreshed. Its key envelope is - /// kept in memory without device-bound encryption, so callers must not use - /// this constructor for production credentials. + /// The host owns envelope deletion via `delete_storage_key_envelope`. This + /// store cannot be reinitialized after destruction; construct a new store + /// with resolved keys. Other key owners are unaffected. /// /// # Errors - /// - /// Returns an error if the in-memory storage handle cannot be created. - #[uniffi::constructor] - pub fn new_ephemeral() -> StorageResult { - Self::from_provider_arc(Arc::new( - super::ephemeral::EphemeralStorageProvider::new(), - )) - } -} - -#[uniffi::export] -impl CredentialStore { - /// Permanently destroys all credential storage data. - /// - /// This removes the encryption key envelope, the vault database, and the - /// cache database. After this call the store is left in an uninitialized - /// state — any subsequent operation (other than re-initialization) will - /// return [`StorageError::NotInitialized`]. - /// - /// Intended for use when the user logs out or deletes their account. - /// - /// # Errors - /// - /// Returns an error if the storage lock cannot be acquired or the key - /// envelope cannot be deleted from the blob store. + /// Returns an error if locking or database file deletion fails. pub fn destroy_storage(&self) -> StorageResult<()> { self.lock_inner()?.destroy_storage() } @@ -657,17 +584,11 @@ impl CredentialStoreInner { return Ok(()); } - let keys = StorageKeys::init( - self.keystore.as_ref(), - self.blob_store.as_ref(), - &self.lock, - now, - )?; + let keys = self.keys.as_ref().ok_or(StorageError::NotInitialized)?; let k_intermediate = keys.intermediate_key(); let vault = CredentialVault::new(&self.paths.vault_db_path(), k_intermediate)?; let cache = CacheDb::new(&self.paths.cache_db_path(), k_intermediate)?; let state = StorageState { - keys, vault, cache, leaf_index, @@ -973,60 +894,36 @@ impl CredentialStoreInner { state.vault.danger_delete_all_credentials() } - /// Permanently destroys all storage data: encryption keys, vault, and cache. + /// Deletes database files and releases the store's resolved keys. fn destroy_storage(&mut self) -> StorageResult<()> { let _guard = self.guard()?; self.state = None; - // Delete the encryption key envelope. Without this key the database - // files are unreadable even if file deletion below fails. - self.blob_store.delete(ACCOUNT_KEYS_FILENAME.to_string())?; - - // Best-effort removal: deleting the key above cryptographically destroys - // the databases even if their encrypted files cannot be removed. - super::delete_database_files(&self.paths.vault_db_path()); - super::delete_database_files(&self.paths.cache_db_path()); - - Ok(()) + self.keys = None; + // Keys may remain recoverable through a passkey or host envelope, so + // deletion failures must be reported rather than treated as crypto-erasure. + let mut first_error = None; + for path in [self.paths.vault_db_path(), self.paths.cache_db_path()] { + for file in [ + path.clone(), + path.with_extension("sqlite-journal"), + path.with_extension("sqlite-wal"), + path.with_extension("sqlite-shm"), + ] { + if let Err(error) = super::delete_database_file(&file) { + first_error.get_or_insert_with(|| { + StorageError::VaultDb(format!( + "delete {}: {error}", + file.display() + )) + }); + } + } + } + first_error.map_or(Ok(()), Err) } } impl CredentialStore { - /// Creates a new storage handle from a platform provider. - /// - /// # Errors - /// - /// Returns an error if the storage lock cannot be opened. - pub fn from_provider(provider: &dyn StorageProvider) -> StorageResult { - let inner = CredentialStoreInner::from_provider(provider)?; - Ok(Self { - inner: Mutex::new(inner), - #[cfg(not(target_arch = "wasm32"))] - vault_changed_tx: Mutex::new(None), - #[cfg(not(target_arch = "wasm32"))] - activity_changed_tx: Mutex::new(None), - }) - } - - /// Creates a new storage handle from explicit components. - /// - /// # Errors - /// - /// Returns an error if the storage lock cannot be opened. - pub fn new( - paths: StoragePaths, - keystore: Arc, - blob_store: Arc, - ) -> StorageResult { - let inner = CredentialStoreInner::new(paths, keystore, blob_store)?; - Ok(Self { - inner: Mutex::new(inner), - #[cfg(not(target_arch = "wasm32"))] - vault_changed_tx: Mutex::new(None), - #[cfg(not(target_arch = "wasm32"))] - activity_changed_tx: Mutex::new(None), - }) - } - /// Returns the storage paths used by this handle. /// /// # Errors @@ -1039,7 +936,24 @@ impl CredentialStore { #[cfg(test)] mod tests { + use super::super::{ + AtomicBlobStore, DeviceKeystore, StorageProvider, ACCOUNT_KEYS_FILENAME, + }; use super::*; + + fn inner_with_components( + paths: StoragePaths, + keystore: Arc, + blob_store: Arc, + ) -> StorageResult { + let keys = StorageKeys::from_envelope( + Arc::new(paths.clone()), + keystore, + blob_store, + 1000, + )?; + CredentialStoreInner::new(paths, Arc::new(keys)) + } use crate::storage::tests_utils::{ cleanup_test_storage, temp_root_path, InMemoryStorageProvider, }; @@ -1097,6 +1011,86 @@ mod tests { ); } + #[test] + fn direct_keys_reopen_retry_and_release_on_destroy() { + let root = temp_root_path(); + let paths = Arc::new(StoragePaths::new(&root)); + let keys = Arc::new(StorageKeys::from_bytes(vec![0x31; 32]).expect("key")); + let weak_keys = Arc::downgrade(&keys); + { + let store = CredentialStore::new(Arc::clone(&paths), Arc::clone(&keys)) + .expect("store"); + store.init(42, 1000).expect("initialize"); + let credential: Credential = world_id_core::Credential::new() + .issuer_schema_id(100) + .genesis_issued_at(1000) + .into(); + store + .store_credential( + &credential, + &FieldElement::from(7u64), + 9999, + None, + 1000, + ) + .expect("write credential"); + } + let wrong_keys = + Arc::new(StorageKeys::from_bytes(vec![0x32; 32]).expect("wrong key")); + let wrong = + CredentialStore::new(Arc::clone(&paths), wrong_keys).expect("store"); + assert!( + wrong.init(42, 1000).is_err(), + "wrong key must not open existing DBs" + ); + drop(wrong); + + let store = CredentialStore::new(Arc::clone(&paths), keys).expect("reopen"); + assert!(matches!( + store.init(43, 1000), + Err(StorageError::InvalidLeafIndex { .. }) + )); + store.init(42, 1000).expect("retry with correct account"); + assert_eq!( + store + .list_credentials(None, 1000) + .expect("read persisted credential") + .len(), + 1 + ); + assert!(weak_keys.upgrade().is_some()); + store.destroy_storage().expect("destroy"); + assert!( + weak_keys.upgrade().is_none(), + "store released its last key reference" + ); + assert!(matches!( + store.init(42, 1000), + Err(StorageError::NotInitialized) + )); + assert!(!paths.vault_db_path().exists()); + assert!(!paths.cache_db_path().exists()); + cleanup_test_storage(&root); + } + + #[test] + fn destruction_reports_failed_file_deletion_and_can_retry() { + let root = temp_root_path(); + let paths = Arc::new(StoragePaths::new(&root)); + let keys = Arc::new(StorageKeys::from_bytes(vec![0x41; 32]).expect("key")); + let store = CredentialStore::new(Arc::clone(&paths), keys).expect("store"); + // A directory at the DB path cannot be removed with remove_file, even as root. + std::fs::create_dir_all(paths.vault_db_path()).expect("block path"); + assert!(store.destroy_storage().is_err()); + assert!(matches!( + store.init(42, 1000), + Err(StorageError::NotInitialized) + )); + std::fs::remove_dir(paths.vault_db_path()).expect("remove obstruction"); + store.destroy_storage().expect("retry cleanup"); + cleanup_test_storage(&root); + } + #[test] fn test_replay_guard_field_element_serialization() { let root = temp_root_path(); @@ -1105,8 +1099,8 @@ mod tests { let keystore = provider.keystore(); let blob_store = provider.blob_store(); - let mut inner = CredentialStoreInner::new(paths, keystore, blob_store) - .expect("create inner"); + let mut inner = + inner_with_components(paths, keystore, blob_store).expect("create inner"); inner.init(42, 1000).expect("init storage"); // Create a FieldElement from a known value @@ -1137,8 +1131,8 @@ mod tests { let keystore = provider.keystore(); let blob_store = provider.blob_store(); - let mut inner = CredentialStoreInner::new(paths, keystore, blob_store) - .expect("create inner"); + let mut inner = + inner_with_components(paths, keystore, blob_store).expect("create inner"); inner.init(42, 1000).expect("init storage"); let nullifier = CoreFieldElement::from(999u64); @@ -1180,8 +1174,8 @@ mod tests { let keystore = provider.keystore(); let blob_store = provider.blob_store(); - let mut inner = CredentialStoreInner::new(paths, keystore, blob_store) - .expect("create inner"); + let mut inner = + inner_with_components(paths, keystore, blob_store).expect("create inner"); inner.init(42, 1000).expect("init storage"); let nullifier = CoreFieldElement::from(555u64); @@ -1226,7 +1220,7 @@ mod tests { let keystore = provider.keystore(); let blob_store = provider.blob_store(); - let mut inner = CredentialStoreInner::new(paths, keystore, blob_store).unwrap(); + let mut inner = inner_with_components(paths, keystore, blob_store).unwrap(); inner.init(42, 1000).expect("init storage"); let nullifier = CoreFieldElement::from(12345u64); @@ -1262,7 +1256,7 @@ mod tests { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); + let store = provider.open_store().expect("create store"); store.init(42, 1000).expect("init storage"); let blinding_factor = FieldElement::from(7u64); @@ -1305,8 +1299,8 @@ mod tests { let keystore = provider.keystore(); let blob_store = provider.blob_store(); - let mut inner = CredentialStoreInner::new(paths, keystore, blob_store) - .expect("create inner"); + let mut inner = + inner_with_components(paths, keystore, blob_store).expect("create inner"); inner.init(42, 1000).expect("init storage"); // Store a test credential @@ -1362,8 +1356,7 @@ mod tests { let src_root = temp_root_path(); let src_provider = InMemoryStorageProvider::new(&src_root); - let src_store = - CredentialStore::from_provider(&src_provider).expect("create src store"); + let src_store = src_provider.open_store().expect("create src store"); src_store.init(42, 1000).expect("init src storage"); let issuer_schema_id = 100u64; @@ -1382,8 +1375,7 @@ mod tests { let dst_root = temp_root_path(); let dst_provider = InMemoryStorageProvider::new(&dst_root); - let dst_store = - CredentialStore::from_provider(&dst_provider).expect("create dst store"); + let dst_store = dst_provider.open_store().expect("create dst store"); dst_store.init(42, 1000).expect("init dst storage"); dst_store @@ -1407,8 +1399,7 @@ mod tests { let src_root = temp_root_path(); let src_provider = InMemoryStorageProvider::new(&src_root); - let src_store = - CredentialStore::from_provider(&src_provider).expect("create src store"); + let src_store = src_provider.open_store().expect("create src store"); src_store.init(42, 1000).expect("init src storage"); // Store credential A (schema 100) without associated data @@ -1451,8 +1442,7 @@ mod tests { let dst_root = temp_root_path(); let dst_provider = InMemoryStorageProvider::new(&dst_root); - let dst_store = - CredentialStore::from_provider(&dst_provider).expect("create dst store"); + let dst_store = dst_provider.open_store().expect("create dst store"); dst_store.init(42, 1000).expect("init dst storage"); dst_store @@ -1492,7 +1482,7 @@ mod tests { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); + let store = provider.open_store().expect("create store"); store.init(42, 1000).expect("init storage"); let blinding_factor = FieldElement::from(7u64); @@ -1526,7 +1516,7 @@ mod tests { fn test_import_vault_backup_invalid_bytes_fails() { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); + let store = provider.open_store().expect("create store"); store.init(42, 1000).expect("init storage"); let result = store.import_vault_from_backup(b"not a sqlite database"); @@ -1543,8 +1533,7 @@ mod tests { let src_root = temp_root_path(); let src_provider = InMemoryStorageProvider::new(&src_root); - let src_store = - CredentialStore::from_provider(&src_provider).expect("create src store"); + let src_store = src_provider.open_store().expect("create src store"); src_store.init(42, 1000).expect("init src storage"); let cred: Credential = CoreCredential::new() @@ -1582,8 +1571,7 @@ mod tests { let dst_root = temp_root_path(); let dst_provider = InMemoryStorageProvider::new(&dst_root); - let dst_store = - CredentialStore::from_provider(&dst_provider).expect("create dst store"); + let dst_store = dst_provider.open_store().expect("create dst store"); dst_store.init(42, 1000).expect("init dst storage"); let result = dst_store.import_vault_from_backup(&corrupt_bytes); @@ -1611,8 +1599,8 @@ mod tests { let keystore = provider.keystore(); let blob_store = provider.blob_store(); - let mut inner = CredentialStoreInner::new(paths, keystore, blob_store) - .expect("create inner"); + let mut inner = + inner_with_components(paths, keystore, blob_store).expect("create inner"); inner.init(42, 1000).expect("init storage"); let blinding_factor = FieldElement::from(42u64); @@ -1643,8 +1631,8 @@ mod tests { let keystore = provider.keystore(); let blob_store = provider.blob_store(); - let mut inner = CredentialStoreInner::new(paths, keystore, blob_store) - .expect("create inner"); + let mut inner = + inner_with_components(paths, keystore, blob_store).expect("create inner"); inner.init(42, 1000).expect("init storage"); let deleted = inner @@ -1665,8 +1653,8 @@ mod tests { let keystore = provider.keystore(); let blob_store = provider.blob_store(); - let mut inner = CredentialStoreInner::new(paths, keystore, blob_store) - .expect("create inner"); + let mut inner = + inner_with_components(paths, keystore, blob_store).expect("create inner"); inner.init(42, 1000).expect("init storage"); let blinding_factor = FieldElement::from(42u64); @@ -1700,7 +1688,7 @@ mod tests { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); + let store = provider.open_store().expect("create store"); store.init(42, 1000).expect("init store"); let cred: Credential = CoreCredential::new() @@ -1733,8 +1721,7 @@ mod tests { let src_root = temp_root_path(); let src_provider = InMemoryStorageProvider::new(&src_root); - let src_store = - CredentialStore::from_provider(&src_provider).expect("create store"); + let src_store = src_provider.open_store().expect("create store"); src_store.init(42, 1000).expect("init store"); let cred: Credential = CoreCredential::new() @@ -1751,8 +1738,7 @@ mod tests { // Import the raw bytes into a fresh store via the public API. let dst_root = temp_root_path(); let dst_provider = InMemoryStorageProvider::new(&dst_root); - let dst_store = - CredentialStore::from_provider(&dst_provider).expect("create dst store"); + let dst_store = dst_provider.open_store().expect("create dst store"); dst_store.init(42, 1000).expect("init dst store"); dst_store @@ -1776,7 +1762,7 @@ mod tests { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); + let store = provider.open_store().expect("create store"); store.init(42, 1000).expect("init store"); let cred: Credential = CoreCredential::new() @@ -1814,7 +1800,7 @@ mod tests { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); + let store = provider.open_store().expect("create store"); store.init(42, 1000).expect("init store"); let cred: Credential = CoreCredential::new() @@ -1864,7 +1850,7 @@ mod tests { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); + let store = provider.open_store().expect("create store"); store.init(42, 1000).expect("init storage"); let count = Arc::new(AtomicU32::new(0)); @@ -1891,7 +1877,7 @@ mod tests { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); + let store = provider.open_store().expect("create store"); store.init(42, 1000).expect("init storage"); let count = Arc::new(AtomicU32::new(0)); @@ -1919,7 +1905,7 @@ mod tests { fn test_vault_changed_listener_not_notified_on_failure() { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); + let store = provider.open_store().expect("create store"); store.init(42, 1000).expect("init storage"); let count = Arc::new(AtomicU32::new(0)); @@ -2001,7 +1987,7 @@ mod tests { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); + let store = provider.open_store().expect("create store"); store.init(42, 1000).expect("init storage"); // No listener registered — mutations should still work fine. @@ -2022,7 +2008,7 @@ mod tests { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); + let store = provider.open_store().expect("create store"); store.init(42, 1000).expect("init storage"); let cred: Credential = CoreCredential::new() @@ -2055,7 +2041,28 @@ mod tests { "expected NotInitialized, got: {err:?}" ); - // Re-initialization should work. + // Destroy releases the key; the old store cannot silently reopen. + assert!(matches!( + store.init(42, 1000), + Err(StorageError::NotInitialized) + )); + // Envelope lifecycle belongs to the host and is unchanged by destruction. + assert!(provider + .blob_store() + .read(ACCOUNT_KEYS_FILENAME.to_string()) + .unwrap() + .is_some()); + super::super::delete_storage_key_envelope( + provider.paths(), + provider.blob_store(), + ) + .expect("delete host envelope"); + assert!(provider + .blob_store() + .read(ACCOUNT_KEYS_FILENAME.to_string()) + .unwrap() + .is_none()); + let store = provider.open_store().expect("construct new store"); store.init(42, 1000).expect("re-init storage"); let list = store .list_credentials(None, 1000) @@ -2074,7 +2081,7 @@ mod tests { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); + let store = provider.open_store().expect("create store"); store.init(42, 1000).expect("init storage"); let count = Arc::new(AtomicU32::new(0)); diff --git a/crates/walletkit-core/src/storage/keys.rs b/crates/walletkit-core/src/storage/keys.rs index 440a84f2..e44b0775 100644 --- a/crates/walletkit-core/src/storage/keys.rs +++ b/crates/walletkit-core/src/storage/keys.rs @@ -1,31 +1,107 @@ -//! Key management for credential storage. +//! Resolved database keys and explicit envelope lifecycle helpers. //! -//! [`StorageKeys`] opens (or creates on first use) the account key envelope via -//! `walletkit-db` and holds the resulting `K_intermediate` in memory for the lifetime -//! of the storage handle; both databases are opened with it. The `K_device` → -//! `K_intermediate` hierarchy, envelope sealing, and encryption are described in the -//! `walletkit-db` README. +//! `StorageKeys` holds `K_intermediate` regardless of its source. Hosts resolve +//! it from a sealed envelope or supply it directly before constructing storage. use secrecy::SecretBox; -use zeroize::{Zeroize, ZeroizeOnDrop}; +use std::sync::Arc; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; use super::{ - error::StorageResult, + error::{StorageError, StorageResult}, traits::{AtomicBlobStore, DeviceKeystore}, - ACCOUNT_KEYS_FILENAME, ACCOUNT_KEY_ENVELOPE_AD, + StoragePaths, StorageProvider, ACCOUNT_KEYS_FILENAME, ACCOUNT_KEY_ENVELOPE_AD, }; use walletkit_db::Lock; -/// In-memory account keys derived from the account key envelope. +/// Resolved in-memory database keys, independent of their source. /// -/// Keys are held in memory for the lifetime of the storage handle. -#[derive(Zeroize, ZeroizeOnDrop)] +/// Keys are zeroized when the last owner drops this object. +#[derive(Zeroize, ZeroizeOnDrop, uniffi::Object)] #[allow(clippy::struct_field_names)] pub struct StorageKeys { intermediate_key: SecretBox<[u8; 32]>, } +#[uniffi::export] impl StorageKeys { + /// Takes a resolved 32-byte database key, for example derived from a passkey PRF. + /// + /// # Errors + /// Returns an error if the key is not exactly 32 bytes. + #[uniffi::constructor] + pub fn from_bytes(database_key: Vec) -> StorageResult { + let database_key = Zeroizing::new(database_key); + if database_key.len() != 32 { + return Err(StorageError::InvalidInput( + "expected a 32-byte database key".into(), + )); + } + let intermediate_key = SecretBox::init_with(|| { + let mut key = [0; 32]; + key.copy_from_slice(&database_key); + key + }); + Ok(Self { intermediate_key }) + } + + /// Resolves the database key from a device-sealed envelope, creating it if absent. + /// The platform integrations are used only during this call and are not retained. + /// + /// # Errors + /// Returns an error if locking, envelope access, or key unsealing fails. + #[uniffi::constructor] + #[expect( + clippy::needless_pass_by_value, + reason = "UniFFI parameters require owned Arc handles" + )] + pub fn from_envelope( + paths: Arc, + keystore: Arc, + blob_store: Arc, + now: u64, + ) -> StorageResult { + let lock = Lock::open(&paths.lock_path())?; + Self::init(keystore.as_ref(), blob_store.as_ref(), &lock, now) + } +} + +/// Deletes the host-owned key envelope after closing/destroying its credential store. +/// This does not invalidate keys already held in memory by other owners. +/// +/// # Errors +/// Returns an error if locking or envelope deletion fails. +#[uniffi::export] +#[expect( + clippy::needless_pass_by_value, + reason = "UniFFI parameters require owned Arc handles" +)] +pub fn delete_storage_key_envelope( + paths: Arc, + blob_store: Arc, +) -> StorageResult<()> { + let lock = Lock::open(&paths.lock_path())?; + let _guard = lock.lock()?; + blob_store.delete(ACCOUNT_KEYS_FILENAME.to_string()) +} + +impl StorageKeys { + /// Resolves an envelope key using a native host's platform provider. + /// + /// # Errors + /// Returns an error if the envelope cannot be opened or created. + pub fn from_provider( + provider: &dyn StorageProvider, + now: u64, + ) -> StorageResult { + Self::from_envelope( + provider.paths(), + provider.keystore(), + provider.blob_store(), + now, + ) + } + /// Initializes storage keys by opening or creating the account key envelope. /// /// # Errors @@ -122,6 +198,32 @@ mod tests { path } + #[test] + fn direct_key_requires_exactly_32_bytes() { + for length in [0, 31, 33] { + assert!(matches!( + StorageKeys::from_bytes(vec![7; length]), + Err(StorageError::InvalidInput(_)) + )); + } + let keys = StorageKeys::from_bytes(vec![7; 32]).expect("direct key"); + assert_eq!(keys.intermediate_key().expose_secret(), &[7; 32]); + } + + #[test] + fn envelope_resolution_does_not_retain_platform_components() { + let root = tempfile::tempdir().expect("temp dir"); + let paths = Arc::new(StoragePaths::new(root.path())); + let keystore = Arc::new(InMemoryKeystore::new()); + let blobs = Arc::new(InMemoryBlobStore::new()); + let weak_keystore = Arc::downgrade(&keystore); + let weak_blobs = Arc::downgrade(&blobs); + let _keys = StorageKeys::from_envelope(paths, keystore, blobs, 1000) + .expect("resolve envelope"); + assert!(weak_keystore.upgrade().is_none()); + assert!(weak_blobs.upgrade().is_none()); + } + #[test] fn test_storage_keys_round_trip() { let keystore = InMemoryKeystore::new(); diff --git a/crates/walletkit-core/src/storage/mod.rs b/crates/walletkit-core/src/storage/mod.rs index 10c4ab01..1c76a198 100644 --- a/crates/walletkit-core/src/storage/mod.rs +++ b/crates/walletkit-core/src/storage/mod.rs @@ -10,7 +10,7 @@ //! ## Components //! //! [`crate::storage::CredentialStore`] is the facade exposed to hosts (via `UniFFI`). -//! It owns the account key envelope and two databases: +//! It owns the resolved storage keys and two databases: //! //! 1. **Vault database (`account.vault.sqlite`)** — authoritative storage for //! credentials, associated data blobs, issuer subject blinding factors, and the account @@ -27,8 +27,9 @@ //! //! ## Keys //! -//! Both databases are opened with the single `K_intermediate` managed by -//! `walletkit-db`. +//! Both databases use the resolved `K_intermediate` supplied through [`crate::storage::StorageKeys`]. +//! Hosts obtain it directly (for example from a passkey PRF) or resolve a sealed +//! envelope before constructing [`crate::storage::CredentialStore`]. //! //! ## On-disk layout //! @@ -36,7 +37,8 @@ //! [`crate::storage::StoragePaths`]. The account key envelope (`account_keys.bin`) is //! written separately through the host's [`crate::storage::AtomicBlobStore`] and its //! location is host-determined (not necessarily under `worldid/`); backup and -//! deletion must include it. +//! deletion must include it when the host uses an envelope. Direct-key stores +//! have no envelope. [`crate::storage::delete_storage_key_envelope`] handles explicit host cleanup. //! //! ## Security and privacy properties //! @@ -46,7 +48,7 @@ pub mod cache; pub mod credential_storage; pub mod credential_vault; -#[cfg(any(test, all(target_arch = "wasm32", feature = "uniffi-wasm")))] +#[cfg(test)] mod ephemeral; pub mod error; pub mod keys; @@ -58,7 +60,7 @@ pub use cache::CacheDb; pub use credential_storage::CredentialStore; pub use credential_vault::CredentialVault; pub use error::{StorageError, StorageResult}; -pub use keys::StorageKeys; +pub use keys::{delete_storage_key_envelope, StorageKeys}; pub use paths::StoragePaths; pub use traits::{ ActivityChangedListener, AtomicBlobStore, DeviceKeystore, StorageProvider, @@ -88,12 +90,12 @@ pub(crate) fn delete_database_files(path: &std::path::Path) { } #[cfg(target_arch = "wasm32")] -fn delete_database_file(path: &std::path::Path) -> Result<(), String> { +pub(super) fn delete_database_file(path: &std::path::Path) -> Result<(), String> { walletkit_sqlite::opfs::delete_file(path).map_err(|err| err.to_string()) } #[cfg(not(target_arch = "wasm32"))] -fn delete_database_file(path: &std::path::Path) -> Result<(), String> { +pub(super) fn delete_database_file(path: &std::path::Path) -> Result<(), String> { match std::fs::remove_file(path) { Ok(()) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), @@ -103,7 +105,7 @@ fn delete_database_file(path: &std::path::Path) -> Result<(), String> { /// Installs persistent encrypted browser storage in the current Web Worker. /// -/// This must be awaited once before initializing a [`CredentialStore`] on +/// This must be awaited once before initializing a [`crate::storage::CredentialStore`] on /// WASM. The function fails when called outside a supported dedicated worker /// or when another browsing context owns the same OPFS SAH pool. /// diff --git a/crates/walletkit-core/src/storage/tests_utils.rs b/crates/walletkit-core/src/storage/tests_utils.rs index f6b334cc..804c3ac4 100644 --- a/crates/walletkit-core/src/storage/tests_utils.rs +++ b/crates/walletkit-core/src/storage/tests_utils.rs @@ -162,6 +162,13 @@ pub struct InMemoryStorageProvider { } impl InMemoryStorageProvider { + pub fn open_store( + &self, + ) -> Result { + let keys = crate::storage::StorageKeys::from_provider(self, 1000)?; + crate::storage::CredentialStore::new(self.paths(), Arc::new(keys)) + } + pub fn new(root: impl AsRef) -> Self { Self { keystore: Arc::new(InMemoryKeystore::new()), diff --git a/crates/walletkit-core/src/storage/traits.rs b/crates/walletkit-core/src/storage/traits.rs index 26658ac5..48566b07 100644 --- a/crates/walletkit-core/src/storage/traits.rs +++ b/crates/walletkit-core/src/storage/traits.rs @@ -1,8 +1,8 @@ //! Platform interfaces for credential storage. //! -//! These traits are the platform integration boundary. The host selects the storage -//! root and provides a [`DeviceKeystore`] and [`AtomicBlobStore`]; core storage code -//! is root-agnostic and consumes a provider-supplied [`StoragePaths`]. +//! These traits support host-owned key envelopes. Hosts resolve [`super::StorageKeys`] +//! using them before constructing credential storage. Direct-key hosts only supply +//! the resolved keys and [`StoragePaths`]. //! //! # Expected platform components //! @@ -12,8 +12,8 @@ //! [`AtomicBlobStore`] over app internal storage (atomic replace). //! - **Node.js:** file-backed [`DeviceKeystore`] (development; production can use an //! OS keystore); [`AtomicBlobStore`] over app internal storage. -//! - **Browser (WASM):** host-provided [`DeviceKeystore`] and [`AtomicBlobStore`] -//! implementations; sqlite persistence itself uses encrypted OPFS storage. +//! - **Browser (WASM):** supplies resolved database keys directly; database +//! persistence uses encrypted OPFS storage without a keystore or envelope. use std::sync::Arc; diff --git a/crates/walletkit-core/tests/common.rs b/crates/walletkit-core/tests/common.rs index 35d084f4..56060d7b 100644 --- a/crates/walletkit-core/tests/common.rs +++ b/crates/walletkit-core/tests/common.rs @@ -141,6 +141,16 @@ pub struct InMemoryStorageProvider { } impl InMemoryStorageProvider { + pub fn open_store( + &self, + ) -> Result< + walletkit_core::storage::CredentialStore, + walletkit_core::storage::StorageError, + > { + let keys = walletkit_core::storage::StorageKeys::from_provider(self, 1000)?; + walletkit_core::storage::CredentialStore::new(self.paths(), Arc::new(keys)) + } + pub fn new(root: impl AsRef) -> Self { Self { keystore: Arc::new(InMemoryKeystore::new()), @@ -174,9 +184,7 @@ pub fn temp_root() -> PathBuf { pub fn create_test_credential_store() -> Arc { let root = temp_root(); let provider = InMemoryStorageProvider::new(&root); - Arc::new( - CredentialStore::from_provider(&provider).expect("create credential store"), - ) + Arc::new(provider.open_store().expect("create credential store")) } #[allow(dead_code, reason = "used in tests")] diff --git a/crates/walletkit-core/tests/credential_storage_integration.rs b/crates/walletkit-core/tests/credential_storage_integration.rs index f846efa8..3612370b 100644 --- a/crates/walletkit-core/tests/credential_storage_integration.rs +++ b/crates/walletkit-core/tests/credential_storage_integration.rs @@ -6,7 +6,6 @@ mod common; use rand::rngs::OsRng; -use walletkit_core::storage::CredentialStore; use walletkit_core::Credential; use world_id_core::api_types::AccountInclusionProof; use world_id_core::primitives::AuthenticatorPublicKeySet; @@ -20,7 +19,7 @@ use world_id_core::{ fn test_storage_flow_end_to_end() { let root = common::temp_root(); let provider = common::InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("store"); + let store = provider.open_store().expect("store"); store.init(42, 100).expect("init"); diff --git a/crates/walletkit-testkit/src/storage.rs b/crates/walletkit-testkit/src/storage.rs index 1cffa77d..773da400 100644 --- a/crates/walletkit-testkit/src/storage.rs +++ b/crates/walletkit-testkit/src/storage.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use uuid::Uuid; use walletkit_core::authenticator::artifacts::caching::CachingZkArtifacts; use walletkit_core::storage::{ - AtomicBlobStore, CredentialStore, DeviceKeystore, StorageError, StoragePaths, - StorageProvider, + AtomicBlobStore, CredentialStore, DeviceKeystore, StorageError, StorageKeys, + StoragePaths, StorageProvider, }; /// No-op device keystore that passes data through without encryption. @@ -149,7 +149,15 @@ pub fn create_fs_credential_store( root: &Path, ) -> Result, StorageError> { let provider = FsStorageProvider::open(root); - Ok(Arc::new(CredentialStore::from_provider(&provider)?)) + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| StorageError::InvalidInput(e.to_string()))? + .as_secs(); + let keys = StorageKeys::from_provider(&provider, now)?; + Ok(Arc::new(CredentialStore::new( + provider.paths(), + Arc::new(keys), + )?)) } /// Creates a `WalletKitZkArtifactSource` backed by the filesystem at `root`. From 937c6fa7a25b4f063effa340d53d864677fffbf2 Mon Sep 17 00:00:00 2001 From: dzejkop Date: Wed, 9 Sep 2026 15:52:14 +0200 Subject: [PATCH 2/6] Construct activity listener test stores with resolved keys --- crates/walletkit-core/src/storage/credential_storage.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/walletkit-core/src/storage/credential_storage.rs b/crates/walletkit-core/src/storage/credential_storage.rs index 1adb7e31..d32fd819 100644 --- a/crates/walletkit-core/src/storage/credential_storage.rs +++ b/crates/walletkit-core/src/storage/credential_storage.rs @@ -1932,7 +1932,7 @@ mod tests { fn test_activity_changed_listener_notified_on_record() { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); + let store = provider.open_store().expect("create store"); store.init(42, 1000).expect("init storage"); let count = Arc::new(AtomicU32::new(0)); @@ -1953,7 +1953,7 @@ mod tests { fn test_activity_changed_listener_not_notified_on_failure() { let root = temp_root_path(); let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); + let store = provider.open_store().expect("create store"); store.init(42, 1000).expect("init storage"); let count = Arc::new(AtomicU32::new(0)); From f7488cc0b6f973f481e35c85db43f142f795a9f3 Mon Sep 17 00:00:00 2001 From: dzejkop Date: Wed, 9 Sep 2026 16:38:51 +0200 Subject: [PATCH 3/6] Expose explicit storage key envelope operations --- .../src/storage/credential_storage.rs | 7 +- .../src/storage/key_envelope.rs | 252 ++++++++++++++++++ crates/walletkit-core/src/storage/keys.rs | 234 +--------------- crates/walletkit-core/src/storage/mod.rs | 9 +- .../walletkit-core/src/storage/tests_utils.rs | 9 +- crates/walletkit-core/src/storage/traits.rs | 4 +- crates/walletkit-core/tests/common.rs | 9 +- crates/walletkit-testkit/src/storage.rs | 14 +- 8 files changed, 288 insertions(+), 250 deletions(-) create mode 100644 crates/walletkit-core/src/storage/key_envelope.rs diff --git a/crates/walletkit-core/src/storage/credential_storage.rs b/crates/walletkit-core/src/storage/credential_storage.rs index d32fd819..9b2222a7 100644 --- a/crates/walletkit-core/src/storage/credential_storage.rs +++ b/crates/walletkit-core/src/storage/credential_storage.rs @@ -937,7 +937,8 @@ impl CredentialStore { #[cfg(test)] mod tests { use super::super::{ - AtomicBlobStore, DeviceKeystore, StorageProvider, ACCOUNT_KEYS_FILENAME, + key_envelope::ACCOUNT_KEYS_FILENAME, open_or_create_storage_keys, + AtomicBlobStore, DeviceKeystore, StorageProvider, }; use super::*; @@ -946,13 +947,13 @@ mod tests { keystore: Arc, blob_store: Arc, ) -> StorageResult { - let keys = StorageKeys::from_envelope( + let keys = open_or_create_storage_keys( Arc::new(paths.clone()), keystore, blob_store, 1000, )?; - CredentialStoreInner::new(paths, Arc::new(keys)) + CredentialStoreInner::new(paths, keys) } use crate::storage::tests_utils::{ cleanup_test_storage, temp_root_path, InMemoryStorageProvider, diff --git a/crates/walletkit-core/src/storage/key_envelope.rs b/crates/walletkit-core/src/storage/key_envelope.rs new file mode 100644 index 00000000..fd9a7aa4 --- /dev/null +++ b/crates/walletkit-core/src/storage/key_envelope.rs @@ -0,0 +1,252 @@ +//! Explicit host-owned credential key envelope lifecycle. + +use std::sync::Arc; +use walletkit_db::Lock; + +use super::{ + AtomicBlobStore, DeviceKeystore, StorageKeys, StoragePaths, StorageResult, +}; + +pub(super) const ACCOUNT_KEYS_FILENAME: &str = "account_keys.bin"; +const ACCOUNT_KEY_ENVELOPE_AD: &[u8] = b"worldid:account-key-envelope"; + +/// Opens the device-sealed key envelope, or generates and persists one if absent. +/// +/// Call this before constructing a credential store. Platform components are +/// used only during this call and are not retained. +/// +/// `now` is Unix time in seconds, used only for a new envelope's creation and +/// update timestamps. It is ignored when opening an existing envelope. +/// +/// # Errors +/// Returns an error if locking, envelope access, key generation, sealing, or +/// unsealing fails. +#[uniffi::export] +#[expect( + clippy::needless_pass_by_value, + reason = "UniFFI parameters require owned Arc handles" +)] +pub fn open_or_create_storage_keys( + paths: Arc, + keystore: Arc, + blob_store: Arc, + now: u64, +) -> StorageResult> { + let lock = Lock::open(&paths.lock_path())?; + let intermediate_key = walletkit_db::init_or_open_envelope_key( + &Ks(keystore.as_ref()), + &Bs(blob_store.as_ref()), + &lock, + ACCOUNT_KEYS_FILENAME, + ACCOUNT_KEY_ENVELOPE_AD, + now, + )?; + Ok(Arc::new(StorageKeys::from_secret(intermediate_key))) +} + +/// Deletes the host-owned key envelope after closing/destroying its credential store. +/// This does not invalidate keys already held in memory by other owners. +/// +/// # Errors +/// Returns an error if locking or envelope deletion fails. +#[uniffi::export] +#[expect( + clippy::needless_pass_by_value, + reason = "UniFFI parameters require owned Arc handles" +)] +pub fn delete_storage_key_envelope( + paths: Arc, + blob_store: Arc, +) -> StorageResult<()> { + let lock = Lock::open(&paths.lock_path())?; + let _guard = lock.lock()?; + blob_store.delete(ACCOUNT_KEYS_FILENAME.to_string()) +} + +// Trait-object bridge from walletkit-core's uniffi-annotated traits onto +// walletkit-db's plain-Rust trait surface. Required because Rust's orphan +// rule prevents a blanket impl across crates. `Keystore::seal` borrows its +// plaintext (see walletkit-db/src/traits.rs); `Ks::seal` is the single +// point where the secret is copied into an owned `Vec`, because +// `DeviceKeystore` is a uniffi callback interface and those only support +// pass-by-value parameters (no `&[u8]`). That copy — and any further copy +// the foreign (Swift/Kotlin/etc.) implementation makes on its own side — is +// outside Rust's control; this is an accepted uniffi limitation, not a bug. + +struct Ks<'a>(&'a dyn DeviceKeystore); +impl walletkit_db::Keystore for Ks<'_> { + fn seal(&self, aad: &[u8], pt: &[u8]) -> walletkit_db::StoreResult> { + self.0 + .seal(aad.to_vec(), pt.to_vec()) + .map_err(|e| walletkit_db::StoreError::Keystore(e.to_string())) + } + fn open_sealed( + &self, + aad: Vec, + ct: Vec, + ) -> walletkit_db::StoreResult> { + self.0 + .open_sealed(aad, ct) + .map_err(|e| walletkit_db::StoreError::Keystore(e.to_string())) + } +} + +struct Bs<'a>(&'a dyn AtomicBlobStore); +impl walletkit_db::AtomicBlobStore for Bs<'_> { + fn read(&self, path: String) -> walletkit_db::StoreResult>> { + self.0 + .read(path) + .map_err(|e| walletkit_db::StoreError::BlobStore(e.to_string())) + } + fn write_atomic( + &self, + path: String, + bytes: Vec, + ) -> walletkit_db::StoreResult<()> { + self.0 + .write_atomic(path, bytes) + .map_err(|e| walletkit_db::StoreError::BlobStore(e.to_string())) + } + fn delete(&self, path: String) -> walletkit_db::StoreResult<()> { + self.0 + .delete(path) + .map_err(|e| walletkit_db::StoreError::BlobStore(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::tests_utils::{InMemoryBlobStore, InMemoryKeystore}; + use crate::storage::StorageError; + use secrecy::ExposeSecret; + + #[test] + fn envelope_resolution_does_not_retain_platform_components() { + let root = tempfile::tempdir().expect("temp dir"); + let paths = Arc::new(StoragePaths::new(root.path())); + let keystore = Arc::new(InMemoryKeystore::new()); + let blobs = Arc::new(InMemoryBlobStore::new()); + let weak_keystore = Arc::downgrade(&keystore); + let weak_blobs = Arc::downgrade(&blobs); + let _keys = open_or_create_storage_keys(paths, keystore, blobs, 1000) + .expect("resolve envelope"); + assert!(weak_keystore.upgrade().is_none()); + assert!(weak_blobs.upgrade().is_none()); + } + + #[test] + fn test_storage_keys_round_trip() { + let keystore = Arc::new(InMemoryKeystore::new()); + let blob_store = Arc::new(InMemoryBlobStore::new()); + let root = tempfile::tempdir().expect("temp dir"); + let paths = Arc::new(StoragePaths::new(root.path())); + let keys_first = open_or_create_storage_keys( + Arc::clone(&paths), + keystore.clone(), + blob_store.clone(), + 100, + ) + .expect("init"); + let envelope_before = blob_store + .read(ACCOUNT_KEYS_FILENAME.to_string()) + .expect("read envelope") + .expect("envelope exists"); + let metadata: ciborium::Value = + ciborium::de::from_reader(envelope_before.as_slice()).expect("CBOR"); + let fields = metadata.as_map().expect("envelope map"); + for name in ["created_at", "updated_at"] { + let timestamp = fields + .iter() + .find(|(key, _)| key.as_text() == Some(name)) + .expect("timestamp field"); + assert_eq!(timestamp.1, ciborium::Value::Integer(100.into())); + } + let keys_second = open_or_create_storage_keys( + Arc::clone(&paths), + keystore, + blob_store.clone(), + 200, + ) + .expect("init"); + + assert_eq!( + keys_first.intermediate_key().expose_secret(), + keys_second.intermediate_key().expose_secret() + ); + assert_eq!( + blob_store + .read(ACCOUNT_KEYS_FILENAME.to_string()) + .expect("read envelope") + .expect("envelope exists"), + envelope_before, + "reopening with a different timestamp must preserve the envelope" + ); + } + + #[test] + fn test_storage_keys_keystore_mismatch_fails() { + let keystore = Arc::new(InMemoryKeystore::new()); + let blob_store = Arc::new(InMemoryBlobStore::new()); + let root = tempfile::tempdir().expect("temp dir"); + let paths = Arc::new(StoragePaths::new(root.path())); + open_or_create_storage_keys( + Arc::clone(&paths), + keystore, + blob_store.clone(), + 123, + ) + .expect("init"); + + let other_keystore = Arc::new(InMemoryKeystore::new()); + match open_or_create_storage_keys( + Arc::clone(&paths), + other_keystore, + blob_store, + 456, + ) { + Err( + StorageError::Crypto(_) + | StorageError::InvalidEnvelope(_) + | StorageError::Keystore(_), + ) => {} + Err(err) => panic!("unexpected error: {err}"), + Ok(_) => panic!("expected error"), + } + } + + #[test] + fn test_storage_keys_tampered_envelope_fails() { + let keystore = Arc::new(InMemoryKeystore::new()); + let blob_store = Arc::new(InMemoryBlobStore::new()); + let root = tempfile::tempdir().expect("temp dir"); + let paths = Arc::new(StoragePaths::new(root.path())); + open_or_create_storage_keys( + Arc::clone(&paths), + keystore.clone(), + blob_store.clone(), + 123, + ) + .expect("init"); + + let mut bytes = blob_store + .read(ACCOUNT_KEYS_FILENAME.to_string()) + .expect("read") + .expect("present"); + bytes[0] ^= 0xFF; + blob_store + .write_atomic(ACCOUNT_KEYS_FILENAME.to_string(), bytes) + .expect("write"); + + match open_or_create_storage_keys(Arc::clone(&paths), keystore, blob_store, 456) + { + Err( + StorageError::Serialization(_) + | StorageError::Crypto(_) + | StorageError::UnsupportedEnvelopeVersion(_), + ) => {} + Err(err) => panic!("unexpected error: {err}"), + Ok(_) => panic!("expected error"), + } + } +} diff --git a/crates/walletkit-core/src/storage/keys.rs b/crates/walletkit-core/src/storage/keys.rs index e44b0775..aa844f94 100644 --- a/crates/walletkit-core/src/storage/keys.rs +++ b/crates/walletkit-core/src/storage/keys.rs @@ -1,18 +1,9 @@ -//! Resolved database keys and explicit envelope lifecycle helpers. -//! -//! `StorageKeys` holds `K_intermediate` regardless of its source. Hosts resolve -//! it from a sealed envelope or supply it directly before constructing storage. +//! Resolved database keys, independent of their source. use secrecy::SecretBox; -use std::sync::Arc; use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; -use super::{ - error::{StorageError, StorageResult}, - traits::{AtomicBlobStore, DeviceKeystore}, - StoragePaths, StorageProvider, ACCOUNT_KEYS_FILENAME, ACCOUNT_KEY_ENVELOPE_AD, -}; -use walletkit_db::Lock; +use super::error::{StorageError, StorageResult}; /// Resolved in-memory database keys, independent of their source. /// @@ -44,85 +35,11 @@ impl StorageKeys { }); Ok(Self { intermediate_key }) } - - /// Resolves the database key from a device-sealed envelope, creating it if absent. - /// The platform integrations are used only during this call and are not retained. - /// - /// # Errors - /// Returns an error if locking, envelope access, or key unsealing fails. - #[uniffi::constructor] - #[expect( - clippy::needless_pass_by_value, - reason = "UniFFI parameters require owned Arc handles" - )] - pub fn from_envelope( - paths: Arc, - keystore: Arc, - blob_store: Arc, - now: u64, - ) -> StorageResult { - let lock = Lock::open(&paths.lock_path())?; - Self::init(keystore.as_ref(), blob_store.as_ref(), &lock, now) - } -} - -/// Deletes the host-owned key envelope after closing/destroying its credential store. -/// This does not invalidate keys already held in memory by other owners. -/// -/// # Errors -/// Returns an error if locking or envelope deletion fails. -#[uniffi::export] -#[expect( - clippy::needless_pass_by_value, - reason = "UniFFI parameters require owned Arc handles" -)] -pub fn delete_storage_key_envelope( - paths: Arc, - blob_store: Arc, -) -> StorageResult<()> { - let lock = Lock::open(&paths.lock_path())?; - let _guard = lock.lock()?; - blob_store.delete(ACCOUNT_KEYS_FILENAME.to_string()) } impl StorageKeys { - /// Resolves an envelope key using a native host's platform provider. - /// - /// # Errors - /// Returns an error if the envelope cannot be opened or created. - pub fn from_provider( - provider: &dyn StorageProvider, - now: u64, - ) -> StorageResult { - Self::from_envelope( - provider.paths(), - provider.keystore(), - provider.blob_store(), - now, - ) - } - - /// Initializes storage keys by opening or creating the account key envelope. - /// - /// # Errors - /// - /// Returns an error if the envelope cannot be read, decrypted, or parsed, - /// or if persistence to the blob store fails. - pub fn init( - keystore: &dyn DeviceKeystore, - blob_store: &dyn AtomicBlobStore, - lock: &Lock, - now: u64, - ) -> StorageResult { - let intermediate_key = walletkit_db::init_or_open_envelope_key( - &Ks(keystore), - &Bs(blob_store), - lock, - ACCOUNT_KEYS_FILENAME, - ACCOUNT_KEY_ENVELOPE_AD, - now, - )?; - Ok(Self { intermediate_key }) + pub(crate) const fn from_secret(intermediate_key: SecretBox<[u8; 32]>) -> Self { + Self { intermediate_key } } /// Returns a reference to the intermediate key's [`SecretBox`]. @@ -132,71 +49,10 @@ impl StorageKeys { } } -// Trait-object bridge from walletkit-core's uniffi-annotated traits onto -// walletkit-db's plain-Rust trait surface. Required because Rust's orphan -// rule prevents a blanket impl across crates. `Keystore::seal` borrows its -// plaintext (see walletkit-db/src/traits.rs); `Ks::seal` is the single -// point where the secret is copied into an owned `Vec`, because -// `DeviceKeystore` is a uniffi callback interface and those only support -// pass-by-value parameters (no `&[u8]`). That copy — and any further copy -// the foreign (Swift/Kotlin/etc.) implementation makes on its own side — is -// outside Rust's control; this is an accepted uniffi limitation, not a bug. - -struct Ks<'a>(&'a dyn DeviceKeystore); -impl walletkit_db::Keystore for Ks<'_> { - fn seal(&self, aad: &[u8], pt: &[u8]) -> walletkit_db::StoreResult> { - self.0 - .seal(aad.to_vec(), pt.to_vec()) - .map_err(|e| walletkit_db::StoreError::Keystore(e.to_string())) - } - fn open_sealed( - &self, - aad: Vec, - ct: Vec, - ) -> walletkit_db::StoreResult> { - self.0 - .open_sealed(aad, ct) - .map_err(|e| walletkit_db::StoreError::Keystore(e.to_string())) - } -} - -struct Bs<'a>(&'a dyn AtomicBlobStore); -impl walletkit_db::AtomicBlobStore for Bs<'_> { - fn read(&self, path: String) -> walletkit_db::StoreResult>> { - self.0 - .read(path) - .map_err(|e| walletkit_db::StoreError::BlobStore(e.to_string())) - } - fn write_atomic( - &self, - path: String, - bytes: Vec, - ) -> walletkit_db::StoreResult<()> { - self.0 - .write_atomic(path, bytes) - .map_err(|e| walletkit_db::StoreError::BlobStore(e.to_string())) - } - fn delete(&self, path: String) -> walletkit_db::StoreResult<()> { - self.0 - .delete(path) - .map_err(|e| walletkit_db::StoreError::BlobStore(e.to_string())) - } -} - #[cfg(test)] mod tests { use super::*; - use crate::storage::error::StorageError; - use crate::storage::tests_utils::{InMemoryBlobStore, InMemoryKeystore}; use secrecy::ExposeSecret; - use uuid::Uuid; - use walletkit_db::Lock; - - fn temp_lock_path() -> std::path::PathBuf { - let mut path = std::env::temp_dir(); - path.push(format!("walletkit-keys-lock-{}.lock", Uuid::new_v4())); - path - } #[test] fn direct_key_requires_exactly_32_bytes() { @@ -209,86 +65,4 @@ mod tests { let keys = StorageKeys::from_bytes(vec![7; 32]).expect("direct key"); assert_eq!(keys.intermediate_key().expose_secret(), &[7; 32]); } - - #[test] - fn envelope_resolution_does_not_retain_platform_components() { - let root = tempfile::tempdir().expect("temp dir"); - let paths = Arc::new(StoragePaths::new(root.path())); - let keystore = Arc::new(InMemoryKeystore::new()); - let blobs = Arc::new(InMemoryBlobStore::new()); - let weak_keystore = Arc::downgrade(&keystore); - let weak_blobs = Arc::downgrade(&blobs); - let _keys = StorageKeys::from_envelope(paths, keystore, blobs, 1000) - .expect("resolve envelope"); - assert!(weak_keystore.upgrade().is_none()); - assert!(weak_blobs.upgrade().is_none()); - } - - #[test] - fn test_storage_keys_round_trip() { - let keystore = InMemoryKeystore::new(); - let blob_store = InMemoryBlobStore::new(); - let lock_path = temp_lock_path(); - let lock = Lock::open(&lock_path).expect("open lock"); - let keys_first = - StorageKeys::init(&keystore, &blob_store, &lock, 100).expect("init"); - let keys_second = - StorageKeys::init(&keystore, &blob_store, &lock, 200).expect("init"); - - assert_eq!( - keys_first.intermediate_key.expose_secret(), - keys_second.intermediate_key.expose_secret() - ); - let _ = std::fs::remove_file(lock_path); - } - - #[test] - fn test_storage_keys_keystore_mismatch_fails() { - let keystore = InMemoryKeystore::new(); - let blob_store = InMemoryBlobStore::new(); - let lock_path = temp_lock_path(); - let lock = Lock::open(&lock_path).expect("open lock"); - StorageKeys::init(&keystore, &blob_store, &lock, 123).expect("init"); - - let other_keystore = InMemoryKeystore::new(); - match StorageKeys::init(&other_keystore, &blob_store, &lock, 456) { - Err( - StorageError::Crypto(_) - | StorageError::InvalidEnvelope(_) - | StorageError::Keystore(_), - ) => {} - Err(err) => panic!("unexpected error: {err}"), - Ok(_) => panic!("expected error"), - } - let _ = std::fs::remove_file(lock_path); - } - - #[test] - fn test_storage_keys_tampered_envelope_fails() { - let keystore = InMemoryKeystore::new(); - let blob_store = InMemoryBlobStore::new(); - let lock_path = temp_lock_path(); - let lock = Lock::open(&lock_path).expect("open lock"); - StorageKeys::init(&keystore, &blob_store, &lock, 123).expect("init"); - - let mut bytes = blob_store - .read(ACCOUNT_KEYS_FILENAME.to_string()) - .expect("read") - .expect("present"); - bytes[0] ^= 0xFF; - blob_store - .write_atomic(ACCOUNT_KEYS_FILENAME.to_string(), bytes) - .expect("write"); - - match StorageKeys::init(&keystore, &blob_store, &lock, 456) { - Err( - StorageError::Serialization(_) - | StorageError::Crypto(_) - | StorageError::UnsupportedEnvelopeVersion(_), - ) => {} - Err(err) => panic!("unexpected error: {err}"), - Ok(_) => panic!("expected error"), - } - let _ = std::fs::remove_file(lock_path); - } } diff --git a/crates/walletkit-core/src/storage/mod.rs b/crates/walletkit-core/src/storage/mod.rs index 1c76a198..f18c0b89 100644 --- a/crates/walletkit-core/src/storage/mod.rs +++ b/crates/walletkit-core/src/storage/mod.rs @@ -29,7 +29,7 @@ //! //! Both databases use the resolved `K_intermediate` supplied through [`crate::storage::StorageKeys`]. //! Hosts obtain it directly (for example from a passkey PRF) or resolve a sealed -//! envelope before constructing [`crate::storage::CredentialStore`]. +//! envelope with [`open_or_create_storage_keys`] before constructing [`crate::storage::CredentialStore`]. //! //! ## On-disk layout //! @@ -51,6 +51,7 @@ pub mod credential_vault; #[cfg(test)] mod ephemeral; pub mod error; +mod key_envelope; pub mod keys; pub mod paths; pub mod traits; @@ -60,7 +61,8 @@ pub use cache::CacheDb; pub use credential_storage::CredentialStore; pub use credential_vault::CredentialVault; pub use error::{StorageError, StorageResult}; -pub use keys::{delete_storage_key_envelope, StorageKeys}; +pub use key_envelope::{delete_storage_key_envelope, open_or_create_storage_keys}; +pub use keys::StorageKeys; pub use paths::StoragePaths; pub use traits::{ ActivityChangedListener, AtomicBlobStore, DeviceKeystore, StorageProvider, @@ -120,8 +122,5 @@ pub async fn initialize_persistent_storage() -> StorageResult<()> { .map_err(|err| StorageError::PersistentStorage(err.to_string())) } -pub(crate) const ACCOUNT_KEYS_FILENAME: &str = "account_keys.bin"; -pub(crate) const ACCOUNT_KEY_ENVELOPE_AD: &[u8] = b"worldid:account-key-envelope"; - #[cfg(test)] pub(crate) mod tests_utils; diff --git a/crates/walletkit-core/src/storage/tests_utils.rs b/crates/walletkit-core/src/storage/tests_utils.rs index 804c3ac4..a8e2f140 100644 --- a/crates/walletkit-core/src/storage/tests_utils.rs +++ b/crates/walletkit-core/src/storage/tests_utils.rs @@ -165,8 +165,13 @@ impl InMemoryStorageProvider { pub fn open_store( &self, ) -> Result { - let keys = crate::storage::StorageKeys::from_provider(self, 1000)?; - crate::storage::CredentialStore::new(self.paths(), Arc::new(keys)) + let keys = crate::storage::open_or_create_storage_keys( + self.paths(), + self.keystore(), + self.blob_store(), + 1000, + )?; + crate::storage::CredentialStore::new(self.paths(), keys) } pub fn new(root: impl AsRef) -> Self { diff --git a/crates/walletkit-core/src/storage/traits.rs b/crates/walletkit-core/src/storage/traits.rs index 48566b07..5d691bf1 100644 --- a/crates/walletkit-core/src/storage/traits.rs +++ b/crates/walletkit-core/src/storage/traits.rs @@ -1,8 +1,8 @@ //! Platform interfaces for credential storage. //! //! These traits support host-owned key envelopes. Hosts resolve [`super::StorageKeys`] -//! using them before constructing credential storage. Direct-key hosts only supply -//! the resolved keys and [`StoragePaths`]. +//! using [`super::open_or_create_storage_keys`] before constructing credential storage. +//! Direct-key hosts only supply the resolved keys and [`StoragePaths`]. //! //! # Expected platform components //! diff --git a/crates/walletkit-core/tests/common.rs b/crates/walletkit-core/tests/common.rs index 56060d7b..c1aa0d67 100644 --- a/crates/walletkit-core/tests/common.rs +++ b/crates/walletkit-core/tests/common.rs @@ -147,8 +147,13 @@ impl InMemoryStorageProvider { walletkit_core::storage::CredentialStore, walletkit_core::storage::StorageError, > { - let keys = walletkit_core::storage::StorageKeys::from_provider(self, 1000)?; - walletkit_core::storage::CredentialStore::new(self.paths(), Arc::new(keys)) + let keys = walletkit_core::storage::open_or_create_storage_keys( + self.paths(), + self.keystore(), + self.blob_store(), + 1000, + )?; + walletkit_core::storage::CredentialStore::new(self.paths(), keys) } pub fn new(root: impl AsRef) -> Self { diff --git a/crates/walletkit-testkit/src/storage.rs b/crates/walletkit-testkit/src/storage.rs index 773da400..88e5a54c 100644 --- a/crates/walletkit-testkit/src/storage.rs +++ b/crates/walletkit-testkit/src/storage.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use uuid::Uuid; use walletkit_core::authenticator::artifacts::caching::CachingZkArtifacts; use walletkit_core::storage::{ - AtomicBlobStore, CredentialStore, DeviceKeystore, StorageError, StorageKeys, - StoragePaths, StorageProvider, + open_or_create_storage_keys, AtomicBlobStore, CredentialStore, DeviceKeystore, + StorageError, StoragePaths, StorageProvider, }; /// No-op device keystore that passes data through without encryption. @@ -153,11 +153,13 @@ pub fn create_fs_credential_store( .duration_since(std::time::UNIX_EPOCH) .map_err(|e| StorageError::InvalidInput(e.to_string()))? .as_secs(); - let keys = StorageKeys::from_provider(&provider, now)?; - Ok(Arc::new(CredentialStore::new( + let keys = open_or_create_storage_keys( provider.paths(), - Arc::new(keys), - )?)) + provider.keystore(), + provider.blob_store(), + now, + )?; + Ok(Arc::new(CredentialStore::new(provider.paths(), keys)?)) } /// Creates a `WalletKitZkArtifactSource` backed by the filesystem at `root`. From 5d5c6dac39d77fff7efdef34d2931d7ee6b7bee7 Mon Sep 17 00:00:00 2001 From: dzejkop Date: Wed, 9 Sep 2026 16:47:59 +0200 Subject: [PATCH 4/6] Qualify envelope helper link for included storage docs --- crates/walletkit-core/src/storage/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/walletkit-core/src/storage/mod.rs b/crates/walletkit-core/src/storage/mod.rs index f18c0b89..02e1d9d5 100644 --- a/crates/walletkit-core/src/storage/mod.rs +++ b/crates/walletkit-core/src/storage/mod.rs @@ -29,7 +29,7 @@ //! //! Both databases use the resolved `K_intermediate` supplied through [`crate::storage::StorageKeys`]. //! Hosts obtain it directly (for example from a passkey PRF) or resolve a sealed -//! envelope with [`open_or_create_storage_keys`] before constructing [`crate::storage::CredentialStore`]. +//! envelope with [`crate::storage::open_or_create_storage_keys`] before constructing [`crate::storage::CredentialStore`]. //! //! ## On-disk layout //! From 90123cba178c11eee572764d328953d2b45900dc Mon Sep 17 00:00:00 2001 From: dzejkop Date: Wed, 9 Sep 2026 16:50:14 +0200 Subject: [PATCH 5/6] Remove redundant envelope component lifetime test --- crates/walletkit-core/src/storage/key_envelope.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/crates/walletkit-core/src/storage/key_envelope.rs b/crates/walletkit-core/src/storage/key_envelope.rs index fd9a7aa4..ba3744c0 100644 --- a/crates/walletkit-core/src/storage/key_envelope.rs +++ b/crates/walletkit-core/src/storage/key_envelope.rs @@ -121,20 +121,6 @@ mod tests { use crate::storage::StorageError; use secrecy::ExposeSecret; - #[test] - fn envelope_resolution_does_not_retain_platform_components() { - let root = tempfile::tempdir().expect("temp dir"); - let paths = Arc::new(StoragePaths::new(root.path())); - let keystore = Arc::new(InMemoryKeystore::new()); - let blobs = Arc::new(InMemoryBlobStore::new()); - let weak_keystore = Arc::downgrade(&keystore); - let weak_blobs = Arc::downgrade(&blobs); - let _keys = open_or_create_storage_keys(paths, keystore, blobs, 1000) - .expect("resolve envelope"); - assert!(weak_keystore.upgrade().is_none()); - assert!(weak_blobs.upgrade().is_none()); - } - #[test] fn test_storage_keys_round_trip() { let keystore = Arc::new(InMemoryKeystore::new()); From 54eb01248db3752f23f00ad6f0bc1cfcab71f749 Mon Sep 17 00:00:00 2001 From: dzejkop Date: Wed, 9 Sep 2026 19:50:47 +0200 Subject: [PATCH 6/6] Restore best-effort database cleanup on storage destruction --- .../src/authenticator/with_storage.rs | 5 ++- .../src/storage/credential_storage.rs | 41 +++++++------------ crates/walletkit-core/src/storage/mod.rs | 4 +- 3 files changed, 19 insertions(+), 31 deletions(-) diff --git a/crates/walletkit-core/src/authenticator/with_storage.rs b/crates/walletkit-core/src/authenticator/with_storage.rs index 7dbb1c53..b16ccb40 100644 --- a/crates/walletkit-core/src/authenticator/with_storage.rs +++ b/crates/walletkit-core/src/authenticator/with_storage.rs @@ -20,9 +20,10 @@ impl Authenticator { Ok(()) } - /// Permanently destroys all credential storage data. + /// Closes credential storage and attempts to delete its database files. /// - /// Releases the store's key reference and removes the vault and cache databases. + /// Releases the store's key reference and removes the vault and cache databases + /// on a best-effort basis. File deletion failures are logged, not returned. /// The host must separately delete any key envelope it owns. /// After this call the authenticator can no longer generate proofs or /// access stored credentials. Intended for logout or account deletion. diff --git a/crates/walletkit-core/src/storage/credential_storage.rs b/crates/walletkit-core/src/storage/credential_storage.rs index 9b2222a7..9fa553a6 100644 --- a/crates/walletkit-core/src/storage/credential_storage.rs +++ b/crates/walletkit-core/src/storage/credential_storage.rs @@ -300,14 +300,15 @@ impl CredentialStore { #[uniffi::export] impl CredentialStore { - /// Closes the databases, releases this store's key reference, and deletes its files. + /// Closes the databases, releases this store's key reference, and attempts file cleanup. /// /// The host owns envelope deletion via `delete_storage_key_envelope`. This /// store cannot be reinitialized after destruction; construct a new store /// with resolved keys. Other key owners are unaffected. + /// Database file deletion is best effort: failures are logged, not returned. /// /// # Errors - /// Returns an error if locking or database file deletion fails. + /// Returns an error if locking fails. pub fn destroy_storage(&self) -> StorageResult<()> { self.lock_inner()?.destroy_storage() } @@ -899,27 +900,9 @@ impl CredentialStoreInner { let _guard = self.guard()?; self.state = None; self.keys = None; - // Keys may remain recoverable through a passkey or host envelope, so - // deletion failures must be reported rather than treated as crypto-erasure. - let mut first_error = None; - for path in [self.paths.vault_db_path(), self.paths.cache_db_path()] { - for file in [ - path.clone(), - path.with_extension("sqlite-journal"), - path.with_extension("sqlite-wal"), - path.with_extension("sqlite-shm"), - ] { - if let Err(error) = super::delete_database_file(&file) { - first_error.get_or_insert_with(|| { - StorageError::VaultDb(format!( - "delete {}: {error}", - file.display() - )) - }); - } - } - } - first_error.map_or(Ok(()), Err) + super::delete_database_files(&self.paths.vault_db_path()); + super::delete_database_files(&self.paths.cache_db_path()); + Ok(()) } } @@ -1075,19 +1058,23 @@ mod tests { } #[test] - fn destruction_reports_failed_file_deletion_and_can_retry() { + fn destruction_continues_after_failed_file_deletion() { let root = temp_root_path(); let paths = Arc::new(StoragePaths::new(&root)); let keys = Arc::new(StorageKeys::from_bytes(vec![0x41; 32]).expect("key")); let store = CredentialStore::new(Arc::clone(&paths), keys).expect("store"); + let blocked_path = paths.vault_db_path(); // A directory at the DB path cannot be removed with remove_file, even as root. - std::fs::create_dir_all(paths.vault_db_path()).expect("block path"); - assert!(store.destroy_storage().is_err()); + std::fs::create_dir_all(&blocked_path).expect("block path"); + std::fs::write(paths.cache_db_path(), b"cache").expect("create cache file"); + store.destroy_storage().expect("best-effort cleanup"); + assert!(blocked_path.exists()); + assert!(!paths.cache_db_path().exists()); assert!(matches!( store.init(42, 1000), Err(StorageError::NotInitialized) )); - std::fs::remove_dir(paths.vault_db_path()).expect("remove obstruction"); + std::fs::remove_dir(blocked_path).expect("remove obstruction"); store.destroy_storage().expect("retry cleanup"); cleanup_test_storage(&root); } diff --git a/crates/walletkit-core/src/storage/mod.rs b/crates/walletkit-core/src/storage/mod.rs index 02e1d9d5..be308034 100644 --- a/crates/walletkit-core/src/storage/mod.rs +++ b/crates/walletkit-core/src/storage/mod.rs @@ -92,12 +92,12 @@ pub(crate) fn delete_database_files(path: &std::path::Path) { } #[cfg(target_arch = "wasm32")] -pub(super) fn delete_database_file(path: &std::path::Path) -> Result<(), String> { +fn delete_database_file(path: &std::path::Path) -> Result<(), String> { walletkit_sqlite::opfs::delete_file(path).map_err(|err| err.to_string()) } #[cfg(not(target_arch = "wasm32"))] -pub(super) fn delete_database_file(path: &std::path::Path) -> Result<(), String> { +fn delete_database_file(path: &std::path::Path) -> Result<(), String> { match std::fs::remove_file(path) { Ok(()) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),