diff options
| author | tv <tv@krebsco.de> | 2026-11-07 22:35:21 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-07 22:35:21 +0100 |
| commit | e696d2f40636a6613eddfb1a3afdf87aa7ffdedd (patch) | |
| tree | b2734381ef1d010d0efad209f116b12f625c0f40 /src | |
| parent | 70ff0ec9e50bf506cc6d80b929a81e350289f6cc (diff) | |
auth: logging in by key
POST /auth/challenge and /auth/login/key: a node-signed challenge,
valid five minutes and taken once, signed with one of the user's SSH
keys (ssh-keygen -Y sign -n continuum-login), for a token.
Auth plan M7 (key login, M2b).
Diffstat (limited to 'src')
| -rw-r--r-- | src/access/challenge.rs | 205 | ||||
| -rw-r--r-- | src/access/mod.rs | 1 | ||||
| -rw-r--r-- | src/api/login.rs | 102 | ||||
| -rw-r--r-- | src/api/mod.rs | 6 | ||||
| -rw-r--r-- | src/node.rs | 1 | ||||
| -rw-r--r-- | src/state/mod.rs | 2 |
6 files changed, 310 insertions, 7 deletions
diff --git a/src/access/challenge.rs b/src/access/challenge.rs new file mode 100644 index 0000000..64cafac --- /dev/null +++ b/src/access/challenge.rs @@ -0,0 +1,205 @@ +//! Logging in by key (`POST /auth/challenge`, `POST /auth/login/key`): a node hands out +//! a challenge, signed by it, naming it, valid for a while (`issue`); the user signs it +//! with one of its keys, as `ssh-keygen -Y sign -n continuum-login` does +//! (`access::sshsig`), and the node takes the signature once, from the user that key is +//! of in the registry (`registry::User::keys`; `take`). A challenge is checked by its +//! signature, so a node keeps none it handed out, only those used, until they expire +//! (`Used`). +//! +//! A challenge is `cc1.<payload>.<signature>` (each base64url, unpadded): the payload is +//! JSON naming the node, a random nonce and when it expires (Unix ms); the signature is +//! the node's, over `continuum/challenge/1\0` and the payload. + +use std::collections::HashMap; + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as B64URL}; +use serde::{Deserialize, Serialize}; + +use super::{registry, sshsig}; +use crate::document::ReadDoc; +use crate::identity::NodeKey; + +/// What a user's signature over a challenge is for (`sshsig`). +pub(crate) const NAMESPACE: &str = "continuum-login"; +/// What every challenge starts with. +const PREFIX: &str = "cc1"; +/// How long a challenge is valid (ms). +pub(crate) const LIFETIME_MS: u64 = 5 * 60 * 1000; +/// Used challenges kept at most: past it, logins by key wait for some to expire. +const MAX_USED: usize = 10_000; + +#[derive(Debug, Serialize, Deserialize)] +struct Payload { + node: String, + nonce: String, + exp: u64, +} + +fn message(payload: &[u8]) -> Vec<u8> { + let mut message = b"continuum/challenge/1\0".to_vec(); + message.extend_from_slice(payload); + message +} + +/// A new challenge by `key`'s node, valid from `now` for `LIFETIME_MS`; with when it +/// expires. +pub(crate) fn issue(key: &NodeKey, now: u64) -> (String, u64) { + let payload = Payload { + node: key.id(), + nonce: registry::new_id(), + exp: now.saturating_add(LIFETIME_MS), + }; + let bytes = serde_json::to_vec(&payload).expect("a challenge serializes"); + let signature = key.sign(&message(&bytes)); + ( + format!( + "{PREFIX}.{}.{}", + B64URL.encode(&bytes), + B64URL.encode(signature) + ), + payload.exp, + ) +} + +/// The challenges used, by nonce, with when each expires. +#[derive(Debug, Default)] +pub(crate) struct Used(HashMap<String, u64>); + +/// Takes `signature` (armored) over `challenge` as a login of `user`, whose registry +/// `doc` holds: a challenge of `key`'s node, unexpired at `now`, not used before (then +/// used, in `used`), signed by one of `user`'s keys, `user` enabled. Why not, if not. +pub(crate) fn take( + doc: &impl ReadDoc, + key: &NodeKey, + challenge: &str, + signature: &str, + user: &str, + now: u64, + used: &mut Used, +) -> Result<(), String> { + let mut parts = challenge.split('.'); + let (Some(PREFIX), Some(payload), Some(signed), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return Err("not a challenge (cc1.<payload>.<signature>)".to_owned()); + }; + let bytes = B64URL + .decode(payload) + .map_err(|_| "not a challenge: its payload doesn't decode".to_owned())?; + let by_node: [u8; 64] = B64URL + .decode(signed) + .ok() + .and_then(|signed| signed.try_into().ok()) + .ok_or("not a challenge: its signature doesn't decode")?; + if !crate::identity::verify(&key.public(), &message(&bytes), &by_node) { + return Err("not a challenge this node handed out".to_owned()); + } + let payload: Payload = serde_json::from_slice(&bytes) + .map_err(|_| "not a challenge: its payload isn't one".to_owned())?; + if payload.node != key.id() { + return Err("a challenge of another node".to_owned()); + } + if payload.exp <= now { + return Err("the challenge expired: ask for another".to_owned()); + } + used.0.retain(|_, exp| *exp > now); + if used.0.contains_key(&payload.nonce) { + return Err("the challenge was used: ask for another".to_owned()); + } + if used.0.len() >= MAX_USED { + return Err("too many logins by key at once: try again in a while".to_owned()); + } + let signer = sshsig::verify(signature, NAMESPACE, challenge.as_bytes())?; + let entry = registry::user(doc, user).ok_or_else(|| format!("no user {user}"))?; + if entry.disabled { + return Err(format!("{user} is disabled")); + } + if !entry.keys.values().any(|known| *known == signer) { + return Err(format!("{user} has no key {}", signer.fingerprint())); + } + used.0.insert(payload.nonce, payload.exp); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::document::Document; + use crate::document::store; + use crate::document::testing::change; + use ed25519_dalek::SigningKey; + use serde_json::json; + + /// A registry with alice, holding `key`'s public key, and carol, disabled, holding + /// it too. + fn registry_with(key: &SigningKey) -> Document { + let line = sshsig::PublicKey(key.verifying_key().to_bytes()).line(); + let mut doc = Document::new(); + change(&mut doc, |doc| { + let mut alice = registry::entry("root", 1, &[]); + alice["keys"] = json!({ "laptop": line }); + store::write_path(doc, ®istry::user_path("alice"), &alice).unwrap(); + let mut carol = registry::entry("root", 1, &[]); + carol["keys"] = json!({ "laptop": line }); + carol["disabled"] = json!(true); + store::write_path(doc, ®istry::user_path("carol"), &carol).unwrap(); + store::write_path( + doc, + ®istry::user_path("bob"), + ®istry::entry("root", 1, &[]), + ) + .unwrap(); + }); + doc + } + + /// Signed by one of the user's keys, of this node, unexpired, once: taken; anything + /// else refused. + #[test] + fn what_a_login_by_key_takes() { + let node = NodeKey::from_seed([1; 32]); + let other_node = NodeKey::from_seed([2; 32]); + let key = SigningKey::from_bytes(&[5; 32]); + let stranger = SigningKey::from_bytes(&[6; 32]); + let doc = registry_with(&key); + let mut used = Used::default(); + let (challenge, exp) = issue(&node, 1000); + assert_eq!(exp, 1000 + LIFETIME_MS); + let signed = sshsig::sign(&key, NAMESPACE, challenge.as_bytes()); + // Another user, a disabled one, an unknown one, another key: refused. + assert!(take(&doc, &node, &challenge, &signed, "bob", 1001, &mut used).is_err()); + assert!(take(&doc, &node, &challenge, &signed, "carol", 1001, &mut used).is_err()); + assert!(take(&doc, &node, &challenge, &signed, "dave", 1001, &mut used).is_err()); + let foreign = sshsig::sign(&stranger, NAMESPACE, challenge.as_bytes()); + assert!(take(&doc, &node, &challenge, &foreign, "alice", 1001, &mut used).is_err()); + // For another purpose: refused. + let elsewhere = sshsig::sign(&key, "continuum-value", challenge.as_bytes()); + assert!( + take( + &doc, &node, &challenge, &elsewhere, "alice", 1001, &mut used + ) + .is_err() + ); + // Another node's, expired: refused. + assert!( + take( + &doc, + &other_node, + &challenge, + &signed, + "alice", + 1001, + &mut used + ) + .is_err() + ); + assert!(take(&doc, &node, &challenge, &signed, "alice", exp, &mut used).is_err()); + // Taken, once. + assert!(take(&doc, &node, &challenge, &signed, "alice", 1001, &mut used).is_ok()); + assert!(take(&doc, &node, &challenge, &signed, "alice", 1002, &mut used).is_err()); + // Tampered with. + let tampered = challenge.replacen("cc1.", "cc1.e30", 1); + let resigned = sshsig::sign(&key, NAMESPACE, tampered.as_bytes()); + assert!(take(&doc, &node, &tampered, &resigned, "alice", 1001, &mut used).is_err()); + } +} diff --git a/src/access/mod.rs b/src/access/mod.rs index bdb5124..73ae7c9 100644 --- a/src/access/mod.rs +++ b/src/access/mod.rs @@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize}; use crate::document::meta; pub(crate) mod bans; +pub(crate) mod challenge; pub(crate) mod envelope; pub(crate) mod policy; pub(crate) mod registry; diff --git a/src/api/login.rs b/src/api/login.rs index 72a4d9d..75d6982 100644 --- a/src/api/login.rs +++ b/src/api/login.rs @@ -5,6 +5,9 @@ //! a token, signed by this node, valid for `--token-lifetime`, refreshable until //! `--session-lifetime` after the login. Optionally less than the user may do //! (`{"limit": [{"path": [...], "rights": [...]}, …]}`). +//! * `POST /auth/challenge` and `POST /auth/login/key`, without a password: a challenge +//! to sign with one of the user's keys, then the same token for it, signed +//! (`access::challenge`). //! * `POST /auth/refresh`, with a token: a new one for the same login, signed by this //! node, optionally narrower still (`limit`, within the token's). //! * `POST /auth/logout`: every token of the user, of every login, no longer taken @@ -32,7 +35,8 @@ use super::auth::{AuthenticatedUser, Via}; use super::users::CapRequest; use super::{ApiError, error_response}; use crate::access::token::{self, Claims}; -use crate::access::{Capability, registry}; +use crate::access::{Capability, challenge, registry}; +use crate::locks::MutexExt; use crate::state::AppState; /// The token a request came with, as taken (a request extension, set by `bearer`). @@ -173,13 +177,27 @@ pub(crate) async fn login( )); } } + let claims = new_login(&state, swarm, now, &user.0, via.0, limit); + info!(user = %user.0, login = via.0, session = %claims.session, "Logged in"); + answer(&state, &claims) +} + +/// The claims of a new login of `user`, by `login`, now: a session of its own. +fn new_login( + state: &AppState, + swarm: String, + now: u64, + user: &str, + login: &str, + limit: Option<Vec<Capability>>, +) -> Claims { let session_exp = now.saturating_add(state.session_lifetime.as_millis() as u64); - let claims = Claims { + Claims { v: 1, swarm, issuer: state.local_id().to_owned(), - user: user.0.clone(), - login: via.0.to_owned(), + user: user.to_owned(), + login: login.to_owned(), iat: now, exp: now .saturating_add(state.token_lifetime.as_millis() as u64) @@ -187,8 +205,80 @@ pub(crate) async fn login( session: token::new_session(), session_exp, limit, - }; - info!(user = %user.0, login = via.0, session = %claims.session, "Logged in"); + } +} + +/// `POST /auth/challenge`: one to sign for a login by key, valid for five minutes. +pub(crate) async fn challenge(State(state): State<AppState>) -> Result<Response, ApiError> { + let (_, now) = issuing(&state).await?; + let key = state + .node_key + .as_ref() + .ok_or(ApiError(StatusCode::SERVICE_UNAVAILABLE, None))?; + let (challenge, exp) = challenge::issue(key, now); + Ok(Json(json!({ + "challenge": challenge, + "namespace": challenge::NAMESPACE, + "expiresAt": exp, + })) + .into_response()) +} + +/// A login by key's request. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct KeyLogin { + user: String, + /// As `POST /auth/challenge` gave it. + challenge: String, + /// The user's signature of it, armored, as `ssh-keygen -Y sign -n continuum-login` + /// writes it. + signature: String, + #[serde(default)] + limit: Option<Vec<CapRequest>>, +} + +/// `POST /auth/login/key`: a token, as `POST /auth/login` gives one, for a challenge +/// signed by one of the user's keys (`401` if not, saying why); `409` in a swarm without +/// a registry, which holds no keys. +pub(crate) async fn login_key( + State(state): State<AppState>, + Json(request): Json<KeyLogin>, +) -> Result<Response, ApiError> { + let limit = LimitRequest { + limit: request.limit, + } + .limit()?; + let (swarm, now) = issuing(&state).await?; + let key = state + .node_key + .as_ref() + .ok_or(ApiError(StatusCode::SERVICE_UNAVAILABLE, None))?; + { + let replica = state.doc.read().await; + if !registry::exists(&replica.doc) { + return Err(ApiError( + StatusCode::CONFLICT, + Some("no user registry, which holds the users' keys".to_owned()), + )); + } + let mut used = state.challenges.lock_or_recover(); + challenge::take( + &replica.doc, + key, + &request.challenge, + &request.signature, + &request.user, + now, + &mut used, + ) + .map_err(|why| { + debug!(user = %request.user, reason = %why, "Refused a login by key"); + ApiError(StatusCode::UNAUTHORIZED, Some(why)) + })?; + } + let claims = new_login(&state, swarm, now, &request.user, "key", limit); + info!(user = %request.user, login = "key", session = %claims.session, "Logged in"); answer(&state, &claims) } diff --git a/src/api/mod.rs b/src/api/mod.rs index a12e219..2a523a4 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -40,7 +40,8 @@ use crate::document::store::StoreError; use crate::state::AppState; /// Builds the complete HTTP router. Everything except the API description (`/swagger-ui`, -/// `/openapi.yaml`), `/health` and `/ready` is authenticated. +/// `/openapi.yaml`), `/health`, `/ready` and logging in by key (`/auth/challenge`, +/// `/auth/login/key`, which prove the user themselves) is authenticated. pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> Router { let mut api: Router<AppState> = Router::new() .route("/store", get(store::get_store_root)) @@ -117,6 +118,9 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> SwaggerUi::new("/swagger-ui").config(utoipa_swagger_ui::Config::from("../openapi.yaml")), ) .route("/openapi.yaml", get(openapi_spec)) + // Logging in by key: a challenge, and the token for it signed, without a password. + .route("/auth/challenge", axum::routing::post(login::challenge)) + .route("/auth/login/key", axum::routing::post(login::login_key)) .route("/health", get(health::node_health)) .route("/ready", get(health::ready)) // Outermost, so authentication failures get the error body too. diff --git a/src/node.rs b/src/node.rs index 736723b..a5e512e 100644 --- a/src/node.rs +++ b/src/node.rs @@ -163,6 +163,7 @@ impl Node { node_key: config.node_key, sign_every: config.sign_every, screening: Default::default(), + challenges: Default::default(), token_lifetime: config.token_lifetime, session_lifetime: config.session_lifetime, cors_origins: Arc::new(config.cors_origins), diff --git a/src/state/mod.rs b/src/state/mod.rs index 7ee0acb..5a36faa 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -153,6 +153,8 @@ pub(crate) struct AppState { /// Received changes waiting for their dependencies, and those refused, while the /// policy has them judged (`access::screen`). Locked after the document. pub(crate) screening: Arc<std::sync::Mutex<crate::access::screen::Screening>>, + /// The challenges logins by key used, until they expire (`access::challenge`). + pub(crate) challenges: Arc<std::sync::Mutex<crate::access::challenge::Used>>, pub(crate) token_lifetime: Duration, pub(crate) session_lifetime: Duration, /// Origins whose pages may call this node's open endpoints (`api::cors`). |
