//! 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..` (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"; /// Used challenges kept at most, of all users: past it, logins by key wait for some to /// expire. A user's own are fewer (`--key-logins-per-user`). const MAX_USED: usize = 10_000; #[derive(Debug, Serialize, Deserialize)] struct Payload { node: String, nonce: String, exp: u64, } fn message(payload: &[u8]) -> Vec { 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, lifetime_ms: 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 and the user it logged in. #[derive(Debug, Default)] pub(crate) struct Used(HashMap); /// 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, with fewer than /// `per_user` of its own still remembered. Why not, if not. #[allow(clippy::too_many_arguments)] pub(crate) fn take( doc: &impl ReadDoc, key: &NodeKey, challenge: &str, signature: &str, user: &str, now: u64, used: &mut Used, per_user: usize, ) -> 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..)".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.values().filter(|(_, by)| by == user).count() >= per_user { return Err(format!( "{user} logged in by key too often of late: try again in a while" )); } 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, user.to_owned())); 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, 300_000); assert_eq!(exp, 301_000); 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, 100 ) .is_err() ); assert!( take( &doc, &node, &challenge, &signed, "carol", 1001, &mut used, 100 ) .is_err() ); assert!( take( &doc, &node, &challenge, &signed, "dave", 1001, &mut used, 100 ) .is_err() ); let foreign = sshsig::sign(&stranger, NAMESPACE, challenge.as_bytes()); assert!( take( &doc, &node, &challenge, &foreign, "alice", 1001, &mut used, 100 ) .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, 100 ) .is_err() ); // Another node's, expired: refused. assert!( take( &doc, &other_node, &challenge, &signed, "alice", 1001, &mut used, 100 ) .is_err() ); assert!( take( &doc, &node, &challenge, &signed, "alice", exp, &mut used, 100 ) .is_err() ); // Taken, once. assert!( take( &doc, &node, &challenge, &signed, "alice", 1001, &mut used, 100 ) .is_ok() ); assert!( take( &doc, &node, &challenge, &signed, "alice", 1002, &mut used, 100 ) .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, 100 ) .is_err() ); } /// A user's logins by key, as many as it may within a challenge's lifetime; past /// them it waits, until those remembered expire. #[test] fn logins_by_key_per_user() { let node = NodeKey::from_seed([1; 32]); let key = SigningKey::from_bytes(&[5; 32]); let doc = registry_with(&key); let mut used = Used::default(); let login = |now: u64, used: &mut Used| { let (challenge, _) = issue(&node, now, 1_000); let signed = sshsig::sign(&key, NAMESPACE, challenge.as_bytes()); take(&doc, &node, &challenge, &signed, "alice", now, used, 2) }; assert!(login(10, &mut used).is_ok()); assert!(login(20, &mut used).is_ok()); assert!(login(30, &mut used).unwrap_err().contains("too often")); // Those remembered expired: in again. assert!(login(1_015, &mut used).is_ok()); } }