Skip to main content

hardware_enclave/internal/app_adapter/
credential_cache.rs

1// Copyright 2026 Jay Gowdy
2// SPDX-License-Identifier: MIT
3
4//! Generic credential caching with lifecycle management for Type 4 (CredentialSource) apps.
5//!
6//! Provides the common infrastructure for any enclave app that obtains credentials
7//! from an external source, encrypts and caches them locally, and hands them to
8//! any consumer that asks.
9//!
10//! **Security boundary:** A Type 4 app secures the *acquisition and storage* of
11//! credentials (hardware-encrypted cache, automatic expiration, risk-level-based
12//! lifecycle). It provides **no guardrails on delivery** — once a credential is
13//! handed out via `get`, the consumer can export it to an environment variable,
14//! pipe it to a file, or use it through a Type 1/2/3 enclave app. Types 1-3
15//! control the entire delivery lifecycle; Type 4 does not.
16//!
17//! # Lifecycle
18//!
19//! Cached credentials pass through a state machine based on age:
20//!
21//! ```text
22//! Fresh → RefreshWindow → Grace → Expired
23//!   │         │              │        │
24//!   │    try refresh    serve stale   must re-acquire
25//!   └── serve from cache
26//! ```
27//!
28//! The transition times are controlled by a [`LifecyclePolicy`] which maps
29//! a risk level (u8) to duration thresholds.
30//!
31//! # Usage
32//!
33//! ```rust,ignore
34//! use crate::internal::app_adapter::credential_cache::*;
35//!
36//! // Define your policy
37//! struct MyPolicy;
38//! impl LifecyclePolicy for MyPolicy {
39//!     fn max_age_secs(&self, risk_level: u8) -> u64 { ... }
40//!     fn refresh_window_secs(&self, risk_level: u8) -> u64 { ... }
41//!     fn grace_period_secs(&self, risk_level: u8) -> u64 { ... }
42//! }
43//!
44//! // Classify cached credential state without decrypting
45//! let state = classify_credential(issued_at, session_start, now, &MyPolicy, risk_level);
46//! match state {
47//!     CredentialState::Fresh => { /* serve from cache */ }
48//!     CredentialState::RefreshWindow => { /* try background refresh, serve stale */ }
49//!     CredentialState::Grace => { /* serve stale, warn */ }
50//!     CredentialState::Expired => { /* must re-acquire */ }
51//! }
52//! ```
53#![allow(dead_code, unused_imports, unused_qualifications, unreachable_patterns)]
54
55use std::path::{Path, PathBuf};
56use std::time::{SystemTime, UNIX_EPOCH};
57
58/// Lifecycle state of a cached credential.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum CredentialState {
61    /// Credential is within its primary validity period. Serve directly.
62    Fresh,
63    /// Credential is aging — a background refresh should be attempted, but the
64    /// cached value can still be served if refresh fails.
65    RefreshWindow,
66    /// Credential is past the refresh window but within the grace period.
67    /// Serve the stale value as a last resort while re-acquisition is attempted.
68    Grace,
69    /// Credential has fully expired. Must re-acquire from the external source.
70    Expired,
71}
72
73/// Policy that controls credential lifecycle transitions.
74///
75/// Implementations map a risk level (0-255) to duration thresholds. Higher risk
76/// levels should use shorter durations.
77///
78/// Example policy for JWT tokens:
79/// - Risk 1 (low): 24h max, 6h refresh window, 1h grace
80/// - Risk 2 (medium): 12h max, 3h refresh window, 30m grace
81/// - Risk 3 (high): 1h max, 15m refresh window, 5m grace
82pub trait LifecyclePolicy: Send + Sync {
83    /// Maximum age in seconds before the credential enters the refresh window.
84    fn max_age_secs(&self, risk_level: u8) -> u64;
85
86    /// Duration of the refresh window in seconds. During this period, the cached
87    /// credential is served while a background refresh is attempted.
88    fn refresh_window_secs(&self, risk_level: u8) -> u64;
89
90    /// Grace period in seconds after the refresh window. The stale credential
91    /// can be served as a last resort.
92    fn grace_period_secs(&self, risk_level: u8) -> u64;
93
94    /// Total session timeout — if the session itself (not just the credential)
95    /// has been active longer than this, force re-acquisition regardless of
96    /// credential age. Returns `None` to disable session timeout.
97    fn session_timeout_secs(&self, _risk_level: u8) -> Option<u64> {
98        None
99    }
100}
101
102/// Classify a cached credential's lifecycle state.
103///
104/// This can be called using only the unencrypted cache header metadata — no
105/// decryption is needed. This avoids unnecessary hardware-backed decrypt
106/// operations when the credential is expired.
107///
108/// # Arguments
109///
110/// - `issued_at` — Unix timestamp when the credential was obtained
111/// - `session_start` — Unix timestamp when the session began (for session timeout)
112/// - `now` — Current Unix timestamp
113/// - `policy` — Lifecycle policy that defines transition durations
114/// - `risk_level` — Risk level for this credential (policy-dependent)
115pub fn classify_credential(
116    issued_at: u64,
117    session_start: u64,
118    now: u64,
119    policy: &dyn LifecyclePolicy,
120    risk_level: u8,
121) -> CredentialState {
122    // Check session timeout first (if configured)
123    if let Some(session_max) = policy.session_timeout_secs(risk_level) {
124        if now.saturating_sub(session_start) >= session_max {
125            return CredentialState::Expired;
126        }
127    }
128
129    let age = now.saturating_sub(issued_at);
130    let max_age = policy.max_age_secs(risk_level);
131    let refresh = policy.refresh_window_secs(risk_level);
132    let grace = policy.grace_period_secs(risk_level);
133
134    if age < max_age {
135        CredentialState::Fresh
136    } else if age < max_age + refresh {
137        CredentialState::RefreshWindow
138    } else if age < max_age + refresh + grace {
139        CredentialState::Grace
140    } else {
141        CredentialState::Expired
142    }
143}
144
145/// Get the current time as Unix seconds.
146pub fn now_secs() -> u64 {
147    system_time_secs(SystemTime::now())
148}
149
150/// Convert a `SystemTime` to Unix seconds.
151pub fn system_time_secs(time: SystemTime) -> u64 {
152    time.duration_since(UNIX_EPOCH)
153        .map(|d| d.as_secs())
154        .unwrap_or(0)
155}
156
157/// Encode a string for safe use as a filename component.
158///
159/// Replaces characters that are problematic in filenames with `~XX` hex encoding.
160/// This is used for cache file paths derived from server names, environments, etc.
161pub fn encode_cache_component(input: &str) -> String {
162    let mut output = String::with_capacity(input.len());
163    for c in input.chars() {
164        match c {
165            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => output.push(c),
166            _ => {
167                for byte in c.to_string().as_bytes() {
168                    output.push('~');
169                    output.push_str(&format!("{byte:02X}"));
170                }
171            }
172        }
173    }
174    output
175}
176
177/// Build a cache file path from components.
178///
179/// Creates a path like `{cache_dir}/{encoded_component1}-{encoded_component2}.cache`
180/// where each component is safely encoded for filesystem use.
181pub fn cache_file_path(cache_dir: &Path, components: &[&str], extension: &str) -> PathBuf {
182    let encoded: Vec<String> = components
183        .iter()
184        .map(|c| encode_cache_component(c))
185        .collect();
186    let filename = format!("{}.{}", encoded.join("-"), extension);
187    cache_dir.join(filename)
188}
189
190/// Validate that a URL uses HTTPS.
191///
192/// Credential source endpoints must use HTTPS to prevent credential interception.
193/// Returns an error with the field name for user-facing diagnostics.
194#[allow(unused_qualifications)]
195pub fn validate_https_url(url: &str, field_name: &str) -> std::result::Result<(), String> {
196    if url.starts_with("https://") {
197        Ok(())
198    } else if url.starts_with("http://") {
199        Err(format!(
200            "{field_name} must use HTTPS (got {url}); cleartext HTTP is not allowed for credential endpoints"
201        ))
202    } else {
203        Err(format!("{field_name} must be an HTTPS URL (got {url})"))
204    }
205}
206
207/// Clear all cache files matching a set of paths.
208///
209/// Best-effort: logs warnings for individual file deletion failures but does
210/// not fail the overall operation.
211pub fn clear_cache_files(paths: &[PathBuf]) {
212    for path in paths {
213        if path.exists() {
214            if let Err(e) = std::fs::remove_file(path) {
215                tracing::warn!("failed to remove cache file {}: {e}", path.display());
216            }
217        }
218    }
219}
220
221/// Run a command with a credential injected as an environment variable.
222///
223/// This is the standard "exec" pattern for Type 4 credential sources:
224/// obtain the credential, then launch the target command with the credential
225/// available in the specified env var.
226pub fn exec_with_credential(
227    env_var: &str,
228    credential: &str,
229    command: &[String],
230) -> std::io::Result<std::process::ExitStatus> {
231    if command.is_empty() {
232        return Err(std::io::Error::new(
233            std::io::ErrorKind::InvalidInput,
234            "no command specified",
235        ));
236    }
237
238    let mut cmd = std::process::Command::new(&command[0]);
239    if command.len() > 1 {
240        cmd.args(&command[1..]);
241    }
242    cmd.env(env_var, credential);
243    cmd.status()
244}
245
246/// Like [`exec_with_credential`], but takes ownership of the credential
247/// string and zeroizes it in memory after the child process exits.
248///
249/// Prefer this over `exec_with_credential` when the caller does not need
250/// the credential value after launching the command.
251pub fn exec_with_credential_owned(
252    env_var: &str,
253    mut credential: String,
254    command: &[String],
255) -> std::io::Result<std::process::ExitStatus> {
256    let status = exec_with_credential(env_var, &credential, command)?;
257    zeroize::Zeroize::zeroize(&mut credential);
258    Ok(status)
259}
260
261#[cfg(test)]
262#[allow(clippy::unwrap_used, clippy::panic)]
263mod tests {
264    use super::*;
265
266    /// Simple test policy with fixed durations.
267    struct TestPolicy {
268        max_age: u64,
269        refresh: u64,
270        grace: u64,
271        session_timeout: Option<u64>,
272    }
273
274    impl TestPolicy {
275        fn new(max_age: u64, refresh: u64, grace: u64) -> Self {
276            Self {
277                max_age,
278                refresh,
279                grace,
280                session_timeout: None,
281            }
282        }
283
284        fn with_session_timeout(mut self, timeout: u64) -> Self {
285            self.session_timeout = Some(timeout);
286            self
287        }
288    }
289
290    impl LifecyclePolicy for TestPolicy {
291        fn max_age_secs(&self, _risk_level: u8) -> u64 {
292            self.max_age
293        }
294        fn refresh_window_secs(&self, _risk_level: u8) -> u64 {
295            self.refresh
296        }
297        fn grace_period_secs(&self, _risk_level: u8) -> u64 {
298            self.grace
299        }
300        fn session_timeout_secs(&self, _risk_level: u8) -> Option<u64> {
301            self.session_timeout
302        }
303    }
304
305    #[test]
306    fn fresh_within_max_age() {
307        let policy = TestPolicy::new(3600, 600, 300);
308        let now = 10_000;
309        let issued = now - 1800; // 30 min ago
310        assert_eq!(
311            classify_credential(issued, issued, now, &policy, 1),
312            CredentialState::Fresh
313        );
314    }
315
316    #[test]
317    fn refresh_window_after_max_age() {
318        let policy = TestPolicy::new(3600, 600, 300);
319        let now = 10_000;
320        let issued = now - 3900; // 65 min ago (past 60 min max, within 10 min refresh)
321        assert_eq!(
322            classify_credential(issued, issued, now, &policy, 1),
323            CredentialState::RefreshWindow
324        );
325    }
326
327    #[test]
328    fn grace_after_refresh_window() {
329        let policy = TestPolicy::new(3600, 600, 300);
330        let now = 10_000;
331        let issued = now - 4300; // past max + refresh, within grace
332        assert_eq!(
333            classify_credential(issued, issued, now, &policy, 1),
334            CredentialState::Grace
335        );
336    }
337
338    #[test]
339    fn expired_after_grace() {
340        let policy = TestPolicy::new(3600, 600, 300);
341        let now = 10_000;
342        let issued = now - 5000; // past everything
343        assert_eq!(
344            classify_credential(issued, issued, now, &policy, 1),
345            CredentialState::Expired
346        );
347    }
348
349    #[test]
350    fn session_timeout_overrides_fresh() {
351        let policy = TestPolicy::new(3600, 600, 300).with_session_timeout(7200);
352        let now = 10_000;
353        let session_start = now - 8000; // session started 8000s ago (> 7200 timeout)
354        let issued = now - 100; // credential itself is fresh
355        assert_eq!(
356            classify_credential(issued, session_start, now, &policy, 1),
357            CredentialState::Expired
358        );
359    }
360
361    #[test]
362    fn no_session_timeout_by_default() {
363        let policy = TestPolicy::new(3600, 600, 300);
364        let now = 10_000;
365        let session_start = 0; // session started at epoch (very old)
366        let issued = now - 100; // credential is fresh
367        assert_eq!(
368            classify_credential(issued, session_start, now, &policy, 1),
369            CredentialState::Fresh
370        );
371    }
372
373    #[test]
374    fn boundary_exactly_at_max_age() {
375        let policy = TestPolicy::new(3600, 600, 300);
376        let now = 10_000;
377        let issued = now - 3600; // exactly at max age
378        assert_eq!(
379            classify_credential(issued, issued, now, &policy, 1),
380            CredentialState::RefreshWindow
381        );
382    }
383
384    #[test]
385    fn zero_age_is_fresh() {
386        let policy = TestPolicy::new(3600, 600, 300);
387        let now = 10_000;
388        assert_eq!(
389            classify_credential(now, now, now, &policy, 1),
390            CredentialState::Fresh
391        );
392    }
393
394    #[test]
395    fn encode_cache_component_simple() {
396        assert_eq!(encode_cache_component("my-server"), "my-server");
397        assert_eq!(
398            encode_cache_component("prod.example.com"),
399            "prod.example.com"
400        );
401    }
402
403    #[test]
404    fn encode_cache_component_special_chars() {
405        assert_eq!(encode_cache_component("foo/bar"), "foo~2Fbar");
406        assert_eq!(encode_cache_component("a:b"), "a~3Ab");
407        assert_eq!(encode_cache_component("hello world"), "hello~20world");
408    }
409
410    #[test]
411    fn encode_cache_component_empty() {
412        assert_eq!(encode_cache_component(""), "");
413    }
414
415    #[test]
416    fn cache_file_path_single_component() {
417        let dir = Path::new("/tmp/cache");
418        let path = cache_file_path(dir, &["myserver"], "bin");
419        assert_eq!(path, PathBuf::from("/tmp/cache/myserver.bin"));
420    }
421
422    #[test]
423    fn cache_file_path_multiple_components() {
424        let dir = Path::new("/tmp/cache");
425        let path = cache_file_path(dir, &["server", "prod", "default"], "bin");
426        assert_eq!(path, PathBuf::from("/tmp/cache/server-prod-default.bin"));
427    }
428
429    #[test]
430    fn cache_file_path_encodes_special_chars() {
431        let dir = Path::new("/tmp/cache");
432        let path = cache_file_path(dir, &["my/server", "env:prod"], "cache");
433        assert_eq!(
434            path,
435            PathBuf::from("/tmp/cache/my~2Fserver-env~3Aprod.cache")
436        );
437    }
438
439    #[test]
440    fn validate_https_url_accepts_https() {
441        assert!(validate_https_url("https://example.com/auth", "oauth_url").is_ok());
442    }
443
444    #[test]
445    fn validate_https_url_rejects_http() {
446        let err = validate_https_url("http://example.com/auth", "oauth_url").unwrap_err();
447        assert!(err.contains("HTTPS"));
448        assert!(err.contains("oauth_url"));
449    }
450
451    #[test]
452    fn validate_https_url_rejects_other() {
453        let err = validate_https_url("ftp://example.com", "token_url").unwrap_err();
454        assert!(err.contains("HTTPS"));
455    }
456
457    #[test]
458    fn exec_with_credential_rejects_empty_command() {
459        let result = exec_with_credential("TOKEN", "secret", &[]);
460        assert!(result.is_err());
461    }
462
463    #[test]
464    fn now_secs_returns_nonzero() {
465        assert!(now_secs() > 1_000_000_000); // after 2001
466    }
467
468    #[test]
469    fn classify_issued_in_future_is_fresh() {
470        // issued_at > now: saturating_sub gives age 0 → Fresh
471        let policy = TestPolicy::new(3600, 600, 300);
472        let now = 10_000;
473        let issued = now + 100; // clock skew: issued in the future
474        assert_eq!(
475            classify_credential(issued, issued, now, &policy, 1),
476            CredentialState::Fresh
477        );
478    }
479
480    #[test]
481    fn classify_exactly_one_before_refresh_window() {
482        let policy = TestPolicy::new(3600, 600, 300);
483        let now = 10_000;
484        let issued = now - 3599; // age 3599 < max_age 3600 → still Fresh
485        assert_eq!(
486            classify_credential(issued, issued, now, &policy, 1),
487            CredentialState::Fresh
488        );
489    }
490
491    #[test]
492    fn classify_exactly_at_refresh_window_end() {
493        let policy = TestPolicy::new(3600, 600, 300);
494        let now = 10_000;
495        // age == max_age + refresh → Grace
496        let issued = now - (3600 + 600);
497        assert_eq!(
498            classify_credential(issued, issued, now, &policy, 1),
499            CredentialState::Grace
500        );
501    }
502
503    #[test]
504    fn classify_one_before_refresh_window_end() {
505        let policy = TestPolicy::new(3600, 600, 300);
506        let now = 10_000;
507        // age == max_age + refresh - 1 → still RefreshWindow
508        let issued = now - (3600 + 600 - 1);
509        assert_eq!(
510            classify_credential(issued, issued, now, &policy, 1),
511            CredentialState::RefreshWindow
512        );
513    }
514
515    #[test]
516    fn classify_exactly_at_grace_end() {
517        let policy = TestPolicy::new(3600, 600, 300);
518        let now = 10_000;
519        // age == max_age + refresh + grace → Expired
520        let issued = now - (3600 + 600 + 300);
521        assert_eq!(
522            classify_credential(issued, issued, now, &policy, 1),
523            CredentialState::Expired
524        );
525    }
526
527    #[test]
528    fn classify_one_before_grace_end() {
529        let policy = TestPolicy::new(3600, 600, 300);
530        let now = 10_000;
531        // age == max_age + refresh + grace - 1 → still Grace
532        let issued = now - (3600 + 600 + 300 - 1);
533        assert_eq!(
534            classify_credential(issued, issued, now, &policy, 1),
535            CredentialState::Grace
536        );
537    }
538
539    #[test]
540    fn session_timeout_at_exact_boundary_is_expired() {
541        // now - session_start == session_max → Expired (>=)
542        let policy = TestPolicy::new(3600, 600, 300).with_session_timeout(1000);
543        let now = 10_000;
544        let session_start = now - 1000;
545        let issued = now - 10; // credential itself is fresh
546        assert_eq!(
547            classify_credential(issued, session_start, now, &policy, 1),
548            CredentialState::Expired
549        );
550    }
551
552    #[test]
553    fn session_timeout_one_before_boundary_is_not_expired() {
554        let policy = TestPolicy::new(3600, 600, 300).with_session_timeout(1000);
555        let now = 10_000;
556        let session_start = now - 999; // one second before timeout
557        let issued = now - 10;
558        assert_eq!(
559            classify_credential(issued, session_start, now, &policy, 1),
560            CredentialState::Fresh
561        );
562    }
563
564    #[test]
565    fn encode_cache_component_tilde_is_encoded() {
566        // Tilde is not in the safe charset, so it must be encoded
567        let encoded = encode_cache_component("a~b");
568        assert!(!encoded.contains('~') || encoded.contains("~7E") || encoded.starts_with("a~7E"));
569        // After encoding, it should decode to the original if we unescape ~XX
570        assert!(encoded.contains("7E") || !encoded.contains('~'));
571    }
572
573    #[test]
574    fn encode_cache_component_unicode_multi_byte() {
575        // A multi-byte UTF-8 character (é = U+00E9 = 0xC3 0xA9 in UTF-8)
576        let encoded = encode_cache_component("café");
577        // The ASCII part is unchanged, the non-ASCII is hex-encoded
578        assert!(encoded.starts_with("caf"));
579        assert!(!encoded.contains('é'));
580        assert!(encoded.contains('~')); // should be percent-style encoded with ~
581    }
582
583    #[test]
584    fn validate_https_url_empty_string_is_error() {
585        let err = validate_https_url("", "endpoint").unwrap_err();
586        assert!(err.contains("HTTPS") || err.contains("endpoint"));
587    }
588
589    #[test]
590    fn validate_https_url_no_scheme() {
591        let err = validate_https_url("example.com/api", "url").unwrap_err();
592        assert!(err.contains("HTTPS"));
593    }
594
595    #[test]
596    fn validate_https_url_ftp_scheme_is_error() {
597        let err = validate_https_url("ftp://example.com/auth", "ftp_url").unwrap_err();
598        assert!(err.contains("HTTPS"));
599        assert!(err.contains("ftp_url"));
600    }
601
602    #[test]
603    fn cache_file_path_empty_components_list() {
604        let dir = Path::new("/tmp/cache");
605        let path = cache_file_path(dir, &[], "bin");
606        // Empty join should produce ".bin"
607        assert_eq!(path, PathBuf::from("/tmp/cache/.bin"));
608    }
609
610    #[test]
611    fn system_time_secs_before_epoch_returns_zero() {
612        // SystemTime before UNIX_EPOCH should not panic and returns 0
613        let before_epoch = SystemTime::UNIX_EPOCH
614            .checked_sub(std::time::Duration::from_secs(1))
615            .unwrap();
616        assert_eq!(system_time_secs(before_epoch), 0);
617    }
618}