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
8use self::store::{DOMAIN_MAX, SessionPrimitive};
9
10mod store;
11#[cfg(feature = "rustls_0_23")]
12mod v23;
13#[cfg(feature = "rustls_0_24")]
14mod v24;
15
16/// Default retention for opaque rustls server-session values.
17///
18/// This is no longer than rustls 0.23's stateful TLS 1.3 ticket lifetime.
19pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
20pub const MAX_SESSION_TTL: Duration = DEFAULT_SESSION_TTL;
21
22/// Stable isolation boundary for one equivalent set of rustls server configs.
23///
24/// Transport family, authentication policy, and any compatibility epoch must
25/// be reflected in this value. Public TLS and mTLS must not share a domain.
26#[derive(Clone, PartialEq, Eq, Hash)]
27pub struct SessionDomain(Arc<[u8]>);
28
29impl SessionDomain {
30    pub fn new(value: impl AsRef<[u8]>) -> io::Result<Self> {
31        let value = value.as_ref();
32        if value.is_empty() {
33            return Err(io::Error::new(
34                io::ErrorKind::InvalidInput,
35                "rustls session domain must not be empty"
36            ));
37        }
38        if value.len() > DOMAIN_MAX {
39            return Err(io::Error::new(
40                io::ErrorKind::InvalidInput,
41                format!(
42                    "rustls session domain is too long: {} bytes (maximum {DOMAIN_MAX})",
43                    value.len()
44                )
45            ));
46        }
47        Ok(Self(Arc::from(value)))
48    }
49
50    fn as_bytes(&self) -> &[u8] {
51        &self.0
52    }
53}
54
55impl fmt::Debug for SessionDomain {
56    fn fmt(
57        &self,
58        formatter: &mut fmt::Formatter<'_>
59    ) -> fmt::Result {
60        formatter.debug_tuple("SessionDomain").field(&String::from_utf8_lossy(&self.0)).finish()
61    }
62}
63
64/// One fleet-wide session table from which isolated rustls views are made.
65#[derive(Clone)]
66pub struct FleetServerSessions {
67    primitive: Arc<SessionPrimitive>,
68    ttl_ms: Arc<AtomicU64>
69}
70
71impl FleetServerSessions {
72    pub fn open(fleet: Arc<Fleet>) -> io::Result<Self> {
73        Self::with_ttl(fleet, DEFAULT_SESSION_TTL)
74    }
75
76    pub fn with_ttl(
77        fleet: Arc<Fleet>,
78        ttl: Duration
79    ) -> io::Result<Self> {
80        let ttl_ms = validate_ttl(ttl)?;
81        Ok(Self {
82            primitive: Arc::new(SessionPrimitive::open(&fleet)?),
83            ttl_ms: Arc::new(AtomicU64::new(ttl_ms))
84        })
85    }
86
87    pub fn set_ttl(
88        &self,
89        ttl: Duration
90    ) -> io::Result<()> {
91        self.ttl_ms.store(validate_ttl(ttl)?, Ordering::Release);
92        Ok(())
93    }
94
95    pub fn ttl(&self) -> Duration {
96        Duration::from_millis(self.ttl_ms.load(Ordering::Acquire))
97    }
98
99    /// Create a rustls storage view isolated by `domain`.
100    pub fn storage(
101        &self,
102        domain: SessionDomain
103    ) -> Arc<OrbitSessionStorage> {
104        Arc::new(OrbitSessionStorage {
105            primitive: self.primitive.clone(),
106            domain,
107            ttl_ms: self.ttl_ms.clone()
108        })
109    }
110
111    /// Clear every domain. Call only during owner-controlled quiescent boot.
112    pub fn reset(&self) -> io::Result<()> {
113        self.primitive.reset()
114    }
115
116    /// Remove the SHM name and companion lock file.
117    ///
118    /// Existing mappings remain valid. This is intended for fleet teardown
119    /// and tests, not runtime cache invalidation.
120    pub fn unlink(&self) -> io::Result<()> {
121        self.primitive.unlink()
122    }
123}
124
125impl fmt::Debug for FleetServerSessions {
126    fn fmt(
127        &self,
128        formatter: &mut fmt::Formatter<'_>
129    ) -> fmt::Result {
130        formatter
131            .debug_struct("FleetServerSessions")
132            .field("ttl", &self.ttl())
133            .finish_non_exhaustive()
134    }
135}
136
137pub struct OrbitSessionStorage {
138    primitive: Arc<SessionPrimitive>,
139    domain: SessionDomain,
140    ttl_ms: Arc<AtomicU64>
141}
142
143impl fmt::Debug for OrbitSessionStorage {
144    fn fmt(
145        &self,
146        formatter: &mut fmt::Formatter<'_>
147    ) -> fmt::Result {
148        formatter
149            .debug_struct("OrbitSessionStorage")
150            .field("domain", &self.domain)
151            .field("ttl", &Duration::from_millis(self.ttl_ms.load(Ordering::Acquire)))
152            .finish_non_exhaustive()
153    }
154}
155
156impl OrbitSessionStorage {
157    fn put_bytes(
158        &self,
159        key: &[u8],
160        value: &[u8]
161    ) -> bool {
162        let ttl = Duration::from_millis(self.ttl_ms.load(Ordering::Acquire));
163        match self.primitive.put(self.domain.as_bytes(), key, value, ttl) {
164            Ok(cached) => {
165                tracing::trace!(
166                    target: "orbit_rustls::session_cache",
167                    operation = "put",
168                    domain = ?self.domain,
169                    key_len = key.len(),
170                    value_len = value.len(),
171                    ?ttl,
172                    cached,
173                    "rustls session cache write"
174                );
175                cached
176            }
177            Err(error) => {
178                tracing::trace!(
179                    target: "orbit_rustls::session_cache",
180                    operation = "put",
181                    domain = ?self.domain,
182                    key_len = key.len(),
183                    value_len = value.len(),
184                    ?ttl,
185                    %error,
186                    "rustls session cache write failed"
187                );
188                false
189            }
190        }
191    }
192
193    fn get_bytes(
194        &self,
195        key: &[u8]
196    ) -> Option<Vec<u8>> {
197        match self.primitive.get(self.domain.as_bytes(), key) {
198            Ok(value) => {
199                tracing::trace!(
200                    target: "orbit_rustls::session_cache",
201                    operation = "get",
202                    domain = ?self.domain,
203                    key_len = key.len(),
204                    value_len = value.as_ref().map_or(0, Vec::len),
205                    hit = value.is_some(),
206                    "rustls session cache read"
207                );
208                value
209            }
210            Err(error) => {
211                tracing::trace!(
212                    target: "orbit_rustls::session_cache",
213                    operation = "get",
214                    domain = ?self.domain,
215                    key_len = key.len(),
216                    %error,
217                    "rustls session cache read failed"
218                );
219                None
220            }
221        }
222    }
223
224    fn take_bytes(
225        &self,
226        key: &[u8]
227    ) -> Option<Vec<u8>> {
228        match self.primitive.take(self.domain.as_bytes(), key) {
229            Ok(value) => {
230                tracing::trace!(
231                    target: "orbit_rustls::session_cache",
232                    operation = "take",
233                    domain = ?self.domain,
234                    key_len = key.len(),
235                    value_len = value.as_ref().map_or(0, Vec::len),
236                    hit = value.is_some(),
237                    "rustls session cache read and consume"
238                );
239                value
240            }
241            Err(error) => {
242                tracing::trace!(
243                    target: "orbit_rustls::session_cache",
244                    operation = "take",
245                    domain = ?self.domain,
246                    key_len = key.len(),
247                    %error,
248                    "rustls session cache read and consume failed"
249                );
250                None
251            }
252        }
253    }
254}
255
256fn validate_ttl(ttl: Duration) -> io::Result<u64> {
257    if ttl.is_zero() {
258        return Err(io::Error::new(
259            io::ErrorKind::InvalidInput,
260            "rustls session TTL must be greater than zero"
261        ));
262    }
263    if ttl > MAX_SESSION_TTL {
264        return Err(io::Error::new(
265            io::ErrorKind::InvalidInput,
266            format!(
267                "rustls session TTL {ttl:?} exceeds rustls' stateful lifetime {MAX_SESSION_TTL:?}"
268            )
269        ));
270    }
271    u64::try_from(ttl.as_millis().max(1)).map_err(|_| {
272        io::Error::new(io::ErrorKind::InvalidInput, "rustls session TTL does not fit milliseconds")
273    })
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    fn sessions() -> FleetServerSessions {
281        let fleet = Arc::new(Fleet::join("rustls-session-unit", 1).expect("fleet"));
282        FleetServerSessions::open(fleet).expect("sessions")
283    }
284
285    #[test]
286    fn rustls_view_put_get_and_take() {
287        let sessions = sessions();
288        let storage = sessions.storage(SessionDomain::new("tcp-public").expect("domain"));
289
290        assert!(storage.put_bytes(b"ticket", b"secret"));
291        assert_eq!(storage.get_bytes(b"ticket"), Some(b"secret".to_vec()));
292        assert_eq!(storage.take_bytes(b"ticket"), Some(b"secret".to_vec()));
293        assert_eq!(storage.take_bytes(b"ticket"), None);
294    }
295
296    #[test]
297    fn domains_are_isolated() {
298        let sessions = sessions();
299        let tcp = sessions.storage(SessionDomain::new("tcp-public").expect("domain"));
300        let quic = sessions.storage(SessionDomain::new("quic-public").expect("domain"));
301
302        assert!(tcp.put_bytes(b"same-ticket", b"tcp"));
303        assert_eq!(quic.take_bytes(b"same-ticket"), None);
304        assert_eq!(tcp.take_bytes(b"same-ticket"), Some(b"tcp".to_vec()));
305    }
306
307    #[test]
308    fn existing_views_observe_ttl_updates() {
309        let sessions = sessions();
310        let storage = sessions.storage(SessionDomain::new("tcp-public").expect("domain"));
311        sessions.set_ttl(Duration::from_millis(1)).expect("TTL update");
312
313        assert!(storage.put_bytes(b"short", b"secret"));
314        std::thread::sleep(Duration::from_millis(5));
315        assert_eq!(storage.get_bytes(b"short"), None);
316    }
317
318    #[test]
319    fn ttl_cannot_outlive_rustls_stateful_tickets() {
320        let fleet = Arc::new(Fleet::join("rustls-session-ttl", 1).expect("fleet"));
321
322        assert!(
323            FleetServerSessions::with_ttl(fleet, MAX_SESSION_TTL + Duration::from_millis(1))
324                .is_err()
325        );
326    }
327}