Skip to main content

aperion_shield/identity/
cache.rs

1//! On-disk cache of signed verification proofs.
2//!
3//! Stored as JSON at `<state_dir>/identity-cache.json`:
4//!
5//! ```json
6//! {
7//!   "v": 1,
8//!   "public_key": "<hex>",            // for diagnostics; not authoritative
9//!   "proofs": [ { ...Proof... }, ... ]
10//! }
11//! ```
12//!
13//! Every entry is signature-verified on load. Any entry that fails
14//! verification (tampered, signed by a different key, expired schema)
15//! is silently dropped. The cache is then rewritten with only the
16//! survivors so the file stays clean.
17//!
18//! Thread-safety: a `parking_lot`-style `RwLock` would be ideal but
19//! we already depend on `std::sync::RwLock`. We hold the write lock
20//! only across in-memory state mutations and the file write, which is
21//! fast (< 1 ms for typical proof counts), so contention is a
22//! non-issue for the standalone Shield's single-user-on-a-laptop case.
23
24use std::fs;
25use std::io::Write;
26use std::path::PathBuf;
27use std::sync::RwLock;
28
29use serde::{Deserialize, Serialize};
30
31use super::proof::{Proof, ProofSigner};
32use super::Requirement;
33
34#[derive(Debug, Serialize, Deserialize)]
35struct CacheFile {
36    #[serde(default = "default_v")]
37    v: u32,
38    #[serde(default)]
39    public_key: String,
40    #[serde(default)]
41    proofs: Vec<Proof>,
42}
43
44fn default_v() -> u32 { 1 }
45
46impl Default for CacheFile {
47    fn default() -> Self {
48        Self { v: 1, public_key: String::new(), proofs: Vec::new() }
49    }
50}
51
52pub struct ProofCache {
53    path: PathBuf,
54    public_key: String,
55    inner: RwLock<Vec<Proof>>,
56}
57
58impl ProofCache {
59    /// Open (and signature-verify) the cache at `path`. Missing files
60    /// are treated as empty caches.
61    pub fn open(path: PathBuf, signer: &ProofSigner) -> anyhow::Result<Self> {
62        let mut survivors = Vec::<Proof>::new();
63        if path.exists() {
64            let raw = fs::read_to_string(&path).unwrap_or_default();
65            let file: CacheFile = serde_json::from_str(&raw).unwrap_or_default();
66            for p in file.proofs {
67                if signer.verify(&p).is_ok() {
68                    survivors.push(p);
69                } else {
70                    log::warn!(
71                        "[shield-identity] dropping proof with bad signature \
72                         (provider={} subject={} scope={})",
73                        p.provider, p.subject, p.scope
74                    );
75                }
76            }
77        }
78        Ok(Self {
79            path,
80            public_key: signer.public_key_hex(),
81            inner: RwLock::new(survivors),
82        })
83    }
84
85    /// Insert a (presumably just-minted, already-signed) proof and
86    /// persist the cache. Replaces any existing proof for the same
87    /// (provider, subject, scope) tuple -- one slot per identity per
88    /// scope, so re-verifying simply refreshes the timestamp.
89    pub fn insert(&self, proof: Proof) -> anyhow::Result<()> {
90        {
91            let mut g = self.inner.write().expect("cache write lock poisoned");
92            g.retain(|p| {
93                !(p.provider == proof.provider
94                    && p.subject == proof.subject
95                    && p.scope == proof.scope)
96            });
97            g.push(proof);
98        }
99        self.persist()
100    }
101
102    /// Drop every cached proof. Returns the number evicted.
103    pub fn flush(&self) -> anyhow::Result<usize> {
104        let n = {
105            let mut g = self.inner.write().expect("cache write lock poisoned");
106            let n = g.len();
107            g.clear();
108            n
109        };
110        self.persist()?;
111        Ok(n)
112    }
113
114    /// Find any proof that satisfies `req` at `now`. Returns the proof
115    /// with the latest `verified_at` if multiple match (most-recently-
116    /// verified wins).
117    pub fn find_satisfying(&self, req: &Requirement, now: u64) -> Option<Proof> {
118        let g = self.inner.read().expect("cache read lock poisoned");
119        g.iter()
120            .filter(|p| req.is_satisfied_by(p, now))
121            .max_by_key(|p| p.verified_at)
122            .cloned()
123    }
124
125    /// How many proofs are currently cached AND not expired at `now`.
126    pub fn count_valid(&self, now: u64) -> usize {
127        let g = self.inner.read().expect("cache read lock poisoned");
128        g.iter().filter(|p| p.expires_at > now).count()
129    }
130
131    /// Persist the in-memory state to disk via write-then-rename so a
132    /// crash mid-write never produces a truncated cache file.
133    fn persist(&self) -> anyhow::Result<()> {
134        let snapshot = {
135            let g = self.inner.read().expect("cache read lock poisoned");
136            CacheFile {
137                v: 1,
138                public_key: self.public_key.clone(),
139                proofs: g.clone(),
140            }
141        };
142        if let Some(parent) = self.path.parent() {
143            fs::create_dir_all(parent)?;
144        }
145        let tmp_path = self.path.with_extension("json.tmp");
146        let body = serde_json::to_vec_pretty(&snapshot)?;
147        {
148            let mut f = fs::File::create(&tmp_path)?;
149            f.write_all(&body)?;
150            f.write_all(b"\n")?;
151            f.sync_all().ok();
152        }
153        fs::rename(&tmp_path, &self.path)?;
154        #[cfg(unix)]
155        {
156            use std::os::unix::fs::PermissionsExt;
157            let _ = fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600));
158        }
159        Ok(())
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    fn proof(subject: &str, scope: &str, ttl: u64) -> Proof {
168        let now = super::super::unix_now();
169        Proof {
170            v: 1, provider: "mock".into(), subject: subject.into(),
171            email: Some("[email protected]".into()),
172            loa: 2, scope: scope.into(),
173            verified_at: now, expires_at: now + ttl,
174            nonce: "abc".into(), sig: String::new(),
175        }
176    }
177
178    #[test]
179    fn round_trip_insert_and_find() {
180        let tmp = tempfile::tempdir().unwrap();
181        let signer = ProofSigner::load_or_create(tmp.path()).unwrap();
182        let cache = ProofCache::open(tmp.path().join("c.json"), &signer).unwrap();
183
184        let p = signer.sign(proof("sub-a", "scm.commit", 600)).unwrap();
185        cache.insert(p).unwrap();
186
187        let req = Requirement {
188            provider: "mock".into(),
189            scope: "scm.commit".into(),
190            allowed_subjects: vec!["sub-a".into()],
191            max_proof_age_seconds: 900,
192            loa: 2,
193        };
194        let now = super::super::unix_now();
195        assert!(cache.find_satisfying(&req, now).is_some());
196    }
197
198    #[test]
199    fn reload_drops_tampered_entries() {
200        let tmp = tempfile::tempdir().unwrap();
201        let signer = ProofSigner::load_or_create(tmp.path()).unwrap();
202        let cache_path = tmp.path().join("c.json");
203        {
204            let cache = ProofCache::open(cache_path.clone(), &signer).unwrap();
205            let p = signer.sign(proof("sub-a", "scm.commit", 600)).unwrap();
206            cache.insert(p).unwrap();
207        }
208        let raw = std::fs::read_to_string(&cache_path).unwrap();
209        let tampered = raw.replace("\"loa\": 2", "\"loa\": 3");
210        std::fs::write(&cache_path, tampered).unwrap();
211
212        let cache2 = ProofCache::open(cache_path.clone(), &signer).unwrap();
213        let now = super::super::unix_now();
214        let req = Requirement {
215            provider: "mock".into(),
216            scope: "scm.commit".into(),
217            allowed_subjects: vec!["*".into()],
218            max_proof_age_seconds: 900,
219            loa: 0,
220        };
221        // The tampered row must NOT survive.
222        assert!(cache2.find_satisfying(&req, now).is_none());
223    }
224
225    #[test]
226    fn insert_replaces_same_scope_subject() {
227        let tmp = tempfile::tempdir().unwrap();
228        let signer = ProofSigner::load_or_create(tmp.path()).unwrap();
229        let cache = ProofCache::open(tmp.path().join("c.json"), &signer).unwrap();
230        let p1 = signer.sign(proof("sub-a", "scm.commit", 600)).unwrap();
231        let p2 = signer.sign(proof("sub-a", "scm.commit", 600)).unwrap();
232        cache.insert(p1).unwrap();
233        cache.insert(p2).unwrap();
234        let now = super::super::unix_now();
235        assert_eq!(cache.count_valid(now), 1);
236    }
237}