oxedyne/fe2o3/fe2o3_steel/src/srv/admin/signed_login.rs
12.5 KiB, 63 runs
created by r1870400018:11578, 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-admin-login handler for the dashboard. |
| 2 | //! |
| 3 | //! Lets an operator authenticate to the admin dashboard by |
| 4 | //! presenting a [`SignedCommand`] rather than a wallet passphrase. |
| 5 | //! The inbound command names a `signer_id`, which the handler |
| 6 | //! looks up in the vhost's [`AdminKey`] list; a successful signature |
| 7 | //! verification against the matching public key, within the |
| 8 | //! freshness window, issues the same session cookie the passphrase |
| 9 | //! flow issues. |
| 10 | //! |
| 11 | //! The classical passphrase login stays available at `/admin/login`; |
| 12 | //! this is a parallel path. A Steel deployment that configures no |
| 13 | //! `admin_keys` never sees the new endpoints active. |
| 14 | //! |
| 15 | //! # Replay protection |
| 16 | //! |
| 17 | //! The handler owns a small |
| 18 | //! [`NonceTracker`](oxedyne_fe2o3_net::guard::nonce::NonceTracker) that rejects duplicate |
| 19 | //! `(signer_id, nonce)` pairs, keyed by the signer. A command whose |
| 20 | //! timestamp is more than [`SIGNED_LOGIN_FRESHNESS_SECS`] (120 s) |
| 21 | //! either side of now is rejected up front by |
| 22 | //! [`SignedCommand::verify_fresh_at`], and the tracker holds each pair |
| 23 | //! until that window after the later of the command's timestamp and |
| 24 | //! its first showing, which is as long as the freshness check could |
| 25 | //! still accept it. Both read one clock value. |
| 26 | //! |
| 27 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 28 | //! Anthropic Claude |
| 29 | |
| 30 | use crate::srv::{ |
| 31 | admin::{ |
| 32 | AdminPrincipal, |
| 33 | SCOPE_WILDCARD, |
| 34 | SCOPE_DASHBOARD_VIEW, |
| 35 | SCOPE_DASHBOARD_ADMIN, |
| 36 | audit::{ |
| 37 | self, |
| 38 | ADMIN_ANON, |
| 39 | VERB_DASHBOARD_LOGIN, |
| 40 | }, |
| 41 | state::AdminState, |
| 42 | }, |
| 43 | cfg::AdminKey, |
| 44 | }; |
| 45 | |
| 46 | use oxedyne_fe2o3_core::prelude::*; |
| 47 | use oxedyne_fe2o3_crypto::command::SignedCommand; |
| 48 | use oxedyne_fe2o3_jdat::prelude::*; |
| 49 | use oxedyne_fe2o3_net::http::{ |
| 50 | fields::{ |
| 51 | HeaderFieldValue, |
| 52 | HeaderName, |
| 53 | }, |
| 54 | msg::HttpMessage, |
| 55 | status::HttpStatus, |
| 56 | }; |
| 57 | |
| 58 | use std::time::{ |
| 59 | Duration, |
| 60 | SystemTime, |
| 61 | UNIX_EPOCH, |
| 62 | }; |
| 63 | |
| 64 | |
| 65 | pub const SIGNED_LOGIN_FRESHNESS_SECS: u64 = 120; |
| 66 | pub const CMD_ADMIN_LOGIN: &str = "admin_login"; |
| 67 | |
| 68 | // The signed-login session does not auto-renew: the caller presents a new |
| 69 | // SignedCommand once it expires. |
| 70 | pub const SIGNED_LOGIN_SESSION_SECS: u64 = 3600; |
| 71 | |
| 72 | |
| 73 | /// Builds the challenge response, a JDAT map carrying: |
| 74 | /// |
| 75 | /// - `server_timestamp`: the server's current unix seconds, for clients that |
| 76 | /// want to align their SignedCommand timestamp with the server's clock. |
| 77 | /// - `freshness_secs`: the size of the freshness window. |
| 78 | /// - `accept_cmd`: the string the inbound command must carry as its `cmd` |
| 79 | /// field (`"admin_login"`). |
| 80 | /// |
| 81 | /// The endpoint does *not* issue a nonce -- nonces are client-generated and |
| 82 | /// carried in the SignedCommand itself, so replay protection happens at verify |
| 83 | /// time. |
| 84 | pub fn handle_challenge(_state: &AdminState) -> HttpMessage { |
| 85 | let now = SystemTime::now() |
| 86 | .duration_since(UNIX_EPOCH) |
| 87 | .map(|d| d.as_secs()) |
| 88 | .unwrap_or(0); |
| 89 | let mut m = DaticleMap::new(); |
| 90 | m.insert(dat!("server_timestamp"), dat!(now)); |
| 91 | m.insert(dat!("freshness_secs"), dat!(SIGNED_LOGIN_FRESHNESS_SECS)); |
| 92 | m.insert(dat!("accept_cmd"), dat!(CMD_ADMIN_LOGIN.to_string())); |
| 93 | let body = Dat::Map(m); |
| 94 | let bytes = match body.as_bytes() { |
| 95 | Ok(b) => b, |
| 96 | Err(e) => { |
| 97 | error!(e, "signed-login challenge: JDAT encoding failed"); |
| 98 | return HttpMessage::respond_with_text( |
| 99 | HttpStatus::InternalServerError, |
| 100 | "Challenge encoding failed.", |
| 101 | ); |
| 102 | }, |
| 103 | }; |
| 104 | HttpMessage::new_response(HttpStatus::OK) |
| 105 | .with_field( |
| 106 | HeaderName::ContentType, |
| 107 | HeaderFieldValue::Generic("application/jdat".to_string()), |
| 108 | ) |
| 109 | .with_body(bytes) |
| 110 | } |
| 111 | |
| 112 | |
| 113 | #[derive(Debug)] |
| 114 | pub enum SignedLoginOutcome { |
| 115 | Ok(AdminPrincipal), |
| 116 | MalformedBody { reason: String }, // body did not parse as a JDAT SignedCommand |
| 117 | WrongCmd { got: String }, // `cmd` is not `admin_login` |
| 118 | UnknownSigner, // signer not in this vhost's `admin_keys` |
| 119 | BadSignature { reason: String }, // bad signature, or outside the freshness window |
| 120 | ReplayedNonce, // nonce already seen inside the replay window |
| 121 | NoDashboardScope { name: String }, // signature valid, no dashboard scope configured |
| 122 | } |
| 123 | |
| 124 | |
| 125 | /// Verifies a signed-admin-login envelope against the configured `admin_keys`. |
| 126 | /// Stateless apart from recording the nonce. |
| 127 | pub fn verify_signed_login( |
| 128 | state: &AdminState, |
| 129 | body: &[u8], |
| 130 | ) |
| 131 | -> SignedLoginOutcome |
| 132 | { |
| 133 | let now = SystemTime::now() |
| 134 | .duration_since(UNIX_EPOCH) |
| 135 | .map(|d| d.as_secs()) |
| 136 | .unwrap_or(0); |
| 137 | verify_signed_login_at(state, body, now) |
| 138 | } |
| 139 | |
| 140 | /// As [`verify_signed_login`], at `now` in unix seconds. The freshness check |
| 141 | /// and the replay record read the same clock value, so a pair the tracker has |
| 142 | /// let go of is one freshness would refuse. |
| 143 | pub fn verify_signed_login_at( |
| 144 | state: &AdminState, |
| 145 | body: &[u8], |
| 146 | now: u64, |
| 147 | ) |
| 148 | -> SignedLoginOutcome |
| 149 | { |
| 150 | // Parse the envelope. |
| 151 | let (dat, _) = match Dat::from_bytes(body) { |
| 152 | Ok(v) => v, |
| 153 | Err(e) => return SignedLoginOutcome::MalformedBody { |
| 154 | reason: fmt!("JDAT decode failed: {}", e), |
| 155 | }, |
| 156 | }; |
| 157 | let env = match SignedCommand::from_dat(dat) { |
| 158 | Ok(e) => e, |
| 159 | Err(e) => return SignedLoginOutcome::MalformedBody { |
| 160 | reason: fmt!("SignedCommand extraction failed: {}", e), |
| 161 | }, |
| 162 | }; |
| 163 | if env.cmd != CMD_ADMIN_LOGIN { |
| 164 | return SignedLoginOutcome::WrongCmd { got: env.cmd }; |
| 165 | } |
| 166 | |
| 167 | // Match signer against the configured admin_keys list. |
| 168 | let admin_key = match match_admin_key(&state.admin_keys, &env.signer_id) { |
| 169 | Some(a) => a, |
| 170 | None => return SignedLoginOutcome::UnknownSigner, |
| 171 | }; |
| 172 | |
| 173 | // Verify signature + freshness. |
| 174 | if let Err(e) = env.verify_fresh_at( |
| 175 | &admin_key.public_key, |
| 176 | now, |
| 177 | Duration::from_secs(SIGNED_LOGIN_FRESHNESS_SECS), |
| 178 | ) { |
| 179 | return SignedLoginOutcome::BadSignature { |
| 180 | reason: fmt!("{}", e), |
| 181 | }; |
| 182 | } |
| 183 | |
| 184 | // Reject replays. |
| 185 | { |
| 186 | let mut tracker = match state.nonce_tracker.lock() { |
| 187 | Ok(t) => t, |
| 188 | Err(_) => return SignedLoginOutcome::BadSignature { |
| 189 | reason: "nonce tracker poisoned".to_string(), |
| 190 | }, |
| 191 | }; |
| 192 | if tracker.record(&env.signer_id, &env.nonce, env.timestamp, now).is_err() { |
| 193 | return SignedLoginOutcome::ReplayedNonce; |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | // Scope check: ensure the caller can actually use the dashboard. |
| 198 | let scopes = admin_key.scopes.clone(); |
| 199 | let has_dashboard_scope = scopes.iter().any(|s| |
| 200 | s == SCOPE_WILDCARD |
| 201 | || s == SCOPE_DASHBOARD_VIEW |
| 202 | || s == SCOPE_DASHBOARD_ADMIN |
| 203 | ); |
| 204 | if !has_dashboard_scope { |
| 205 | return SignedLoginOutcome::NoDashboardScope { |
| 206 | name: admin_key.name.clone(), |
| 207 | }; |
| 208 | } |
| 209 | |
| 210 | let expires_at = now.saturating_add(SIGNED_LOGIN_SESSION_SECS); |
| 211 | SignedLoginOutcome::Ok(AdminPrincipal { |
| 212 | name: admin_key.name.clone(), |
| 213 | scopes, |
| 214 | expires_at, |
| 215 | }) |
| 216 | } |
| 217 | |
| 218 | |
| 219 | /// The envelope's `signer_id` is expected to be the public key bytes; the first |
| 220 | /// matching entry wins. |
| 221 | fn match_admin_key<'a>( |
| 222 | admin_keys: &'a [AdminKey], |
| 223 | signer_id: &[u8], |
| 224 | ) |
| 225 | -> Option<&'a AdminKey> |
| 226 | { |
| 227 | admin_keys.iter().find(|k| k.public_key.as_slice() == signer_id) |
| 228 | } |
| 229 | |
| 230 | |
| 231 | /// Writes the outcome to the admin audit log with a short reason tag, in the |
| 232 | /// passphrase flow's line format. |
| 233 | pub fn audit_signed_login(outcome: &SignedLoginOutcome) { |
| 234 | match outcome { |
| 235 | SignedLoginOutcome::Ok(principal) => audit::append( |
| 236 | &principal.name, |
| 237 | VERB_DASHBOARD_LOGIN, |
| 238 | "ok", |
| 239 | &fmt!("signed; scopes={}", principal.scopes.join(",")), |
| 240 | ), |
| 241 | SignedLoginOutcome::MalformedBody { reason } => audit::append( |
| 242 | ADMIN_ANON, VERB_DASHBOARD_LOGIN, "err", |
| 243 | &fmt!("signed; reason=malformed_body: {}", reason), |
| 244 | ), |
| 245 | SignedLoginOutcome::WrongCmd { got } => audit::append( |
| 246 | ADMIN_ANON, VERB_DASHBOARD_LOGIN, "err", |
| 247 | &fmt!("signed; reason=wrong_cmd: got={}", got), |
| 248 | ), |
| 249 | SignedLoginOutcome::UnknownSigner => audit::append( |
| 250 | ADMIN_ANON, VERB_DASHBOARD_LOGIN, "err", |
| 251 | "signed; reason=unknown_signer", |
| 252 | ), |
| 253 | SignedLoginOutcome::BadSignature { reason } => audit::append( |
| 254 | ADMIN_ANON, VERB_DASHBOARD_LOGIN, "err", |
| 255 | &fmt!("signed; reason=bad_signature: {}", reason), |
| 256 | ), |
| 257 | SignedLoginOutcome::ReplayedNonce => audit::append( |
| 258 | ADMIN_ANON, VERB_DASHBOARD_LOGIN, "err", |
| 259 | "signed; reason=replayed_nonce", |
| 260 | ), |
| 261 | SignedLoginOutcome::NoDashboardScope { name } => audit::append( |
| 262 | name, VERB_DASHBOARD_LOGIN, "err", |
| 263 | "signed; reason=no_dashboard_scope", |
| 264 | ), |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | |
| 269 | #[cfg(test)] |
| 270 | mod tests { |
| 271 | use super::*; |
| 272 | use crate::srv::admin::{ |
| 273 | guard, |
| 274 | host_sampler::HostSampler, |
| 275 | traffic::TrafficRecorder, |
| 276 | }; |
| 277 | |
| 278 | use oxedyne_fe2o3_crypto::{ |
| 279 | keystore::Wallet, |
| 280 | sign::SignatureScheme, |
| 281 | }; |
| 282 | use oxedyne_fe2o3_iop_crypto::keys::KeyManager; |
| 283 | |
| 284 | use std::{ |
| 285 | path::PathBuf, |
| 286 | sync::{ |
| 287 | Arc, |
| 288 | RwLock, |
| 289 | }, |
| 290 | }; |
| 291 | |
| 292 | /// An admin state with one Ed25519 admin key of every scope, and that key. |
| 293 | fn ops_state() -> Outcome<(AdminState, SignatureScheme, Vec<u8>)> { |
| 294 | let key = SignatureScheme::new_ed25519(); |
| 295 | let public = match res!(key.get_public_key()) { |
| 296 | Some(pk) => pk.to_vec(), |
| 297 | None => return Err(err!("A new Ed25519 key has no public half."; Test, Missing)), |
| 298 | }; |
| 299 | let state = res!(AdminState::new( |
| 300 | Arc::new(RwLock::new(Wallet::default())), |
| 301 | PathBuf::from("./wallet.jdat"), |
| 302 | Some([0u8; 32].to_vec()), |
| 303 | 0, // no databases |
| 304 | None, // no alerter |
| 305 | TrafficRecorder::new_shared(0), |
| 306 | HostSampler::new_shared(), |
| 307 | res!(guard::new_shared()), |
| 308 | res!(guard::new_shared()), |
| 309 | vec![AdminKey { |
| 310 | name: "ops".to_string(), |
| 311 | public_key: public.clone(), |
| 312 | scheme: "Ed25519".to_string(), |
| 313 | scopes: vec![SCOPE_WILDCARD.to_string()], |
| 314 | }], |
| 315 | None, |
| 316 | )); |
| 317 | Ok((state, key, public)) |
| 318 | } |
| 319 | |
| 320 | /// The replay guard is wired into the signed login, wherever the tracker |
| 321 | /// lives: one signed envelope logs in once, and its second showing inside |
| 322 | /// the window is refused. |
| 323 | #[test] |
| 324 | fn test_a_replayed_signed_login_is_refused_00() -> Outcome<()> { |
| 325 | let (state, key, public) = res!(ops_state()); |
| 326 | let env = res!(SignedCommand::sign(public, CMD_ADMIN_LOGIN, Dat::Empty, &key)); |
| 327 | let body = res!(res!(env.to_dat()).as_bytes()); |
| 328 | match verify_signed_login(&state, &body) { |
| 329 | SignedLoginOutcome::Ok(principal) => req!(principal.name, "ops".to_string()), |
| 330 | other => return Err(err!("A fresh signed login earned {:?}.", other; Test)), |
| 331 | } |
| 332 | match verify_signed_login(&state, &body) { |
| 333 | SignedLoginOutcome::ReplayedNonce => (), |
| 334 | other => return Err(err!("The same signed login shown twice earned {:?}.", other; Test)), |
| 335 | } |
| 336 | Ok(()) |
| 337 | } |
| 338 | |
| 339 | /// An envelope stamped 110 s ahead is fresh until 230 s after it is first |
| 340 | /// shown, so the tracker must hold its nonce that long. Shown at `t` and |
| 341 | /// again at `t + 121`, the second showing is a replay. |
| 342 | #[test] |
| 343 | fn test_a_future_stamped_signed_login_is_not_replayed_00() -> Outcome<()> { |
| 344 | let (state, key, public) = res!(ops_state()); |
| 345 | let t = 1_800_000_000; |
| 346 | let env = res!(SignedCommand::sign_with( |
| 347 | public, CMD_ADMIN_LOGIN.to_string(), Dat::Empty, &key, t + 110, [7u8; 32])); |
| 348 | let body = res!(res!(env.to_dat()).as_bytes()); |
| 349 | match verify_signed_login_at(&state, &body, t) { |
| 350 | SignedLoginOutcome::Ok(_) => (), |
| 351 | other => return Err(err!("A signed login stamped 110 s ahead earned {:?}.", other; Test)), |
| 352 | } |
| 353 | match verify_signed_login_at(&state, &body, t + 121) { |
| 354 | SignedLoginOutcome::ReplayedNonce => (), |
| 355 | other => return Err(err!("Its replay 121 s later earned {:?}.", other; Test)), |
| 356 | } |
| 357 | match verify_signed_login_at(&state, &body, t + 231) { |
| 358 | SignedLoginOutcome::BadSignature { .. } => (), |
| 359 | other => return Err(err!("Its replay once stale earned {:?}.", other; Test)), |
| 360 | } |
| 361 | Ok(()) |
| 362 | } |
| 363 | } |