Skip to main content

walletkit_core/storage/cache/
mod.rs

1//! Encrypted cache database for credential storage.
2
3use std::path::Path;
4
5use crate::storage::error::StorageResult;
6use crate::storage::types::{ActivityEntry, ActivityMetadata, ActivityQuery};
7use secrecy::SecretBox;
8use walletkit_db::Vault;
9
10mod activity;
11mod maintenance;
12mod merkle;
13mod nullifiers;
14mod schema;
15mod session;
16mod util;
17
18/// Encrypted cache database wrapper.
19///
20/// Stores non-authoritative, regenerable data (proof cache, session keys,
21/// replay guard). Wraps [`walletkit_db::Vault`].
22///
23/// Unlike the credential vault, cache corruption is recoverable: open
24/// failures or integrity failures trigger a wipe-and-rebuild rather than
25/// a fatal error.
26#[derive(Debug)]
27pub struct CacheDb {
28    vault: Vault,
29}
30
31impl CacheDb {
32    /// Opens or rebuilds the encrypted cache database at `path`.
33    ///
34    /// If the database is corrupted or unreadable, the file is deleted
35    /// and a fresh empty cache is created.
36    ///
37    /// # Errors
38    ///
39    /// Returns an error if the database cannot be opened or rebuilt.
40    pub fn new(
41        path: &Path,
42        k_intermediate: &SecretBox<[u8; 32]>,
43    ) -> StorageResult<Self> {
44        let vault = maintenance::open_or_rebuild(path, k_intermediate)?;
45        Ok(Self { vault })
46    }
47
48    /// Fetches a cached Merkle proof if it remains valid beyond `valid_until`.
49    ///
50    /// Returns `None` when missing or expired so callers can refetch from the
51    /// indexer without relying on stale proofs.
52    ///
53    /// # Errors
54    ///
55    /// Returns an error if the query fails.
56    pub fn merkle_cache_get(&self, valid_until: u64) -> StorageResult<Option<Vec<u8>>> {
57        merkle::get(self.vault.connection(), valid_until)
58    }
59
60    /// Inserts a cached Merkle proof with a TTL. Existing entries for the
61    /// same key are replaced.
62    ///
63    /// # Errors
64    ///
65    /// Returns an error if the insert fails.
66    pub fn merkle_cache_put(
67        &self,
68        proof_bytes: &[u8],
69        now: u64,
70        ttl_seconds: u64,
71    ) -> StorageResult<()> {
72        merkle::put(self.vault.connection(), proof_bytes, now, ttl_seconds)
73    }
74
75    /// Fetches a cached `session_id_r_seed` for the given RP and `oprf_seed`.
76    ///
77    /// Returns `None` when missing or expired.
78    ///
79    /// # Errors
80    ///
81    /// Returns an error if the query fails.
82    pub fn session_seed_get(
83        &self,
84        rp_id: u64,
85        oprf_seed: [u8; 32],
86        now: u64,
87    ) -> StorageResult<Option<[u8; 32]>> {
88        let key = util::session_cache_key(rp_id, oprf_seed);
89        session::get(self.vault.connection(), &key, now)
90    }
91
92    /// Stores a `session_id_r_seed` keyed by RP and `oprf_seed` with a TTL.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if the insert fails.
97    pub fn session_seed_put(
98        &self,
99        rp_id: u64,
100        oprf_seed: [u8; 32],
101        session_id_r_seed: [u8; 32],
102        now: u64,
103        ttl_seconds: u64,
104    ) -> StorageResult<()> {
105        let key = util::session_cache_key(rp_id, oprf_seed);
106        session::put(
107            self.vault.connection(),
108            &key,
109            session_id_r_seed,
110            now,
111            ttl_seconds,
112        )
113    }
114
115    /// Checks whether a replay guard entry exists for the given nullifier.
116    ///
117    /// # Returns
118    ///
119    /// - `true` if a replay guard entry exists (nullifier replay).
120    /// - `false` otherwise.
121    ///
122    /// # Errors
123    ///
124    /// Returns an error if the query to the cache unexpectedly fails.
125    pub fn is_nullifier_replay(
126        &self,
127        nullifier: [u8; 32],
128        now: u64,
129    ) -> StorageResult<bool> {
130        nullifiers::is_nullifier_replay(self.vault.connection(), nullifier, now)
131    }
132
133    /// After a proof has been successfully generated, creates a replay guard
134    /// entry locally to avoid future replays of the same nullifier.
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if the query to the cache unexpectedly fails.
139    pub fn replay_guard_set(&self, nullifier: [u8; 32], now: u64) -> StorageResult<()> {
140        nullifiers::replay_guard_set(self.vault.connection(), nullifier, now)
141    }
142
143    /// Records an activity entry.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if the entry is misconfigured or the insert fails.
148    pub fn record_activity(
149        &self,
150        entry: &ActivityEntry,
151        now: u64,
152    ) -> StorageResult<u64> {
153        activity::record(self.vault.connection(), entry, now)
154    }
155
156    /// Lists activity entries, most recent first.
157    ///
158    /// # Errors
159    ///
160    /// Returns an error if the query fails.
161    pub fn list_activities(
162        &self,
163        query: &ActivityQuery,
164        limit: u32,
165        offset: u32,
166    ) -> StorageResult<Vec<ActivityEntry>> {
167        activity::list(self.vault.connection(), query, limit, offset)
168    }
169
170    /// Returns aggregate activity metadata.
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if the query fails.
175    pub fn activity_metadata(&self) -> StorageResult<ActivityMetadata> {
176        activity::metadata(self.vault.connection())
177    }
178
179    /// Deletes the provided activity entry IDs and returns the
180    /// number of deleted entries.
181    ///
182    /// # Errors
183    ///
184    /// Returns an error if the query fails.
185    pub fn delete_activities(&self, entry_ids: &[u64]) -> StorageResult<u64> {
186        activity::delete(self.vault.connection(), entry_ids)
187    }
188
189    /// Deletes all activity entries. Returns the number of entries deleted.
190    ///
191    /// # Errors
192    ///
193    /// Returns an error if the delete fails.
194    pub fn clear_activities(&self) -> StorageResult<u64> {
195        activity::clear(self.vault.connection())
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::storage::types::{ActivityOutcome, ProtocolVersion};
203    use secrecy::SecretBox;
204    use std::collections::HashSet;
205    use std::fs;
206    use std::path::PathBuf;
207    use uuid::Uuid;
208
209    fn sample_new_activity_entry() -> ActivityEntry {
210        ActivityEntry {
211            id: None,
212            rp_id: 1,
213            app_identifier: "app_test".to_string(),
214            client_id: "req-1".to_string(),
215            protocol: ProtocolVersion::V3,
216            timestamp: None,
217            issuer_schema_ids: vec![],
218            outcome: ActivityOutcome::Completed,
219            failure_reason: None,
220        }
221    }
222
223    fn temp_cache_path() -> PathBuf {
224        let mut path = std::env::temp_dir();
225        path.push(format!("walletkit-cache-{}.sqlite", Uuid::new_v4()));
226        path
227    }
228
229    fn cleanup_cache_files(path: &Path) {
230        let _ = fs::remove_file(path);
231        let _ = fs::remove_file(path.with_extension("sqlite-wal"));
232        let _ = fs::remove_file(path.with_extension("sqlite-shm"));
233    }
234
235    fn temp_lock_path() -> PathBuf {
236        let mut path = std::env::temp_dir();
237        path.push(format!("walletkit-cache-lock-{}.lock", Uuid::new_v4()));
238        path
239    }
240
241    fn cleanup_lock_file(path: &Path) {
242        let _ = fs::remove_file(path);
243    }
244
245    #[test]
246    fn test_cache_create_and_open() {
247        let path = temp_cache_path();
248        let key = SecretBox::init_with(|| [0x11u8; 32]);
249        let lock_path = temp_lock_path();
250        let db = CacheDb::new(&path, &key).expect("create cache");
251        drop(db);
252        CacheDb::new(&path, &key).expect("open cache");
253        cleanup_cache_files(&path);
254        cleanup_lock_file(&lock_path);
255    }
256
257    #[test]
258    fn test_cache_rebuild_on_corruption() {
259        let path = temp_cache_path();
260        let key = SecretBox::init_with(|| [0x22u8; 32]);
261        let lock_path = temp_lock_path();
262        let db = CacheDb::new(&path, &key).expect("create cache");
263        let oprf_seed = [0x01u8; 32];
264        let r_seed = [0x02u8; 32];
265        let now = 1_000;
266        db.session_seed_put(1, oprf_seed, r_seed, now, 1000)
267            .expect("put session seed");
268        drop(db);
269
270        fs::write(&path, b"corrupt").expect("corrupt cache file");
271
272        let db = CacheDb::new(&path, &key).expect("rebuild cache");
273        let value = db
274            .session_seed_get(1, oprf_seed, now)
275            .expect("get session seed");
276        assert!(value.is_none());
277        cleanup_cache_files(&path);
278        cleanup_lock_file(&lock_path);
279    }
280
281    #[test]
282    fn test_merkle_cache_ttl() {
283        let path = temp_cache_path();
284        let key = SecretBox::init_with(|| [0x33u8; 32]);
285        let lock_path = temp_lock_path();
286        let db = CacheDb::new(&path, &key).expect("create cache");
287        db.merkle_cache_put(&[1, 2, 3], 100, 10)
288            .expect("put merkle proof");
289        let hit = db.merkle_cache_get(105).expect("get merkle proof");
290        assert!(hit.is_some());
291        let miss = db.merkle_cache_get(111).expect("get merkle proof");
292        assert!(miss.is_none());
293        cleanup_cache_files(&path);
294        cleanup_lock_file(&lock_path);
295    }
296
297    #[test]
298    fn test_session_seed_cache_ttl() {
299        let path = temp_cache_path();
300        let key = SecretBox::init_with(|| [0x44u8; 32]);
301        let lock_path = temp_lock_path();
302        let db = CacheDb::new(&path, &key).expect("create cache");
303        let oprf_seed = [0x55u8; 32];
304        let r_seed = [0x66u8; 32];
305        let now = 100;
306        db.session_seed_put(1, oprf_seed, r_seed, now, 10)
307            .expect("put session seed");
308        let hit = db.session_seed_get(1, oprf_seed, now).expect("get");
309        assert_eq!(hit, Some(r_seed));
310        let miss = db.session_seed_get(1, oprf_seed, now + 11).expect("get");
311        assert!(miss.is_none());
312        cleanup_cache_files(&path);
313        cleanup_lock_file(&lock_path);
314    }
315
316    #[test]
317    fn test_schema_version_mismatch_resets_database() {
318        let path = temp_cache_path();
319        let key = SecretBox::init_with(|| [0x77u8; 32]);
320        let lock_path = temp_lock_path();
321        let db = CacheDb::new(&path, &key).expect("create cache");
322
323        db.record_activity(&sample_new_activity_entry(), 1000)
324            .expect("record activity");
325
326        db.session_seed_put(1, [0x01u8; 32], [0x02u8; 32], 1000, 1000)
327            .expect("put session seed");
328
329        drop(db);
330
331        let conn = walletkit_sqlite::cipher::open_encrypted(&path, &key)
332            .expect("open raw connection");
333        conn.execute(
334            "UPDATE cache_meta SET schema_version = schema_version + 1",
335            &[],
336        )
337        .expect("bump schema version");
338        drop(conn);
339
340        let db = CacheDb::new(&path, &key).expect("reopen cache after version bump");
341
342        let seed = db
343            .session_seed_get(1, [0x01u8; 32], 1000)
344            .expect("get session seed");
345
346        assert!(
347            seed.is_none(),
348            "cache_entries should be wiped on a schema version mismatch"
349        );
350
351        let entries = db
352            .list_activities(&ActivityQuery::new(), 10, 0)
353            .expect("list activities after version bump");
354
355        assert!(
356            entries.is_empty(),
357            "activity history shares the cache schema, so it is reset too"
358        );
359
360        cleanup_cache_files(&path);
361        cleanup_lock_file(&lock_path);
362    }
363
364    #[test]
365    fn test_activity_migration_applies_to_preexisting_cache_file() {
366        let path = temp_cache_path();
367        let key = SecretBox::init_with(|| [0x88u8; 32]);
368        let lock_path = temp_lock_path();
369
370        let conn = walletkit_sqlite::cipher::open_encrypted(&path, &key)
371            .expect("create raw connection");
372        conn.execute_batch(
373            "CREATE TABLE cache_meta (
374                schema_version INTEGER NOT NULL,
375                created_at INTEGER NOT NULL,
376                updated_at INTEGER NOT NULL
377            );
378            CREATE TABLE cache_entries (
379                key_bytes BLOB NOT NULL,
380                value_bytes BLOB NOT NULL,
381                inserted_at INTEGER NOT NULL,
382                expires_at INTEGER NOT NULL,
383                PRIMARY KEY (key_bytes)
384            );
385            INSERT INTO cache_meta (schema_version, created_at, updated_at)
386            VALUES (2, 1000, 1000);
387            INSERT INTO cache_entries (key_bytes, value_bytes, inserted_at, expires_at)
388            VALUES (X'AA', X'BB', 1000, 999999999);",
389        )
390        .expect("seed legacy cache schema");
391        drop(conn);
392
393        let db = CacheDb::new(&path, &key).expect("open legacy cache file");
394
395        db.record_activity(&sample_new_activity_entry(), 1000)
396            .expect("record activity after migration");
397
398        let entries = db
399            .list_activities(&ActivityQuery::new(), 10, 0)
400            .expect("list activities");
401
402        assert_eq!(entries.len(), 1, "migration should add activity_entries");
403
404        drop(db);
405
406        let conn = walletkit_sqlite::cipher::open_encrypted(&path, &key)
407            .expect("reopen raw connection");
408
409        let count = conn
410            .query_row("SELECT COUNT(*) FROM cache_entries", &[], |stmt| {
411                Ok(stmt.column_i64(0))
412            })
413            .expect("count cache_entries");
414
415        assert_eq!(
416            count, 1,
417            "pre-existing cache_entries row must survive the activity migration"
418        );
419
420        cleanup_cache_files(&path);
421        cleanup_lock_file(&lock_path);
422    }
423
424    #[test]
425    fn test_activity_issuer_schema_migration_backfills_legacy_blob() {
426        let path = temp_cache_path();
427        let key = SecretBox::init_with(|| [0x89u8; 32]);
428        let lock_path = temp_lock_path();
429
430        let conn = walletkit_sqlite::cipher::open_encrypted(&path, &key)
431            .expect("create raw connection");
432        conn.execute_batch(
433            "CREATE TABLE cache_meta (
434                schema_version INTEGER NOT NULL,
435                created_at INTEGER NOT NULL,
436                updated_at INTEGER NOT NULL
437            );
438            CREATE TABLE activity_entries (
439                entry_id           INTEGER PRIMARY KEY,
440                client_id          TEXT NOT NULL,
441                protocol           INTEGER NOT NULL,
442                created_at         INTEGER NOT NULL,
443                outcome            TEXT NOT NULL,
444                rp_id              INTEGER NOT NULL,
445                app_identifier     TEXT NOT NULL,
446                issuer_schema_ids  BLOB NOT NULL,
447                failure_reason     TEXT NOT NULL
448            );
449            INSERT INTO cache_meta (schema_version, created_at, updated_at)
450            VALUES (3, 1000, 1000);
451            INSERT INTO activity_entries (
452                entry_id, client_id, protocol, created_at, outcome, rp_id,
453                app_identifier, issuer_schema_ids, failure_reason
454            ) VALUES (
455                1, 'req-legacy', 3, 1000, 'completed', 7, 'app_legacy',
456                X'000000000000000A0000000000000014', ''
457            );",
458        )
459        .expect("seed legacy activity schema");
460        drop(conn);
461
462        let db = CacheDb::new(&path, &key).expect("open legacy cache file");
463
464        let by_schema = db
465            .list_activities(
466                &ActivityQuery::new().with_issuer_schema_ids(HashSet::from([20])),
467                10,
468                0,
469            )
470            .expect("list filtered");
471
472        assert_eq!(by_schema.len(), 1, "legacy blob must be backfilled");
473        assert_eq!(by_schema[0].issuer_schema_ids, vec![10, 20]);
474
475        let entries = db
476            .list_activities(&ActivityQuery::new(), 10, 0)
477            .expect("list all");
478        assert_eq!(entries.len(), 1);
479        assert_eq!(entries[0].id, Some(1));
480        assert_eq!(entries[0].app_identifier, "app_legacy");
481
482        cleanup_cache_files(&path);
483        cleanup_lock_file(&lock_path);
484    }
485
486    #[test]
487    fn test_activity_migration_rebuilds_on_malformed_legacy_blob() {
488        let path = temp_cache_path();
489        let key = SecretBox::init_with(|| [0x8Au8; 32]);
490        let lock_path = temp_lock_path();
491
492        let conn = walletkit_sqlite::cipher::open_encrypted(&path, &key)
493            .expect("create raw connection");
494        conn.execute_batch(
495            "CREATE TABLE cache_meta (
496                schema_version INTEGER NOT NULL,
497                created_at INTEGER NOT NULL,
498                updated_at INTEGER NOT NULL
499            );
500            CREATE TABLE activity_entries (
501                entry_id           INTEGER PRIMARY KEY,
502                client_id          TEXT NOT NULL,
503                protocol           INTEGER NOT NULL,
504                created_at         INTEGER NOT NULL,
505                outcome            TEXT NOT NULL,
506                rp_id              INTEGER NOT NULL,
507                app_identifier     TEXT NOT NULL,
508                issuer_schema_ids  BLOB NOT NULL,
509                failure_reason     TEXT NOT NULL
510            );
511            INSERT INTO cache_meta (schema_version, created_at, updated_at)
512            VALUES (3, 1000, 1000);
513            INSERT INTO activity_entries (
514                entry_id, client_id, protocol, created_at, outcome, rp_id,
515                app_identifier, issuer_schema_ids, failure_reason
516            ) VALUES (
517                1, 'req-corrupt', 3, 1000, 'completed', 7, 'app_corrupt',
518                X'000000000000000A00', ''
519            );",
520        )
521        .expect("seed malformed legacy activity schema");
522        drop(conn);
523
524        let db =
525            CacheDb::new(&path, &key).expect("open cache with malformed legacy blob");
526
527        let entries = db
528            .list_activities(&ActivityQuery::new(), 10, 0)
529            .expect("list all");
530        assert!(
531            entries.is_empty(),
532            "a malformed legacy blob must not be silently truncated"
533        );
534
535        cleanup_cache_files(&path);
536        cleanup_lock_file(&lock_path);
537    }
538
539    #[test]
540    fn test_schema_version_is_recorded() {
541        let path = temp_cache_path();
542        let key = SecretBox::init_with(|| [0x99u8; 32]);
543        let lock_path = temp_lock_path();
544
545        let db = CacheDb::new(&path, &key).expect("create cache");
546        db.record_activity(&sample_new_activity_entry(), 1000)
547            .expect("record activity");
548        drop(db);
549
550        let conn = walletkit_sqlite::cipher::open_encrypted(&path, &key)
551            .expect("open raw connection");
552        let version = conn
553            .query_row("SELECT schema_version FROM cache_meta", &[], |stmt| {
554                Ok(stmt.column_i64(0))
555            })
556            .expect("read cache schema version");
557        drop(conn);
558
559        // Four migrations: cache_entries, activity init, activity rebuild,
560        // issuer-schema association table.
561        assert_eq!(version, 4, "the cache schema registers as version 4");
562
563        let db = CacheDb::new(&path, &key).expect("reopen cache");
564        let entries = db
565            .list_activities(&ActivityQuery::new(), 10, 0)
566            .expect("list activities");
567
568        assert_eq!(entries.len(), 1, "reopening must not restamp or reset");
569        assert_eq!(entries[0].rp_id, 1);
570        assert_eq!(entries[0].app_identifier, "app_test");
571
572        cleanup_cache_files(&path);
573        cleanup_lock_file(&lock_path);
574    }
575}