Skip to main content

clawft_kernel/
token_authority.rs

1//! Token authority (ADR-102 D3/D4, ADR-103 user role).
2//!
3//! The daemon is the single issuer and validator of bearer tokens. A
4//! token is a 256-bit random secret shown once to the issuer; only its
5//! SHA-256 is kept. Every issue and revoke is a chain event, so the table
6//! is rebuilt at boot as `issued - revoked - expired`.
7//!
8//! The chain carries the SHA-256 of the secret (needed to validate after a
9//! restart) but never the secret itself. The 16-hex token id is the first
10//! 16 characters of that hash, so the hash reveals nothing that helps an
11//! attacker: a 256-bit preimage is not searchable.
12//!
13//! Scope is [`TokenScope::Owner`] (full surface, ADR-102 D4, an owner
14//! decision): a token is owner-equivalent, including lifecycle verbs such
15//! as `kernel.shutdown`, with one exception enforced in the daemon: a
16//! token cannot issue, revoke or list tokens. TTL is the limit: default
17//! 15 minutes, maximum 24 hours.
18//!
19//! Durability: the chain is saved only on clean shutdown, so a revocation
20//! held only on the chain would fail open after a crash. Each revoke is
21//! therefore also appended to a small fsynced journal
22//! ([`TokenAuthority::with_journal`]) before it is acknowledged. The
23//! journal holds revocations ONLY and rebuild accepts `issued` events
24//! only from the chain: anyone who can write the journal file could
25//! otherwise mint an owner token for a secret of their choosing. A forged
26//! revoke is at worst a denial of service. Losing an issue in a crash
27//! fails closed (the token does not validate), which is acceptable.
28
29use std::collections::HashMap;
30use std::sync::{Arc, Mutex};
31
32use chrono::{DateTime, Duration, Utc};
33use rand::RngCore;
34use serde::Serialize;
35use serde_json::{Value, json};
36use sha2::{Digest, Sha256};
37
38use crate::chain::ChainManager;
39
40/// Chain event source for token events.
41pub const SOURCE: &str = "auth.token";
42/// Chain event kind for an issue.
43pub const KIND_ISSUED: &str = "auth.token.issued";
44/// Chain event kind for a revoke.
45pub const KIND_REVOKED: &str = "auth.token.revoked";
46/// Default token lifetime.
47pub const DEFAULT_TTL: Duration = Duration::minutes(15);
48/// Longest token lifetime accepted.
49pub const MAX_TTL: Duration = Duration::hours(24);
50/// Longest accepted label, in characters.
51pub const MAX_LABEL_LEN: usize = 64;
52/// Prefix on issued secrets, so a leaked one is recognisable.
53pub const SECRET_PREFIX: &str = "wft_";
54
55/// What a token grants.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
57#[serde(rename_all = "snake_case")]
58pub enum TokenScope {
59    /// Full surface (ADR-102 D4).
60    Owner,
61    /// A per-project child kernel's credential (ADR-103 A6, Phase 2 G):
62    /// read and write only, never Admin, bound to one project by
63    /// [`TokenInfo::project`]. It cannot mint, revoke or list tokens and
64    /// reaches no Admin-gated method.
65    Project,
66    /// A read-only operator credential (ADR-102 D4 amendment): `Read` only,
67    /// never Write or Admin. The gateway opens only its GET read routes to
68    /// it, so a console need not hold an owner-equivalent token. It may also
69    /// carry a project ([`TokenInfo::project`]): the daemon then treats the
70    /// caller as that project's (fleet snapshot filtered) and the gateway
71    /// confines it to that project.
72    Read,
73}
74
75impl TokenScope {
76    /// The literal capability scopes this token grants (see
77    /// `capability::CallerCapabilities::from_scopes` in the weave crate).
78    pub fn capability_scopes(self) -> &'static [&'static str] {
79        match self {
80            TokenScope::Owner => &["admin"],
81            TokenScope::Project => &["write"],
82            TokenScope::Read => &["read"],
83        }
84    }
85
86    /// An absent scope is an old owner token; an unknown one is `None`, so
87    /// the token is dropped (fail closed) rather than promoted to owner.
88    fn parse(s: Option<&str>) -> Option<Self> {
89        match s {
90            None | Some("owner") => Some(TokenScope::Owner),
91            Some("project") => Some(TokenScope::Project),
92            Some("read") => Some(TokenScope::Read),
93            Some(_) => None,
94        }
95    }
96
97    fn as_str(self) -> &'static str {
98        match self {
99            TokenScope::Owner => "owner",
100            TokenScope::Project => "project",
101            TokenScope::Read => "read",
102        }
103    }
104}
105
106/// Public metadata of a live token. Never contains the secret or hash.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
108pub struct TokenInfo {
109    pub id: String,
110    pub label: String,
111    pub issued_at: DateTime<Utc>,
112    pub expires_at: DateTime<Utc>,
113    pub scope: TokenScope,
114    /// Optional project claim (ADR-103), a ULID string.
115    pub project: Option<String>,
116}
117
118/// Why an issue was refused.
119#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
120pub enum TokenError {
121    #[error("ttl must be positive")]
122    TtlNotPositive,
123    #[error("ttl exceeds the 24 h maximum")]
124    TtlTooLong,
125    #[error("label must be 1-{MAX_LABEL_LEN} printable characters")]
126    BadLabel,
127    #[error("could not persist the token record: {0}")]
128    Persist(String),
129}
130
131/// Who asked for the token (recorded on the chain, unverified in Phase 1).
132#[derive(Debug, Clone, Default)]
133pub struct Issuer {
134    pub uid: Option<u32>,
135}
136
137struct Entry {
138    hash: [u8; 32],
139    info: TokenInfo,
140}
141
142/// The token table, backed by chain events.
143pub struct TokenAuthority {
144    chain: Arc<ChainManager>,
145    node_id: String,
146    journal: Option<std::path::PathBuf>,
147    /// Serialises journal appends (open + write + fsync).
148    journal_lock: Mutex<()>,
149    table: Mutex<HashMap<String, Entry>>,
150}
151
152fn hash_secret(secret: &str) -> [u8; 32] {
153    Sha256::digest(secret.as_bytes()).into()
154}
155
156fn id_of(hash: &[u8; 32]) -> String {
157    hex(hash)[..16].to_owned()
158}
159
160fn hex(bytes: &[u8]) -> String {
161    bytes.iter().map(|b| format!("{b:02x}")).collect()
162}
163
164fn unhex32(s: &str) -> Option<[u8; 32]> {
165    if s.len() != 64 || !s.is_ascii() {
166        return None;
167    }
168    let mut out = [0u8; 32];
169    for (i, o) in out.iter_mut().enumerate() {
170        *o = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
171    }
172    Some(out)
173}
174
175/// Constant-time equality over two hashes.
176fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool {
177    a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
178}
179
180fn parse_time(v: &Value, key: &str) -> Option<DateTime<Utc>> {
181    DateTime::parse_from_rfc3339(v.get(key)?.as_str()?)
182        .ok()
183        .map(|t| t.with_timezone(&Utc))
184}
185
186/// WARN when the directory holding the journal is group/world-writable.
187fn warn_if_dir_writable(path: &std::path::Path) {
188    #[cfg(unix)]
189    if let Some(dir) = path.parent()
190        && let Ok(m) = std::fs::metadata(dir)
191    {
192        use std::os::unix::fs::PermissionsExt;
193        if m.permissions().mode() & 0o022 != 0 {
194            tracing::warn!(dir = %dir.display(), "token journal directory is group/world-writable");
195        }
196    }
197    #[cfg(not(unix))]
198    let _ = path;
199}
200
201/// One token event, from the chain or the journal.
202struct Record<'a> {
203    kind: &'a str,
204    payload: &'a Value,
205}
206
207/// Parse an `issued` payload; `None` for anything malformed, forged
208/// (id not the hash prefix) or longer-lived than [`MAX_TTL`].
209fn parse_issued(p: &Value) -> Option<Entry> {
210    let id = p.get("id").and_then(Value::as_str)?;
211    let hash = p.get("sha256").and_then(Value::as_str).and_then(unhex32)?;
212    let issued_at = parse_time(p, "issued_at")?;
213    let expires_at = parse_time(p, "expires_at")?;
214    let life = expires_at - issued_at;
215    if id_of(&hash) != id || life <= Duration::zero() || life > MAX_TTL {
216        return None;
217    }
218    Some(Entry {
219        hash,
220        info: TokenInfo {
221            id: id.to_owned(),
222            label: p
223                .get("label")
224                .and_then(Value::as_str)
225                .unwrap_or_default()
226                .to_owned(),
227            issued_at,
228            expires_at,
229            scope: TokenScope::parse(p.get("scope").and_then(Value::as_str))?,
230            project: p.get("project").and_then(Value::as_str).map(str::to_owned),
231        },
232    })
233}
234
235/// Fold records into the live table: issued minus revoked minus expired.
236/// The revocation set wins over any `issued` for the same id, whatever
237/// the order, so a replayed or forged re-issue cannot revive a token.
238fn fold(records: &[Record<'_>], now: DateTime<Utc>) -> HashMap<String, Entry> {
239    let mut revoked = std::collections::HashSet::new();
240    let mut table = HashMap::new();
241    for r in records {
242        match r.kind {
243            KIND_ISSUED => {
244                if let Some(e) = parse_issued(r.payload) {
245                    table.insert(e.info.id.clone(), e);
246                }
247            }
248            KIND_REVOKED => {
249                if let Some(id) = r.payload.get("id").and_then(Value::as_str) {
250                    revoked.insert(id.to_owned());
251                }
252            }
253            _ => {}
254        }
255    }
256    table.retain(|id, e| !revoked.contains(id) && e.info.expires_at > now);
257    table
258}
259
260impl TokenAuthority {
261    /// Build the authority over `chain` (no journal) and replay it.
262    pub fn new(chain: Arc<ChainManager>, node_id: impl Into<String>) -> Self {
263        Self::with_journal(chain, node_id, None)
264    }
265
266    /// Build the authority with a durable revocation journal at `journal`
267    /// (see the module docs). `None` means revocations are not crash-durable.
268    pub fn with_journal(
269        chain: Arc<ChainManager>,
270        node_id: impl Into<String>,
271        journal: Option<std::path::PathBuf>,
272    ) -> Self {
273        match &journal {
274            None => tracing::warn!(
275                "token authority has no journal path: revocations are not durable across a crash"
276            ),
277            Some(path) => warn_if_dir_writable(path),
278        }
279        let a = Self {
280            chain,
281            node_id: node_id.into(),
282            journal,
283            journal_lock: Mutex::new(()),
284            table: Mutex::new(HashMap::new()),
285        };
286        a.rebuild_at(Utc::now());
287        a
288    }
289
290    /// Strong references to the underlying chain (the authority holds one).
291    pub fn chain_refs(&self) -> usize {
292        Arc::strong_count(&self.chain)
293    }
294
295    /// Revoked ids recorded in the journal. Lines that cannot be parsed
296    /// are logged loudly (a lost revocation is security-relevant); `issued`
297    /// lines are ignored by design.
298    fn read_journal(&self) -> Vec<Value> {
299        let Some(path) = &self.journal else {
300            return Vec::new();
301        };
302        let text = match std::fs::read_to_string(path) {
303            Ok(t) => t,
304            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
305            Err(e) => {
306                tracing::error!(path = %path.display(), error = %e, "token journal unreadable: revocations may be lost");
307                return Vec::new();
308            }
309        };
310        let mut out = Vec::new();
311        for (n, line) in text.lines().enumerate() {
312            if line.trim().is_empty() {
313                continue;
314            }
315            let Ok(v) = serde_json::from_str::<Value>(line) else {
316                tracing::warn!(path = %path.display(), line = n + 1, "token journal: unparseable line skipped; a revocation may be lost");
317                continue;
318            };
319            let kind = v.get("kind").and_then(Value::as_str);
320            let payload = v.get("payload");
321            match (kind, payload) {
322                (Some(KIND_REVOKED), Some(p)) if p.get("id").and_then(Value::as_str).is_some() => {
323                    out.push(p.clone());
324                }
325                (Some(KIND_ISSUED), _) => {
326                    tracing::warn!(path = %path.display(), line = n + 1, "token journal: ignoring an `issued` entry (the journal is revocations only)");
327                }
328                _ => {
329                    tracing::warn!(path = %path.display(), line = n + 1, "token journal: malformed entry skipped");
330                }
331            }
332        }
333        out
334    }
335
336    /// Append one revocation to the journal and fsync it. One `write_all`
337    /// of a newline-terminated line under a lock; a torn previous tail is
338    /// terminated first so it cannot swallow this record.
339    fn journal_revoke(&self, payload: &Value) -> Result<(), TokenError> {
340        use std::io::{Read, Seek, SeekFrom, Write};
341        let Some(path) = &self.journal else {
342            return Ok(());
343        };
344        let mut line =
345            json!({ "source": SOURCE, "kind": KIND_REVOKED, "payload": payload }).to_string();
346        line.push('\n');
347        let _guard = self.journal_lock.lock().unwrap_or_else(|e| e.into_inner());
348        let mut write = || -> std::io::Result<()> {
349            let dir = path.parent();
350            if let Some(dir) = dir {
351                std::fs::create_dir_all(dir)?;
352            }
353            let created = !path.exists();
354            let mut opts = std::fs::OpenOptions::new();
355            opts.create(true).append(true).read(true);
356            #[cfg(unix)]
357            {
358                use std::os::unix::fs::OpenOptionsExt;
359                opts.mode(0o600);
360            }
361            let mut f = opts.open(path)?;
362            let len = f.metadata()?.len();
363            if len > 0 {
364                f.seek(SeekFrom::Start(len - 1))?;
365                let mut last = [0u8; 1];
366                f.read_exact(&mut last)?;
367                if last[0] != b'\n' {
368                    line.insert(0, '\n');
369                }
370            }
371            f.write_all(line.as_bytes())?;
372            f.sync_all()?;
373            if created && let Some(dir) = dir {
374                std::fs::File::open(dir)?.sync_all()?;
375            }
376            Ok(())
377        };
378        write().map_err(|e| TokenError::Persist(e.to_string()))
379    }
380
381    /// Replay the chain and journal: issued minus revoked minus expired at
382    /// `now`. Only events from [`SOURCE`] count.
383    pub fn rebuild_at(&self, now: DateTime<Utc>) {
384        // Held across the journal read AND the prune rewrite, so a revoke
385        // appended between them is never dropped by the rewrite.
386        let _guard = self.journal_lock.lock().unwrap_or_else(|e| e.into_inner());
387        let events = self.chain.tail_from(0);
388        let revoked_in_journal = self.read_journal();
389        let mut records: Vec<Record<'_>> = Vec::new();
390        for ev in &events {
391            if ev.source != SOURCE {
392                continue;
393            }
394            if let Some(payload) = ev.payload.as_ref() {
395                records.push(Record {
396                    kind: ev.kind.as_str(),
397                    payload,
398                });
399            }
400        }
401        for payload in &revoked_in_journal {
402            records.push(Record {
403                kind: KIND_REVOKED,
404                payload,
405            });
406        }
407        *self.table.lock().unwrap_or_else(|e| e.into_inner()) = fold(&records, now);
408        self.prune_journal(&events, &revoked_in_journal, now);
409    }
410
411    /// Drop journal revocations whose token can no longer be live
412    /// (`issued_at + MAX_TTL <= horizon`, by the chain's issued event). A
413    /// revocation whose issued event is unknown is kept. Rewrites
414    /// atomically (temp, fsync, rename, fsync dir, mode 0600), and only
415    /// when something is dropped.
416    ///
417    /// The horizon is `min(now, newest chain event time)`, never the bare
418    /// wall clock: a daemon booting with a clock far in the future (a Pi
419    /// with a bad RTC) must not delete revocations of tokens that are still
420    /// live by the real clock. The chain's newest event is a time the
421    /// daemon itself recorded, so a token expired by then is expired for
422    /// real; pruning merely lags by one boot. Caller holds `journal_lock`
423    /// ([`Self::rebuild_at`]).
424    fn prune_journal(
425        &self,
426        events: &[crate::chain::ChainEvent],
427        revoked: &[Value],
428        now: DateTime<Utc>,
429    ) {
430        use std::io::Write;
431        let Some(path) = &self.journal else { return };
432        let Some(newest) = events.iter().map(|e| e.timestamp).max() else {
433            return;
434        };
435        let horizon = now.min(newest);
436        let mut issued_at: HashMap<&str, DateTime<Utc>> = HashMap::new();
437        for ev in events {
438            if ev.source != SOURCE || ev.kind != KIND_ISSUED {
439                continue;
440            }
441            if let Some(p) = ev.payload.as_ref()
442                && let (Some(id), Some(t)) = (
443                    p.get("id").and_then(Value::as_str),
444                    parse_time(p, "issued_at"),
445                )
446            {
447                issued_at.insert(id, t);
448            }
449        }
450        let keep: Vec<&Value> = revoked
451            .iter()
452            .filter(|p| {
453                let id = p.get("id").and_then(Value::as_str).unwrap_or_default();
454                issued_at.get(id).is_none_or(|t| *t + MAX_TTL > horizon)
455            })
456            .collect();
457        if keep.len() == revoked.len() {
458            return;
459        }
460        let write = || -> std::io::Result<()> {
461            let tmp = path.with_extension("jsonl.tmp");
462            let mut opts = std::fs::OpenOptions::new();
463            opts.create(true).write(true).truncate(true);
464            #[cfg(unix)]
465            {
466                use std::os::unix::fs::OpenOptionsExt;
467                opts.mode(0o600);
468            }
469            let mut f = opts.open(&tmp)?;
470            for p in &keep {
471                let mut line =
472                    json!({ "source": SOURCE, "kind": KIND_REVOKED, "payload": p }).to_string();
473                line.push('\n');
474                f.write_all(line.as_bytes())?;
475            }
476            f.sync_all()?;
477            std::fs::rename(&tmp, path)?;
478            if let Some(dir) = path.parent() {
479                std::fs::File::open(dir)?.sync_all()?;
480            }
481            Ok(())
482        };
483        if let Err(e) = write() {
484            tracing::warn!(path = %path.display(), error = %e, "token journal prune failed; keeping the old file");
485        }
486    }
487
488    /// Issue a token. Returns the secret (shown once) and its metadata.
489    pub fn issue(
490        &self,
491        label: &str,
492        ttl: Option<Duration>,
493        project: Option<String>,
494        issuer: &Issuer,
495    ) -> Result<(String, TokenInfo), TokenError> {
496        self.issue_at(Utc::now(), label, ttl, project, issuer)
497    }
498
499    /// [`Self::issue`] at an explicit clock.
500    pub fn issue_at(
501        &self,
502        now: DateTime<Utc>,
503        label: &str,
504        ttl: Option<Duration>,
505        project: Option<String>,
506        issuer: &Issuer,
507    ) -> Result<(String, TokenInfo), TokenError> {
508        self.issue_scoped_at(now, label, ttl, project, TokenScope::Owner, issuer)
509    }
510
511    /// Issue a [`TokenScope::Project`] token for `project_id`: Write only,
512    /// never Admin, valid for `ttl`.
513    pub fn issue_project(
514        &self,
515        project_id: &str,
516        ttl: Duration,
517        issuer: &Issuer,
518    ) -> Result<(String, TokenInfo), TokenError> {
519        self.issue_scoped_at(
520            Utc::now(),
521            &format!("project:{project_id}"),
522            Some(ttl),
523            Some(project_id.to_owned()),
524            TokenScope::Project,
525            issuer,
526        )
527    }
528
529    /// Issue with an explicit scope at an explicit clock.
530    pub fn issue_scoped_at(
531        &self,
532        now: DateTime<Utc>,
533        label: &str,
534        ttl: Option<Duration>,
535        project: Option<String>,
536        scope: TokenScope,
537        issuer: &Issuer,
538    ) -> Result<(String, TokenInfo), TokenError> {
539        let ttl = ttl.unwrap_or(DEFAULT_TTL);
540        if ttl <= Duration::zero() {
541            return Err(TokenError::TtlNotPositive);
542        }
543        if ttl > MAX_TTL {
544            return Err(TokenError::TtlTooLong);
545        }
546        if label.is_empty()
547            || label.chars().count() > MAX_LABEL_LEN
548            || label.chars().any(char::is_control)
549        {
550            return Err(TokenError::BadLabel);
551        }
552        let mut raw = [0u8; 32];
553        rand::rngs::OsRng.fill_bytes(&mut raw);
554        let secret = format!("{SECRET_PREFIX}{}", hex(&raw));
555        let hash = hash_secret(&secret);
556        let info = TokenInfo {
557            id: id_of(&hash),
558            label: label.to_owned(),
559            issued_at: now,
560            expires_at: now + ttl,
561            scope,
562            project,
563        };
564        let mut payload = json!({
565            "id": info.id,
566            "sha256": hex(&hash),
567            "label": info.label,
568            "issued_at": info.issued_at.to_rfc3339(),
569            "expires_at": info.expires_at.to_rfc3339(),
570            "issuer": { "node_id": self.node_id, "uid": issuer.uid, "uid_verified": false },
571        });
572        if let Some(p) = &info.project {
573            payload["project"] = json!(p);
574        }
575        if scope != TokenScope::Owner {
576            payload["scope"] = json!(scope.as_str());
577        }
578        self.chain.append(SOURCE, KIND_ISSUED, Some(payload));
579        self.lock().insert(
580            info.id.clone(),
581            Entry {
582                hash,
583                info: info.clone(),
584            },
585        );
586        Ok((secret, info))
587    }
588
589    /// Revoke by id. Returns whether a live token was revoked. The token
590    /// stops validating immediately even if journaling then fails (the
591    /// error is returned so the caller knows the revocation may not
592    /// survive a crash).
593    pub fn revoke(&self, id: &str) -> Result<bool, TokenError> {
594        if self.lock().remove(id).is_none() {
595            return Ok(false);
596        }
597        let payload = json!({ "id": id, "revoked_at": Utc::now().to_rfc3339() });
598        self.chain
599            .append(SOURCE, KIND_REVOKED, Some(payload.clone()));
600        self.journal_revoke(&payload)?;
601        Ok(true)
602    }
603
604    /// Validate a presented secret. Compares hashes in constant time.
605    pub fn validate(&self, secret: &str) -> Option<TokenInfo> {
606        self.validate_at(Utc::now(), secret)
607    }
608
609    /// [`Self::validate`] at an explicit clock.
610    pub fn validate_at(&self, now: DateTime<Utc>, secret: &str) -> Option<TokenInfo> {
611        let hash = hash_secret(secret);
612        let table = self.lock();
613        let e = table.get(&id_of(&hash))?;
614        (ct_eq(&e.hash, &hash) && e.info.expires_at > now).then(|| e.info.clone())
615    }
616
617    /// Live tokens, oldest first.
618    pub fn list(&self) -> Vec<TokenInfo> {
619        self.list_at(Utc::now())
620    }
621
622    /// [`Self::list`] at an explicit clock.
623    pub fn list_at(&self, now: DateTime<Utc>) -> Vec<TokenInfo> {
624        let mut v: Vec<_> = self
625            .lock()
626            .values()
627            .filter(|e| e.info.expires_at > now)
628            .map(|e| e.info.clone())
629            .collect();
630        v.sort_by(|a, b| a.issued_at.cmp(&b.issued_at).then_with(|| a.id.cmp(&b.id)));
631        v
632    }
633
634    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Entry>> {
635        self.table.lock().unwrap_or_else(|e| e.into_inner())
636    }
637}
638
639#[cfg(test)]
640mod tests;