Oregami
Repositories/oxedyne/fe2o3

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
30use crate::srv::admin::{
31 session::now_secs,
32 state::AdminState,
33};
34
35use oxedyne_fe2o3_core::prelude::*;
36use oxedyne_fe2o3_iop_crypto::enc::Encrypter;
37use oxedyne_fe2o3_net::http::fields::{
38 HeaderFields,
39 HeaderFieldValue,
40 HeaderName,
41};
42use oxedyne_fe2o3_text::base2x;
43
44use 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.
48pub const MANAGE_COOKIE_NAME: &str = "manage_session";
49pub const MANAGE_SESSION_TTL_SECS: u64 = 30 * 60;
50pub 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.
54const KIND_TAG: &[u8] = b"steel-site-admin-v1";
55
56const 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.
68pub 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
75fn 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.
103pub 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.
122fn 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.
182pub 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.
200pub 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)]
216mod 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}