oxedyne/fe2o3/fe2o3_steel/src/srv/console/session.rs
11.3 KiB, 25 runs
created by r1870400018:16155, 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 site-admin session cookies for the console. |
| 2 | //! |
| 3 | //! A site owner signs in at `/manage/login` with the operator's own wallet |
| 4 | //! passphrase -- the same one the `/admin` dashboard verifies -- and is given a |
| 5 | //! *site-admin* session. That session opens the `/manage` console and nothing |
| 6 | //! else. |
| 7 | //! |
| 8 | //! # What the session is, and what it is not |
| 9 | //! |
| 10 | //! It is a small record -- a kind tag, the admin's name, and an expiry -- |
| 11 | //! encrypted with AES-256-GCM under the per-process dashboard session key, the |
| 12 | //! very [`EncryptionScheme`](oxedyne_fe2o3_crypto::enc::EncryptionScheme) the |
| 13 | //! operator session uses. Reusing that key buys two things for free: forging a |
| 14 | //! session needs the key, which never leaves the process, and a restart mints a |
| 15 | //! fresh key and so invalidates every outstanding manage session exactly as it |
| 16 | //! does every operator one. |
| 17 | //! |
| 18 | //! It is deliberately a *different cookie*, on a *different path*, in a |
| 19 | //! *different record layout* from the operator session. The operator cookie is |
| 20 | //! `Path=/admin`, so a browser never sends it here; this cookie is `Path=/`, so |
| 21 | //! it reaches `/manage`. The record carries a leading [`KIND_TAG`] the operator |
| 22 | //! record does not, so a blob minted for one use cannot be read as the other |
| 23 | //! even though both are sealed under the same key. The credential opens the |
| 24 | //! console -- the site's content -- and carries no operator scope: it can never |
| 25 | //! stand in for an `/admin` session. |
| 26 | //! |
| 27 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 28 | //! Anthropic Claude |
| 29 | |
| 30 | use crate::srv::admin::{ |
| 31 | session::now_secs, |
| 32 | state::AdminState, |
| 33 | }; |
| 34 | |
| 35 | use oxedyne_fe2o3_core::prelude::*; |
| 36 | use oxedyne_fe2o3_iop_crypto::enc::Encrypter; |
| 37 | use oxedyne_fe2o3_net::http::fields::{ |
| 38 | HeaderFields, |
| 39 | HeaderFieldValue, |
| 40 | HeaderName, |
| 41 | }; |
| 42 | use oxedyne_fe2o3_text::base2x; |
| 43 | |
| 44 | use std::sync::Arc; |
| 45 | |
| 46 | // Distinct from the operator session cookie, so neither can ever be presented where the other is |
| 47 | // expected. The lifetime matches the operator session's default. |
| 48 | pub const MANAGE_COOKIE_NAME: &str = "manage_session"; |
| 49 | pub const MANAGE_SESSION_TTL_SECS: u64 = 30 * 60; |
| 50 | pub const MANAGE_FORMAT_VERSION: &str = "m1"; // bump on an incompatible record layout |
| 51 | |
| 52 | // The record's leading tag, so a blob from any other use of the same session key cannot be |
| 53 | // mistaken for a site-admin session. |
| 54 | const KIND_TAG: &[u8] = b"steel-site-admin-v1"; |
| 55 | |
| 56 | const MAX_NAME_LEN: usize = 64; // bytes |
| 57 | |
| 58 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 59 | // │ ENCODE │ |
| 60 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 61 | |
| 62 | /// Mint a signed site-admin session cookie value for `name`, expiring |
| 63 | /// [`MANAGE_SESSION_TTL_SECS`] from now. |
| 64 | /// |
| 65 | /// The name is the admin entry whose passphrase unwrapped the wallet, kept only |
| 66 | /// so the console header can say who is signed in. It is never a secret and |
| 67 | /// never a scope. |
| 68 | pub fn encode(state: &AdminState, name: &str) -> Outcome<String> { |
| 69 | let plain = res!(encode_record(name)); |
| 70 | let cipher = res!(state.session_enc.encrypt(&plain)); |
| 71 | let blob = base2x::HEMATITE64.to_string(&cipher); |
| 72 | Ok(fmt!("{}.{}", MANAGE_FORMAT_VERSION, blob)) |
| 73 | } |
| 74 | |
| 75 | fn encode_record(name: &str) -> Outcome<Vec<u8>> { |
| 76 | let nb = name.as_bytes(); |
| 77 | if nb.len() > MAX_NAME_LEN { |
| 78 | return Err(err!( |
| 79 | "Admin name is {} bytes; max is {} for a manage session.", |
| 80 | nb.len(), MAX_NAME_LEN; |
| 81 | Input, TooBig)); |
| 82 | } |
| 83 | let exp = now_secs().saturating_add(MANAGE_SESSION_TTL_SECS); |
| 84 | let mut out = Vec::with_capacity(KIND_TAG.len() + 2 + nb.len() + 8); |
| 85 | out.extend_from_slice(KIND_TAG); |
| 86 | out.extend_from_slice(&(nb.len() as u16).to_be_bytes()); |
| 87 | out.extend_from_slice(nb); |
| 88 | out.extend_from_slice(&exp.to_be_bytes()); |
| 89 | Ok(out) |
| 90 | } |
| 91 | |
| 92 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 93 | // │ DECODE │ |
| 94 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 95 | |
| 96 | /// Decode and verify a manage session cookie value, returning the admin's name |
| 97 | /// when the ciphertext authenticates, the tag matches, and the expiry has not |
| 98 | /// passed. |
| 99 | /// |
| 100 | /// A tagged error, rather than an `Option`, so the caller can log *why* a |
| 101 | /// session was rejected -- tampering, a wrong key, expiry, a bad format -- |
| 102 | /// without any of it reaching the client. |
| 103 | pub fn decode(state: &AdminState, cookie: &str) -> Outcome<String> { |
| 104 | let (ver, blob) = match cookie.split_once('.') { |
| 105 | Some(p) => p, |
| 106 | None => return Err(err!( |
| 107 | "Manage session cookie has no version prefix."; |
| 108 | Input, Invalid)), |
| 109 | }; |
| 110 | if ver != MANAGE_FORMAT_VERSION { |
| 111 | return Err(err!( |
| 112 | "Manage session cookie version '{}' is not recognised (expected '{}').", |
| 113 | ver, MANAGE_FORMAT_VERSION; |
| 114 | Input, Invalid, Mismatch)); |
| 115 | } |
| 116 | let cipher = res!(base2x::HEMATITE64.from_str(blob)); |
| 117 | let plain = res!(state.session_enc.decrypt(&cipher)); |
| 118 | decode_record(&plain) |
| 119 | } |
| 120 | |
| 121 | /// Refuses an expired record. |
| 122 | fn decode_record(bytes: &[u8]) -> Outcome<String> { |
| 123 | // The tag first: a record that does not open with it is not a site-admin |
| 124 | // session, whatever else it might decrypt to. |
| 125 | if bytes.len() < KIND_TAG.len() || &bytes[..KIND_TAG.len()] != KIND_TAG { |
| 126 | return Err(err!( |
| 127 | "Manage session record is not tagged as a site-admin session."; |
| 128 | Input, Invalid, Mismatch)); |
| 129 | } |
| 130 | let mut p = KIND_TAG.len(); |
| 131 | |
| 132 | if p + 2 > bytes.len() { |
| 133 | return Err(err!( |
| 134 | "Manage session record truncated reading the name length."; |
| 135 | Input, Invalid, TooSmall)); |
| 136 | } |
| 137 | let name_len = u16::from_be_bytes([bytes[p], bytes[p + 1]]) as usize; |
| 138 | p += 2; |
| 139 | if name_len > MAX_NAME_LEN { |
| 140 | return Err(err!( |
| 141 | "Manage session record claims a {}-byte name; max is {}.", |
| 142 | name_len, MAX_NAME_LEN; |
| 143 | Input, TooBig)); |
| 144 | } |
| 145 | if p + name_len > bytes.len() { |
| 146 | return Err(err!( |
| 147 | "Manage session record truncated reading the name."; |
| 148 | Input, Invalid, TooSmall)); |
| 149 | } |
| 150 | let name = res!(std::str::from_utf8(&bytes[p..p + name_len]), |
| 151 | Decode, String).to_string(); |
| 152 | p += name_len; |
| 153 | |
| 154 | if p + 8 != bytes.len() { |
| 155 | return Err(err!( |
| 156 | "Manage session record has an unexpected length after the name."; |
| 157 | Input, Invalid)); |
| 158 | } |
| 159 | let mut exp_arr = [0u8; 8]; |
| 160 | exp_arr.copy_from_slice(&bytes[p..p + 8]); |
| 161 | let exp = u64::from_be_bytes(exp_arr); |
| 162 | |
| 163 | let now = now_secs(); |
| 164 | if exp <= now { |
| 165 | return Err(err!( |
| 166 | "Manage session expired at unix {} (now {}).", exp, now; |
| 167 | Input, Invalid, Security)); |
| 168 | } |
| 169 | Ok(name) |
| 170 | } |
| 171 | |
| 172 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 173 | // │ FROM A REQUEST │ |
| 174 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 175 | |
| 176 | /// The raw manage session cookie value a request carries, if any. |
| 177 | /// |
| 178 | /// The bytes as the browser sent them, unverified. Used both as the seed the |
| 179 | /// console's CSRF token is derived from -- it is `HttpOnly` and `SameSite=Strict`, |
| 180 | /// so no script reads it and no cross-site request sends it -- and by |
| 181 | /// [`authenticate`] on its way to a verified name. |
| 182 | pub fn cookie_value(headers: &Arc<HeaderFields>) -> Option<String> { |
| 183 | if let Some(HeaderFieldValue::Cookie(cookies)) = |
| 184 | headers.get_one(&HeaderName::Cookie) |
| 185 | { |
| 186 | for c in cookies { |
| 187 | if c.key == MANAGE_COOKIE_NAME { |
| 188 | return Some(c.val.clone()); |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | None |
| 193 | } |
| 194 | |
| 195 | /// The admin a request's manage session names, if it carries a valid one. |
| 196 | /// |
| 197 | /// Every rejection -- no cookie, a tampered or forged one, an expired one -- |
| 198 | /// flattens to `None`, logged at debug, so the gate can simply fall through to |
| 199 | /// the member paths. |
| 200 | pub fn authenticate(state: &AdminState, headers: &Arc<HeaderFields>) -> Option<String> { |
| 201 | let value = cookie_value(headers)?; |
| 202 | match decode(state, &value) { |
| 203 | Ok(name) => Some(name), |
| 204 | Err(e) => { |
| 205 | debug!("console: manage session rejected: {}", e); |
| 206 | None |
| 207 | } |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 212 | // │ TESTS │ |
| 213 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 214 | |
| 215 | #[cfg(test)] |
| 216 | mod tests { |
| 217 | use super::*; |
| 218 | use crate::srv::admin::{ |
| 219 | host_sampler::HostSampler, |
| 220 | state::AdminState, |
| 221 | traffic::TrafficRecorder, |
| 222 | }; |
| 223 | use oxedyne_fe2o3_crypto::keystore::Wallet; |
| 224 | use std::{ |
| 225 | path::PathBuf, |
| 226 | sync::RwLock, |
| 227 | }; |
| 228 | |
| 229 | fn mkstate() -> AdminState { |
| 230 | AdminState::new( |
| 231 | Arc::new(RwLock::new(Wallet::default())), |
| 232 | PathBuf::from("./wallet.jdat"), |
| 233 | Some([0u8; 32].to_vec()), |
| 234 | 1, |
| 235 | None, |
| 236 | TrafficRecorder::new_shared(0), |
| 237 | HostSampler::new_shared(), |
| 238 | crate::srv::admin::guard::new_shared().expect("addr guard"), |
| 239 | crate::srv::admin::guard::new_shared().expect("auth guard"), |
| 240 | Vec::new(), |
| 241 | None, |
| 242 | ).expect("admin state") |
| 243 | } |
| 244 | |
| 245 | /// A minted session round-trips to the name it was minted for. |
| 246 | #[test] |
| 247 | fn round_trip_00() -> Outcome<()> { |
| 248 | let state = mkstate(); |
| 249 | let cookie = res!(encode(&state, "jason")); |
| 250 | assert!(cookie.starts_with("m1.")); |
| 251 | assert_eq!(res!(decode(&state, &cookie)), fmt!("jason")); |
| 252 | Ok(()) |
| 253 | } |
| 254 | |
| 255 | /// A tampered ciphertext fails to authenticate: the AES-GCM tag does not |
| 256 | /// verify, so no forged name comes back. |
| 257 | #[test] |
| 258 | fn rejects_tampered_01() -> Outcome<()> { |
| 259 | let state = mkstate(); |
| 260 | let cookie = res!(encode(&state, "jason")); |
| 261 | let mut bytes = cookie.into_bytes(); |
| 262 | let idx = bytes.len() - 5; |
| 263 | bytes[idx] ^= 0x01; |
| 264 | let tampered = String::from_utf8_lossy(&bytes).into_owned(); |
| 265 | assert!(decode(&state, &tampered).is_err(), "a tampered manage cookie passed"); |
| 266 | Ok(()) |
| 267 | } |
| 268 | |
| 269 | /// Garbage in the cookie slot is refused, not read as a session. |
| 270 | #[test] |
| 271 | fn rejects_garbage_02() -> Outcome<()> { |
| 272 | let state = mkstate(); |
| 273 | assert!(decode(&state, "not-a-cookie").is_err()); |
| 274 | assert!(decode(&state, "m1.").is_err()); |
| 275 | assert!(decode(&state, "m1.zzzz").is_err()); |
| 276 | assert!(decode(&state, "").is_err()); |
| 277 | Ok(()) |
| 278 | } |
| 279 | |
| 280 | /// A session sealed under one process's key is refused by another's: the |
| 281 | /// key is per-process, so a manage cookie is not portable. |
| 282 | #[test] |
| 283 | fn not_portable_between_processes_03() -> Outcome<()> { |
| 284 | let a = mkstate(); |
| 285 | let b = mkstate(); |
| 286 | let cookie = res!(encode(&a, "jason")); |
| 287 | assert!(decode(&a, &cookie).is_ok()); |
| 288 | assert!(decode(&b, &cookie).is_err(), "a manage cookie crossed processes"); |
| 289 | Ok(()) |
| 290 | } |
| 291 | } |