Skip to main content

specter/transport/
session.rs

1//! TLS session resumption and caching.
2//!
3//! Implements session ticket caching for TLS 1.2 and TLS 1.3 session resumption.
4//! Browsers cache session tickets to enable 0-RTT (early data) and faster handshakes.
5
6use std::collections::HashMap;
7use std::sync::{Arc, Mutex};
8use std::time::{Duration, Instant};
9
10/// TLS session ticket cache.
11///
12/// Stores session tickets per host to enable session resumption.
13/// Session tickets are provided by the server during TLS handshake
14/// and can be reused for subsequent connections.
15#[derive(Debug, Clone)]
16pub struct SessionCache {
17    inner: Arc<Mutex<SessionCacheInner>>,
18}
19
20#[derive(Debug)]
21struct SessionCacheInner {
22    /// Session tickets by host:port
23    tickets: HashMap<String, SessionTicket>,
24    /// Maximum age for session tickets
25    max_age: Duration,
26}
27
28#[derive(Debug, Clone)]
29struct SessionTicket {
30    /// Session ticket data (opaque blob from server)
31    data: Vec<u8>,
32    /// When this ticket was received
33    received_at: Instant,
34    /// Maximum age for this ticket (from server)
35    max_age: Duration,
36}
37
38impl SessionCache {
39    /// Create a new session cache with default max age (24 hours).
40    pub fn new() -> Self {
41        Self {
42            inner: Arc::new(Mutex::new(SessionCacheInner {
43                tickets: HashMap::new(),
44                max_age: Duration::from_secs(86400), // 24 hours
45            })),
46        }
47    }
48
49    /// Create a session cache with custom max age.
50    pub fn with_max_age(max_age: Duration) -> Self {
51        Self {
52            inner: Arc::new(Mutex::new(SessionCacheInner {
53                tickets: HashMap::new(),
54                max_age,
55            })),
56        }
57    }
58
59    /// Store a session ticket for a host.
60    pub fn store_ticket(&self, host: &str, ticket_data: Vec<u8>, max_age: Option<Duration>) {
61        let mut inner = self.inner.lock().expect("Session cache mutex poisoned");
62        let max_age = max_age.unwrap_or(inner.max_age);
63
64        inner.tickets.insert(
65            host.to_string(),
66            SessionTicket {
67                data: ticket_data,
68                received_at: Instant::now(),
69                max_age,
70            },
71        );
72    }
73
74    /// Get a session ticket for a host (if valid and not expired).
75    pub fn get_ticket(&self, host: &str) -> Option<Vec<u8>> {
76        let mut inner = self.inner.lock().expect("Session cache mutex poisoned");
77
78        if let Some(ticket) = inner.tickets.get(host) {
79            // Check if ticket is still valid
80            if ticket.received_at.elapsed() < ticket.max_age {
81                return Some(ticket.data.clone());
82            } else {
83                // Expired, remove it
84                inner.tickets.remove(host);
85            }
86        }
87
88        None
89    }
90
91    /// Clear all cached tickets.
92    pub fn clear(&self) {
93        let mut inner = self.inner.lock().expect("Session cache mutex poisoned");
94        inner.tickets.clear();
95    }
96
97    /// Remove expired tickets.
98    pub fn cleanup_expired(&self) {
99        let mut inner = self.inner.lock().expect("Session cache mutex poisoned");
100        inner
101            .tickets
102            .retain(|_, ticket| ticket.received_at.elapsed() < ticket.max_age);
103    }
104
105    /// Get the number of cached tickets.
106    pub fn len(&self) -> usize {
107        let inner = self.inner.lock().expect("Session cache mutex poisoned");
108        inner.tickets.len()
109    }
110
111    /// Check if cache is empty.
112    pub fn is_empty(&self) -> bool {
113        self.len() == 0
114    }
115}
116
117impl Default for SessionCache {
118    fn default() -> Self {
119        Self::new()
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn test_session_cache_store_and_retrieve() {
129        let cache = SessionCache::new();
130        cache.store_ticket("example.com", vec![1, 2, 3], None);
131
132        assert_eq!(cache.get_ticket("example.com"), Some(vec![1, 2, 3]));
133        assert_eq!(cache.get_ticket("other.com"), None);
134    }
135
136    #[test]
137    fn test_session_cache_expiration() {
138        let cache = SessionCache::with_max_age(Duration::from_secs(1));
139        cache.store_ticket("example.com", vec![1, 2, 3], None);
140
141        assert_eq!(cache.get_ticket("example.com"), Some(vec![1, 2, 3]));
142
143        // Wait for expiration
144        std::thread::sleep(Duration::from_secs(2));
145
146        // Ticket should be expired
147        assert_eq!(cache.get_ticket("example.com"), None);
148        assert_eq!(cache.len(), 0);
149    }
150
151    #[test]
152    fn test_session_cache_clear() {
153        let cache = SessionCache::new();
154        cache.store_ticket("example.com", vec![1, 2, 3], None);
155        cache.store_ticket("other.com", vec![4, 5, 6], None);
156
157        assert_eq!(cache.len(), 2);
158        cache.clear();
159        assert_eq!(cache.len(), 0);
160    }
161}