diff options
Diffstat (limited to 'src/access/token.rs')
| -rw-r--r-- | src/access/token.rs | 284 |
1 files changed, 284 insertions, 0 deletions
diff --git a/src/access/token.rs b/src/access/token.rs new file mode 100644 index 0000000..5d82aa6 --- /dev/null +++ b/src/access/token.rs @@ -0,0 +1,284 @@ +//! 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.<claims>.<signature>`: 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). + +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<Vec<Capability>>, +} + +/// `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("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`, 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<Claims, Refused> { + 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); + } + 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) + ); + // 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][..])]); + alice["tokensNotBefore"] = json!(1_000); + change(&mut doc, |tx| { + store::write_path(tx, ®istry::user_path("alice"), &alice).unwrap() + }); + assert!(verify(&doc, &token, "s", 1_500).is_ok()); + alice["tokensNotBefore"] = json!(1_001); + change(&mut doc, |tx| { + store::write_path(tx, ®istry::user_path("alice"), &alice).unwrap() + }); + assert_eq!(verify(&doc, &token, "s", 1_500), Err(Refused::Revoked)); + alice["tokensNotBefore"] = json!(0); + alice["disabled"] = json!(true); + change(&mut doc, |tx| { + store::write_path(tx, ®istry::user_path("alice"), &alice).unwrap() + }); + assert_eq!(verify(&doc, &token, "s", 1_500), Err(Refused::Disabled)); + } +} |
