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: u64,
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-E4981",
66            Self::Json(_) => "AUTHS-E4982",
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: u64,
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::Prefix;
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!["DKey1".to_string()],
374            vec!["ENext1".to_string()],
375            1,
376            1,
377            Said::new_unchecked("ESaid123".to_string()),
378        )
379    }
380
381    #[test]
382    fn test_cache_path_uses_hash() {
383        let dir = TempDir::new().unwrap();
384        let path = cache_path_for_did(dir.path(), "did:keri:ETestPrefix");
385        let filename = path.file_name().unwrap().to_string_lossy();
386        // Should be a 64-char hex hash + .json
387        assert!(filename.ends_with(".json"));
388        assert_eq!(filename.len(), 64 + 5); // 64 hex chars + ".json"
389    }
390
391    #[test]
392    fn test_different_dids_get_different_paths() {
393        let dir = TempDir::new().unwrap();
394        let path1 = cache_path_for_did(dir.path(), "did:keri:ETest1");
395        let path2 = cache_path_for_did(dir.path(), "did:keri:ETest2");
396        assert_ne!(path1, path2);
397    }
398
399    #[test]
400    fn test_cache_write_and_read() {
401        let dir = TempDir::new().unwrap();
402        let did = "did:keri:ETest123";
403        let state = create_test_state();
404        let tip_said = "ELatestSaid";
405
406        // Write cache
407        write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
408
409        // Read it back
410        let loaded = try_load_cached_state(dir.path(), did, tip_said);
411        assert!(loaded.is_some());
412        let loaded = loaded.unwrap();
413        assert_eq!(loaded.prefix, state.prefix);
414        assert_eq!(loaded.sequence, state.sequence);
415    }
416
417    #[test]
418    fn test_cache_invalidation_on_said_mismatch() {
419        let dir = TempDir::new().unwrap();
420        let did = "did:keri:EMismatch";
421        let state = create_test_state();
422
423        // Write cache with one SAID
424        write_kel_cache(dir.path(), did, &state, "EOldSaid", VALID_OID, Utc::now()).unwrap();
425
426        // Try to load with different SAID - should return None
427        let result = try_load_cached_state(dir.path(), did, "ENewSaid");
428        assert!(result.is_none());
429
430        // But loading with correct SAID works
431        let result = try_load_cached_state(dir.path(), did, "EOldSaid");
432        assert!(result.is_some());
433    }
434
435    #[test]
436    fn test_cache_invalidation_on_did_mismatch() {
437        let dir = TempDir::new().unwrap();
438        let did = "did:keri:EOriginal";
439        let state = create_test_state();
440        let tip_said = "ESaid";
441
442        // Write cache
443        write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
444
445        // Manually corrupt the cache by writing wrong DID
446        let path = cache_path_for_did(dir.path(), did);
447        let mut cache: CachedKelState =
448            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
449        cache.did = IdentityDID::new_unchecked("did:keri:EWrongDid");
450        fs::write(&path, serde_json::to_vec_pretty(&cache).unwrap()).unwrap();
451
452        // Try to load - should return None due to DID mismatch
453        let result = try_load_cached_state(dir.path(), did, tip_said);
454        assert!(result.is_none());
455    }
456
457    #[test]
458    fn test_cache_handles_missing_file() {
459        let dir = TempDir::new().unwrap();
460        let result = try_load_cached_state(dir.path(), "did:keri:ENonexistent", "ESomeSaid");
461        assert!(result.is_none());
462    }
463
464    #[test]
465    fn test_cache_handles_corrupt_file() {
466        let dir = TempDir::new().unwrap();
467        let did = "did:keri:ECorrupt";
468        let path = cache_path_for_did(dir.path(), did);
469
470        // Create parent dir and write corrupt JSON
471        fs::create_dir_all(path.parent().unwrap()).unwrap();
472        fs::write(&path, "{ invalid json }").unwrap();
473
474        // Should return None
475        let result = try_load_cached_state(dir.path(), did, "ESomeSaid");
476        assert!(result.is_none());
477    }
478
479    #[test]
480    fn test_cache_version_mismatch() {
481        let dir = TempDir::new().unwrap();
482        let did = "did:keri:EVersionTest";
483        let state = create_test_state();
484        let tip_said = "ESaid";
485
486        // Write cache
487        write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
488
489        // Manually change version
490        let path = cache_path_for_did(dir.path(), did);
491        let mut cache: CachedKelState =
492            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
493        cache.version = CACHE_VERSION + 1;
494        fs::write(&path, serde_json::to_vec_pretty(&cache).unwrap()).unwrap();
495
496        // Should return None due to version mismatch
497        let result = try_load_cached_state(dir.path(), did, tip_said);
498        assert!(result.is_none());
499    }
500
501    #[test]
502    fn test_invalidate_cache() {
503        let dir = TempDir::new().unwrap();
504        let did = "did:keri:EToInvalidate";
505        let state = create_test_state();
506
507        // Write and verify cache exists
508        write_kel_cache(dir.path(), did, &state, "ESaid", VALID_OID, Utc::now()).unwrap();
509        assert!(try_load_cached_state(dir.path(), did, "ESaid").is_some());
510
511        // Invalidate
512        invalidate_cache(dir.path(), did).unwrap();
513
514        // Should be gone
515        assert!(try_load_cached_state(dir.path(), did, "ESaid").is_none());
516    }
517
518    #[test]
519    fn test_invalidate_nonexistent_cache() {
520        let dir = TempDir::new().unwrap();
521        // Should succeed even if file doesn't exist
522        let result = invalidate_cache(dir.path(), "did:keri:ENeverExisted");
523        assert!(result.is_ok());
524    }
525
526    #[test]
527    fn test_list_cached_entries() {
528        let dir = TempDir::new().unwrap();
529        let state = create_test_state();
530
531        // Write multiple caches
532        write_kel_cache(
533            dir.path(),
534            "did:keri:ETest1",
535            &state,
536            "ESaid1",
537            VALID_OID,
538            Utc::now(),
539        )
540        .unwrap();
541        write_kel_cache(
542            dir.path(),
543            "did:keri:ETest2",
544            &state,
545            "ESaid2",
546            VALID_OID,
547            Utc::now(),
548        )
549        .unwrap();
550
551        let entries = list_cached_entries(dir.path()).unwrap();
552        assert_eq!(entries.len(), 2);
553
554        let dids: Vec<_> = entries.iter().map(|e| e.did.as_str()).collect();
555        assert!(dids.contains(&"did:keri:ETest1"));
556        assert!(dids.contains(&"did:keri:ETest2"));
557    }
558
559    #[test]
560    fn test_clear_all_caches() {
561        let dir = TempDir::new().unwrap();
562        let state = create_test_state();
563
564        // Write multiple caches
565        write_kel_cache(
566            dir.path(),
567            "did:keri:EClear1",
568            &state,
569            "ESaid",
570            VALID_OID,
571            Utc::now(),
572        )
573        .unwrap();
574        write_kel_cache(
575            dir.path(),
576            "did:keri:EClear2",
577            &state,
578            "ESaid",
579            VALID_OID,
580            Utc::now(),
581        )
582        .unwrap();
583
584        // Clear all
585        let count = clear_all_caches(dir.path()).unwrap();
586        assert_eq!(count, 2);
587
588        // Verify empty
589        let entries = list_cached_entries(dir.path()).unwrap();
590        assert!(entries.is_empty());
591    }
592
593    #[test]
594    fn test_inspect_cache() {
595        let dir = TempDir::new().unwrap();
596        let did = "did:keri:EInspect";
597        let state = create_test_state();
598        let tip_said = "ESaid";
599
600        write_kel_cache(dir.path(), did, &state, tip_said, VALID_OID, Utc::now()).unwrap();
601
602        let inspected = inspect_cache(dir.path(), did).unwrap();
603        assert!(inspected.is_some());
604        let inspected = inspected.unwrap();
605        assert_eq!(inspected.did, did);
606        assert_eq!(inspected.validated_against_tip_said, tip_said);
607    }
608}