hasp_core/cache.rs
1//! Per-invocation in-process secret cache.
2//!
3//! `ProcessCache` holds `Arc<SecretString>` values keyed by a
4//! `(scheme, identity)` tuple. The Arc is the only handle the cache
5//! retains; on eviction, the listener explicitly drops the Arc, and
6//! the inner `SecretString`'s `Drop` impl zeroizes the heap buffer
7//! once the last holder (the cache, or a borrowed clone) goes away.
8//!
9//! Cache construction requires a [`crate::hardening::HardeningToken`].
10//! Without it the type is unconstructible. The CLI binary obtains the
11//! token at startup via [`crate::hardening::install`]; library
12//! consumers wanting caching must do the same. This is the
13//! architectural lever that makes `PR_SET_DUMPABLE=0`,
14//! `RLIMIT_CORE=0`, and env-injection refusal non-bypassable
15//! preconditions for any cached secret.
16//!
17//! ## What this cache is and is not
18//!
19//! - It is a **per-invocation memoization** layer. Lifetime = process
20//! lifetime. No on-disk persistence, no daemon, no IPC.
21//! - It eliminates the duplicate-URL footgun within a single batch
22//! (`hasp get URL URL URL` triggers one backend fetch).
23//! - It is not a defense against `/proc/<pid>/mem` inspection by a
24//! same-uid attacker. The hardening token's underlying mitigations
25//! are the only such defense, and they are best-effort.
26//!
27//! Cross-invocation persistence (Approach A in
28//! `RESEARCH-op-caching.md`) lives behind the `cache-persistent`
29//! Cargo feature and is opt-in by binary builders only.
30
31use std::sync::Arc;
32use std::time::Duration;
33
34use moka::notification::RemovalCause;
35use moka::sync::Cache;
36
37use crate::audit::{AuditEvent, AuditSink, CacheEvent};
38use crate::hardening::HardeningToken;
39use crate::SecretString;
40
41/// Cache key. `scheme` is the URL scheme and is intentionally
42/// scheme-namespaced so the same URL string handled by two different
43/// backends cannot alias.
44#[derive(Debug, Clone, Hash, Eq, PartialEq)]
45pub struct CacheKey {
46 pub scheme: &'static str,
47 pub identity: String,
48}
49
50impl CacheKey {
51 pub fn new(scheme: &'static str, identity: impl Into<String>) -> Self {
52 Self {
53 scheme,
54 identity: identity.into(),
55 }
56 }
57}
58
59/// Per-invocation in-process cache policy.
60///
61/// `Disabled` is the safe default and skips every cache code path.
62/// `Process` enables in-process memoization with the given TTL and
63/// capacity ceiling; capacity-eviction is LRU within moka's segmented
64/// design.
65///
66/// `Persistent` (gated on the `cache-persistent` Cargo feature) is
67/// reserved for a future cross-invocation encrypted-file path.
68/// Constructing it via [`ProcessCache::new`] currently downgrades to
69/// the in-process `Process` policy with the persistent's TTL and
70/// capacity — the on-disk encrypted-file implementation is not yet
71/// in tree.
72#[derive(Debug, Clone, Default)]
73pub enum CachePolicy {
74 #[default]
75 Disabled,
76 Process {
77 ttl: Duration,
78 capacity: u64,
79 },
80 #[cfg(feature = "cache-persistent")]
81 Persistent(PersistentPolicy),
82}
83
84/// Persistent cache configuration (scaffold only).
85///
86/// `ttl` is the per-entry time-to-live, clamped against AWS Secrets
87/// Manager Agent's published envelope (300s default, 3600s max, 0
88/// disables persistence).
89///
90/// `path` is the intended encrypted cache file location. Default:
91/// `$XDG_CACHE_HOME/hasp/cache.bin`. File mode `0o600` on Unix when
92/// the implementation lands.
93///
94/// `keyring_service` and `keyring_account` identify the OS-keyring
95/// entry holding the per-host symmetric key (XChaCha20-Poly1305).
96/// Default service is `"hasp"`; default account is
97/// `"cache:{user}@{hostname}"`.
98///
99/// The struct currently pins the design in code; constructing it and
100/// passing to [`ProcessCache::new`] is safe but downgrades to the
101/// in-process `Process` policy with the persistent's TTL and
102/// capacity. The on-disk encrypted-file implementation is not yet
103/// in tree.
104#[cfg(feature = "cache-persistent")]
105#[derive(Debug, Clone)]
106pub struct PersistentPolicy {
107 pub ttl: Duration,
108 pub path: std::path::PathBuf,
109 pub keyring_service: String,
110 pub keyring_account: String,
111 pub capacity: u64,
112}
113
114#[cfg(feature = "cache-persistent")]
115impl PersistentPolicy {
116 /// Maximum permitted TTL. Mirrors AWS Secrets Manager Agent's
117 /// 1-hour ceiling. Values above this are clamped to keep the
118 /// envelope honest about the worst-case staleness.
119 pub const MAX_TTL: Duration = Duration::from_secs(3600);
120
121 /// AWS Secrets Manager Agent's default TTL: 300 seconds.
122 pub const DEFAULT_TTL: Duration = Duration::from_secs(300);
123
124 /// Construct with the AWS-Agent default envelope and the default
125 /// file path. Returns `None` if `dirs::cache_dir()` fails (e.g.,
126 /// `$HOME` is unset and there is no platform default).
127 pub fn defaults() -> Option<Self> {
128 let dir = dirs::cache_dir()?.join("hasp");
129 Some(Self {
130 ttl: Self::DEFAULT_TTL,
131 path: dir.join("cache.bin"),
132 keyring_service: "hasp".into(),
133 keyring_account: format!("cache:{}", whoami_or_unknown()),
134 capacity: 1024,
135 })
136 }
137
138 /// Clamp `ttl` to `MAX_TTL`. A `ttl` of zero disables persistence.
139 pub fn with_ttl(mut self, ttl: Duration) -> Self {
140 self.ttl = if ttl > Self::MAX_TTL {
141 Self::MAX_TTL
142 } else {
143 ttl
144 };
145 self
146 }
147}
148
149#[cfg(feature = "cache-persistent")]
150fn whoami_or_unknown() -> String {
151 std::env::var("USER")
152 .or_else(|_| std::env::var("USERNAME"))
153 .unwrap_or_else(|_| "unknown".into())
154}
155
156impl CachePolicy {
157 /// `Process` policy with the canonical AWS-Secrets-Manager-Agent
158 /// envelope: 5-minute TTL, 1024-entry capacity ceiling. Capacity
159 /// is intentionally generous — moka's overhead per entry is well
160 /// under 1 KiB and the per-invocation Store will never approach
161 /// the ceiling in practice.
162 pub fn process_default() -> Self {
163 Self::Process {
164 ttl: Duration::from_secs(300),
165 capacity: 1024,
166 }
167 }
168}
169
170/// In-process moka-backed cache of `Arc<SecretString>`.
171///
172/// Constructed via [`ProcessCache::new`], which requires a
173/// [`HardeningToken`]. Cache operations are sync (no async runtime
174/// involvement); moka's eviction listener fires synchronously on Drop
175/// in the `sync` flavor, so the `Arc<SecretString>` zeroize-on-Drop
176/// semantics are preserved for evicted entries.
177#[derive(Clone)]
178pub struct ProcessCache {
179 inner: Cache<CacheKey, Arc<SecretString>>,
180}
181
182impl ProcessCache {
183 /// Construct a cache governed by `policy`. Returns `None` when the
184 /// policy is `Disabled`.
185 ///
186 /// The `_token` parameter is the architectural lever: callers who
187 /// have not installed hardening cannot obtain a token and
188 /// therefore cannot construct a cache. The token is consumed by
189 /// value (it is `Copy`) and is not retained.
190 ///
191 /// `audit_sink`, when provided, receives `cache.expire` events on
192 /// TTL-driven evictions. Explicit `invalidate` / `invalidate_all`
193 /// calls do not emit events here — `Store` emits its own
194 /// `cache.clear` on the user-facing path.
195 pub fn new(
196 policy: &CachePolicy,
197 _token: HardeningToken,
198 audit_sink: Option<Arc<dyn AuditSink>>,
199 ) -> Option<Self> {
200 match policy {
201 #[cfg(feature = "cache-persistent")]
202 CachePolicy::Persistent(p) => {
203 // Scaffold downgrade: with the on-disk encrypted-file path
204 // not yet implemented, `Persistent` runs as an in-process
205 // `Process` policy carrying the persistent's TTL and
206 // capacity. CLI integration (`HASP_CACHE_TTL`,
207 // `hasp cache clear`) operates against the in-memory
208 // layer without a behavioral surprise.
209 let process = CachePolicy::Process {
210 ttl: p.ttl,
211 capacity: p.capacity,
212 };
213 Self::new(&process, _token, audit_sink)
214 }
215 CachePolicy::Disabled => None,
216 CachePolicy::Process { ttl, capacity } => {
217 let sink = audit_sink.clone();
218 let inner = Cache::builder()
219 .max_capacity(*capacity)
220 .time_to_live(*ttl)
221 .eviction_listener(move |k: Arc<CacheKey>, v, cause| {
222 // Drop the Arc<SecretString> first so the inner
223 // zeroize fires promptly. moka 0.12 sync flavor
224 // runs this listener synchronously on the
225 // eviction-causing thread.
226 drop(v);
227 if matches!(cause, RemovalCause::Expired) {
228 if let Some(s) = &sink {
229 s.emit(&AuditEvent::cache(CacheEvent::Expire, k.scheme));
230 }
231 }
232 })
233 .build();
234 Some(Self { inner })
235 }
236 }
237 }
238
239 /// Read a cached value. Returns the `Arc<SecretString>` if a
240 /// fresh entry exists, or `None` on miss / TTL expiry. Callers
241 /// hold the clone — the cache retains its own Arc.
242 pub fn get(&self, key: &CacheKey) -> Option<Arc<SecretString>> {
243 self.inner.get(key)
244 }
245
246 /// Insert or replace a value.
247 pub fn insert(&self, key: CacheKey, value: Arc<SecretString>) {
248 self.inner.insert(key, value);
249 }
250
251 /// Invalidate a single entry. No-op if the key is absent.
252 pub fn invalidate(&self, key: &CacheKey) {
253 self.inner.invalidate(key);
254 }
255
256 /// Drop every entry. Used by `hasp cache clear` and tests.
257 pub fn invalidate_all(&self) {
258 self.inner.invalidate_all();
259 }
260
261 /// Synchronously run pending eviction listeners. Used by tests to
262 /// observe deterministic eviction behavior — production callers
263 /// do not need to invoke this.
264 pub fn run_pending_tasks(&self) {
265 self.inner.run_pending_tasks();
266 }
267
268 /// Approximate entry count after pending tasks are processed.
269 /// Useful for tests and `--explain` diagnostics; not authoritative
270 /// under concurrent insertion.
271 pub fn entry_count(&self) -> u64 {
272 self.inner.entry_count()
273 }
274}
275
276impl std::fmt::Debug for ProcessCache {
277 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278 // Never expose contents — only entry count, which is a
279 // value-free wall-clock-derived counter.
280 f.debug_struct("ProcessCache")
281 .field("entry_count", &self.inner.entry_count())
282 .finish_non_exhaustive()
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use crate::hardening;
290 use secrecy::ExposeSecret;
291
292 fn token() -> HardeningToken {
293 // Tests run in-process and may interleave; install() is idempotent
294 // for the underlying syscalls. If a CI runner sets LD_PRELOAD this
295 // returns an error — but that's a real failure we want to surface.
296 hardening::install().expect("hardening install should succeed in tests")
297 }
298
299 #[test]
300 fn disabled_policy_returns_none() {
301 let policy = CachePolicy::Disabled;
302 assert!(ProcessCache::new(&policy, token(), None).is_none());
303 }
304
305 #[test]
306 fn process_policy_returns_some() {
307 let policy = CachePolicy::process_default();
308 assert!(ProcessCache::new(&policy, token(), None).is_some());
309 }
310
311 #[test]
312 fn insert_then_get_returns_same_secret_bytes() {
313 let cache = ProcessCache::new(&CachePolicy::process_default(), token(), None).unwrap();
314 let key = CacheKey::new("env", "USER");
315 let secret = Arc::new(SecretString::new("alice".to_string().into()));
316 cache.insert(key.clone(), secret.clone());
317
318 let got = cache.get(&key).expect("cache hit");
319 assert_eq!(got.expose_secret(), "alice");
320 }
321
322 #[test]
323 fn invalidate_removes_entry() {
324 let cache = ProcessCache::new(&CachePolicy::process_default(), token(), None).unwrap();
325 let key = CacheKey::new("env", "USER");
326 cache.insert(
327 key.clone(),
328 Arc::new(SecretString::new("v".to_string().into())),
329 );
330 cache.invalidate(&key);
331 cache.run_pending_tasks();
332 assert!(cache.get(&key).is_none());
333 }
334
335 #[test]
336 fn ttl_expiry_returns_none() {
337 let policy = CachePolicy::Process {
338 ttl: Duration::from_millis(50),
339 capacity: 16,
340 };
341 let cache = ProcessCache::new(&policy, token(), None).unwrap();
342 let key = CacheKey::new("env", "USER");
343 cache.insert(
344 key.clone(),
345 Arc::new(SecretString::new("v".to_string().into())),
346 );
347 std::thread::sleep(Duration::from_millis(120));
348 cache.run_pending_tasks();
349 assert!(cache.get(&key).is_none());
350 }
351
352 /// Capture-only sink for observing cache events from the eviction
353 /// listener under test.
354 #[derive(Default)]
355 struct TestSink {
356 events: std::sync::Mutex<Vec<String>>,
357 }
358
359 impl AuditSink for TestSink {
360 fn emit(&self, event: &AuditEvent) {
361 if let Ok(mut v) = self.events.lock() {
362 v.push(event.event.to_string());
363 }
364 }
365 }
366
367 #[test]
368 fn ttl_expiry_emits_cache_expire_event() {
369 // moka 0.12 sync flavor fires the eviction listener
370 // synchronously on the eviction-causing thread; for time-based
371 // expiry that means a subsequent op or `run_pending_tasks` is
372 // what surfaces the listener invocation. The test sleeps past
373 // the TTL, then drives an op + run_pending_tasks.
374 let sink: Arc<TestSink> = Arc::new(TestSink::default());
375 let policy = CachePolicy::Process {
376 ttl: Duration::from_millis(50),
377 capacity: 16,
378 };
379 let cache = ProcessCache::new(&policy, token(), Some(sink.clone())).unwrap();
380 let key = CacheKey::new("env", "EXPIRE_TEST");
381 cache.insert(
382 key.clone(),
383 Arc::new(SecretString::new("v".to_string().into())),
384 );
385
386 std::thread::sleep(Duration::from_millis(120));
387 // Drive the listener: a subsequent get + run_pending_tasks
388 // forces moka to process the expired entry.
389 let _ = cache.get(&key);
390 cache.run_pending_tasks();
391
392 let events = sink.events.lock().unwrap().clone();
393 assert!(
394 events.iter().any(|e| e == "cache.expire"),
395 "expected a cache.expire event, got {events:?}"
396 );
397 }
398
399 #[test]
400 fn capacity_eviction_drops_oldest() {
401 let policy = CachePolicy::Process {
402 ttl: Duration::from_secs(60),
403 capacity: 2,
404 };
405 let cache = ProcessCache::new(&policy, token(), None).unwrap();
406 cache.insert(
407 CacheKey::new("env", "A"),
408 Arc::new(SecretString::new("a".to_string().into())),
409 );
410 cache.insert(
411 CacheKey::new("env", "B"),
412 Arc::new(SecretString::new("b".to_string().into())),
413 );
414 cache.insert(
415 CacheKey::new("env", "C"),
416 Arc::new(SecretString::new("c".to_string().into())),
417 );
418 cache.run_pending_tasks();
419 // moka's segmented LRU may not evict deterministically when
420 // capacity is tiny; we only assert that the cache stayed at or
421 // below its ceiling.
422 assert!(cache.entry_count() <= 2);
423 }
424
425 #[test]
426 fn scheme_namespacing_prevents_cross_backend_alias() {
427 let cache = ProcessCache::new(&CachePolicy::process_default(), token(), None).unwrap();
428 let k1 = CacheKey::new("env", "DUP");
429 let k2 = CacheKey::new("file", "DUP");
430 cache.insert(
431 k1.clone(),
432 Arc::new(SecretString::new("env-value".to_string().into())),
433 );
434 cache.insert(
435 k2.clone(),
436 Arc::new(SecretString::new("file-value".to_string().into())),
437 );
438 assert_eq!(cache.get(&k1).unwrap().expose_secret(), "env-value");
439 assert_eq!(cache.get(&k2).unwrap().expose_secret(), "file-value");
440 }
441}