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 clear_activities(&self) -> StorageResult<u64> {
185 activity::clear(self.vault.connection())
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::storage::types::{ActivityOutcome, ProtocolVersion};
193 use secrecy::SecretBox;
194 use std::fs;
195 use std::path::PathBuf;
196 use uuid::Uuid;
197
198 fn sample_new_activity_entry() -> ActivityEntry {
199 ActivityEntry {
200 id: None,
201 rp_id: 1,
202 app_identifier: "app_test".to_string(),
203 client_id: "req-1".to_string(),
204 protocol: ProtocolVersion::V3,
205 timestamp: None,
206 issuer_schema_ids: vec![],
207 outcome: ActivityOutcome::Completed,
208 failure_reason: None,
209 }
210 }
211
212 fn temp_cache_path() -> PathBuf {
213 let mut path = std::env::temp_dir();
214 path.push(format!("walletkit-cache-{}.sqlite", Uuid::new_v4()));
215 path
216 }
217
218 fn cleanup_cache_files(path: &Path) {
219 let _ = fs::remove_file(path);
220 let _ = fs::remove_file(path.with_extension("sqlite-wal"));
221 let _ = fs::remove_file(path.with_extension("sqlite-shm"));
222 }
223
224 fn temp_lock_path() -> PathBuf {
225 let mut path = std::env::temp_dir();
226 path.push(format!("walletkit-cache-lock-{}.lock", Uuid::new_v4()));
227 path
228 }
229
230 fn cleanup_lock_file(path: &Path) {
231 let _ = fs::remove_file(path);
232 }
233
234 #[test]
235 fn test_cache_create_and_open() {
236 let path = temp_cache_path();
237 let key = SecretBox::init_with(|| [0x11u8; 32]);
238 let lock_path = temp_lock_path();
239 let db = CacheDb::new(&path, &key).expect("create cache");
240 drop(db);
241 CacheDb::new(&path, &key).expect("open cache");
242 cleanup_cache_files(&path);
243 cleanup_lock_file(&lock_path);
244 }
245
246 #[test]
247 fn test_cache_rebuild_on_corruption() {
248 let path = temp_cache_path();
249 let key = SecretBox::init_with(|| [0x22u8; 32]);
250 let lock_path = temp_lock_path();
251 let db = CacheDb::new(&path, &key).expect("create cache");
252 let oprf_seed = [0x01u8; 32];
253 let r_seed = [0x02u8; 32];
254 let now = 1_000;
255 db.session_seed_put(1, oprf_seed, r_seed, now, 1000)
256 .expect("put session seed");
257 drop(db);
258
259 fs::write(&path, b"corrupt").expect("corrupt cache file");
260
261 let db = CacheDb::new(&path, &key).expect("rebuild cache");
262 let value = db
263 .session_seed_get(1, oprf_seed, now)
264 .expect("get session seed");
265 assert!(value.is_none());
266 cleanup_cache_files(&path);
267 cleanup_lock_file(&lock_path);
268 }
269
270 #[test]
271 fn test_merkle_cache_ttl() {
272 let path = temp_cache_path();
273 let key = SecretBox::init_with(|| [0x33u8; 32]);
274 let lock_path = temp_lock_path();
275 let db = CacheDb::new(&path, &key).expect("create cache");
276 db.merkle_cache_put(&[1, 2, 3], 100, 10)
277 .expect("put merkle proof");
278 let hit = db.merkle_cache_get(105).expect("get merkle proof");
279 assert!(hit.is_some());
280 let miss = db.merkle_cache_get(111).expect("get merkle proof");
281 assert!(miss.is_none());
282 cleanup_cache_files(&path);
283 cleanup_lock_file(&lock_path);
284 }
285
286 #[test]
287 fn test_session_seed_cache_ttl() {
288 let path = temp_cache_path();
289 let key = SecretBox::init_with(|| [0x44u8; 32]);
290 let lock_path = temp_lock_path();
291 let db = CacheDb::new(&path, &key).expect("create cache");
292 let oprf_seed = [0x55u8; 32];
293 let r_seed = [0x66u8; 32];
294 let now = 100;
295 db.session_seed_put(1, oprf_seed, r_seed, now, 10)
296 .expect("put session seed");
297 let hit = db.session_seed_get(1, oprf_seed, now).expect("get");
298 assert_eq!(hit, Some(r_seed));
299 let miss = db.session_seed_get(1, oprf_seed, now + 11).expect("get");
300 assert!(miss.is_none());
301 cleanup_cache_files(&path);
302 cleanup_lock_file(&lock_path);
303 }
304
305 #[test]
306 fn test_schema_version_mismatch_resets_database() {
307 let path = temp_cache_path();
308 let key = SecretBox::init_with(|| [0x77u8; 32]);
309 let lock_path = temp_lock_path();
310 let db = CacheDb::new(&path, &key).expect("create cache");
311
312 db.record_activity(&sample_new_activity_entry(), 1000)
313 .expect("record activity");
314
315 db.session_seed_put(1, [0x01u8; 32], [0x02u8; 32], 1000, 1000)
316 .expect("put session seed");
317
318 drop(db);
319
320 let conn = walletkit_sqlite::cipher::open_encrypted(&path, &key)
321 .expect("open raw connection");
322 conn.execute(
323 "UPDATE cache_meta SET schema_version = schema_version + 1",
324 &[],
325 )
326 .expect("bump schema version");
327 drop(conn);
328
329 let db = CacheDb::new(&path, &key).expect("reopen cache after version bump");
330
331 let seed = db
332 .session_seed_get(1, [0x01u8; 32], 1000)
333 .expect("get session seed");
334
335 assert!(
336 seed.is_none(),
337 "cache_entries should be wiped on a schema version mismatch"
338 );
339
340 let entries = db
341 .list_activities(&ActivityQuery::new(), 10, 0)
342 .expect("list activities after version bump");
343
344 assert!(
345 entries.is_empty(),
346 "activity history shares the cache schema, so it is reset too"
347 );
348
349 cleanup_cache_files(&path);
350 cleanup_lock_file(&lock_path);
351 }
352
353 #[test]
354 fn test_activity_migration_applies_to_preexisting_cache_file() {
355 let path = temp_cache_path();
356 let key = SecretBox::init_with(|| [0x88u8; 32]);
357 let lock_path = temp_lock_path();
358
359 let conn = walletkit_sqlite::cipher::open_encrypted(&path, &key)
360 .expect("create raw connection");
361 conn.execute_batch(
362 "CREATE TABLE cache_meta (
363 schema_version INTEGER NOT NULL,
364 created_at INTEGER NOT NULL,
365 updated_at INTEGER NOT NULL
366 );
367 CREATE TABLE cache_entries (
368 key_bytes BLOB NOT NULL,
369 value_bytes BLOB NOT NULL,
370 inserted_at INTEGER NOT NULL,
371 expires_at INTEGER NOT NULL,
372 PRIMARY KEY (key_bytes)
373 );
374 INSERT INTO cache_meta (schema_version, created_at, updated_at)
375 VALUES (2, 1000, 1000);
376 INSERT INTO cache_entries (key_bytes, value_bytes, inserted_at, expires_at)
377 VALUES (X'AA', X'BB', 1000, 999999999);",
378 )
379 .expect("seed legacy cache schema");
380 drop(conn);
381
382 let db = CacheDb::new(&path, &key).expect("open legacy cache file");
383
384 db.record_activity(&sample_new_activity_entry(), 1000)
385 .expect("record activity after migration");
386
387 let entries = db
388 .list_activities(&ActivityQuery::new(), 10, 0)
389 .expect("list activities");
390
391 assert_eq!(entries.len(), 1, "migration should add activity_entries");
392
393 drop(db);
394
395 let conn = walletkit_sqlite::cipher::open_encrypted(&path, &key)
396 .expect("reopen raw connection");
397
398 let count = conn
399 .query_row("SELECT COUNT(*) FROM cache_entries", &[], |stmt| {
400 Ok(stmt.column_i64(0))
401 })
402 .expect("count cache_entries");
403
404 assert_eq!(
405 count, 1,
406 "pre-existing cache_entries row must survive the activity migration"
407 );
408
409 cleanup_cache_files(&path);
410 cleanup_lock_file(&lock_path);
411 }
412
413 #[test]
414 fn test_activity_issuer_schema_migration_backfills_legacy_blob() {
415 let path = temp_cache_path();
416 let key = SecretBox::init_with(|| [0x89u8; 32]);
417 let lock_path = temp_lock_path();
418
419 let conn = walletkit_sqlite::cipher::open_encrypted(&path, &key)
420 .expect("create raw connection");
421 conn.execute_batch(
422 "CREATE TABLE cache_meta (
423 schema_version INTEGER NOT NULL,
424 created_at INTEGER NOT NULL,
425 updated_at INTEGER NOT NULL
426 );
427 CREATE TABLE activity_entries (
428 entry_id INTEGER PRIMARY KEY,
429 client_id TEXT NOT NULL,
430 protocol INTEGER NOT NULL,
431 created_at INTEGER NOT NULL,
432 outcome TEXT NOT NULL,
433 rp_id INTEGER NOT NULL,
434 app_identifier TEXT NOT NULL,
435 issuer_schema_ids BLOB NOT NULL,
436 failure_reason TEXT NOT NULL
437 );
438 INSERT INTO cache_meta (schema_version, created_at, updated_at)
439 VALUES (3, 1000, 1000);
440 INSERT INTO activity_entries (
441 entry_id, client_id, protocol, created_at, outcome, rp_id,
442 app_identifier, issuer_schema_ids, failure_reason
443 ) VALUES (
444 1, 'req-legacy', 3, 1000, 'completed', 7, 'app_legacy',
445 X'000000000000000A0000000000000014', ''
446 );",
447 )
448 .expect("seed legacy activity schema");
449 drop(conn);
450
451 let db = CacheDb::new(&path, &key).expect("open legacy cache file");
452
453 let by_schema = db
454 .list_activities(&ActivityQuery::new().with_issuer_schema_id(20), 10, 0)
455 .expect("list filtered");
456
457 assert_eq!(by_schema.len(), 1, "legacy blob must be backfilled");
458 assert_eq!(by_schema[0].issuer_schema_ids, vec![10, 20]);
459
460 let entries = db
461 .list_activities(&ActivityQuery::new(), 10, 0)
462 .expect("list all");
463 assert_eq!(entries.len(), 1);
464 assert_eq!(entries[0].id, Some(1));
465 assert_eq!(entries[0].app_identifier, "app_legacy");
466
467 cleanup_cache_files(&path);
468 cleanup_lock_file(&lock_path);
469 }
470
471 #[test]
472 fn test_activity_migration_rebuilds_on_malformed_legacy_blob() {
473 let path = temp_cache_path();
474 let key = SecretBox::init_with(|| [0x8Au8; 32]);
475 let lock_path = temp_lock_path();
476
477 let conn = walletkit_sqlite::cipher::open_encrypted(&path, &key)
478 .expect("create raw connection");
479 conn.execute_batch(
480 "CREATE TABLE cache_meta (
481 schema_version INTEGER NOT NULL,
482 created_at INTEGER NOT NULL,
483 updated_at INTEGER NOT NULL
484 );
485 CREATE TABLE activity_entries (
486 entry_id INTEGER PRIMARY KEY,
487 client_id TEXT NOT NULL,
488 protocol INTEGER NOT NULL,
489 created_at INTEGER NOT NULL,
490 outcome TEXT NOT NULL,
491 rp_id INTEGER NOT NULL,
492 app_identifier TEXT NOT NULL,
493 issuer_schema_ids BLOB NOT NULL,
494 failure_reason TEXT NOT NULL
495 );
496 INSERT INTO cache_meta (schema_version, created_at, updated_at)
497 VALUES (3, 1000, 1000);
498 INSERT INTO activity_entries (
499 entry_id, client_id, protocol, created_at, outcome, rp_id,
500 app_identifier, issuer_schema_ids, failure_reason
501 ) VALUES (
502 1, 'req-corrupt', 3, 1000, 'completed', 7, 'app_corrupt',
503 X'000000000000000A00', ''
504 );",
505 )
506 .expect("seed malformed legacy activity schema");
507 drop(conn);
508
509 let db =
510 CacheDb::new(&path, &key).expect("open cache with malformed legacy blob");
511
512 let entries = db
513 .list_activities(&ActivityQuery::new(), 10, 0)
514 .expect("list all");
515 assert!(
516 entries.is_empty(),
517 "a malformed legacy blob must not be silently truncated"
518 );
519
520 cleanup_cache_files(&path);
521 cleanup_lock_file(&lock_path);
522 }
523
524 #[test]
525 fn test_schema_version_is_recorded() {
526 let path = temp_cache_path();
527 let key = SecretBox::init_with(|| [0x99u8; 32]);
528 let lock_path = temp_lock_path();
529
530 let db = CacheDb::new(&path, &key).expect("create cache");
531 db.record_activity(&sample_new_activity_entry(), 1000)
532 .expect("record activity");
533 drop(db);
534
535 let conn = walletkit_sqlite::cipher::open_encrypted(&path, &key)
536 .expect("open raw connection");
537 let version = conn
538 .query_row("SELECT schema_version FROM cache_meta", &[], |stmt| {
539 Ok(stmt.column_i64(0))
540 })
541 .expect("read cache schema version");
542 drop(conn);
543
544 assert_eq!(version, 4, "the cache schema registers as version 4");
547
548 let db = CacheDb::new(&path, &key).expect("reopen cache");
549 let entries = db
550 .list_activities(&ActivityQuery::new(), 10, 0)
551 .expect("list activities");
552
553 assert_eq!(entries.len(), 1, "reopening must not restamp or reset");
554 assert_eq!(entries[0].rp_id, 1);
555 assert_eq!(entries[0].app_identifier, "app_test");
556
557 cleanup_cache_files(&path);
558 cleanup_lock_file(&lock_path);
559 }
560}