diff options
| author | tv <tv@krebsco.de> | 2026-11-07 22:35:09 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-07 22:35:09 +0100 |
| commit | eed8f86912f53f549d9f9068f9bdf2c8e23aa0f7 (patch) | |
| tree | e60c309a16f479bdb684f9307b148604c9ec45dd /src | |
| parent | de12c862b3f4e69290c8e93b3b4520024ba458f6 (diff) | |
access: tokens
ct1 tokens: who, how they logged in, a session, when they expire, and
optionally a narrower limit; signed by the issuing node's key and taken
by every member (a node ID is its key). Revocation by the registry as
it stands when a request comes.
Auth plan M2.
Diffstat (limited to 'src')
| -rw-r--r-- | src/access/mod.rs | 69 | ||||
| -rw-r--r-- | src/access/registry.rs | 8 | ||||
| -rw-r--r-- | src/access/token.rs | 284 | ||||
| -rw-r--r-- | src/identity.rs | 15 |
4 files changed, 369 insertions, 7 deletions
diff --git a/src/access/mod.rs b/src/access/mod.rs index 8961312..83a47b5 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 registry; +pub(crate) mod token; /// What a capability lets its holder do, at its path and below. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -75,29 +76,60 @@ impl Capability { } /// Everything a user may do: its capabilities, as its registry entry has them; none if -/// it has none, or is disabled. +/// it has none, or is disabled. Narrowed by a token that says less (`limit`, +/// `token::Claims::limit`): then only what both allow. #[derive(Clone, Debug, Default, PartialEq, Eq)] -pub(crate) struct Caps(Vec<Capability>); +pub(crate) struct Caps { + caps: Vec<Capability>, + limit: Option<Vec<Capability>>, +} impl Caps { pub(crate) fn new(caps: Vec<Capability>) -> Self { - Self(caps) + Self { caps, limit: None } + } + + /// These, only where `limit` allows too. + pub(crate) fn within(mut self, limit: Vec<Capability>) -> Self { + self.limit = Some(match self.limit.take() { + // Narrowed twice: the earlier limit's capabilities that lie within the new + // one's (less than what both allow, where one only partly covers the + // other: never more). + Some(mut earlier) => { + earlier.retain(|cap| { + limit.iter().any(|outer| { + cap.path.starts_with(&outer.path) && cap.rights.is_subset(&outer.rights) + }) + }); + earlier + } + None => limit, + }); + self } - /// Whether one of them lets its holder do `right` at `path`. + /// Whether one of them lets its holder do `right` at `path` (and the limit too). pub(crate) fn allows(&self, right: Right, path: &[String]) -> bool { - self.0.iter().any(|cap| cap.covers(right, path)) + self.caps.iter().any(|cap| cap.covers(right, path)) + && self + .limit + .as_ref() + .is_none_or(|limit| limit.iter().any(|cap| cap.covers(right, path))) } /// Whether `path` leads to a place where one of them lets its holder do `right`: a /// listing of `path` shows the way there, as a directory one may pass through but not /// list does (`Capability::reaches`). pub(crate) fn reaches(&self, right: Right, path: &[String]) -> bool { - self.0.iter().any(|cap| cap.reaches(right, path)) + self.caps.iter().any(|cap| cap.reaches(right, path)) + && self + .limit + .as_ref() + .is_none_or(|limit| limit.iter().any(|cap| cap.reaches(right, path))) } pub(crate) fn is_empty(&self) -> bool { - self.0.is_empty() + self.caps.is_empty() || self.limit.as_ref().is_some_and(Vec::is_empty) } } @@ -155,6 +187,29 @@ mod tests { } #[test] + fn a_limit_narrows() { + let caps = + Caps::new(vec![cap("", &["read", "write"])]).within(vec![cap("notes", &["read"])]); + assert!(caps.allows(Right::Read, &path("notes/a"))); + assert!(!caps.allows(Right::Write, &path("notes/a"))); + assert!(!caps.allows(Right::Read, &path("logs"))); + assert!(caps.reaches(Right::Read, &[])); + // Never wider than what the user has. + let narrow = + Caps::new(vec![cap("notes", &["read"])]).within(vec![cap("", &["read", "write"])]); + assert!(!narrow.allows(Right::Write, &path("notes"))); + assert!(!narrow.allows(Right::Read, &path("logs"))); + // Narrowed again: within both. + let twice = caps.within(vec![cap("notes/a", &["read", "write"])]); + assert!(!twice.allows(Right::Read, &path("notes/a"))); + assert!( + Caps::new(vec![cap("", &["read"])]) + .within(Vec::new()) + .is_empty() + ); + } + + #[test] fn a_listing_shows_the_way_to_what_may_be_read() { let caps = Caps::new(vec![cap("servers/eu", &["read"])]); // The way there, as a directory passed through. diff --git a/src/access/registry.rs b/src/access/registry.rs index 9eafc82..e0cbe47 100644 --- a/src/access/registry.rs +++ b/src/access/registry.rs @@ -63,6 +63,9 @@ pub(crate) struct User { pub(crate) by: String, pub(crate) caps: BTreeMap<String, Capability>, pub(crate) disabled: bool, + /// Tokens issued before (Unix ms) are no longer taken (`token::verify`): how all + /// of a user's tokens are revoked at once. + pub(crate) tokens_not_before: u64, } impl User { @@ -90,6 +93,11 @@ impl User { disabled: value .get("disabled") .is_some_and(|disabled| disabled != &JsonValue::Bool(false)), + // A damaged one revokes every token. + tokens_not_before: match value.get("tokensNotBefore") { + None => 0, + Some(at) => at.as_u64().unwrap_or(u64::MAX), + }, } } 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)); + } +} diff --git a/src/identity.rs b/src/identity.rs index 04b7e6f..1012d23 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -88,6 +88,19 @@ pub(crate) fn verify(key: &[u8; 32], message: &[u8], signature: &[u8; 64]) -> bo .is_ok() } +/// The public key of the node with ID `id` (`node_id_of`), if it is one. +pub(crate) fn public_of(id: &str) -> Option<[u8; KEY_BYTES]> { + if !is_node_id(id) { + return None; + } + let hex = id.strip_prefix(NODE_ID_PREFIX)?; + let mut key = [0u8; KEY_BYTES]; + for (i, byte) in key.iter_mut().enumerate() { + *byte = u8::from_str_radix(hex.get(2 * i..2 * i + 2)?, 16).ok()?; + } + Some(key) +} + /// Whether `id` has the form of a node ID. pub(crate) fn is_node_id(id: &str) -> bool { id.strip_prefix(NODE_ID_PREFIX).is_some_and(|hex| { @@ -139,6 +152,8 @@ mod tests { let other = NodeKey::from_seed([3; KEY_BYTES]); assert!(!verify(&other.public(), b"message", &signature)); assert_eq!(node_id_of(&key.public()), key.id()); + assert_eq!(public_of(&key.id()), Some(key.public())); + assert_eq!(public_of("continuum:00"), None); } #[test] |
