diff --git a/crates/walletkit-core/src/storage/cache/activity.rs b/crates/walletkit-core/src/storage/cache/activity.rs new file mode 100644 index 00000000..8ab15a3d --- /dev/null +++ b/crates/walletkit-core/src/storage/cache/activity.rs @@ -0,0 +1,366 @@ +use crate::storage::error::{StorageError, StorageResult}; +use crate::storage::types::{ + ActivityEntry, ActivityFailureReason, ActivityMetadata, ActivityOutcome, + ActivityQuery, ProtocolVersion, +}; +use walletkit_sqlite::{params, Connection, Row, StepResult, Value}; + +use super::util::{map_db_err, to_i64, to_u64}; + +pub(super) fn record( + conn: &Connection, + entry: &ActivityEntry, + now: u64, +) -> StorageResult { + match (entry.outcome, entry.failure_reason) { + (ActivityOutcome::Failed, None) => { + return Err(StorageError::ActivityInvalidRecord( + "failure_reason must be present when outcome is Failed".to_string(), + )); + } + (outcome, Some(_)) if outcome != ActivityOutcome::Failed => { + return Err(StorageError::ActivityInvalidRecord( + "failure_reason must be absent unless outcome is Failed".to_string(), + )); + } + _ => {} + } + + let now_i64 = to_i64(now, "now")?; + + let failure_reason_value = entry.failure_reason.map_or(Value::Null, |reason| { + Value::Integer(failure_reason_to_i64(reason)) + }); + + let entry_id = conn + .query_row( + "INSERT INTO activity_entries ( + client_id, protocol, created_at, + outcome, app_identifier, issuer_schema_ids, failure_reason + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + RETURNING entry_id", + params![ + entry.client_id.as_str(), + entry.protocol.as_i64(), + now_i64, + entry.outcome.to_string(), + entry.rp_id.to_string(), + encode_issuer_schema_ids(&entry.issuer_schema_ids), + failure_reason_value, + ], + |stmt| Ok(stmt.column_i64(0)), + ) + .map_err(|err| map_db_err(&err))?; + + to_u64(entry_id, "entry_id") +} + +/// Lists activity entries, most recent first. +pub(super) fn list( + conn: &Connection, + query: ActivityQuery, + limit: u32, + offset: u32, +) -> StorageResult> { + let _ = query; + let limit_i64 = i64::from(limit); + let offset_i64 = i64::from(offset); + + let sql = "SELECT entry_id, client_id, protocol, created_at, outcome, + app_identifier, issuer_schema_ids, failure_reason + FROM activity_entries + ORDER BY created_at DESC, entry_id DESC + LIMIT ?1 OFFSET ?2"; + + let mut entries = Vec::new(); + + let mut stmt = conn.prepare(sql).map_err(|err| map_db_err(&err))?; + + stmt.bind_values(params![limit_i64, offset_i64]) + .map_err(|err| map_db_err(&err))?; + + while let StepResult::Row(row) = stmt.step().map_err(|err| map_db_err(&err))? { + entries.push(map_entry(&row)?); + } + + Ok(entries) +} + +/// Returns aggregate activity metadata. +pub(super) fn metadata(conn: &Connection) -> StorageResult { + let total_count = conn + .query_row("SELECT COUNT(*) FROM activity_entries", &[], |stmt| { + Ok(stmt.column_i64(0)) + }) + .map_err(|err| map_db_err(&err))?; + + Ok(ActivityMetadata { + total_count: to_u64(total_count, "total_count")?, + }) +} + +pub(super) fn clear(conn: &Connection) -> StorageResult { + let deleted = conn + .execute("DELETE FROM activity_entries", &[]) + .map_err(|err| map_db_err(&err))?; + + Ok(deleted as u64) +} + +fn encode_issuer_schema_ids(issuer_schema_ids: &[u64]) -> Vec { + let mut bytes = Vec::with_capacity(issuer_schema_ids.len() * 8); + for id in issuer_schema_ids { + bytes.extend_from_slice(&id.to_be_bytes()); + } + bytes +} + +fn decode_issuer_schema_ids(bytes: &[u8]) -> StorageResult> { + if !bytes.len().is_multiple_of(8) { + return Err(StorageError::ActivityDb(format!( + "invalid issuer_schema_ids blob length: {}", + bytes.len() + ))); + } + + Ok(bytes + .chunks_exact(8) + .map(|chunk| { + let mut buf = [0u8; 8]; + buf.copy_from_slice(chunk); + u64::from_be_bytes(buf) + }) + .collect()) +} + +fn map_entry(row: &Row<'_, '_>) -> StorageResult { + let id = to_u64(row.column_i64(0), "entry_id")?; + let client_id = row.column_text(1); + let protocol = ProtocolVersion::try_from(row.column_i64(2))?; + let timestamp = to_u64(row.column_i64(3), "created_at")?; + let outcome_text = row.column_text(4); + let outcome: ActivityOutcome = outcome_text.parse().map_err(|_| { + StorageError::ActivityDb(format!("invalid outcome: {outcome_text}")) + })?; + let rp_id = parse_rp_id(&row.column_text(5))?; + let issuer_schema_ids = decode_issuer_schema_ids(&row.column_blob(6))?; + let failure_reason = if row.is_column_null(7) { + None + } else { + Some(i64_to_failure_reason(row.column_i64(7))?) + }; + + Ok(ActivityEntry { + id: Some(id), + client_id, + protocol, + timestamp: Some(timestamp), + outcome, + rp_id, + issuer_schema_ids, + failure_reason, + }) +} + +fn parse_rp_id(text: &str) -> StorageResult { + text.parse().map_err(|_| { + StorageError::ActivityDb(format!("invalid app_identifier: {text}")) + }) +} + +const fn failure_reason_to_i64(reason: ActivityFailureReason) -> i64 { + match reason { + ActivityFailureReason::NetworkError => 1, + ActivityFailureReason::Timeout => 2, + ActivityFailureReason::DeviceAuthenticationFailed => 3, + ActivityFailureReason::ProofGenerationFailed => 4, + ActivityFailureReason::RelyingPartyRejected => 5, + } +} + +fn i64_to_failure_reason(value: i64) -> StorageResult { + match value { + 1 => Ok(ActivityFailureReason::NetworkError), + 2 => Ok(ActivityFailureReason::Timeout), + 3 => Ok(ActivityFailureReason::DeviceAuthenticationFailed), + 4 => Ok(ActivityFailureReason::ProofGenerationFailed), + 5 => Ok(ActivityFailureReason::RelyingPartyRejected), + other => Err(StorageError::ActivityDb(format!( + "invalid failure reason: {other}" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::cache::CacheDb; + use secrecy::SecretBox; + use std::fs; + use std::path::{Path, PathBuf}; + use uuid::Uuid; + + fn temp_cache_path() -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!( + "walletkit-cache-activity-{}.sqlite", + Uuid::new_v4() + )); + path + } + + fn cleanup_cache_files(path: &Path) { + let _ = fs::remove_file(path); + let _ = fs::remove_file(path.with_extension("sqlite-wal")); + let _ = fs::remove_file(path.with_extension("sqlite-shm")); + } + + fn sample_entry() -> ActivityEntry { + ActivityEntry { + id: None, + rp_id: 1, + client_id: "request-uuid-1".to_string(), + protocol: ProtocolVersion::V3, + timestamp: None, + issuer_schema_ids: vec![10], + outcome: ActivityOutcome::Completed, + failure_reason: None, + } + } + + #[test] + fn test_record_and_list_activity() { + let path = temp_cache_path(); + let key = SecretBox::init_with(|| [0x42u8; 32]); + let db = CacheDb::new(&path, &key).expect("create cache"); + + let entry_id = db + .record_activity(&sample_entry(), 1000) + .expect("record activity"); + + let entries = db + .list_activities(ActivityQuery::default(), 10, 0) + .expect("list activities"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].id, Some(entry_id)); + assert_eq!(entries[0].outcome, ActivityOutcome::Completed); + assert_eq!(entries[0].issuer_schema_ids.len(), 1); + + cleanup_cache_files(&path); + } + + #[test] + fn test_record_activity_failed_requires_failure_reason() { + let path = temp_cache_path(); + let key = SecretBox::init_with(|| [0x02u8; 32]); + let db = CacheDb::new(&path, &key).expect("create cache"); + + let entry = ActivityEntry { + outcome: ActivityOutcome::Failed, + failure_reason: None, + ..sample_entry() + }; + + let err = db + .record_activity(&entry, 1000) + .expect_err("Failed without failure_reason should be rejected"); + + assert!(matches!(err, StorageError::ActivityInvalidRecord(_))); + + cleanup_cache_files(&path); + } + + #[test] + fn test_record_activity_rejects_failure_reason_without_failed_outcome() { + let path = temp_cache_path(); + let key = SecretBox::init_with(|| [0x03u8; 32]); + let db = CacheDb::new(&path, &key).expect("create cache"); + + let entry = ActivityEntry { + outcome: ActivityOutcome::Completed, + failure_reason: Some(ActivityFailureReason::NetworkError), + ..sample_entry() + }; + + let err = db + .record_activity(&entry, 1000) + .expect_err("failure_reason without Failed outcome should be rejected"); + + assert!(matches!(err, StorageError::ActivityInvalidRecord(_))); + + cleanup_cache_files(&path); + } + + #[test] + fn test_list_activities_paginates_with_offset() { + let path = temp_cache_path(); + let key = SecretBox::init_with(|| [0x05u8; 32]); + let db = CacheDb::new(&path, &key).expect("create cache"); + + for i in 0..5u64 { + db.record_activity(&sample_entry(), 1000 + i) + .expect("record activity"); + } + + let page1 = db + .list_activities(ActivityQuery::default(), 2, 0) + .expect("list page 1"); + let page2 = db + .list_activities(ActivityQuery::default(), 2, 2) + .expect("list page 2"); + let page3 = db + .list_activities(ActivityQuery::default(), 2, 4) + .expect("list page 3"); + + assert_eq!(page1.len(), 2); + assert_eq!(page2.len(), 2); + assert_eq!(page3.len(), 1); + + assert_eq!(page1[0].timestamp, Some(1004)); + assert_eq!(page1[1].timestamp, Some(1003)); + assert_eq!(page2[0].timestamp, Some(1002)); + assert_eq!(page3[0].timestamp, Some(1000)); + + cleanup_cache_files(&path); + } + + #[test] + fn test_activity_metadata_total_count() { + let path = temp_cache_path(); + let key = SecretBox::init_with(|| [0x06u8; 32]); + let db = CacheDb::new(&path, &key).expect("create cache"); + + assert_eq!(db.activity_metadata().expect("metadata").total_count, 0); + + db.record_activity(&sample_entry(), 1000) + .expect("record activity"); + db.record_activity(&sample_entry(), 1001) + .expect("record activity"); + + assert_eq!(db.activity_metadata().expect("metadata").total_count, 2); + + cleanup_cache_files(&path); + } + + #[test] + fn test_activity_survives_cache_reopen() { + let path = temp_cache_path(); + let key = SecretBox::init_with(|| [0x07u8; 32]); + let db = CacheDb::new(&path, &key).expect("create cache"); + db.record_activity(&sample_entry(), 1000) + .expect("record activity"); + drop(db); + + let db = CacheDb::new(&path, &key).expect("reopen cache"); + let entries = db + .list_activities(ActivityQuery::default(), 10, 0) + .expect("list after reopen"); + assert_eq!( + entries.len(), + 1, + "activity history must survive a cache reopen" + ); + + cleanup_cache_files(&path); + } +} diff --git a/crates/walletkit-core/src/storage/cache/mod.rs b/crates/walletkit-core/src/storage/cache/mod.rs index 64956a48..d27d0b00 100644 --- a/crates/walletkit-core/src/storage/cache/mod.rs +++ b/crates/walletkit-core/src/storage/cache/mod.rs @@ -3,9 +3,11 @@ use std::path::Path; use crate::storage::error::StorageResult; +use crate::storage::types::{ActivityEntry, ActivityMetadata, ActivityQuery}; use secrecy::SecretBox; use walletkit_db::Vault; +mod activity; mod maintenance; mod merkle; mod nullifiers; @@ -133,16 +135,75 @@ impl CacheDb { pub fn replay_guard_set(&self, nullifier: [u8; 32], now: u64) -> StorageResult<()> { nullifiers::replay_guard_set(self.vault.connection(), nullifier, now) } + + /// Records an activity entry. + /// + /// # Errors + /// + /// Returns an error if the entry is misconfigured or the insert fails. + pub fn record_activity( + &self, + entry: &ActivityEntry, + now: u64, + ) -> StorageResult { + activity::record(self.vault.connection(), entry, now) + } + + /// Lists activity entries, most recent first. + /// + /// # Errors + /// + /// Returns an error if the query fails. + pub fn list_activities( + &self, + query: ActivityQuery, + limit: u32, + offset: u32, + ) -> StorageResult> { + activity::list(self.vault.connection(), query, limit, offset) + } + + /// Returns aggregate activity metadata. + /// + /// # Errors + /// + /// Returns an error if the query fails. + pub fn activity_metadata(&self) -> StorageResult { + activity::metadata(self.vault.connection()) + } + + /// Deletes all activity entries. Returns the number of entries deleted. + /// + /// # Errors + /// + /// Returns an error if the delete fails. + pub fn clear_activities(&self) -> StorageResult { + activity::clear(self.vault.connection()) + } } #[cfg(test)] mod tests { use super::*; + use crate::storage::types::{ActivityOutcome, ProtocolVersion}; use secrecy::SecretBox; use std::fs; use std::path::PathBuf; use uuid::Uuid; + fn sample_new_activity_entry() -> ActivityEntry { + ActivityEntry { + id: None, + rp_id: 1, + client_id: "req-1".to_string(), + protocol: ProtocolVersion::V3, + timestamp: None, + issuer_schema_ids: vec![], + outcome: ActivityOutcome::Completed, + failure_reason: None, + } + } + fn temp_cache_path() -> PathBuf { let mut path = std::env::temp_dir(); path.push(format!("walletkit-cache-{}.sqlite", Uuid::new_v4())); @@ -235,4 +296,113 @@ mod tests { cleanup_cache_files(&path); cleanup_lock_file(&lock_path); } + + #[test] + fn test_activity_survives_disposable_cache_reset() { + let path = temp_cache_path(); + let key = SecretBox::init_with(|| [0x77u8; 32]); + let lock_path = temp_lock_path(); + let db = CacheDb::new(&path, &key).expect("create cache"); + + db.record_activity(&sample_new_activity_entry(), 1000) + .expect("record activity"); + + db.session_seed_put([0x01u8; 32], [0x02u8; 32], 1000, 1000) + .expect("put session seed"); + + drop(db); + + let conn = walletkit_sqlite::cipher::open_encrypted(&path, &key, false) + .expect("open raw connection"); + conn.execute( + "UPDATE cache_meta SET schema_version = schema_version + 1", + &[], + ) + .expect("bump schema version"); + drop(conn); + + let db = CacheDb::new(&path, &key).expect("reopen cache after version bump"); + + let seed = db + .session_seed_get([0x01u8; 32], 1000) + .expect("get session seed"); + + assert!( + seed.is_none(), + "disposable cache_entries should be wiped on a schema version mismatch" + ); + + let entries = db + .list_activities(ActivityQuery::default(), 10, 0) + .expect("list activities after version bump"); + + assert_eq!( + entries.len(), + 1, + "activity history must survive a disposable-cache schema reset" + ); + + cleanup_cache_files(&path); + cleanup_lock_file(&lock_path); + } + + #[test] + fn test_activity_migration_applies_to_preexisting_cache_file() { + let path = temp_cache_path(); + let key = SecretBox::init_with(|| [0x88u8; 32]); + let lock_path = temp_lock_path(); + + let conn = walletkit_sqlite::cipher::open_encrypted(&path, &key, false) + .expect("create raw connection"); + conn.execute_batch( + "CREATE TABLE cache_meta ( + schema_version INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE cache_entries ( + key_bytes BLOB NOT NULL, + value_bytes BLOB NOT NULL, + inserted_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (key_bytes) + ); + INSERT INTO cache_meta (schema_version, created_at, updated_at) + VALUES (2, 1000, 1000); + INSERT INTO cache_entries (key_bytes, value_bytes, inserted_at, expires_at) + VALUES (X'AA', X'BB', 1000, 999999999);", + ) + .expect("seed legacy cache schema"); + drop(conn); + + let db = CacheDb::new(&path, &key).expect("open legacy cache file"); + + db.record_activity(&sample_new_activity_entry(), 1000) + .expect("record activity after migration"); + + let entries = db + .list_activities(ActivityQuery::default(), 10, 0) + .expect("list activities"); + + assert_eq!(entries.len(), 1, "migration should add activity_entries"); + + drop(db); + + let conn = walletkit_sqlite::cipher::open_encrypted(&path, &key, false) + .expect("reopen raw connection"); + + let count = conn + .query_row("SELECT COUNT(*) FROM cache_entries", &[], |stmt| { + Ok(stmt.column_i64(0)) + }) + .expect("count cache_entries"); + + assert_eq!( + count, 1, + "pre-existing cache_entries row must survive the activity migration" + ); + + cleanup_cache_files(&path); + cleanup_lock_file(&lock_path); + } } diff --git a/crates/walletkit-core/src/storage/cache/schema.rs b/crates/walletkit-core/src/storage/cache/schema.rs index c669b8a1..ae5ef443 100644 --- a/crates/walletkit-core/src/storage/cache/schema.rs +++ b/crates/walletkit-core/src/storage/cache/schema.rs @@ -49,6 +49,9 @@ pub(super) fn ensure_schema(conn: &Connection) -> DbResult<()> { insert_meta(conn)?; } } + + ensure_activity_schema(conn)?; + Ok(()) } @@ -88,3 +91,21 @@ fn insert_meta(conn: &Connection) -> DbResult<()> { )?; Ok(()) } + +pub(super) fn ensure_activity_schema(conn: &Connection) -> DbResult<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS activity_entries ( + entry_id INTEGER PRIMARY KEY, + client_id TEXT NOT NULL, + protocol INTEGER NOT NULL, + created_at INTEGER NOT NULL, + outcome TEXT NOT NULL, + app_identifier TEXT NOT NULL, + issuer_schema_ids BLOB NOT NULL, + failure_reason INTEGER NULL + ); + + CREATE INDEX IF NOT EXISTS idx_activity_entries_created_at + ON activity_entries (created_at DESC);", + ) +} diff --git a/crates/walletkit-core/src/storage/cache/util.rs b/crates/walletkit-core/src/storage/cache/util.rs index 7c7ae510..07723139 100644 --- a/crates/walletkit-core/src/storage/cache/util.rs +++ b/crates/walletkit-core/src/storage/cache/util.rs @@ -230,3 +230,9 @@ pub(super) fn to_i64(value: u64, label: &str) -> StorageResult { StorageError::CacheDb(format!("{label} out of range for i64: {value}")) }) } + +pub(super) fn to_u64(value: i64, label: &str) -> StorageResult { + u64::try_from(value).map_err(|_| { + StorageError::CacheDb(format!("{label} out of range for u64: {value}")) + }) +} diff --git a/crates/walletkit-core/src/storage/credential_storage.rs b/crates/walletkit-core/src/storage/credential_storage.rs index 4db2d27c..35dd707c 100644 --- a/crates/walletkit-core/src/storage/credential_storage.rs +++ b/crates/walletkit-core/src/storage/credential_storage.rs @@ -11,9 +11,9 @@ use super::keys::StorageKeys; use super::paths::StoragePaths; use super::traits::StorageProvider; #[cfg(not(target_arch = "wasm32"))] -use super::traits::VaultChangedListener; +use super::traits::{ActivityChangedListener, VaultChangedListener}; use super::traits::{AtomicBlobStore, DeviceKeystore}; -use super::types::CredentialRecord; +use super::types::{ActivityEntry, ActivityMetadata, ActivityQuery, CredentialRecord}; use super::ACCOUNT_KEYS_FILENAME; use super::{CacheDb, CredentialVault}; use super::{StorageLock, StorageLockGuard}; @@ -54,6 +54,8 @@ pub struct CredentialStore { /// Kept outside `inner` so we can notify after releasing the storage mutex. #[cfg(not(target_arch = "wasm32"))] vault_changed_tx: Mutex>>, + #[cfg(not(target_arch = "wasm32"))] + activity_changed_tx: Mutex>>, } impl std::fmt::Debug for CredentialStore { @@ -145,6 +147,8 @@ impl CredentialStore { 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), }) } @@ -163,6 +167,8 @@ impl CredentialStore { 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), }) } @@ -281,6 +287,63 @@ impl CredentialStore { pub fn danger_delete_all_credentials(&self) -> StorageResult { self.lock_inner()?.danger_delete_all_credentials() } + + /// Records a new activity entry. + /// + /// # Errors + /// + /// Returns an error if the store is not initialized or the query fails. + pub fn record_activity( + &self, + entry: &ActivityEntry, + now: u64, + ) -> StorageResult { + let result = self.lock_inner()?.record_activity(entry, now); + + if result.is_ok() { + self.notify_activity_changed(); + } + + result + } + + /// Lists activity entries, most recent first. + /// + /// # Errors + /// + /// Returns an error if the store is not initialized or the query fails. + pub fn list_activities( + &self, + query: ActivityQuery, + limit: u32, + offset: u32, + ) -> StorageResult> { + self.lock_inner()?.list_activities(query, limit, offset) + } + + /// Returns aggregate credential-activity metadata. + /// + /// # Errors + /// + /// Returns an error if the store is not initialized or the query fails. + pub fn activity_metadata(&self) -> StorageResult { + self.lock_inner()?.activity_metadata() + } + + /// Deletes all activity entries. Returns the number of entries deleted. + /// + /// # Errors + /// + /// Returns an error if the store is not initialized or the delete fails. + pub fn clear_activities(&self) -> StorageResult { + let result = self.lock_inner()?.clear_activities(); + + if result.is_ok() { + self.notify_activity_changed(); + } + + result + } } #[uniffi::export] @@ -380,6 +443,35 @@ impl CredentialStore { } } } + + /// Registers a listener that is called after activity history changes. + /// Listeners must not call back into the store or a deadlock will occur. + #[cfg(not(target_arch = "wasm32"))] + pub fn set_activity_changed_listener( + &self, + listener: Arc, + ) { + let (tx, rx) = mpsc::sync_channel(1); + + let spawn_result = std::thread::Builder::new() + .name("walletkit-activity-notify".into()) + .spawn(move || { + for () in rx { + listener.on_activity_changed(); + } + }); + + match spawn_result { + Ok(_) => { + if let Ok(mut guard) = self.activity_changed_tx.lock() { + *guard = Some(tx); + } + } + Err(e) => { + tracing::error!("failed to spawn activity notification thread: {e}"); + } + } + } } /// Implementation not exposed to foreign bindings @@ -459,6 +551,28 @@ impl CredentialStore { } } + /// Notify to the registered activity-changed listener there has been changes. + fn notify_activity_changed(&self) { + #[cfg(not(target_arch = "wasm32"))] + match self.activity_changed_tx.lock() { + Ok(guard) => { + if let Some(tx) = guard.as_ref() { + match tx.try_send(()) { + Ok(()) | Err(mpsc::TrySendError::Full(())) => {} + Err(mpsc::TrySendError::Disconnected(())) => { + tracing::warn!("activity-changed listener disconnected"); + } + } + } + } + Err(_) => { + tracing::warn!( + "activity-changed-tx mutex poisoned; dropping notification" + ); + } + } + } + fn lock_inner( &self, ) -> StorageResult> { @@ -615,6 +729,35 @@ impl CredentialStoreInner { ) } + fn record_activity( + &mut self, + entry: &ActivityEntry, + now: u64, + ) -> StorageResult { + let state = self.state_mut()?; + state.cache.record_activity(entry, now) + } + + fn list_activities( + &self, + query: ActivityQuery, + limit: u32, + offset: u32, + ) -> StorageResult> { + let state = self.state()?; + state.cache.list_activities(query, limit, offset) + } + + fn activity_metadata(&self) -> StorageResult { + let state = self.state()?; + state.cache.activity_metadata() + } + + fn clear_activities(&mut self) -> StorageResult { + let state = self.state_mut()?; + state.cache.clear_activities() + } + fn store_session_seed( &mut self, oprf_seed: CoreFieldElement, @@ -805,7 +948,6 @@ impl CredentialStoreInner { /// Permanently destroys all storage data: encryption keys, vault, and cache. fn destroy_storage(&mut self) -> StorageResult<()> { let _guard = self.guard()?; - // Drop in-memory state: zeroizes keys, closes database connections. self.state = None; // Delete the encryption key envelope. Without this key the database // files are unreadable even if file deletion below fails. @@ -832,6 +974,8 @@ impl CredentialStore { 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), }) } @@ -850,6 +994,8 @@ impl CredentialStore { 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), }) } @@ -869,6 +1015,7 @@ mod tests { use crate::storage::tests_utils::{ cleanup_test_storage, temp_root_path, InMemoryStorageProvider, }; + use crate::storage::types::{ActivityOutcome, ProtocolVersion}; use std::sync::atomic::{AtomicU32, Ordering}; @@ -880,6 +1027,27 @@ mod tests { } } + struct TestActivityListener(Arc); + + impl ActivityChangedListener for TestActivityListener { + fn on_activity_changed(&self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + fn sample_new_activity_entry() -> ActivityEntry { + ActivityEntry { + id: None, + rp_id: 1, + client_id: "bridge-request-1".to_string(), + protocol: ProtocolVersion::V3, + timestamp: None, + issuer_schema_ids: vec![], + outcome: ActivityOutcome::Completed, + failure_reason: None, + } + } + fn wait_for_listener_count(count: &AtomicU32, expected: u32) { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); @@ -1746,6 +1914,59 @@ mod tests { cleanup_test_storage(&root); } + #[test] + 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"); + store.init(42, 1000).expect("init storage"); + + let count = Arc::new(AtomicU32::new(0)); + store.set_activity_changed_listener(Arc::new(TestActivityListener( + Arc::clone(&count), + ))); + + store + .record_activity(&sample_new_activity_entry(), 1000) + .expect("record activity"); + + wait_for_listener_count(&count, 1); + + cleanup_test_storage(&root); + } + + #[test] + 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"); + store.init(42, 1000).expect("init storage"); + + let count = Arc::new(AtomicU32::new(0)); + store.set_activity_changed_listener(Arc::new(TestActivityListener( + Arc::clone(&count), + ))); + + let invalid_entry = ActivityEntry { + outcome: ActivityOutcome::Failed, + failure_reason: None, + ..sample_new_activity_entry() + }; + + let result = store.record_activity(&invalid_entry, 1000); + assert!(result.is_err()); + + std::thread::sleep(std::time::Duration::from_millis(50)); + + assert_eq!( + count.load(Ordering::SeqCst), + 0, + "listener should not be notified when record_activity fails" + ); + + cleanup_test_storage(&root); + } + #[test] fn test_no_listener_does_not_panic() { use world_id_core::Credential as CoreCredential; diff --git a/crates/walletkit-core/src/storage/error.rs b/crates/walletkit-core/src/storage/error.rs index a7b2f240..e16729b2 100644 --- a/crates/walletkit-core/src/storage/error.rs +++ b/crates/walletkit-core/src/storage/error.rs @@ -91,6 +91,14 @@ pub enum StorageError { key_prefix: u8, }, + /// Errors coming from the activity database. + #[error("activity db error: {0}")] + ActivityDb(String), + + /// An `ActivityEntry` violated the `failure_reason`/`outcome` invariant. + #[error("invalid activity record: {0}")] + ActivityInvalidRecord(String), + /// Unexpected `UniFFI` callback error. #[error("unexpected uniffi callback error: {0}")] UnexpectedUniFFICallbackError(String), diff --git a/crates/walletkit-core/src/storage/mod.rs b/crates/walletkit-core/src/storage/mod.rs index 4717c193..529b5c64 100644 --- a/crates/walletkit-core/src/storage/mod.rs +++ b/crates/walletkit-core/src/storage/mod.rs @@ -59,11 +59,13 @@ pub use error::{StorageError, StorageResult}; pub use keys::StorageKeys; pub use paths::StoragePaths; pub use traits::{ - AtomicBlobStore, DeviceKeystore, StorageProvider, VaultChangedListener, + ActivityChangedListener, AtomicBlobStore, DeviceKeystore, StorageProvider, + VaultChangedListener, }; pub use types::{ - BlobKind, ContentId, CredentialRecord, Nullifier, ReplayGuardKind, - ReplayGuardResult, RequestId, + ActivityEntry, ActivityFailureReason, ActivityMetadata, ActivityOutcome, + ActivityQuery, BlobKind, ContentId, CredentialRecord, Nullifier, ProtocolVersion, + ReplayGuardKind, ReplayGuardResult, RequestId, }; pub use walletkit_db::{Lock as StorageLock, LockGuard as StorageLockGuard}; diff --git a/crates/walletkit-core/src/storage/traits.rs b/crates/walletkit-core/src/storage/traits.rs index 3e142a16..26658ac5 100644 --- a/crates/walletkit-core/src/storage/traits.rs +++ b/crates/walletkit-core/src/storage/traits.rs @@ -115,3 +115,28 @@ pub trait VaultChangedListener: Send + Sync { /// Called after a credential is added or removed. fn on_vault_changed(&self); } + +/// Listener notified when credential-activity history changes. +/// +/// Register via [`super::CredentialStore::set_activity_changed_listener`]. The +/// callback is delivered on a dedicated background thread to avoid re-entering +/// the `UniFFI` call stack (see `logger.rs` for rationale). +/// +/// This is only called when an activity entry is recorded. +/// +/// # Expected usage +/// +/// The host app should treat this as a trigger to refresh from the store. It +/// is a signal only and is not intended to carry the changed data with it. +/// +/// # Safety +/// +/// **Warning:** implementors **must not** call back into +/// [`super::CredentialStore`] from +/// [`on_activity_changed`](ActivityChangedListener::on_activity_changed) — +/// doing so will deadlock. +#[cfg_attr(not(target_arch = "wasm32"), uniffi::export(with_foreign))] +pub trait ActivityChangedListener: Send + Sync { + /// Called after an activity entry is recorded, finalized, or reconciled. + fn on_activity_changed(&self); +} diff --git a/crates/walletkit-core/src/storage/types.rs b/crates/walletkit-core/src/storage/types.rs index 7758cae0..592f1d6a 100644 --- a/crates/walletkit-core/src/storage/types.rs +++ b/crates/walletkit-core/src/storage/types.rs @@ -1,5 +1,7 @@ //! Public types for credential storage. +use strum::{Display, EnumString}; + use super::error::{StorageError, StorageResult}; /// Kind of blob stored in the vault. @@ -78,3 +80,97 @@ pub struct ReplayGuardResult { /// Stored proof package bytes. pub bytes: Vec, } + +/// Which World ID protocol handled a proof-share request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +#[repr(u8)] +pub enum ProtocolVersion { + /// Legacy Semaphore-based protocol. + V3 = 3, + /// Current. Reference: + V4 = 4, +} + +impl ProtocolVersion { + pub(crate) const fn as_i64(self) -> i64 { + self as i64 + } +} + +impl TryFrom for ProtocolVersion { + type Error = StorageError; + + fn try_from(value: i64) -> StorageResult { + match value { + 3 => Ok(Self::V3), + 4 => Ok(Self::V4), + _ => Err(StorageError::ActivityDb(format!( + "invalid protocol version {value}" + ))), + } + } +} + +/// Terminal outcome of a proof-share request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, Display, uniffi::Enum)] +#[strum(serialize_all = "lowercase")] +pub enum ActivityOutcome { + /// Proof request was completed successfully. + Completed, + /// The user declined the request. + Declined, + /// The user cancelled or dismissed the request without an explicit decline. + Cancelled, + /// The request failed (see [`ActivityFailureReason`]). + Failed, + /// The request never reached a terminal outcome (e.g. the app was killed + /// or backgrounded before completion). + Incomplete, +} + +/// Reasons a proof fails. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum ActivityFailureReason { + /// A network request failed. + NetworkError, + /// The request timed out. + Timeout, + /// Device authentication (e.g. Face ID/passcode) failed. + DeviceAuthenticationFailed, + /// Proof generation itself failed. + ProofGenerationFailed, + /// The relying party rejected the proof. + RelyingPartyRejected, +} + +/// A single row of credential activity history. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct ActivityEntry { + /// Unique identifier for this entry. + pub id: Option, + /// The relying party identifier. + pub rp_id: u64, + /// Host-app-defined identifier correlating this entry with its request. + pub client_id: String, + /// Protocol used for this request. + pub protocol: ProtocolVersion, + /// Activity time. + pub timestamp: Option, + /// The result of the activity. + pub outcome: ActivityOutcome, + /// The credentials which produced an output proof for the request. + pub issuer_schema_ids: Vec, + /// Present only when `outcome` is `Failed`. + pub failure_reason: Option, +} + +/// Aggregate counts over credential activity history. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Record)] +pub struct ActivityMetadata { + /// Total number of recorded entries. + pub total_count: u64, +} + +/// Filtering/sorting options for [`super::CredentialStore::list_activities`]. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, uniffi::Record)] +pub struct ActivityQuery {} diff --git a/crates/walletkit-sqlite/src/transaction.rs b/crates/walletkit-sqlite/src/transaction.rs index 12583348..d3380a36 100644 --- a/crates/walletkit-sqlite/src/transaction.rs +++ b/crates/walletkit-sqlite/src/transaction.rs @@ -81,6 +81,20 @@ impl<'conn> Transaction<'conn> { self.conn.query_row(sql, params, mapper) } + /// See [`Connection::query_row_optional`]. + /// + /// # Errors + /// + /// Returns `Error` if preparation, execution, or the mapper fails. + pub fn query_row_optional( + &self, + sql: &str, + params: &[Value], + mapper: impl FnOnce(&Row<'_, '_>) -> DbResult, + ) -> DbResult> { + self.conn.query_row_optional(sql, params, mapper) + } + /// See [`Connection::prepare`]. /// /// # Errors