Skip to main content

orbit_rustls/session/
mod.rs

1use std::sync::Arc;
2use std::sync::atomic::{AtomicU64, Ordering};
3use std::time::Duration;
4use std::{fmt, io};
5
6use orbit_core::Fleet;
7
8#[cfg(feature = "rustls_0_24")]
9use self::store::ClientSessionPrimitive;
10use self::store::{DOMAIN_MAX, ServerSessionPrimitive};
11
12#[cfg(feature = "rustls_0_24")]
13mod client_v24;
14mod store;
15#[cfg(feature = "rustls_0_23")]
16mod v23;
17#[cfg(feature = "rustls_0_24")]
18mod v24;
19
20/// Default retention for opaque rustls server-session values.
21///
22/// This is no longer than rustls 0.23's stateful TLS 1.3 ticket lifetime.
23pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
24pub const MAX_SESSION_TTL: Duration = DEFAULT_SESSION_TTL;
25pub const SERVER_SESSION_STATE_KIND: u8 = store::SERVER_SESSION_STATE_KIND;
26#[cfg(feature = "rustls_0_24")]
27pub const CLIENT_SESSION_STATE_KIND: u8 = store::CLIENT_SESSION_STATE_KIND;
28
29/// Stable isolation boundary for one equivalent set of rustls server configs.
30///
31/// Transport family, authentication policy, and any compatibility epoch must
32/// be reflected in this value. Public TLS and mTLS must not share a domain.
33#[derive(Clone, PartialEq, Eq, Hash)]
34pub struct SessionDomain(Arc<[u8]>);
35
36impl SessionDomain {
37    pub fn new(value: impl AsRef<[u8]>) -> io::Result<Self> {
38        let value = value.as_ref();
39        if value.is_empty() {
40            return Err(io::Error::new(
41                io::ErrorKind::InvalidInput,
42                "rustls session domain must not be empty",
43            ));
44        }
45        if value.len() > DOMAIN_MAX {
46            return Err(io::Error::new(
47                io::ErrorKind::InvalidInput,
48                format!(
49                    "rustls session domain is too long: {} bytes (maximum {DOMAIN_MAX})",
50                    value.len()
51                ),
52            ));
53        }
54        Ok(Self(Arc::from(value)))
55    }
56
57    fn as_bytes(&self) -> &[u8] {
58        &self.0
59    }
60}
61
62impl fmt::Debug for SessionDomain {
63    fn fmt(
64        &self,
65        formatter: &mut fmt::Formatter<'_>,
66    ) -> fmt::Result {
67        formatter
68            .debug_tuple("SessionDomain")
69            .field(&String::from_utf8_lossy(&self.0))
70            .finish()
71    }
72}
73
74/// One fleet-wide session table from which isolated rustls views are made.
75#[derive(Clone)]
76pub struct FleetServerSessions {
77    primitive: Arc<ServerSessionPrimitive>,
78    ttl_ms: Arc<AtomicU64>,
79}
80
81impl FleetServerSessions {
82    pub fn open(fleet: Arc<Fleet>) -> io::Result<Self> {
83        Self::with_ttl(fleet, DEFAULT_SESSION_TTL)
84    }
85
86    pub fn with_ttl(
87        fleet: Arc<Fleet>,
88        ttl: Duration,
89    ) -> io::Result<Self> {
90        let ttl_ms = validate_ttl(ttl)?;
91        Ok(Self {
92            primitive: Arc::new(ServerSessionPrimitive::open(&fleet)?),
93            ttl_ms: Arc::new(AtomicU64::new(ttl_ms)),
94        })
95    }
96
97    pub fn set_ttl(
98        &self,
99        ttl: Duration,
100    ) -> io::Result<()> {
101        self.ttl_ms.store(validate_ttl(ttl)?, Ordering::Release);
102        Ok(())
103    }
104
105    pub fn ttl(&self) -> Duration {
106        Duration::from_millis(self.ttl_ms.load(Ordering::Acquire))
107    }
108
109    /// Create a rustls storage view isolated by `domain`.
110    pub fn storage(
111        &self,
112        domain: SessionDomain,
113    ) -> Arc<OrbitSessionStorage> {
114        Arc::new(OrbitSessionStorage {
115            primitive: self.primitive.clone(),
116            domain,
117            ttl_ms: self.ttl_ms.clone(),
118        })
119    }
120
121    /// Clear every domain. Call only during owner-controlled quiescent boot.
122    pub fn reset(&self) -> io::Result<()> {
123        self.primitive.reset()
124    }
125
126    /// Remove the SHM name and companion lock file.
127    ///
128    /// Existing mappings remain valid. This is intended for fleet teardown
129    /// and tests, not runtime cache invalidation.
130    pub fn unlink(&self) -> io::Result<()> {
131        self.primitive.unlink()
132    }
133}
134
135impl fmt::Debug for FleetServerSessions {
136    fn fmt(
137        &self,
138        formatter: &mut fmt::Formatter<'_>,
139    ) -> fmt::Result {
140        formatter
141            .debug_struct("FleetServerSessions")
142            .field("ttl", &self.ttl())
143            .finish_non_exhaustive()
144    }
145}
146
147pub struct OrbitSessionStorage {
148    primitive: Arc<ServerSessionPrimitive>,
149    domain: SessionDomain,
150    ttl_ms: Arc<AtomicU64>,
151}
152
153/// Explicit server-side name for [`OrbitSessionStorage`].
154///
155/// The original name remains available for API compatibility.
156pub type OrbitServerSessionStorage = OrbitSessionStorage;
157
158impl fmt::Debug for OrbitSessionStorage {
159    fn fmt(
160        &self,
161        formatter: &mut fmt::Formatter<'_>,
162    ) -> fmt::Result {
163        formatter
164            .debug_struct("OrbitSessionStorage")
165            .field("domain", &self.domain)
166            .field(
167                "ttl",
168                &Duration::from_millis(self.ttl_ms.load(Ordering::Acquire)),
169            )
170            .finish_non_exhaustive()
171    }
172}
173
174impl OrbitSessionStorage {
175    fn put_bytes(
176        &self,
177        key: &[u8],
178        value: &[u8],
179    ) -> bool {
180        let ttl = Duration::from_millis(self.ttl_ms.load(Ordering::Acquire));
181        match self.primitive.put(self.domain.as_bytes(), key, value, ttl) {
182            Ok(cached) => {
183                tracing::trace!(
184                    target: "orbit_rustls::session_cache",
185                    operation = "put",
186                    domain = ?self.domain,
187                    key_len = key.len(),
188                    value_len = value.len(),
189                    ?ttl,
190                    cached,
191                    "rustls session cache write"
192                );
193                cached
194            }
195            Err(error) => {
196                tracing::trace!(
197                    target: "orbit_rustls::session_cache",
198                    operation = "put",
199                    domain = ?self.domain,
200                    key_len = key.len(),
201                    value_len = value.len(),
202                    ?ttl,
203                    %error,
204                    "rustls session cache write failed"
205                );
206                false
207            }
208        }
209    }
210
211    fn get_bytes(
212        &self,
213        key: &[u8],
214    ) -> Option<Vec<u8>> {
215        match self.primitive.get(self.domain.as_bytes(), key) {
216            Ok(value) => {
217                tracing::trace!(
218                    target: "orbit_rustls::session_cache",
219                    operation = "get",
220                    domain = ?self.domain,
221                    key_len = key.len(),
222                    value_len = value.as_ref().map_or(0, Vec::len),
223                    hit = value.is_some(),
224                    "rustls session cache read"
225                );
226                value
227            }
228            Err(error) => {
229                tracing::trace!(
230                    target: "orbit_rustls::session_cache",
231                    operation = "get",
232                    domain = ?self.domain,
233                    key_len = key.len(),
234                    %error,
235                    "rustls session cache read failed"
236                );
237                None
238            }
239        }
240    }
241
242    fn take_bytes(
243        &self,
244        key: &[u8],
245    ) -> Option<Vec<u8>> {
246        match self.primitive.take(self.domain.as_bytes(), key) {
247            Ok(value) => {
248                tracing::trace!(
249                    target: "orbit_rustls::session_cache",
250                    operation = "take",
251                    domain = ?self.domain,
252                    key_len = key.len(),
253                    value_len = value.as_ref().map_or(0, Vec::len),
254                    hit = value.is_some(),
255                    "rustls session cache read and consume"
256                );
257                value
258            }
259            Err(error) => {
260                tracing::trace!(
261                    target: "orbit_rustls::session_cache",
262                    operation = "take",
263                    domain = ?self.domain,
264                    key_len = key.len(),
265                    %error,
266                    "rustls session cache read and consume failed"
267                );
268                None
269            }
270        }
271    }
272}
273
274/// One fleet-wide rustls 0.24 client-session table.
275///
276/// rustls 0.24 supplies a security-context hash and stable persistence codec
277/// for client sessions. Earlier rustls lines do not expose that codec, so this
278/// adapter is intentionally available only with `rustls_0_24`.
279#[cfg(feature = "rustls_0_24")]
280#[derive(Clone)]
281pub struct FleetClientSessions {
282    primitive: Arc<ClientSessionPrimitive>,
283    ttl_ms: Arc<AtomicU64>,
284}
285
286#[cfg(feature = "rustls_0_24")]
287impl FleetClientSessions {
288    pub fn open(fleet: Arc<Fleet>) -> io::Result<Self> {
289        Self::with_ttl(fleet, DEFAULT_SESSION_TTL)
290    }
291
292    pub fn with_ttl(
293        fleet: Arc<Fleet>,
294        ttl: Duration,
295    ) -> io::Result<Self> {
296        let ttl_ms = validate_ttl(ttl)?;
297        Ok(Self {
298            primitive: Arc::new(ClientSessionPrimitive::open(&fleet)?),
299            ttl_ms: Arc::new(AtomicU64::new(ttl_ms)),
300        })
301    }
302
303    pub fn set_ttl(
304        &self,
305        ttl: Duration,
306    ) -> io::Result<()> {
307        self.ttl_ms.store(validate_ttl(ttl)?, Ordering::Release);
308        Ok(())
309    }
310
311    pub fn ttl(&self) -> Duration {
312        Duration::from_millis(self.ttl_ms.load(Ordering::Acquire))
313    }
314
315    pub fn storage(
316        &self,
317        provider: Arc<rustls_0_24::crypto::CryptoProvider>,
318    ) -> Arc<OrbitClientSessionStorage> {
319        Arc::new(OrbitClientSessionStorage {
320            primitive: self.primitive.clone(),
321            provider,
322            ttl_ms: self.ttl_ms.clone(),
323        })
324    }
325
326    pub fn reset(&self) -> io::Result<()> {
327        self.primitive.reset()
328    }
329
330    pub fn unlink(&self) -> io::Result<()> {
331        self.primitive.unlink()
332    }
333}
334
335#[cfg(feature = "rustls_0_24")]
336impl fmt::Debug for FleetClientSessions {
337    fn fmt(
338        &self,
339        formatter: &mut fmt::Formatter<'_>,
340    ) -> fmt::Result {
341        formatter
342            .debug_struct("FleetClientSessions")
343            .field("ttl", &self.ttl())
344            .finish_non_exhaustive()
345    }
346}
347
348#[cfg(feature = "rustls_0_24")]
349pub struct OrbitClientSessionStorage {
350    primitive: Arc<ClientSessionPrimitive>,
351    provider: Arc<rustls_0_24::crypto::CryptoProvider>,
352    ttl_ms: Arc<AtomicU64>,
353}
354
355#[cfg(feature = "rustls_0_24")]
356impl fmt::Debug for OrbitClientSessionStorage {
357    fn fmt(
358        &self,
359        formatter: &mut fmt::Formatter<'_>,
360    ) -> fmt::Result {
361        formatter
362            .debug_struct("OrbitClientSessionStorage")
363            .field(
364                "ttl",
365                &Duration::from_millis(self.ttl_ms.load(Ordering::Acquire)),
366            )
367            .finish_non_exhaustive()
368    }
369}
370
371#[cfg(feature = "rustls_0_24")]
372impl OrbitClientSessionStorage {
373    fn ttl(&self) -> Duration {
374        Duration::from_millis(self.ttl_ms.load(Ordering::Acquire))
375    }
376}
377
378fn validate_ttl(ttl: Duration) -> io::Result<u64> {
379    if ttl.is_zero() {
380        return Err(io::Error::new(
381            io::ErrorKind::InvalidInput,
382            "rustls session TTL must be greater than zero",
383        ));
384    }
385    if ttl > MAX_SESSION_TTL {
386        return Err(io::Error::new(
387            io::ErrorKind::InvalidInput,
388            format!(
389                "rustls session TTL {ttl:?} exceeds rustls' stateful lifetime {MAX_SESSION_TTL:?}"
390            ),
391        ));
392    }
393    u64::try_from(ttl.as_millis().max(1)).map_err(|_| {
394        io::Error::new(
395            io::ErrorKind::InvalidInput,
396            "rustls session TTL does not fit milliseconds",
397        )
398    })
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    fn sessions() -> FleetServerSessions {
406        let fleet =
407            Arc::new(Fleet::join("rustls-session-unit", 1).expect("fleet"));
408        FleetServerSessions::open(fleet).expect("sessions")
409    }
410
411    #[test]
412    fn rustls_view_put_get_and_take() {
413        let sessions = sessions();
414        let storage =
415            sessions.storage(SessionDomain::new("tcp-public").expect("domain"));
416
417        assert!(storage.put_bytes(b"ticket", b"secret"));
418        assert_eq!(storage.get_bytes(b"ticket"), Some(b"secret".to_vec()));
419        assert_eq!(storage.take_bytes(b"ticket"), Some(b"secret".to_vec()));
420        assert_eq!(storage.take_bytes(b"ticket"), None);
421    }
422
423    #[test]
424    fn domains_are_isolated() {
425        let sessions = sessions();
426        let tcp =
427            sessions.storage(SessionDomain::new("tcp-public").expect("domain"));
428        let quic = sessions
429            .storage(SessionDomain::new("quic-public").expect("domain"));
430
431        assert!(tcp.put_bytes(b"same-ticket", b"tcp"));
432        assert_eq!(quic.take_bytes(b"same-ticket"), None);
433        assert_eq!(tcp.take_bytes(b"same-ticket"), Some(b"tcp".to_vec()));
434    }
435
436    #[test]
437    fn existing_views_observe_ttl_updates() {
438        let sessions = sessions();
439        let storage =
440            sessions.storage(SessionDomain::new("tcp-public").expect("domain"));
441        sessions.set_ttl(Duration::from_millis(1)).expect("TTL update");
442
443        assert!(storage.put_bytes(b"short", b"secret"));
444        std::thread::sleep(Duration::from_millis(5));
445        assert_eq!(storage.get_bytes(b"short"), None);
446    }
447
448    #[test]
449    fn ttl_cannot_outlive_rustls_stateful_tickets() {
450        let fleet =
451            Arc::new(Fleet::join("rustls-session-ttl", 1).expect("fleet"));
452
453        assert!(
454            FleetServerSessions::with_ttl(
455                fleet,
456                MAX_SESSION_TTL + Duration::from_millis(1)
457            )
458            .is_err()
459        );
460    }
461}