Skip to main content

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}