walletkit_core/storage/cache/
mod.rs1use 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 client_id: "req-1".to_string(),
203 protocol: ProtocolVersion::V3,
204 timestamp: None,
205 issuer_schema_ids: vec![],
206 outcome: ActivityOutcome::Completed,
207 failure_reason: None,
208 }
209 }
210
211 fn temp_cache_path() -> PathBuf {
212 let mut path = std::env::temp_dir();
213 path.push(format!("walletkit-cache-{}.sqlite", Uuid::new_v4()));
214 path
215 }
216
217 fn cleanup_cache_files(path: &Path) {
218 let _ = fs::remove_file(path);
219 let _ = fs::remove_file(path.with_extension("sqlite-wal"));
220 let _ = fs::remove_file(path.with_extension("sqlite-shm"));
221 }
222
223 fn temp_lock_path() -> PathBuf {
224 let mut path = std::env::temp_dir();
225 path.push(format!("walletkit-cache-lock-{}.lock", Uuid::new_v4()));
226 path
227 }
228
229 fn cleanup_lock_file(path: &Path) {
230 let _ = fs::remove_file(path);
231 }
232
233 #[test]
234 fn test_cache_create_and_open() {
235 let path = temp_cache_path();
236 let key = SecretBox::init_with(|| [0x11u8; 32]);
237 let lock_path = temp_lock_path();
238 let db = CacheDb::new(&path, &key).expect("create cache");
239 drop(db);
240 CacheDb::new(&path, &key).expect("open cache");
241 cleanup_cache_files(&path);
242 cleanup_lock_file(&lock_path);
243 }
244
245 #[test]
246 fn test_cache_rebuild_on_corruption() {
247 let path = temp_cache_path();
248 let key = SecretBox::init_with(|| [0x22u8; 32]);
249 let lock_path = temp_lock_path();
250 let db = CacheDb::new(&path, &key).expect("create cache");
251 let oprf_seed = [0x01u8; 32];
252 let r_seed = [0x02u8; 32];
253 let now = 1_000;
254 db.session_seed_put(1, oprf_seed, r_seed, now, 1000)
255 .expect("put session seed");
256 drop(db);
257
258 fs::write(&path, b"corrupt").expect("corrupt cache file");
259
260 let db = CacheDb::new(&path, &key).expect("rebuild cache");
261 let value = db
262 .session_seed_get(1, oprf_seed, now)
263 .expect("get session seed");
264 assert!(value.is_none());
265 cleanup_cache_files(&path);
266 cleanup_lock_file(&lock_path);
267 }
268
269 #[test]
270 fn test_merkle_cache_ttl() {
271 let path = temp_cache_path();
272 let key = SecretBox::init_with(|| [0x33u8; 32]);
273 let lock_path = temp_lock_path();
274 let db = CacheDb::new(&path, &key).expect("create cache");
275 db.merkle_cache_put(&[1, 2, 3], 100, 10)
276 .expect("put merkle proof");
277 let hit = db.merkle_cache_get(105).expect("get merkle proof");
278 assert!(hit.is_some());
279 let miss = db.merkle_cache_get(111).expect("get merkle proof");
280 assert!(miss.is_none());
281 cleanup_cache_files(&path);
282 cleanup_lock_file(&lock_path);
283 }
284
285 #[test]
286 fn test_session_seed_cache_ttl() {
287 let path = temp_cache_path();
288 let key = SecretBox::init_with(|| [0x44u8; 32]);
289 let lock_path = temp_lock_path();
290 let db = CacheDb::new(&path, &key).expect("create cache");
291 let oprf_seed = [0x55u8; 32];
292 let r_seed = [0x66u8; 32];
293 let now = 100;
294 db.session_seed_put(1, oprf_seed, r_seed, now, 10)
295 .expect("put session seed");
296 let hit = db.session_seed_get(1, oprf_seed, now).expect("get");
297 assert_eq!(hit, Some(r_seed));
298 let miss = db.session_seed_get(1, oprf_seed, now + 11).expect("get");
299 assert!(miss.is_none());
300 cleanup_cache_files(&path);
301 cleanup_lock_file(&lock_path);
302 }
303
304 #[test]
305 fn test_activity_survives_disposable_cache_reset() {
306 let path = temp_cache_path();
307 let key = SecretBox::init_with(|| [0x77u8; 32]);
308 let lock_path = temp_lock_path();
309 let db = CacheDb::new(&path, &key).expect("create cache");
310
311 db.record_activity(&sample_new_activity_entry(), 1000)
312 .expect("record activity");
313
314 db.session_seed_put(1, [0x01u8; 32], [0x02u8; 32], 1000, 1000)
315 .expect("put session seed");
316
317 drop(db);
318
319 let conn = walletkit_sqlite::cipher::open_encrypted(&path, &key, false)
320 .expect("open raw connection");
321 conn.execute(
322 "UPDATE cache_meta SET schema_version = schema_version + 1",
323 &[],
324 )
325 .expect("bump schema version");
326 drop(conn);
327
328 let db = CacheDb::new(&path, &key).expect("reopen cache after version bump");
329
330 let seed = db
331 .session_seed_get(1, [0x01u8; 32], 1000)
332 .expect("get session seed");
333
334 assert!(
335 seed.is_none(),
336 "disposable cache_entries should be wiped on a schema version mismatch"
337 );
338
339 let entries = db
340 .list_activities(ActivityQuery::default(), 10, 0)
341 .expect("list activities after version bump");
342
343 assert_eq!(entries.len(), 1);
344
345 cleanup_cache_files(&path);
346 cleanup_lock_file(&lock_path);
347 }
348
349 #[test]
350 fn test_activity_migration_applies_to_preexisting_cache_file() {
351 let path = temp_cache_path();
352 let key = SecretBox::init_with(|| [0x88u8; 32]);
353 let lock_path = temp_lock_path();
354
355 let conn = walletkit_sqlite::cipher::open_encrypted(&path, &key, false)
356 .expect("create raw connection");
357 conn.execute_batch(
358 "CREATE TABLE cache_meta (
359 schema_version INTEGER NOT NULL,
360 created_at INTEGER NOT NULL,
361 updated_at INTEGER NOT NULL
362 );
363 CREATE TABLE cache_entries (
364 key_bytes BLOB NOT NULL,
365 value_bytes BLOB NOT NULL,
366 inserted_at INTEGER NOT NULL,
367 expires_at INTEGER NOT NULL,
368 PRIMARY KEY (key_bytes)
369 );
370 INSERT INTO cache_meta (schema_version, created_at, updated_at)
371 VALUES (2, 1000, 1000);
372 INSERT INTO cache_entries (key_bytes, value_bytes, inserted_at, expires_at)
373 VALUES (X'AA', X'BB', 1000, 999999999);",
374 )
375 .expect("seed legacy cache schema");
376 drop(conn);
377
378 let db = CacheDb::new(&path, &key).expect("open legacy cache file");
379
380 db.record_activity(&sample_new_activity_entry(), 1000)
381 .expect("record activity after migration");
382
383 let entries = db
384 .list_activities(ActivityQuery::default(), 10, 0)
385 .expect("list activities");
386
387 assert_eq!(entries.len(), 1, "migration should add activity_entries");
388
389 drop(db);
390
391 let conn = walletkit_sqlite::cipher::open_encrypted(&path, &key, false)
392 .expect("reopen raw connection");
393
394 let count = conn
395 .query_row("SELECT COUNT(*) FROM cache_entries", &[], |stmt| {
396 Ok(stmt.column_i64(0))
397 })
398 .expect("count cache_entries");
399
400 assert_eq!(
401 count, 1,
402 "pre-existing cache_entries row must survive the activity migration"
403 );
404
405 cleanup_cache_files(&path);
406 cleanup_lock_file(&lock_path);
407 }
408}