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, Exclusion, 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(
93                &fleet,
94                Exclusion::Set,
95            )?),
96            ttl_ms: Arc::new(AtomicU64::new(ttl_ms)),
97        })
98    }
99
100    pub fn set_ttl(
101        &self,
102        ttl: Duration,
103    ) -> io::Result<()> {
104        self.ttl_ms.store(validate_ttl(ttl)?, Ordering::Release);
105        Ok(())
106    }
107
108    pub fn ttl(&self) -> Duration {
109        Duration::from_millis(self.ttl_ms.load(Ordering::Acquire))
110    }
111
112    /// Create a rustls storage view isolated by `domain`.
113    pub fn storage(
114        &self,
115        domain: SessionDomain,
116    ) -> Arc<OrbitSessionStorage> {
117        Arc::new(OrbitSessionStorage {
118            primitive: self.primitive.clone(),
119            domain,
120            ttl_ms: self.ttl_ms.clone(),
121        })
122    }
123
124    /// Clear every domain. Call only during owner-controlled quiescent boot.
125    ///
126    /// Fails when another live handle is found at work in the table; what
127    /// was cleared before that stays cleared.
128    pub fn reset(&self) -> io::Result<()> {
129        self.primitive.reset()
130    }
131
132    /// Remove the SHM name and companion lock file.
133    ///
134    /// Existing mappings remain valid. This is intended for fleet teardown
135    /// and tests, not runtime cache invalidation.
136    pub fn unlink(&self) -> io::Result<()> {
137        self.primitive.unlink()
138    }
139}
140
141impl fmt::Debug for FleetServerSessions {
142    fn fmt(
143        &self,
144        formatter: &mut fmt::Formatter<'_>,
145    ) -> fmt::Result {
146        formatter
147            .debug_struct("FleetServerSessions")
148            .field("ttl", &self.ttl())
149            .finish_non_exhaustive()
150    }
151}
152
153pub struct OrbitSessionStorage {
154    primitive: Arc<ServerSessionPrimitive>,
155    domain: SessionDomain,
156    ttl_ms: Arc<AtomicU64>,
157}
158
159/// Explicit server-side name for [`OrbitSessionStorage`].
160///
161/// The original name remains available for API compatibility.
162pub type OrbitServerSessionStorage = OrbitSessionStorage;
163
164impl fmt::Debug for OrbitSessionStorage {
165    fn fmt(
166        &self,
167        formatter: &mut fmt::Formatter<'_>,
168    ) -> fmt::Result {
169        formatter
170            .debug_struct("OrbitSessionStorage")
171            .field("domain", &self.domain)
172            .field(
173                "ttl",
174                &Duration::from_millis(self.ttl_ms.load(Ordering::Acquire)),
175            )
176            .finish_non_exhaustive()
177    }
178}
179
180impl OrbitSessionStorage {
181    fn put_bytes(
182        &self,
183        key: &[u8],
184        value: &[u8],
185    ) -> bool {
186        let ttl = Duration::from_millis(self.ttl_ms.load(Ordering::Acquire));
187        match self.primitive.put(self.domain.as_bytes(), key, value, ttl) {
188            Ok(cached) => {
189                tracing::trace!(
190                    target: "orbit_rustls::session_cache",
191                    operation = "put",
192                    domain = ?self.domain,
193                    key_len = key.len(),
194                    value_len = value.len(),
195                    ?ttl,
196                    cached,
197                    "rustls session cache write"
198                );
199                cached
200            }
201            Err(error) => {
202                tracing::trace!(
203                    target: "orbit_rustls::session_cache",
204                    operation = "put",
205                    domain = ?self.domain,
206                    key_len = key.len(),
207                    value_len = value.len(),
208                    ?ttl,
209                    %error,
210                    "rustls session cache write failed"
211                );
212                false
213            }
214        }
215    }
216
217    fn get_bytes(
218        &self,
219        key: &[u8],
220    ) -> Option<Vec<u8>> {
221        match self.primitive.get(self.domain.as_bytes(), key) {
222            Ok(value) => {
223                tracing::trace!(
224                    target: "orbit_rustls::session_cache",
225                    operation = "get",
226                    domain = ?self.domain,
227                    key_len = key.len(),
228                    value_len = value.as_ref().map_or(0, Vec::len),
229                    hit = value.is_some(),
230                    "rustls session cache read"
231                );
232                value
233            }
234            Err(error) => {
235                tracing::trace!(
236                    target: "orbit_rustls::session_cache",
237                    operation = "get",
238                    domain = ?self.domain,
239                    key_len = key.len(),
240                    %error,
241                    "rustls session cache read failed"
242                );
243                None
244            }
245        }
246    }
247
248    fn take_bytes(
249        &self,
250        key: &[u8],
251    ) -> Option<Vec<u8>> {
252        match self.primitive.take(self.domain.as_bytes(), key) {
253            Ok(value) => {
254                tracing::trace!(
255                    target: "orbit_rustls::session_cache",
256                    operation = "take",
257                    domain = ?self.domain,
258                    key_len = key.len(),
259                    value_len = value.as_ref().map_or(0, Vec::len),
260                    hit = value.is_some(),
261                    "rustls session cache read and consume"
262                );
263                value
264            }
265            Err(error) => {
266                tracing::trace!(
267                    target: "orbit_rustls::session_cache",
268                    operation = "take",
269                    domain = ?self.domain,
270                    key_len = key.len(),
271                    %error,
272                    "rustls session cache read and consume failed"
273                );
274                None
275            }
276        }
277    }
278}
279
280/// One fleet-wide rustls 0.24 client-session table.
281///
282/// rustls 0.24 supplies a security-context hash and stable persistence codec
283/// for client sessions. Earlier rustls lines do not expose that codec, so this
284/// adapter is intentionally available only with `rustls_0_24`.
285#[cfg(feature = "rustls_0_24")]
286#[derive(Clone)]
287pub struct FleetClientSessions {
288    primitive: Arc<ClientSessionPrimitive>,
289    ttl_ms: Arc<AtomicU64>,
290}
291
292#[cfg(feature = "rustls_0_24")]
293impl FleetClientSessions {
294    pub fn open(fleet: Arc<Fleet>) -> io::Result<Self> {
295        Self::with_ttl(fleet, DEFAULT_SESSION_TTL)
296    }
297
298    pub fn with_ttl(
299        fleet: Arc<Fleet>,
300        ttl: Duration,
301    ) -> io::Result<Self> {
302        let ttl_ms = validate_ttl(ttl)?;
303        Ok(Self {
304            primitive: Arc::new(ClientSessionPrimitive::open(
305                &fleet,
306                Exclusion::Table,
307            )?),
308            ttl_ms: Arc::new(AtomicU64::new(ttl_ms)),
309        })
310    }
311
312    pub fn set_ttl(
313        &self,
314        ttl: Duration,
315    ) -> io::Result<()> {
316        self.ttl_ms.store(validate_ttl(ttl)?, Ordering::Release);
317        Ok(())
318    }
319
320    pub fn ttl(&self) -> Duration {
321        Duration::from_millis(self.ttl_ms.load(Ordering::Acquire))
322    }
323
324    pub fn storage(
325        &self,
326        provider: Arc<rustls_0_24::crypto::CryptoProvider>,
327    ) -> Arc<OrbitClientSessionStorage> {
328        Arc::new(OrbitClientSessionStorage {
329            primitive: self.primitive.clone(),
330            provider,
331            ttl_ms: self.ttl_ms.clone(),
332        })
333    }
334
335    pub fn reset(&self) -> io::Result<()> {
336        self.primitive.reset()
337    }
338
339    pub fn unlink(&self) -> io::Result<()> {
340        self.primitive.unlink()
341    }
342}
343
344#[cfg(feature = "rustls_0_24")]
345impl fmt::Debug for FleetClientSessions {
346    fn fmt(
347        &self,
348        formatter: &mut fmt::Formatter<'_>,
349    ) -> fmt::Result {
350        formatter
351            .debug_struct("FleetClientSessions")
352            .field("ttl", &self.ttl())
353            .finish_non_exhaustive()
354    }
355}
356
357#[cfg(feature = "rustls_0_24")]
358pub struct OrbitClientSessionStorage {
359    primitive: Arc<ClientSessionPrimitive>,
360    provider: Arc<rustls_0_24::crypto::CryptoProvider>,
361    ttl_ms: Arc<AtomicU64>,
362}
363
364#[cfg(feature = "rustls_0_24")]
365impl fmt::Debug for OrbitClientSessionStorage {
366    fn fmt(
367        &self,
368        formatter: &mut fmt::Formatter<'_>,
369    ) -> fmt::Result {
370        formatter
371            .debug_struct("OrbitClientSessionStorage")
372            .field(
373                "ttl",
374                &Duration::from_millis(self.ttl_ms.load(Ordering::Acquire)),
375            )
376            .finish_non_exhaustive()
377    }
378}
379
380#[cfg(feature = "rustls_0_24")]
381impl OrbitClientSessionStorage {
382    fn ttl(&self) -> Duration {
383        Duration::from_millis(self.ttl_ms.load(Ordering::Acquire))
384    }
385}
386
387fn validate_ttl(ttl: Duration) -> io::Result<u64> {
388    if ttl.is_zero() {
389        return Err(io::Error::new(
390            io::ErrorKind::InvalidInput,
391            "rustls session TTL must be greater than zero",
392        ));
393    }
394    if ttl > MAX_SESSION_TTL {
395        return Err(io::Error::new(
396            io::ErrorKind::InvalidInput,
397            format!(
398                "rustls session TTL {ttl:?} exceeds rustls' stateful lifetime {MAX_SESSION_TTL:?}"
399            ),
400        ));
401    }
402    u64::try_from(ttl.as_millis().max(1)).map_err(|_| {
403        io::Error::new(
404            io::ErrorKind::InvalidInput,
405            "rustls session TTL does not fit milliseconds",
406        )
407    })
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    fn sessions() -> FleetServerSessions {
415        let fleet =
416            Arc::new(Fleet::join("rustls-session-unit", 1).expect("fleet"));
417        FleetServerSessions::open(fleet).expect("sessions")
418    }
419
420    #[test]
421    fn rustls_view_put_get_and_take() {
422        let sessions = sessions();
423        let storage =
424            sessions.storage(SessionDomain::new("tcp-public").expect("domain"));
425
426        assert!(storage.put_bytes(b"ticket", b"secret"));
427        assert_eq!(storage.get_bytes(b"ticket"), Some(b"secret".to_vec()));
428        assert_eq!(storage.take_bytes(b"ticket"), Some(b"secret".to_vec()));
429        assert_eq!(storage.take_bytes(b"ticket"), None);
430    }
431
432    #[test]
433    fn domains_are_isolated() {
434        let sessions = sessions();
435        let tcp =
436            sessions.storage(SessionDomain::new("tcp-public").expect("domain"));
437        let quic = sessions
438            .storage(SessionDomain::new("quic-public").expect("domain"));
439
440        assert!(tcp.put_bytes(b"same-ticket", b"tcp"));
441        assert_eq!(quic.take_bytes(b"same-ticket"), None);
442        assert_eq!(tcp.take_bytes(b"same-ticket"), Some(b"tcp".to_vec()));
443    }
444
445    #[test]
446    fn existing_views_observe_ttl_updates() {
447        let sessions = sessions();
448        let storage =
449            sessions.storage(SessionDomain::new("tcp-public").expect("domain"));
450        sessions.set_ttl(Duration::from_millis(1)).expect("TTL update");
451
452        assert!(storage.put_bytes(b"short", b"secret"));
453        std::thread::sleep(Duration::from_millis(5));
454        assert_eq!(storage.get_bytes(b"short"), None);
455    }
456
457    #[test]
458    fn ttl_cannot_outlive_rustls_stateful_tickets() {
459        let fleet =
460            Arc::new(Fleet::join("rustls-session-ttl", 1).expect("fleet"));
461
462        assert!(
463            FleetServerSessions::with_ttl(
464                fleet,
465                MAX_SESSION_TTL + Duration::from_millis(1)
466            )
467            .is_err()
468        );
469    }
470}