//! Tokens: a node that checked who a user is (its `--htpasswd`, a proxy's //! `--user-header`) issues one, signed with its node key, and every node of the swarm //! takes it, as the member list gives the keys (`access.md`, Tokens). //! //! `ct1..`: the claims as JSON and an Ed25519 signature by the //! issuing node over `continuum/token/1`, a zero byte and the claims' bytes, both in //! base64url without padding. A token says who (`user`), never what: every node judges //! a request by the registry it holds then, so revoking a capability counts at once //! wherever the change has arrived. It may say less (`limit`): then only what both //! allow (`Caps::within`). //! //! Not Biscuit or UCAN (November 2026): a token only names a user, narrowing is asked //! of a node (which issues a narrower one), and Biscuit would bring about a hundred //! crates, older versions of several of ours among them (Ed25519, SHA-2, protobuf): //! `future-work.md`, Authentication across nodes. use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as BASE64URL}; use serde::{Deserialize, Serialize}; use super::{Capability, registry}; use crate::document::{ReadDoc, meta, store}; use crate::identity::{self, NodeKey}; const PREFIX: &str = "ct1."; const LABEL: &[u8] = b"continuum/token/1\0"; /// How far a token's issue time may be ahead of this node's clock: the issuer's may /// be, within what the swarm warns of (`--clock-skew-warn`). pub(crate) const SKEW_MS: u64 = 5 * 60 * 1000; /// What a token says. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct Claims { /// 1. pub(crate) v: u32, /// The swarm it is for. pub(crate) swarm: String, /// The node that issued it (its node ID): its key signed it. pub(crate) issuer: String, pub(crate) user: String, /// How the user proved who it is, where it logged in: `htpasswd`, `header`. pub(crate) login: String, /// Issued (Unix ms). pub(crate) iat: u64, /// Taken until (Unix ms). pub(crate) exp: u64, /// The login it comes from, kept by refreshed tokens (16 hex digits). pub(crate) session: String, /// Until when it may be refreshed (Unix ms): the login's end. pub(crate) session_exp: u64, /// Less than the user may do: only what these allow too. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) limit: Option>, } /// `claims`, signed by `key`, as a token. pub(crate) fn issue(key: &NodeKey, claims: &Claims) -> String { let payload = serde_json::to_vec(claims).expect("claims serialize"); let mut signed = LABEL.to_vec(); signed.extend_from_slice(&payload); let signature = key.sign(&signed); format!( "{PREFIX}{}.{}", BASE64URL.encode(&payload), BASE64URL.encode(signature) ) } /// Why a token isn't taken. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub(crate) enum Refused { #[error("not a token")] Malformed, #[error("issued for another swarm")] OtherSwarm, #[error("issued by a node that isn't a member here (not yet, or no longer)")] UnknownIssuer, #[error("issued by a node the swarm banned")] BannedIssuer, #[error("its signature doesn't hold")] BadSignature, #[error("expired")] Expired, #[error("issued in the future, by a clock that is off")] Future, #[error("its user isn't registered here")] UnknownUser, #[error("its user is disabled")] Disabled, #[error("revoked: issued before its user's tokens were")] Revoked, } /// The claims of `token` if this node takes it: well formed, for `swarm`, signed by a /// node that is a member in `doc` and not banned, not expired at `now` (Unix ms), nor issued later /// than a clock off by `SKEW_MS` would; and in a swarm with a registry, for a /// registered user that is enabled, issued after its tokens were last revoked /// (`registry::User::tokens_not_before`). pub(crate) fn verify( doc: &impl ReadDoc, token: &str, swarm: &str, now: u64, ) -> Result { let rest = token .trim() .strip_prefix(PREFIX) .ok_or(Refused::Malformed)?; let (payload, signature) = rest.split_once('.').ok_or(Refused::Malformed)?; let payload = BASE64URL.decode(payload).map_err(|_| Refused::Malformed)?; let signature: [u8; 64] = BASE64URL .decode(signature) .ok() .and_then(|bytes| bytes.try_into().ok()) .ok_or(Refused::Malformed)?; let claims: Claims = serde_json::from_slice(&payload).map_err(|_| Refused::Malformed)?; if claims.v != 1 { return Err(Refused::Malformed); } if claims.swarm != swarm { return Err(Refused::OtherSwarm); } let key = identity::public_of(&claims.issuer).ok_or(Refused::Malformed)?; let member = store::read_path(doc, &meta::path(&[meta::MEMBERS, &claims.issuer])) .ok() .flatten() .is_some(); if !member { return Err(Refused::UnknownIssuer); } if super::bans::banned(doc, &claims.issuer) { return Err(Refused::BannedIssuer); } let mut signed = LABEL.to_vec(); signed.extend_from_slice(&payload); if !identity::verify(&key, &signed, &signature) { return Err(Refused::BadSignature); } if claims.exp <= now { return Err(Refused::Expired); } if claims.iat > now.saturating_add(SKEW_MS) { return Err(Refused::Future); } if registry::exists(doc) { let user = registry::user(doc, &claims.user).ok_or(Refused::UnknownUser)?; if user.disabled { return Err(Refused::Disabled); } if claims.iat < user.tokens_not_before { return Err(Refused::Revoked); } } Ok(claims) } /// A new session's ID (8 random bytes, hex). pub(crate) fn new_session() -> String { registry::new_id() } #[cfg(test)] mod tests { use super::*; use crate::access::Right; use crate::document::Document; use crate::document::testing::change; use serde_json::json; fn claims(issuer: &NodeKey) -> Claims { Claims { v: 1, swarm: "s".to_owned(), issuer: issuer.id(), user: "alice".to_owned(), login: "htpasswd".to_owned(), iat: 1_000, exp: 2_000, session: new_session(), session_exp: 10_000, limit: None, } } fn member(doc: &mut Document, key: &NodeKey) { let path = meta::path(&[meta::MEMBERS, &key.id()]); change(doc, |tx| store::write_path(tx, &path, &json!({})).unwrap()); } #[test] fn issued_and_taken() { let key = NodeKey::from_seed([1; 32]); let mut doc = Document::new(); member(&mut doc, &key); let token = issue(&key, &claims(&key)); assert!(token.starts_with("ct1.")); assert_eq!(verify(&doc, &token, "s", 1_500), Ok(claims_of(&token))); assert_eq!(verify(&doc, &token, "t", 1_500), Err(Refused::OtherSwarm)); assert_eq!(verify(&doc, &token, "s", 2_000), Err(Refused::Expired)); assert_eq!(verify(&doc, &token, "s", 0), Ok(claims_of(&token))); let future = Claims { iat: 1_000 + SKEW_MS + 1, exp: u64::MAX, ..claims(&key) }; assert_eq!( verify(&doc, &issue(&key, &future), "s", 0), Err(Refused::Future) ); assert_eq!( verify(&doc, "ct1.e30.AAAA", "s", 1_500), Err(Refused::Malformed) ); assert_eq!(verify(&doc, "Basic x", "s", 1_500), Err(Refused::Malformed)); } fn claims_of(token: &str) -> Claims { let payload = token .strip_prefix(PREFIX) .unwrap() .split('.') .next() .unwrap(); serde_json::from_slice(&BASE64URL.decode(payload).unwrap()).unwrap() } #[test] fn only_members_issue_and_nobody_forges() { let key = NodeKey::from_seed([1; 32]); let stranger = NodeKey::from_seed([2; 32]); let mut doc = Document::new(); member(&mut doc, &key); let token = issue(&stranger, &claims(&stranger)); assert_eq!( verify(&doc, &token, "s", 1_500), Err(Refused::UnknownIssuer) ); // Claiming a member as issuer, signed by another key. let forged = issue(&stranger, &claims(&key)); assert_eq!( verify(&doc, &forged, "s", 1_500), Err(Refused::BadSignature) ); // By a member banned since. let token = issue(&key, &claims(&key)); let ban = crate::access::bans::path(&key.id()); let mut banned = doc.fork(); change(&mut banned, |tx| { store::write_path(tx, &ban, &json!({"reason": "lost"})).unwrap() }); assert_eq!( verify(&banned, &token, "s", 1_500), Err(Refused::BannedIssuer) ); // Changed claims, the signature kept. let token = issue(&key, &claims(&key)); let (_, signature) = token.rsplit_once('.').unwrap(); let other = Claims { user: "mallory".to_owned(), ..claims(&key) }; let payload = BASE64URL.encode(serde_json::to_vec(&other).unwrap()); let tampered = format!("{PREFIX}{payload}.{signature}"); assert_eq!( verify(&doc, &tampered, "s", 1_500), Err(Refused::BadSignature) ); } #[test] fn the_registry_decides_who_still_counts() { let key = NodeKey::from_seed([1; 32]); let mut doc = Document::new(); member(&mut doc, &key); let token = issue(&key, &claims(&key)); // Without a registry, any user. assert!(verify(&doc, &token, "s", 1_500).is_ok()); let bob = registry::entry("x", 0, ®istry::all_caps()); change(&mut doc, |tx| { store::write_path(tx, ®istry::user_path("bob"), &bob).unwrap() }); assert_eq!(verify(&doc, &token, "s", 1_500), Err(Refused::UnknownUser)); let mut alice = registry::entry("x", 0, &[(Vec::new(), &[Right::Read][..])]); change(&mut doc, |tx| { store::write_path(tx, ®istry::user_path("alice"), &alice).unwrap(); store::write_path(tx, ®istry::logouts_path("alice"), &json!(1_000)).unwrap() }); assert!(verify(&doc, &token, "s", 1_500).is_ok()); change(&mut doc, |tx| { store::write_path(tx, ®istry::logouts_path("alice"), &json!(1_001)).unwrap() }); assert_eq!(verify(&doc, &token, "s", 1_500), Err(Refused::Revoked)); alice["disabled"] = json!(true); change(&mut doc, |tx| { store::write_path(tx, ®istry::logouts_path("alice"), &json!(0)).unwrap(); store::write_path(tx, ®istry::user_path("alice"), &alice).unwrap() }); assert_eq!(verify(&doc, &token, "s", 1_500), Err(Refused::Disabled)); } }