Skip to main content

aube_util/http/
ticket_cache.rs

1//! Cross-invocation TLS session ticket cache.
2//!
3//! rustls 0.23+ exposes `ClientSessionStore` for caching session
4//! tickets in-memory; the default impl is per-process and dies with
5//! the CLI. Persisting tickets on disk lets the second `aube install`
6//! invocation skip the full TLS handshake and resume against the
7//! cached session, saving 1 RTT (~50-150 ms per origin) on cold
8//! invocations after the first one. No PM in the npm-CM-space ships
9//! this — npm/pnpm/yarn/bun/vlt all start with an empty session
10//! store every invocation.
11//!
12//! Format: serde-json blob at `$XDG_CACHE_HOME/aube/tls-tickets.json`
13//! containing per-host entries `(server_name, port) -> TicketEntry`.
14//! Each entry holds the rustls ticket bytes plus the SPKI fingerprint
15//! observed at ticket-acquire time. The rustls wiring layer compares
16//! the live cert's SPKI fingerprint against `spki_fp` and calls
17//! `invalidate(host, port)` on mismatch so a rotated cert never
18//! silently downgrades to a stale resumption. Entries past `MAX_AGE`
19//! (24 h) are pruned at load.
20//!
21//! On Unix the on-disk file is created with mode 0600 so ticket bytes
22//! are not world-readable on multi-user hosts.
23//!
24//! `AUBE_DISABLE_TLS_TICKET_CACHE=1` skips load + save; rustls falls
25//! back to its per-process in-memory store.
26//!
27//! The rustls `ClientSessionStore` trait wiring lives at the
28//! `aube-registry` integration site so `aube-util` keeps zero rustls
29//! dependency. This module ships the on-disk format, the in-memory
30//! map, and the load/save/expire/invalidate APIs the wiring layer
31//! reads.
32
33use serde::{Deserialize, Serialize};
34use std::collections::HashMap;
35use std::path::{Path, PathBuf};
36use std::sync::{Mutex, RwLock};
37use std::time::{Duration, SystemTime, UNIX_EPOCH};
38
39/// Tickets older than this are pruned at load. Matches the typical
40/// session-ticket-lifetime hint Cloudflare/Fastly send (~24 h).
41pub const MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
42
43const FORMAT_MAGIC: &str = "aube-tls-tickets/v1";
44
45/// Returns true when the on-disk ticket cache is disabled.
46#[inline]
47pub fn is_disabled() -> bool {
48    crate::env::embedder_env("DISABLE_TLS_TICKET_CACHE").is_some()
49}
50
51/// One serialized ticket entry. `ticket` is opaque to this module —
52/// the rustls `ClientSessionStore` wiring layer encodes/decodes it.
53/// `spki_fp` binds the ticket to the cert observed when it was
54/// acquired so a rotated cert force-invalidates the resumption.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct TicketEntry {
57    /// Opaque rustls ticket bytes.
58    pub ticket: Vec<u8>,
59    /// SHA-256 over the server's SubjectPublicKeyInfo at ticket-acquire time.
60    pub spki_fp: [u8; 32],
61    /// Wall-clock (UNIX seconds) when the ticket was stored. Used for `MAX_AGE` pruning.
62    pub stored_at_unix_secs: u64,
63}
64
65/// Storage key — `(host, port)`. Lowercased host, normalized port.
66#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
67pub struct HostPort {
68    pub host: String,
69    pub port: u16,
70}
71
72impl HostPort {
73    pub fn new(host: impl Into<String>, port: u16) -> Self {
74        Self {
75            host: host.into().to_ascii_lowercase(),
76            port,
77        }
78    }
79}
80
81#[derive(Debug, Default, Serialize, Deserialize)]
82struct OnDisk {
83    /// Format magic; bumping invalidates the whole file.
84    magic: String,
85    /// Per-host tickets serialized as a flat list because serde_json
86    /// refuses non-string map keys (`HostPort` is a struct). Vec lets
87    /// rustls' multi-ticket convention through (most servers issue 2
88    /// NewSessionTicket frames per handshake).
89    entries: Vec<(HostPort, Vec<TicketEntry>)>,
90}
91
92/// In-memory ticket cache. Backed by an on-disk JSON blob; load and
93/// save are explicit so the rustls wiring layer can drive them at
94/// install start / install end.
95#[derive(Debug)]
96pub struct TicketCache {
97    path: PathBuf,
98    inner: RwLock<HashMap<HostPort, Vec<TicketEntry>>>,
99    /// Serializes file reads/writes against concurrent open() calls
100    /// in the same process; cross-process is best-effort (last-writer
101    /// wins, idempotent payload).
102    file_lock: Mutex<()>,
103}
104
105impl TicketCache {
106    /// Open the cache at the canonical path under
107    /// `XDG_CACHE_HOME/aube/tls-tickets.json`. Caller responsible for
108    /// `XDG_CACHE_HOME` resolution; pass an explicit path here.
109    pub fn open(path: impl Into<PathBuf>) -> Self {
110        let path = path.into();
111        let inner = if is_disabled() {
112            HashMap::new()
113        } else {
114            load_from_disk(&path).unwrap_or_default()
115        };
116        Self {
117            path,
118            inner: RwLock::new(inner),
119            file_lock: Mutex::new(()),
120        }
121    }
122
123    /// Look up cached tickets for `(host, port)`. Stale entries beyond
124    /// `MAX_AGE` are filtered transparently; callers receive only
125    /// fresh tickets.
126    pub fn get(&self, host: &str, port: u16) -> Vec<TicketEntry> {
127        if is_disabled() {
128            return Vec::new();
129        }
130        let key = HostPort::new(host, port);
131        let now = unix_now();
132        let inner = self.inner.read().unwrap_or_else(|e| e.into_inner());
133        inner
134            .get(&key)
135            .map(|tickets| {
136                tickets
137                    .iter()
138                    .filter(|t| now.saturating_sub(t.stored_at_unix_secs) < MAX_AGE.as_secs())
139                    .cloned()
140                    .collect()
141            })
142            .unwrap_or_default()
143    }
144
145    /// Store a fresh ticket for `(host, port)`. Multiple tickets per
146    /// origin are kept (rustls servers typically issue 2 per
147    /// handshake); `prune_max_per_host` caps the queue.
148    pub fn put(&self, host: &str, port: u16, entry: TicketEntry) {
149        if is_disabled() {
150            return;
151        }
152        const MAX_PER_HOST: usize = 4;
153        let key = HostPort::new(host, port);
154        let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner());
155        let bucket = inner.entry(key).or_default();
156        bucket.push(entry);
157        if bucket.len() > MAX_PER_HOST {
158            let drop = bucket.len() - MAX_PER_HOST;
159            bucket.drain(..drop);
160        }
161    }
162
163    /// Evict every ticket for `(host, port)`. Called when a TLS
164    /// handshake observes a cert whose SPKI fingerprint does not
165    /// match the cached entry — the cert rotated, so the ticket is
166    /// stale.
167    pub fn invalidate(&self, host: &str, port: u16) {
168        let key = HostPort::new(host, port);
169        let mut inner = self.inner.write().unwrap_or_else(|e| e.into_inner());
170        inner.remove(&key);
171    }
172
173    /// Persist the in-memory cache to disk. Atomic via
174    /// `aube_util::fs_atomic::atomic_write`.
175    pub fn save(&self) -> std::io::Result<()> {
176        if is_disabled() {
177            return Ok(());
178        }
179        let _guard = self.file_lock.lock().unwrap_or_else(|e| e.into_inner());
180        let inner = self.inner.read().unwrap_or_else(|e| e.into_inner());
181        let payload = OnDisk {
182            magic: FORMAT_MAGIC.to_string(),
183            entries: inner.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
184        };
185        let bytes = serde_json::to_vec(&payload).map_err(std::io::Error::other)?;
186        crate::fs_atomic::atomic_write(&self.path, &bytes)?;
187        // Tighten POSIX perms after the atomic rename so ticket bytes
188        // are not world-readable. Windows inherits the parent ACL,
189        // which already restricts %LOCALAPPDATA% to the user; nothing
190        // to do there.
191        #[cfg(unix)]
192        {
193            use std::os::unix::fs::PermissionsExt as _;
194            let _ = std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600));
195        }
196        Ok(())
197    }
198
199    /// Total ticket count across all hosts (for diagnostics).
200    pub fn len(&self) -> usize {
201        let inner = self.inner.read().unwrap_or_else(|e| e.into_inner());
202        inner.values().map(|v| v.len()).sum()
203    }
204
205    pub fn is_empty(&self) -> bool {
206        self.len() == 0
207    }
208}
209
210fn load_from_disk(path: &Path) -> Option<HashMap<HostPort, Vec<TicketEntry>>> {
211    let bytes = std::fs::read(path).ok()?;
212    let payload: OnDisk = serde_json::from_slice(&bytes).ok()?;
213    if payload.magic != FORMAT_MAGIC {
214        return None;
215    }
216    let now = unix_now();
217    let map: HashMap<HostPort, Vec<TicketEntry>> = payload
218        .entries
219        .into_iter()
220        .filter_map(|(k, v)| {
221            let fresh: Vec<TicketEntry> = v
222                .into_iter()
223                .filter(|t| now.saturating_sub(t.stored_at_unix_secs) < MAX_AGE.as_secs())
224                .collect();
225            if fresh.is_empty() {
226                None
227            } else {
228                Some((k, fresh))
229            }
230        })
231        .collect();
232    Some(map)
233}
234
235fn unix_now() -> u64 {
236    SystemTime::now()
237        .duration_since(UNIX_EPOCH)
238        .map(|d| d.as_secs())
239        .unwrap_or(0)
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use tempfile::tempdir;
246
247    fn entry(label: u8) -> TicketEntry {
248        TicketEntry {
249            ticket: vec![label, label + 1, label + 2],
250            spki_fp: [label; 32],
251            stored_at_unix_secs: unix_now(),
252        }
253    }
254
255    #[test]
256    fn roundtrip_persists_across_open() {
257        let dir = tempdir().unwrap();
258        let path = dir.path().join("tickets.json");
259        {
260            let cache = TicketCache::open(&path);
261            cache.put("registry.npmjs.org", 443, entry(1));
262            cache.save().unwrap();
263        }
264        let reopened = TicketCache::open(&path);
265        let tickets = reopened.get("registry.npmjs.org", 443);
266        assert_eq!(tickets.len(), 1);
267        assert_eq!(tickets[0].ticket, vec![1, 2, 3]);
268    }
269
270    #[test]
271    fn host_port_lowercases() {
272        let a = HostPort::new("Registry.NPMJS.ORG", 443);
273        let b = HostPort::new("registry.npmjs.org", 443);
274        assert_eq!(a, b);
275    }
276
277    #[test]
278    fn invalidate_removes_all_for_host() {
279        let dir = tempdir().unwrap();
280        let cache = TicketCache::open(dir.path().join("tickets.json"));
281        cache.put("a.example", 443, entry(1));
282        cache.put("a.example", 443, entry(2));
283        assert_eq!(cache.len(), 2);
284        cache.invalidate("a.example", 443);
285        assert!(cache.is_empty());
286    }
287
288    #[test]
289    fn max_per_host_evicts_oldest() {
290        let dir = tempdir().unwrap();
291        let cache = TicketCache::open(dir.path().join("tickets.json"));
292        for i in 0..6u8 {
293            cache.put("a.example", 443, entry(i));
294        }
295        let kept = cache.get("a.example", 443);
296        assert_eq!(kept.len(), 4, "MAX_PER_HOST = 4");
297        // Oldest two (label 0, 1) should be gone.
298        assert!(kept.iter().all(|t| t.ticket[0] >= 2));
299    }
300
301    #[test]
302    fn stale_entries_filtered_at_load() {
303        let dir = tempdir().unwrap();
304        let path = dir.path().join("tickets.json");
305        {
306            let cache = TicketCache::open(&path);
307            let mut stale = entry(9);
308            stale.stored_at_unix_secs = 0;
309            cache.put("a.example", 443, stale);
310            cache.save().unwrap();
311        }
312        let reopened = TicketCache::open(&path);
313        assert!(reopened.get("a.example", 443).is_empty());
314    }
315
316    /// Panic-safe cleanup so a failed assertion inside the killswitch
317    /// test doesn't leave `AUBE_DISABLE_TLS_TICKET_CACHE=1` set —
318    /// `RUST_TEST_THREADS=1` serializes the suite but doesn't reset
319    /// process env between tests, so a leaked killswitch would still
320    /// poison subsequent tests in the same binary.
321    struct EnvVarGuard {
322        key: &'static str,
323    }
324    impl Drop for EnvVarGuard {
325        fn drop(&mut self) {
326            // SAFETY: tests run serially via RUST_TEST_THREADS=1; no
327            // other thread is mid-setenv when this guard drops.
328            unsafe { std::env::remove_var(self.key) };
329        }
330    }
331
332    #[test]
333    fn killswitch_short_circuits() {
334        // SAFETY: tests run serially via RUST_TEST_THREADS=1; no
335        // other thread is reading the env while we mutate it.
336        unsafe { std::env::set_var("AUBE_DISABLE_TLS_TICKET_CACHE", "1") };
337        let _cleanup = EnvVarGuard {
338            key: "AUBE_DISABLE_TLS_TICKET_CACHE",
339        };
340        let dir = tempdir().unwrap();
341        let cache = TicketCache::open(dir.path().join("tickets.json"));
342        cache.put("a.example", 443, entry(1));
343        assert!(cache.get("a.example", 443).is_empty());
344    }
345
346    #[test]
347    fn missing_file_loads_empty() {
348        let dir = tempdir().unwrap();
349        let cache = TicketCache::open(dir.path().join("nonexistent.json"));
350        assert!(cache.is_empty());
351    }
352
353    #[test]
354    fn corrupt_magic_loads_empty() {
355        let dir = tempdir().unwrap();
356        let path = dir.path().join("tickets.json");
357        std::fs::write(&path, br#"{"magic":"wrong","entries":[]}"#).unwrap();
358        let cache = TicketCache::open(&path);
359        assert!(cache.is_empty());
360    }
361}