aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/access/challenge.rs205
-rw-r--r--src/access/mod.rs1
-rw-r--r--src/api/login.rs102
-rw-r--r--src/api/mod.rs6
-rw-r--r--src/node.rs1
-rw-r--r--src/state/mod.rs2
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, &registry::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, &registry::user_path("carol"), &carol).unwrap();
+ store::write_path(
+ doc,
+ &registry::user_path("bob"),
+ &registry::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`).