1use 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#[derive(Debug)]
27pub struct CacheDb {
28 vault: Vault,
29}
30
31impl CacheDb {
32 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 pub fn merkle_cache_get(&self, valid_until: u64) -> StorageResult<Option<Vec<u8>>> {
57 merkle::get(self.vault.connection(), valid_until)
58 }
59
60 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 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 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 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 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 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 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 pub fn activity_metadata(&self) -> StorageResult<ActivityMetadata> {
176 activity::metadata(self.vault.connection())
177 }
178
179 pub fn delete_activities(&self, entry_ids: &[u64]) -> StorageResult<u64> {
186 activity::delete(self.vault.connection(), entry_ids)
187 }
188
189 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 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}