Oregami
Repositories/oxedyne/fe2o3

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
30use 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
46use oxedyne_fe2o3_core::prelude::*;
47use oxedyne_fe2o3_crypto::command::SignedCommand;
48use oxedyne_fe2o3_jdat::prelude::*;
49use oxedyne_fe2o3_net::http::{
50 fields::{
51 HeaderFieldValue,
52 HeaderName,
53 },
54 msg::HttpMessage,
55 status::HttpStatus,
56};
57
58use std::time::{
59 Duration,
60 SystemTime,
61 UNIX_EPOCH,
62};
63
64
65pub const SIGNED_LOGIN_FRESHNESS_SECS: u64 = 120;
66pub 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.
70pub 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.
84pub 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)]
114pub 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.
127pub 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.
143pub 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.
221fn 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.
233pub 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)]
270mod 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}