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