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