Skip to main content

auths_id/keri/
cache.rs

1//! Local KEL state cache for performance optimization.
2//!
3//! This module provides a local, file-based cache for validated KERI key states.
4//! The cache eliminates repeated O(n) replays of the Key Event Log by storing
5//! the validated `KeyState` keyed by DID and validated against the tip SAID.
6//!
7//! ## Security Properties
8//!
9//! - Cache is purely a performance accelerator - never trusted without validation
10//! - Always validated against current KEL tip SAID before use
11//! - DID stored in cache must match requested DID (prevents file swap attacks)
12//! - On any mismatch, cache is treated as a miss and full replay occurs
13//! - Cache files are local-only, never committed to Git or replicated
14
15use auths_verifier::CommitOid;
16use auths_verifier::types::IdentityDID;
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20#[allow(clippy::disallowed_types)]
21// INVARIANT: file-based cache adapter — fs types are core to this module
22use std::fs::{self, OpenOptions};
23use std::io::{self, Write};
24use std::path::{Path, PathBuf};
25
26use super::state::KeyState;
27use super::types::Said;
28
29/// Cache format version. Increment when CachedKelState structure changes.
30pub const CACHE_VERSION: u32 = 2;
31
32/// Cached key state from a validated KEL.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct CachedKelState {
35    /// Cache format version for forward compatibility
36    pub version: u32,
37    /// The DID this cache entry is for (authoritative, verified on load)
38    pub did: IdentityDID,
39    /// Sequence number of the last event
40    pub sequence: u128,
41    /// SAID of the tip event when this cache was validated
42    pub validated_against_tip_said: Said,
43    /// Git commit OID of the tip event (hex-encoded) - enables incremental validation
44    pub last_commit_oid: CommitOid,
45    /// The validated key state
46    pub state: KeyState,
47    /// When this cache entry was created
48    pub cached_at: DateTime<Utc>,
49}
50
51/// Errors that can occur during cache operations.
52#[derive(Debug, thiserror::Error)]
53#[non_exhaustive]
54pub enum CacheError {
55    #[error("I/O error: {0}")]
56    Io(#[from] io::Error),
57
58    #[error("JSON serialization error: {0}")]
59    Json(#[from] serde_json::Error),
60}
61
62impl auths_core::error::AuthsErrorInfo for CacheError {
63    fn error_code(&self) -> &'static str {
64        match self {
65            Self::Io(_) => "AUTHS-E4986",
66            Self::Json(_) => "AUTHS-E4987",
67        }
68    }
69
70    fn suggestion(&self) -> Option<&'static str> {
71        match self {
72            Self::Io(_) => {
73                Some("Check cache directory permissions; the cache is optional and can be cleared")
74            }
75            Self::Json(_) => {
76                Some("The cache file may be corrupted; try clearing it with 'auths cache clear'")
77            }
78        }
79    }
80}
81
82/// Returns the cache file path for a given DID.
83///
84/// Uses SHA-256 hash of the DID for the filename to avoid collisions
85/// from different DIDs that might sanitize to the same string.
86/// The actual DID is stored inside the JSON for verification.
87///
88/// Args:
89/// * `auths_home` - The Auths home directory (e.g. `~/.auths`).
90/// * `did` - The DID to compute the cache path for.
91pub fn cache_path_for_did(auths_home: &Path, did: &str) -> PathBuf {
92    let mut hasher = Sha256::new();
93    hasher.update(did.as_bytes());
94    let hash = hasher.finalize();
95    let hex = hex::encode(hash);
96
97    auths_home
98        .join("cache")
99        .join("kel")
100        .join(format!("{}.json", hex))
101}
102
103/// Write a validated key state to the cache.
104///
105/// This performs an atomic write using a temp file, fsync, and rename
106/// to prevent corrupted cache files from partial writes.
107///
108/// Args:
109/// * `auths_home` - The Auths home directory (e.g. `~/.auths`).
110/// * `did` - The DID identifier for this key state.
111/// * `state` - The validated KeyState to cache.
112/// * `tip_said` - The SAID of the tip event when validation occurred.
113/// * `commit_oid` - The Git commit OID of the tip event (hex-encoded).
114/// * `now` - The timestamp to record in the cache entry.
115///
116/// # Errors
117/// Returns `CacheError` if the write fails. Callers should generally ignore
118/// cache write errors since the cache is optional.
119pub fn write_kel_cache(
120    auths_home: &Path,
121    did: &str,
122    state: &KeyState,
123    tip_said: &str,
124    commit_oid: &str,
125    now: DateTime<Utc>,
126) -> Result<(), CacheError> {
127    let cache = CachedKelState {
128        version: CACHE_VERSION,
129        #[allow(clippy::disallowed_methods)] // INVARIANT: callers pass a did:keri string that was resolved via parse_did_keri()
130        did: IdentityDID::new_unchecked(did),
131        sequence: state.sequence,
132        validated_against_tip_said: Said::new_unchecked(tip_said.to_string()),
133        #[allow(clippy::disallowed_methods)] // INVARIANT: callers pass the hex-encoded Git commit OID read from the repository
134        last_commit_oid: CommitOid::new_unchecked(commit_oid),
135        state: state.clone(),
136        cached_at: now,
137    };
138
139    let path = cache_path_for_did(auths_home, did);
140
141    // Ensure parent directory exists
142    if let Some(parent) = path.parent() {
143        fs::create_dir_all(parent)?;
144    }
145
146    // Atomic write: temp file -> fsync -> rename
147    let temp_path = path.with_extension("tmp");
148    {
149        let mut file = OpenOptions::new()
150            .write(true)
151            .create(true)
152            .truncate(true)
153            .open(&temp_path)?;
154
155        // Set restrictive permissions on Unix (owner read/write only)
156        #[cfg(unix)]
157        {
158            use std::os::unix::fs::PermissionsExt;
159            file.set_permissions(fs::Permissions::from_mode(0o600))?;
160        }
161
162        let json = serde_json::to_vec_pretty(&cache)?;
163        file.write_all(&json)?;
164        file.sync_all()?;
165    }
166
167    fs::rename(&temp_path, &path)?;
168
169    // Set permissions on final file too (rename can inherit different perms)
170    #[cfg(unix)]
171    {
172        use std::os::unix::fs::PermissionsExt;
173        fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
174    }
175
176    Ok(())
177}
178
179/// Try to load a cached key state for a given DID.
180///
181/// This performs strict validation of the cache entry:
182/// - Version must match current CACHE_VERSION
183/// - The stored DID must match the requested DID (prevents file swap attacks)
184/// - The `validated_against_tip_said` must match the expected tip SAID
185///
186/// Returns `None` on any error or mismatch, causing a fallback to full replay.
187///
188/// Args:
189/// * `auths_home` - The Auths home directory (e.g. `~/.auths`).
190/// * `did` - The DID to look up.
191/// * `expected_tip_said` - The SAID of the current KEL tip event.
192///
193/// # Returns
194/// `Some(KeyState)` if a valid cache entry exists, `None` otherwise.
195pub fn try_load_cached_state(
196    auths_home: &Path,
197    did: &str,
198    expected_tip_said: &str,
199) -> Option<KeyState> {
200    let cache = try_load_cached_state_full(auths_home, did)?;
201
202    // Strict SAID match - any mismatch means cache is stale
203    if cache.validated_against_tip_said != expected_tip_said {
204        return None;
205    }
206
207    Some(cache.state)
208}
209
210/// Try to load the full cached state entry for incremental validation.
211///
212/// This validates version and DID but does NOT check if the cache matches the
213/// current tip. Used by the incremental validator to check if the cached
214/// position is an ancestor of the current tip.
215///
216/// Args:
217/// * `auths_home` - The Auths home directory (e.g. `~/.auths`).
218/// * `did` - The DID to look up.
219///
220/// # Returns
221/// `Some(CachedKelState)` if a parseable, valid-version cache entry exists.
222pub fn try_load_cached_state_full(auths_home: &Path, did: &str) -> Option<CachedKelState> {
223    let path = cache_path_for_did(auths_home, did);
224
225    // Fail silently on any error - cache miss triggers full replay
226    let contents = fs::read_to_string(&path).ok()?;
227    let cache: CachedKelState = serde_json::from_str(&contents).ok()?;
228
229    // Version check - cache format may have changed
230    if cache.version != CACHE_VERSION {
231        return None;
232    }
233
234    // DID must match - prevents file swap/collision attacks
235    if cache.did.as_str() != did {
236        return None;
237    }
238
239    Some(cache)
240}
241
242/// Invalidate (delete) the cache entry for a given DID.
243///
244/// This is useful when you know the KEL has changed and want to force
245/// a full replay on the next `get_state()` call.
246///
247/// Args:
248/// * `auths_home` - The Auths home directory (e.g. `~/.auths`).
249/// * `did` - The DID whose cache entry should be deleted.
250///
251/// # Errors
252/// Returns an error if the file exists but cannot be deleted.
253pub fn invalidate_cache(auths_home: &Path, did: &str) -> Result<(), io::Error> {
254    let path = cache_path_for_did(auths_home, did);
255    if path.exists() {
256        fs::remove_file(&path)?;
257    }
258    Ok(())
259}
260
261/// Information about a cached entry for display purposes.
262#[derive(Debug, Clone)]
263pub struct CacheEntry {
264    /// The DID this cache is for
265    pub did: IdentityDID,
266    /// Sequence number
267    pub sequence: u128,
268    /// SAID the cache was validated against
269    pub validated_against_tip_said: Said,
270    /// Git commit OID of the cached position
271    pub last_commit_oid: CommitOid,
272    /// When the cache was created
273    pub cached_at: DateTime<Utc>,
274    /// Path to the cache file
275    pub path: PathBuf,
276}
277
278/// List all cached entries with their metadata.
279///
280/// Since filenames are hashes, we need to read each file to get the DID.
281///
282/// Args:
283/// * `auths_home` - The Auths home directory (e.g. `~/.auths`).
284pub fn list_cached_entries(auths_home: &Path) -> Result<Vec<CacheEntry>, io::Error> {
285    let cache_dir = auths_home.join("cache").join("kel");
286
287    if !cache_dir.exists() {
288        return Ok(Vec::new());
289    }
290
291    let mut entries = Vec::new();
292    for entry in fs::read_dir(&cache_dir)? {
293        let entry = entry?;
294        let path = entry.path();
295        if path.extension().is_some_and(|ext| ext == "json")
296            && let Ok(contents) = fs::read_to_string(&path)
297            && let Ok(cache) = serde_json::from_str::<CachedKelState>(&contents)
298        {
299            entries.push(CacheEntry {
300                did: cache.did,
301                sequence: cache.sequence,
302                validated_against_tip_said: cache.validated_against_tip_said,
303                last_commit_oid: cache.last_commit_oid,
304                cached_at: cache.cached_at,
305                path: path.clone(),
306            });
307        }
308    }
309
310    Ok(entries)
311}
312
313/// Clear all KEL cache entries.
314///
315/// Args:
316/// * `auths_home` - The Auths home directory (e.g. `~/.auths`).
317///
318/// # Errors
319/// Returns an error if the cache directory cannot be read or files cannot be deleted.
320pub fn clear_all_caches(auths_home: &Path) -> Result<usize, io::Error> {
321    let cache_dir = auths_home.join("cache").join("kel");
322
323    if !cache_dir.exists() {
324        return Ok(0);
325    }
326
327    let mut count = 0;
328    for entry in fs::read_dir(&cache_dir)? {
329        let entry = entry?;
330        let path = entry.path();
331        if path.extension().is_some_and(|ext| ext == "json") {
332            fs::remove_file(&path)?;
333            count += 1;
334        }
335    }
336
337    Ok(count)
338}
339
340/// Inspect a specific cache entry by DID.
341///
342/// Returns the full cached state if it exists and is parseable.
343///
344/// Args:
345/// * `auths_home` - The Auths home directory (e.g. `~/.auths`).
346/// * `did` - The DID to inspect.
347pub fn inspect_cache(auths_home: &Path, did: &str) -> Result<Option<CachedKelState>, io::Error> {
348    let path = cache_path_for_did(auths_home, did);
349
350    if !path.exists() {
351        return Ok(None);
352    }
353
354    let contents = fs::read_to_string(&path)?;
355    match serde_json::from_str::<CachedKelState>(&contents) {
356        Ok(cache) => Ok(Some(cache)),
357        Err(_) => Ok(None), // Treat parse errors as missing
358    }
359}
360
361#[cfg(test)]
362#[allow(clippy::disallowed_methods)]
363mod tests {
364    use super::*;
365    use crate::keri::{CesrKey, Prefix, Threshold};
366    use tempfile::TempDir;
367
368    const VALID_OID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
369
370    fn create_test_state() -> KeyState {
371        KeyState::from_inception(
372            Prefix::new_unchecked("ETestPrefix".to_string()),
373            vec![CesrKey::new_unchecked("DKey1".to_string())],
374            vec![Said::new_unchecked("ENext1".to_string())],
375            Threshold::Simple(1),
376            Threshold::Simple(1),
377            Said::new_unchecked("ESaid123".to_string()),
378            vec![],
379            Threshold::Simple(0),
380            vec![],
381        )
382    }
383
384    #[test]
385    fn test_cache_path_uses_hash() {
386        let dir = TempDir::new().unwrap();
387        let path = cache_path_for_did(dir.path(), "did:keri:ETestPrefix");
388        let filename = path.file_name().unwrap().to_string_lossy();
389        // Should be a 64-char hex hash + .json
390        assert!(filename.ends_with(".json"));
391        assert_eq!(filename.len(), 64 + 5); // 64 hex chars + ".json"
392    }
393
394    #[test]
395    fn test_different_dids_get_different_paths() {
396        let dir = TempDir::new().unwrap();
397        let path1 = cache_path_for_did(dir.path(), "did:keri:ETest1");
398        let path2 = cache_path_for_did(dir.path(), "did:keri:ETest2");
399        assert_ne!(path1, path2);
400    }
401
402    #[test]
403    fn test_cache_write_and_read() {
404        let dir = TempDir::new().unwrap();
405        let did = "did:keri:ETest123";
406        let state = create_test_state();
407        let tip_said = "ELatestSaid";
408
409        // Write cache
410        write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
411
412        // Read it back
413        let loaded = try_load_cached_state(dir.path(), did, tip_said);
414        assert!(loaded.is_some());
415        let loaded = loaded.unwrap();
416        assert_eq!(loaded.prefix, state.prefix);
417        assert_eq!(loaded.sequence, state.sequence);
418    }
419
420    #[test]
421    fn test_cache_invalidation_on_said_mismatch() {
422        let dir = TempDir::new().unwrap();
423        let did = "did:keri:EMismatch";
424        let state = create_test_state();
425
426        // Write cache with one SAID
427        write_kel_cache(dir.path(), did, &state, "EOldSaid", VALID_OID, Utc::now()).unwrap();
428
429        // Try to load with different SAID - should return None
430        let result = try_load_cached_state(dir.path(), did, "ENewSaid");
431        assert!(result.is_none());
432
433        // But loading with correct SAID works
434        let result = try_load_cached_state(dir.path(), did, "EOldSaid");
435        assert!(result.is_some());
436    }
437
438    #[test]
439    fn test_cache_invalidation_on_did_mismatch() {
440        let dir = TempDir::new().unwrap();
441        let did = "did:keri:EOriginal";
442        let state = create_test_state();
443        let tip_said = "ESaid";
444
445        // Write cache
446        write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
447
448        // Manually corrupt the cache by writing wrong DID
449        let path = cache_path_for_did(dir.path(), did);
450        let mut cache: CachedKelState =
451            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
452        cache.did = IdentityDID::new_unchecked("did:keri:EWrongDid");
453        fs::write(&path, serde_json::to_vec_pretty(&cache).unwrap()).unwrap();
454
455        // Try to load - should return None due to DID mismatch
456        let result = try_load_cached_state(dir.path(), did, tip_said);
457        assert!(result.is_none());
458    }
459
460    #[test]
461    fn test_cache_handles_missing_file() {
462        let dir = TempDir::new().unwrap();
463        let result = try_load_cached_state(dir.path(), "did:keri:ENonexistent", "ESomeSaid");
464        assert!(result.is_none());
465    }
466
467    #[test]
468    fn test_cache_handles_corrupt_file() {
469        let dir = TempDir::new().unwrap();
470        let did = "did:keri:ECorrupt";
471        let path = cache_path_for_did(dir.path(), did);
472
473        // Create parent dir and write corrupt JSON
474        fs::create_dir_all(path.parent().unwrap()).unwrap();
475        fs::write(&path, "{ invalid json }").unwrap();
476
477        // Should return None
478        let result = try_load_cached_state(dir.path(), did, "ESomeSaid");
479        assert!(result.is_none());
480    }
481
482    #[test]
483    fn test_cache_version_mismatch() {
484        let dir = TempDir::new().unwrap();
485        let did = "did:keri:EVersionTest";
486        let state = create_test_state();
487        let tip_said = "ESaid";
488
489        // Write cache
490        write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
491
492        // Manually change version
493        let path = cache_path_for_did(dir.path(), did);
494        let mut cache: CachedKelState =
495            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
496        cache.version = CACHE_VERSION + 1;
497        fs::write(&path, serde_json::to_vec_pretty(&cache).unwrap()).unwrap();
498
499        // Should return None due to version mismatch
500        let result = try_load_cached_state(dir.path(), did, tip_said);
501        assert!(result.is_none());
502    }
503
504    #[test]
505    fn test_invalidate_cache() {
506        let dir = TempDir::new().unwrap();
507        let did = "did:keri:EToInvalidate";
508        let state = create_test_state();
509
510        // Write and verify cache exists
511        write_kel_cache(dir.path(), did, &state, "ESaid", VALID_OID, Utc::now()).unwrap();
512        assert!(try_load_cached_state(dir.path(), did, "ESaid").is_some());
513
514        // Invalidate
515        invalidate_cache(dir.path(), did).unwrap();
516
517        // Should be gone
518        assert!(try_load_cached_state(dir.path(), did, "ESaid").is_none());
519    }
520
521    #[test]
522    fn test_invalidate_nonexistent_cache() {
523        let dir = TempDir::new().unwrap();
524        // Should succeed even if file doesn't exist
525        let result = invalidate_cache(dir.path(), "did:keri:ENeverExisted");
526        assert!(result.is_ok());
527    }
528
529    #[test]
530    fn test_list_cached_entries() {
531        let dir = TempDir::new().unwrap();
532        let state = create_test_state();
533
534        // Write multiple caches
535        write_kel_cache(
536            dir.path(),
537            "did:keri:ETest1",
538            &state,
539            "ESaid1",
540            VALID_OID,
541            Utc::now(),
542        )
543        .unwrap();
544        write_kel_cache(
545            dir.path(),
546            "did:keri:ETest2",
547            &state,
548            "ESaid2",
549            VALID_OID,
550            Utc::now(),
551        )
552        .unwrap();
553
554        let entries = list_cached_entries(dir.path()).unwrap();
555        assert_eq!(entries.len(), 2);
556
557        let dids: Vec<_> = entries.iter().map(|e| e.did.as_str()).collect();
558        assert!(dids.contains(&"did:keri:ETest1"));
559        assert!(dids.contains(&"did:keri:ETest2"));
560    }
561
562    #[test]
563    fn test_clear_all_caches() {
564        let dir = TempDir::new().unwrap();
565        let state = create_test_state();
566
567        // Write multiple caches
568        write_kel_cache(
569            dir.path(),
570            "did:keri:EClear1",
571            &state,
572            "ESaid",
573            VALID_OID,
574            Utc::now(),
575        )
576        .unwrap();
577        write_kel_cache(
578            dir.path(),
579            "did:keri:EClear2",
580            &state,
581            "ESaid",
582            VALID_OID,
583            Utc::now(),
584        )
585        .unwrap();
586
587        // Clear all
588        let count = clear_all_caches(dir.path()).unwrap();
589        assert_eq!(count, 2);
590
591        // Verify empty
592        let entries = list_cached_entries(dir.path()).unwrap();
593        assert!(entries.is_empty());
594    }
595
596    #[test]
597    fn test_inspect_cache() {
598        let dir = TempDir::new().unwrap();
599        let did = "did:keri:EInspect";
600        let state = create_test_state();
601        let tip_said = "ESaid";
602
603        write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
604
605        let inspected = inspect_cache(dir.path(), did).unwrap();
606        assert!(inspected.is_some());
607        let inspected = inspected.unwrap();
608        assert_eq!(inspected.did, did);
609        assert_eq!(inspected.validated_against_tip_said, tip_said);
610    }
611}