Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_steel/src/srv/admin/session.rs

16.5 KiB, 61 runs

created by r1870400018:10330, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Signed session cookies for the admin dashboard.
2//!
3//! Sessions are stateless: the principal's name, scopes and expiry
4//! are encoded into a length-prefixed binary record, encrypted with
5//! AES-256-GCM under the dashboard session key, and handed to the
6//! browser as a cookie. No session table lives on disk or in memory,
7//! so a restart rotates the session key and invalidates every
8//! outstanding session at once.
9//!
10//! # Wire format
11//!
12//! The cookie string is:
13//!
14//! ```text
15//! v1.<base2x(ciphertext)>
16//! ```
17//!
18//! where the plaintext record is:
19//!
20//! ```text
21//! u16 name_len BE
22//! [name_len] bytes -- admin name UTF-8
23//! u8 num_scopes
24//! for each scope:
25//! u16 len BE
26//! [len] bytes -- scope string UTF-8
27//! u64 exp BE -- unix seconds at which the session expires
28//! ```
29//!
30//! The ciphertext is the output of `EncryptionScheme::encrypt`,
31//! which for AES-256-GCM is `ciphertext || tag || nonce`. Decoding
32//! routes the same bytes back through `decrypt`, which verifies the
33//! tag, strips the nonce, and returns the plaintext.
34//!
35//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
36//! Anthropic Claude
37
38use crate::srv::admin::{
39 AdminPrincipal,
40 state::AdminState,
41};
42
43use oxedyne_fe2o3_core::prelude::*;
44use oxedyne_fe2o3_iop_crypto::enc::Encrypter;
45use oxedyne_fe2o3_text::base2x;
46
47use std::time::{
48 SystemTime,
49 UNIX_EPOCH,
50};
51
52// The TTL slides: the handler refreshes the expiry on every authenticated
53// request, so only an idle session eventually expires. The maxima bound the
54// worst-case cookie size and give the decoder a reject point before it
55// allocates.
56pub const SESSION_COOKIE_NAME: &str = "steel_admin_sess";
57pub const DEFAULT_SESSION_TTL_SECS: u64 = 30 * 60;
58pub const SESSION_FORMAT_VERSION: &str = "v1"; // bump on an incompatible record layout
59pub const MAX_SESSION_SCOPES: u8 = 32;
60pub const MAX_SCOPE_LEN: usize = 64; // bytes
61pub const MAX_NAME_LEN: usize = 64; // bytes
62
63// ┌───────────────────────────────────────────────────────────────────────────┐
64// │ ENCODE │
65// └───────────────────────────────────────────────────────────────────────────┘
66
67/// The caller sets `expires_at` on the principal; `refresh_principal` produces
68/// an expiry `DEFAULT_SESSION_TTL_SECS` ahead of now.
69pub fn encode_session(
70 state: &AdminState,
71 principal: &AdminPrincipal,
72)
73 -> Outcome<String>
74{
75 let plain = res!(encode_record(principal));
76 let cipher = res!(state.session_enc.encrypt(&plain));
77 let blob = base2x::HEMATITE64.to_string(&cipher);
78 Ok(fmt!("{}.{}", SESSION_FORMAT_VERSION, blob))
79}
80
81/// Serialises to the length-prefixed plaintext record described in the module
82/// header.
83fn encode_record(p: &AdminPrincipal) -> Outcome<Vec<u8>> {
84 let name_bytes = p.name.as_bytes();
85 if name_bytes.len() > MAX_NAME_LEN {
86 return Err(err!(
87 "Admin name is {} bytes; max is {} for session encoding.",
88 name_bytes.len(), MAX_NAME_LEN;
89 Input, TooBig));
90 }
91 if p.scopes.len() > MAX_SESSION_SCOPES as usize {
92 return Err(err!(
93 "Principal has {} scopes; session format supports up to {}.",
94 p.scopes.len(), MAX_SESSION_SCOPES;
95 Input, TooBig));
96 }
97 let mut out = Vec::with_capacity(
98 2 + name_bytes.len() + 1 + p.scopes.len() * 10 + 8);
99 out.extend_from_slice(&(name_bytes.len() as u16).to_be_bytes());
100 out.extend_from_slice(name_bytes);
101 out.push(p.scopes.len() as u8);
102 for s in &p.scopes {
103 let sb = s.as_bytes();
104 if sb.len() > MAX_SCOPE_LEN {
105 return Err(err!(
106 "Scope '{}' is {} bytes; max is {} for session encoding.",
107 s, sb.len(), MAX_SCOPE_LEN;
108 Input, TooBig));
109 }
110 out.extend_from_slice(&(sb.len() as u16).to_be_bytes());
111 out.extend_from_slice(sb);
112 }
113 out.extend_from_slice(&p.expires_at.to_be_bytes());
114 Ok(out)
115}
116
117// ┌───────────────────────────────────────────────────────────────────────────┐
118// │ DECODE │
119// └───────────────────────────────────────────────────────────────────────────┘
120
121/// Decodes and verifies a session cookie, yielding the principal only if the
122/// ciphertext authenticates and the expiry has not passed.
123///
124/// Errors are tagged rather than reduced to `Option` so the handler can log why
125/// a session was rejected -- tampering, expiry, bad format -- without leaking
126/// the distinction to the client.
127pub fn decode_session(
128 state: &AdminState,
129 cookie: &str,
130)
131 -> Outcome<AdminPrincipal>
132{
133 let (ver, blob) = match cookie.split_once('.') {
134 Some(p) => p,
135 None => return Err(err!(
136 "Session cookie does not contain a version prefix.";
137 Input, Invalid)),
138 };
139 if ver != SESSION_FORMAT_VERSION {
140 return Err(err!(
141 "Session cookie version '{}' is not recognised (expected '{}').",
142 ver, SESSION_FORMAT_VERSION;
143 Input, Invalid, Mismatch));
144 }
145 let cipher = res!(base2x::HEMATITE64.from_str(blob));
146 let plain = res!(state.session_enc.decrypt(&cipher));
147 let principal = res!(decode_record(&plain));
148
149 let now = now_secs();
150 if principal.expires_at <= now {
151 return Err(err!(
152 "Session expired at unix {} (now {}).",
153 principal.expires_at, now;
154 Input, Invalid, Security));
155 }
156 Ok(principal)
157}
158
159fn decode_record(bytes: &[u8]) -> Outcome<AdminPrincipal> {
160 let mut p = 0usize;
161 let name_len = res!(read_u16(bytes, &mut p)) as usize;
162 if name_len > MAX_NAME_LEN {
163 return Err(err!(
164 "Session record claims a {}-byte name; max is {}.",
165 name_len, MAX_NAME_LEN;
166 Input, TooBig));
167 }
168 let name_bytes = res!(read_slice(bytes, &mut p, name_len));
169 let name = res!(std::str::from_utf8(name_bytes),
170 Decode, String).to_string();
171
172 let num_scopes = res!(read_u8(bytes, &mut p));
173 if num_scopes > MAX_SESSION_SCOPES {
174 return Err(err!(
175 "Session record claims {} scopes; max is {}.",
176 num_scopes, MAX_SESSION_SCOPES;
177 Input, TooBig));
178 }
179 let mut scopes = Vec::with_capacity(num_scopes as usize);
180 for _ in 0..num_scopes {
181 let slen = res!(read_u16(bytes, &mut p)) as usize;
182 if slen > MAX_SCOPE_LEN {
183 return Err(err!(
184 "Session record claims a {}-byte scope; max is {}.",
185 slen, MAX_SCOPE_LEN;
186 Input, TooBig));
187 }
188 let sb = res!(read_slice(bytes, &mut p, slen));
189 let s = res!(std::str::from_utf8(sb),
190 Decode, String).to_string();
191 scopes.push(s);
192 }
193
194 let exp_bytes = res!(read_slice(bytes, &mut p, 8));
195 let mut exp_arr = [0u8; 8];
196 exp_arr.copy_from_slice(exp_bytes);
197 let expires_at = u64::from_be_bytes(exp_arr);
198
199 if p != bytes.len() {
200 return Err(err!(
201 "Session record has {} trailing bytes after the expiry.",
202 bytes.len() - p;
203 Input, Invalid));
204 }
205 Ok(AdminPrincipal {
206 name,
207 scopes,
208 expires_at,
209 })
210}
211
212// ┌───────────────────────────────────────────────────────────────────────────┐
213// │ PRINCIPAL REFRESH │
214// └───────────────────────────────────────────────────────────────────────────┘
215
216/// Advances `expires_at` to `now + DEFAULT_SESSION_TTL_SECS`. The handler calls
217/// it on every authenticated request, giving sessions a sliding expiry.
218pub fn refresh_principal(principal: &AdminPrincipal) -> AdminPrincipal {
219 AdminPrincipal {
220 name: principal.name.clone(),
221 scopes: principal.scopes.clone(),
222 expires_at: now_secs().saturating_add(DEFAULT_SESSION_TTL_SECS),
223 }
224}
225
226// ┌───────────────────────────────────────────────────────────────────────────┐
227// │ HELPERS │
228// └───────────────────────────────────────────────────────────────────────────┘
229
230/// Current unix time in seconds, clamped to zero on clock error.
231pub fn now_secs() -> u64 {
232 SystemTime::now()
233 .duration_since(UNIX_EPOCH)
234 .map(|d| d.as_secs())
235 .unwrap_or(0)
236}
237
238fn read_u8(bytes: &[u8], p: &mut usize) -> Outcome<u8> {
239 if *p >= bytes.len() {
240 return Err(err!(
241 "Session record truncated at byte {} (reading u8).", *p;
242 Input, Invalid, TooSmall));
243 }
244 let v = bytes[*p];
245 *p += 1;
246 Ok(v)
247}
248
249fn read_u16(bytes: &[u8], p: &mut usize) -> Outcome<u16> {
250 if *p + 2 > bytes.len() {
251 return Err(err!(
252 "Session record truncated at byte {} (reading u16).", *p;
253 Input, Invalid, TooSmall));
254 }
255 let v = u16::from_be_bytes([bytes[*p], bytes[*p + 1]]);
256 *p += 2;
257 Ok(v)
258}
259
260fn read_slice<'a>(
261 bytes: &'a [u8],
262 p: &mut usize,
263 n: usize,
264)
265 -> Outcome<&'a [u8]>
266{
267 if *p + n > bytes.len() {
268 return Err(err!(
269 "Session record truncated at byte {} (reading {} bytes).",
270 *p, n;
271 Input, Invalid, TooSmall));
272 }
273 let s = &bytes[*p..*p + n];
274 *p += n;
275 Ok(s)
276}
277
278// ┌───────────────────────────────────────────────────────────────────────────┐
279// │ TESTS │
280// └───────────────────────────────────────────────────────────────────────────┘
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::srv::admin::{
286 host_sampler::HostSampler,
287 state::AdminState,
288 traffic::TrafficRecorder,
289 };
290 use oxedyne_fe2o3_crypto::keystore::Wallet;
291 use std::{
292 path::PathBuf,
293 sync::{
294 Arc,
295 RwLock,
296 },
297 };
298
299 fn mkstate() -> AdminState {
300 mkstate_with_key(Some([0u8; 32].to_vec()))
301 }
302
303 fn mkstate_with_key(master_key: Option<Vec<u8>>) -> AdminState {
304 AdminState::new(
305 Arc::new(RwLock::new(Wallet::default())),
306 PathBuf::from("./wallet.jdat"),
307 master_key,
308 1, // one database configured, so the seal withholds data
309 None, // no alerter in tests
310 TrafficRecorder::new_shared(0),
311 HostSampler::new_shared(),
312 crate::srv::admin::guard::new_shared().expect("addr guard"),
313 crate::srv::admin::guard::new_shared().expect("auth guard"),
314 Vec::new(),
315 None,
316 ).expect("admin state")
317 }
318
319 #[test]
320 fn test_seal_state_00() {
321 // A state built without a master key is sealed, and says so
322 // rather than handing back a key that is not there.
323 let sealed = mkstate_with_key(None);
324 assert!(sealed.is_sealed());
325 assert!(sealed.master_key().is_err());
326
327 // One built with a key is not sealed and yields it.
328 let open = mkstate_with_key(Some(vec![7u8; 32]));
329 assert!(!open.is_sealed());
330 assert_eq!(open.master_key().expect("master key"), vec![7u8; 32]);
331 }
332
333 /// Being sealed and withholding data are different things. A
334 /// deployment of static sites has no database, so its seal holds
335 /// nothing shut -- and telling its operator "the databases are shut"
336 /// is how a healthy server gets mistaken for a broken one.
337 #[test]
338 fn test_a_seal_with_no_databases_withholds_nothing_00() {
339 let no_dbs = AdminState::new(
340 Arc::new(RwLock::new(Wallet::default())),
341 PathBuf::from("./wallet.jdat"),
342 None, // sealed
343 0, // but nothing is configured to need the key
344 None, // no alerter
345 TrafficRecorder::new_shared(0),
346 HostSampler::new_shared(),
347 crate::srv::admin::guard::new_shared().expect("addr guard"),
348 crate::srv::admin::guard::new_shared().expect("auth guard"),
349 Vec::new(),
350 None,
351 ).expect("admin state");
352 assert!(no_dbs.is_sealed(), "no master key means sealed");
353 assert!(!no_dbs.seal_withholds_data(),
354 "a seal over zero databases holds nothing shut");
355
356 // With a database configured, the same seal does withhold data.
357 let with_db = mkstate_with_key(None);
358 assert!(with_db.is_sealed());
359 assert!(with_db.seal_withholds_data(),
360 "a seal over a configured database holds it shut");
361 }
362
363 #[test]
364 fn test_session_key_is_per_process_00() {
365 // The session key must not be derived from the master key: a
366 // sealed Steel has none, yet must still issue session cookies
367 // to the admin on their way to unseal. Two states built from
368 // the *same* master key must therefore still disagree about
369 // each other's cookies.
370 let a = mkstate_with_key(Some([0u8; 32].to_vec()));
371 let b = mkstate_with_key(Some([0u8; 32].to_vec()));
372 let principal = mkprincipal();
373 let cookie = encode_session(&a, &principal).expect("encode");
374 assert!(decode_session(&a, &cookie).is_ok());
375 assert!(decode_session(&b, &cookie).is_err(),
376 "a session cookie must not be portable between processes");
377 }
378
379 fn mkprincipal() -> AdminPrincipal {
380 AdminPrincipal {
381 name: "alice".to_string(),
382 scopes: vec![
383 "dashboard.view".to_string(),
384 "dashboard.admin".to_string(),
385 ],
386 expires_at: now_secs() + DEFAULT_SESSION_TTL_SECS,
387 }
388 }
389
390 #[test]
391 fn round_trip() {
392 let state = mkstate();
393 let p = mkprincipal();
394 let cookie = encode_session(&state, &p).expect("encode");
395 assert!(cookie.starts_with("v1."));
396 let p2 = decode_session(&state, &cookie).expect("decode");
397 assert_eq!(p2.name, p.name);
398 assert_eq!(p2.scopes, p.scopes);
399 assert_eq!(p2.expires_at, p.expires_at);
400 }
401
402 #[test]
403 fn rejects_tampered_cookie() {
404 let state = mkstate();
405 let p = mkprincipal();
406 let cookie = encode_session(&state, &p).expect("encode");
407 // Flip a byte in the ciphertext. Must still decode as a
408 // valid cookie string but fail AES-GCM authentication.
409 let mut bytes: Vec<u8> = cookie.into_bytes();
410 let tamper_idx = bytes.len() - 5;
411 bytes[tamper_idx] ^= 0x01;
412 let tampered = String::from_utf8(bytes).expect("utf8");
413 assert!(decode_session(&state, &tampered).is_err());
414 }
415
416 #[test]
417 fn rejects_expired_cookie() {
418 let state = mkstate();
419 let mut p = mkprincipal();
420 p.expires_at = 1;
421 let cookie = encode_session(&state, &p).expect("encode");
422 assert!(decode_session(&state, &cookie).is_err());
423 }
424
425 #[test]
426 fn rejects_version_mismatch() {
427 let state = mkstate();
428 let p = mkprincipal();
429 let cookie = encode_session(&state, &p).expect("encode");
430 let swapped = cookie.replacen("v1.", "v2.", 1);
431 assert!(decode_session(&state, &swapped).is_err());
432 }
433}