aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-07 22:35:09 +0100
committertv <tv@krebsco.de>2026-11-07 22:35:09 +0100
commiteed8f86912f53f549d9f9068f9bdf2c8e23aa0f7 (patch)
treee60c309a16f479bdb684f9307b148604c9ec45dd
parentde12c862b3f4e69290c8e93b3b4520024ba458f6 (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.
-rw-r--r--src/access/mod.rs69
-rw-r--r--src/access/registry.rs8
-rw-r--r--src/access/token.rs284
-rw-r--r--src/identity.rs15
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, &registry::all_caps());
+ change(&mut doc, |tx| {
+ store::write_path(tx, &registry::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, &registry::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, &registry::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, &registry::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]