aperion_shield/identity/
cache.rs1use 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 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 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 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 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 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 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 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}