aboutsummaryrefslogtreecommitdiffstats
path: root/src/access/token.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/access/token.rs')
-rw-r--r--src/access/token.rs284
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, &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));
+ }
+}