Skip to main content

clawft_kernel/revocation/
subjects.rs

1//! Revocation of package ids, signer keys and artifact hashes (ADR-099
2//! section 7).
3//!
4//! Extends [`RevocationList`] beyond host bans. Subjects persist in a
5//! sibling file, `revoked_subjects.json`, next to the host ban file, so
6//! the existing host file format is unchanged.
7//!
8//! Failure handling is fail-closed: if the subjects file exists but cannot
9//! be read or parsed, the list is marked *poisoned*. A poisoned list refuses
10//! further writes (so the unreadable evidence is never overwritten) and
11//! [`RevocationList::subjects_error`] reports it, which the workload gate
12//! turns into a deny for every request.
13
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17use tracing::{info, warn};
18
19use super::{RevocationInner, RevocationList};
20
21/// File name of the subject revocation list, next to the host ban file.
22pub const SUBJECTS_FILE_NAME: &str = "revoked_subjects.json";
23/// Chain event kind of a subject revocation (`chain::EVENT_KIND_WORKLOAD_REVOKE`).
24pub const AUDIT_REVOKE_KIND: &str = "workload.revoke";
25/// Chain event kind of a lifted revocation (`chain::EVENT_KIND_WORKLOAD_UNREVOKE`).
26pub const AUDIT_UNREVOKE_KIND: &str = "workload.unrevoke";
27
28/// Records an audit event: `(kind, payload)`.
29pub type AuditSink = std::sync::Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
30
31/// Per-call audit override that takes precedence over the installed [`AuditSink`].
32type AuditOverride<'a> = Option<&'a dyn Fn(&str, serde_json::Value)>;
33
34/// Maximum package id length.
35pub const MAX_PACKAGE_ID_LEN: usize = 128;
36
37/// What a subject revocation targets.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum RevocationKind {
41    /// A package id (e.g. a cog id or model package id).
42    Package,
43    /// An Ed25519 signer public key, 64 hex chars.
44    SignerKey,
45    /// A BLAKE3 artifact hash, 64 hex chars.
46    ArtifactHash,
47}
48
49impl RevocationKind {
50    /// Validate `id` for this kind and return its canonical form.
51    ///
52    /// Hex ids are lowercased so `AB..` and `ab..` revoke the same key.
53    pub fn normalize(self, id: &str) -> Result<String, RevocationError> {
54        match self {
55            RevocationKind::Package => {
56                let ok_len = !id.is_empty() && id.len() <= MAX_PACKAGE_ID_LEN;
57                let ok_first = id.chars().next().is_some_and(|c| c.is_ascii_alphanumeric());
58                let ok_chars = id
59                    .chars()
60                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '@' | '/' | ':' | '+'));
61                if ok_len && ok_first && ok_chars {
62                    // A package id that is a BLAKE3 hex (the usual shape)
63                    // is lowercased like the other hex ids, so `AB..` and
64                    // `ab..` revoke the same package.
65                    if id.len() == 64 && id.chars().all(|c| c.is_ascii_hexdigit()) {
66                        Ok(id.to_ascii_lowercase())
67                    } else {
68                        Ok(id.to_owned())
69                    }
70                } else {
71                    Err(RevocationError::InvalidId {
72                        kind: self,
73                        reason: format!(
74                            "package id must be 1..={MAX_PACKAGE_ID_LEN} chars of [A-Za-z0-9._-@/:+] starting alphanumeric"
75                        ),
76                    })
77                }
78            }
79            RevocationKind::SignerKey | RevocationKind::ArtifactHash => {
80                if id.len() == 64 && id.chars().all(|c| c.is_ascii_hexdigit()) {
81                    Ok(id.to_ascii_lowercase())
82                } else {
83                    Err(RevocationError::InvalidId {
84                        kind: self,
85                        reason: "must be exactly 64 hex characters".into(),
86                    })
87                }
88            }
89        }
90    }
91}
92
93impl std::fmt::Display for RevocationKind {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        match self {
96            RevocationKind::Package => write!(f, "package"),
97            RevocationKind::SignerKey => write!(f, "signer_key"),
98            RevocationKind::ArtifactHash => write!(f, "artifact_hash"),
99        }
100    }
101}
102
103/// One revoked subject.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct RevokedSubject {
106    /// What kind of subject.
107    pub kind: RevocationKind,
108    /// Canonical id (see [`RevocationKind::normalize`]).
109    pub id: String,
110    /// Unix timestamp (seconds) of the revocation.
111    pub revoked_at: u64,
112    /// Human-readable reason.
113    pub reason: String,
114}
115
116/// Errors from subject revocation.
117#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
118pub enum RevocationError {
119    /// The id failed validation.
120    #[error("invalid {kind} id: {reason}")]
121    InvalidId {
122        /// Subject kind.
123        kind: RevocationKind,
124        /// Why it was rejected.
125        reason: String,
126    },
127    /// The subjects file was unreadable at load; writes are refused.
128    #[error("subject revocation list is poisoned: {0}")]
129    Poisoned(String),
130    /// Persisting to disk failed. The in-memory entry is kept (fail-closed).
131    #[error("failed to persist subject revocations: {0}")]
132    Persist(String),
133}
134
135/// Subject state held inside [`RevocationInner`].
136#[derive(Debug, Default)]
137pub(super) struct SubjectState {
138    pub(super) subjects: Vec<RevokedSubject>,
139    pub(super) path: PathBuf,
140    pub(super) poisoned: Option<String>,
141}
142
143impl SubjectState {
144    /// Path of the subjects file for a given host ban file path.
145    pub(super) fn path_for(host_path: &Path) -> PathBuf {
146        host_path.with_file_name(SUBJECTS_FILE_NAME)
147    }
148
149    /// Empty state (no load).
150    pub(super) fn empty(host_path: &Path) -> Self {
151        Self {
152            subjects: Vec::new(),
153            path: Self::path_for(host_path),
154            poisoned: None,
155        }
156    }
157
158    /// Load from disk; a missing file is an empty list, a bad file poisons.
159    pub(super) fn load(host_path: &Path) -> Self {
160        let path = Self::path_for(host_path);
161        if !path.exists() {
162            return Self { subjects: Vec::new(), path, poisoned: None };
163        }
164        let parsed = std::fs::read_to_string(&path)
165            .map_err(|e| format!("read {}: {e}", path.display()))
166            .and_then(|data| {
167                serde_json::from_str::<Vec<RevokedSubject>>(&data)
168                    .map_err(|e| format!("parse {}: {e}", path.display()))
169            });
170        match parsed {
171            Ok(subjects) => {
172                info!(count = subjects.len(), "loaded subject revocation list");
173                Self { subjects, path, poisoned: None }
174            }
175            Err(e) => {
176                warn!(error = %e, "subject revocation list unreadable; failing closed");
177                Self { subjects: Vec::new(), path, poisoned: Some(e) }
178            }
179        }
180    }
181
182    fn save(&self) -> Result<(), RevocationError> {
183        if let Some(parent) = self.path.parent() {
184            std::fs::create_dir_all(parent).map_err(|e| RevocationError::Persist(e.to_string()))?;
185        }
186        let json = serde_json::to_string_pretty(&self.subjects)
187            .map_err(|e| RevocationError::Persist(e.to_string()))?;
188        // Write-then-rename so a crash never leaves a truncated file.
189        let tmp = self.path.with_extension("json.tmp");
190        std::fs::write(&tmp, json).map_err(|e| RevocationError::Persist(e.to_string()))?;
191        std::fs::rename(&tmp, &self.path).map_err(|e| RevocationError::Persist(e.to_string()))
192    }
193}
194
195fn persist_message(e: RevocationError) -> String {
196    match e {
197        RevocationError::Persist(m) => m,
198        other => other.to_string(),
199    }
200}
201
202fn now_secs() -> u64 {
203    std::time::SystemTime::now()
204        .duration_since(std::time::UNIX_EPOCH)
205        .unwrap_or_default()
206        .as_secs()
207}
208
209impl RevocationList {
210    fn with_subjects<T>(&self, f: impl FnOnce(&mut SubjectState) -> T) -> T {
211        let mut inner: std::sync::MutexGuard<'_, RevocationInner> = self.inner.lock().unwrap();
212        f(&mut inner.subjects)
213    }
214
215    /// How many times the subject list has changed in this process. A caller
216    /// that read the list, then did something slow, compares this to know a
217    /// revocation landed in between.
218    pub fn generation(&self) -> u64 {
219        self.generation.load(std::sync::atomic::Ordering::SeqCst)
220    }
221
222    /// Install the sink every revocation and un-revocation is recorded in
223    /// (first call wins). The kernel points it at the chain at boot, so a
224    /// revocation applied by the operator, by a mesh notice or by any other
225    /// caller is chained without that caller having to remember to.
226    pub fn set_audit(&self, sink: AuditSink) -> bool {
227        self.audit.set(sink).is_ok()
228    }
229
230    fn emit(&self, over: AuditOverride<'_>, kind: &str, p: serde_json::Value) {
231        match (over, self.audit.get()) {
232            (Some(f), _) => f(kind, p),
233            (None, Some(sink)) => sink(kind, p),
234            (None, None) => {}
235        }
236    }
237
238    /// Revoke a package id, signer key or artifact hash and persist it,
239    /// recorded as revoked by `local`. See [`Self::revoke_subject_by`].
240    pub fn revoke_subject(
241        &self,
242        kind: RevocationKind,
243        id: &str,
244        reason: &str,
245    ) -> Result<bool, RevocationError> {
246        self.revoke_subject_by(kind, id, reason, "local")
247    }
248
249    /// [`Self::revoke_subject`] naming who revoked it, for the audit record.
250    ///
251    /// Returns `Ok(true)` if newly added, `Ok(false)` if already revoked.
252    /// A new revocation is audited before the persistence result is
253    /// returned: if the write fails the entry still holds in memory
254    /// (fail-closed) and the record says `persisted: false`, so the event
255    /// is never lost with the write. The caller still gets the error.
256    pub fn revoke_subject_by(
257        &self,
258        kind: RevocationKind,
259        id: &str,
260        reason: &str,
261        by: &str,
262    ) -> Result<bool, RevocationError> {
263        self.revoke_audited(kind, id, reason, by, None)
264    }
265
266    /// [`Self::revoke_subject_by`] recording to `sink` instead of the
267    /// installed one.
268    pub fn revoke_audited(
269        &self,
270        kind: RevocationKind,
271        id: &str,
272        reason: &str,
273        by: &str,
274        sink: AuditOverride<'_>,
275    ) -> Result<bool, RevocationError> {
276        let id = kind.normalize(id)?;
277        // `Some(None)`: added and saved; `Some(Some(e))`: added, save failed.
278        let added: Option<Option<String>> = self.with_subjects(|s| {
279            if let Some(p) = &s.poisoned {
280                return Err(RevocationError::Poisoned(p.clone()));
281            }
282            if s.subjects.iter().any(|e| e.kind == kind && e.id == id) {
283                return Ok(None);
284            }
285            s.subjects.push(RevokedSubject {
286                kind,
287                id: id.clone(),
288                revoked_at: now_secs(),
289                reason: reason.to_owned(),
290            });
291            Ok(Some(s.save().err().map(persist_message)))
292        })?;
293        let Some(save_err) = added else {
294            return Ok(false);
295        };
296        self.generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
297        info!(%kind, id, reason, "subject revoked");
298        self.emit(
299            sink,
300            AUDIT_REVOKE_KIND,
301            serde_json::json!({
302                "decision": "revoked",
303                "subject_kind": kind,
304                "subject_id": id,
305                "reason": reason,
306                "revoked_by": by,
307                "persisted": save_err.is_none(),
308                "persist_error": save_err,
309            }),
310        );
311        match save_err {
312            None => Ok(true),
313            Some(e) => Err(RevocationError::Persist(e)),
314        }
315    }
316
317    /// Remove a subject revocation, recorded as lifted by `local`.
318    /// Returns `Ok(true)` if it was present.
319    pub fn unrevoke_subject(&self, kind: RevocationKind, id: &str) -> Result<bool, RevocationError> {
320        self.unrevoke_subject_by(kind, id, "local")
321    }
322
323    /// [`Self::unrevoke_subject`] naming who lifted it. Chained like a
324    /// revocation (`workload.unrevoke`), including when the write fails.
325    pub fn unrevoke_subject_by(
326        &self,
327        kind: RevocationKind,
328        id: &str,
329        by: &str,
330    ) -> Result<bool, RevocationError> {
331        self.unrevoke_audited(kind, id, by, None)
332    }
333
334    /// [`Self::unrevoke_subject_by`] recording to `sink` instead of the
335    /// installed one.
336    pub fn unrevoke_audited(
337        &self,
338        kind: RevocationKind,
339        id: &str,
340        by: &str,
341        sink: AuditOverride<'_>,
342    ) -> Result<bool, RevocationError> {
343        let id = kind.normalize(id)?;
344        let removed: Option<Option<String>> = self.with_subjects(|s| {
345            if let Some(p) = &s.poisoned {
346                return Err(RevocationError::Poisoned(p.clone()));
347            }
348            let before = s.subjects.len();
349            s.subjects.retain(|e| !(e.kind == kind && e.id == id));
350            if s.subjects.len() == before {
351                return Ok(None);
352            }
353            Ok(Some(s.save().err().map(persist_message)))
354        })?;
355        let Some(save_err) = removed else {
356            return Ok(false);
357        };
358        self.generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
359        info!(%kind, id, "subject unrevoked");
360        self.emit(
361            sink,
362            AUDIT_UNREVOKE_KIND,
363            serde_json::json!({
364                "decision": "unrevoked",
365                "subject_kind": kind,
366                "subject_id": id,
367                "unrevoked_by": by,
368                "persisted": save_err.is_none(),
369                "persist_error": save_err,
370            }),
371        );
372        match save_err {
373            None => Ok(true),
374            Some(e) => Err(RevocationError::Persist(e)),
375        }
376    }
377
378    /// Whether a subject is revoked. Invalid ids are never revoked.
379    pub fn is_subject_revoked(&self, kind: RevocationKind, id: &str) -> bool {
380        self.find_subject(kind, id).is_some()
381    }
382
383    /// Look up a revoked subject by kind and id.
384    pub fn find_subject(&self, kind: RevocationKind, id: &str) -> Option<RevokedSubject> {
385        let id = kind.normalize(id).ok()?;
386        self.with_subjects(|s| {
387            s.subjects
388                .iter()
389                .find(|e| e.kind == kind && e.id == id)
390                .cloned()
391        })
392    }
393
394    /// List revoked subjects, optionally filtered by kind.
395    pub fn list_subjects(&self, kind: Option<RevocationKind>) -> Vec<RevokedSubject> {
396        self.with_subjects(|s| {
397            s.subjects
398                .iter()
399                .filter(|e| kind.is_none_or(|k| e.kind == k))
400                .cloned()
401                .collect()
402        })
403    }
404
405    /// First revoked subject among a package id, signers and artifact hashes.
406    pub fn first_revoked<'a>(
407        &self,
408        package_id: Option<&str>,
409        signer_keys: impl IntoIterator<Item = &'a String>,
410        artifact_hashes: impl IntoIterator<Item = &'a String>,
411    ) -> Option<RevokedSubject> {
412        package_id
413            .and_then(|p| self.find_subject(RevocationKind::Package, p))
414            .or_else(|| {
415                signer_keys
416                    .into_iter()
417                    .find_map(|k| self.find_subject(RevocationKind::SignerKey, k))
418            })
419            .or_else(|| {
420                artifact_hashes
421                    .into_iter()
422                    .find_map(|h| self.find_subject(RevocationKind::ArtifactHash, h))
423            })
424    }
425
426    /// Load error of the subjects file, if the list is poisoned.
427    pub fn subjects_error(&self) -> Option<String> {
428        self.with_subjects(|s| s.poisoned.clone())
429    }
430
431    /// Path of the subjects file.
432    pub fn subjects_path(&self) -> PathBuf {
433        self.with_subjects(|s| s.path.clone())
434    }
435}