clawft-kernel 0.8.3

WeftOS kernel: boot sequence, process table, service registry, and health subsystem
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
//! Revocation of package ids, signer keys and artifact hashes (ADR-099
//! section 7).
//!
//! Extends [`RevocationList`] beyond host bans. Subjects persist in a
//! sibling file, `revoked_subjects.json`, next to the host ban file, so
//! the existing host file format is unchanged.
//!
//! Failure handling is fail-closed: if the subjects file exists but cannot
//! be read or parsed, the list is marked *poisoned*. A poisoned list refuses
//! further writes (so the unreadable evidence is never overwritten) and
//! [`RevocationList::subjects_error`] reports it, which the workload gate
//! turns into a deny for every request.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};
use tracing::{info, warn};

use super::{RevocationInner, RevocationList};

/// File name of the subject revocation list, next to the host ban file.
pub const SUBJECTS_FILE_NAME: &str = "revoked_subjects.json";
/// Chain event kind of a subject revocation (`chain::EVENT_KIND_WORKLOAD_REVOKE`).
pub const AUDIT_REVOKE_KIND: &str = "workload.revoke";
/// Chain event kind of a lifted revocation (`chain::EVENT_KIND_WORKLOAD_UNREVOKE`).
pub const AUDIT_UNREVOKE_KIND: &str = "workload.unrevoke";

/// Records an audit event: `(kind, payload)`.
pub type AuditSink = std::sync::Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;

/// Per-call audit override that takes precedence over the installed [`AuditSink`].
type AuditOverride<'a> = Option<&'a dyn Fn(&str, serde_json::Value)>;

/// Maximum package id length.
pub const MAX_PACKAGE_ID_LEN: usize = 128;

/// What a subject revocation targets.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RevocationKind {
    /// A package id (e.g. a cog id or model package id).
    Package,
    /// An Ed25519 signer public key, 64 hex chars.
    SignerKey,
    /// A BLAKE3 artifact hash, 64 hex chars.
    ArtifactHash,
}

impl RevocationKind {
    /// Validate `id` for this kind and return its canonical form.
    ///
    /// Hex ids are lowercased so `AB..` and `ab..` revoke the same key.
    pub fn normalize(self, id: &str) -> Result<String, RevocationError> {
        match self {
            RevocationKind::Package => {
                let ok_len = !id.is_empty() && id.len() <= MAX_PACKAGE_ID_LEN;
                let ok_first = id.chars().next().is_some_and(|c| c.is_ascii_alphanumeric());
                let ok_chars = id
                    .chars()
                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '@' | '/' | ':' | '+'));
                if ok_len && ok_first && ok_chars {
                    // A package id that is a BLAKE3 hex (the usual shape)
                    // is lowercased like the other hex ids, so `AB..` and
                    // `ab..` revoke the same package.
                    if id.len() == 64 && id.chars().all(|c| c.is_ascii_hexdigit()) {
                        Ok(id.to_ascii_lowercase())
                    } else {
                        Ok(id.to_owned())
                    }
                } else {
                    Err(RevocationError::InvalidId {
                        kind: self,
                        reason: format!(
                            "package id must be 1..={MAX_PACKAGE_ID_LEN} chars of [A-Za-z0-9._-@/:+] starting alphanumeric"
                        ),
                    })
                }
            }
            RevocationKind::SignerKey | RevocationKind::ArtifactHash => {
                if id.len() == 64 && id.chars().all(|c| c.is_ascii_hexdigit()) {
                    Ok(id.to_ascii_lowercase())
                } else {
                    Err(RevocationError::InvalidId {
                        kind: self,
                        reason: "must be exactly 64 hex characters".into(),
                    })
                }
            }
        }
    }
}

impl std::fmt::Display for RevocationKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RevocationKind::Package => write!(f, "package"),
            RevocationKind::SignerKey => write!(f, "signer_key"),
            RevocationKind::ArtifactHash => write!(f, "artifact_hash"),
        }
    }
}

/// One revoked subject.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RevokedSubject {
    /// What kind of subject.
    pub kind: RevocationKind,
    /// Canonical id (see [`RevocationKind::normalize`]).
    pub id: String,
    /// Unix timestamp (seconds) of the revocation.
    pub revoked_at: u64,
    /// Human-readable reason.
    pub reason: String,
}

/// Errors from subject revocation.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RevocationError {
    /// The id failed validation.
    #[error("invalid {kind} id: {reason}")]
    InvalidId {
        /// Subject kind.
        kind: RevocationKind,
        /// Why it was rejected.
        reason: String,
    },
    /// The subjects file was unreadable at load; writes are refused.
    #[error("subject revocation list is poisoned: {0}")]
    Poisoned(String),
    /// Persisting to disk failed. The in-memory entry is kept (fail-closed).
    #[error("failed to persist subject revocations: {0}")]
    Persist(String),
}

/// Subject state held inside [`RevocationInner`].
#[derive(Debug, Default)]
pub(super) struct SubjectState {
    pub(super) subjects: Vec<RevokedSubject>,
    pub(super) path: PathBuf,
    pub(super) poisoned: Option<String>,
}

impl SubjectState {
    /// Path of the subjects file for a given host ban file path.
    pub(super) fn path_for(host_path: &Path) -> PathBuf {
        host_path.with_file_name(SUBJECTS_FILE_NAME)
    }

    /// Empty state (no load).
    pub(super) fn empty(host_path: &Path) -> Self {
        Self {
            subjects: Vec::new(),
            path: Self::path_for(host_path),
            poisoned: None,
        }
    }

    /// Load from disk; a missing file is an empty list, a bad file poisons.
    pub(super) fn load(host_path: &Path) -> Self {
        let path = Self::path_for(host_path);
        if !path.exists() {
            return Self { subjects: Vec::new(), path, poisoned: None };
        }
        let parsed = std::fs::read_to_string(&path)
            .map_err(|e| format!("read {}: {e}", path.display()))
            .and_then(|data| {
                serde_json::from_str::<Vec<RevokedSubject>>(&data)
                    .map_err(|e| format!("parse {}: {e}", path.display()))
            });
        match parsed {
            Ok(subjects) => {
                info!(count = subjects.len(), "loaded subject revocation list");
                Self { subjects, path, poisoned: None }
            }
            Err(e) => {
                warn!(error = %e, "subject revocation list unreadable; failing closed");
                Self { subjects: Vec::new(), path, poisoned: Some(e) }
            }
        }
    }

    fn save(&self) -> Result<(), RevocationError> {
        if let Some(parent) = self.path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| RevocationError::Persist(e.to_string()))?;
        }
        let json = serde_json::to_string_pretty(&self.subjects)
            .map_err(|e| RevocationError::Persist(e.to_string()))?;
        // Write-then-rename so a crash never leaves a truncated file.
        let tmp = self.path.with_extension("json.tmp");
        std::fs::write(&tmp, json).map_err(|e| RevocationError::Persist(e.to_string()))?;
        std::fs::rename(&tmp, &self.path).map_err(|e| RevocationError::Persist(e.to_string()))
    }
}

fn persist_message(e: RevocationError) -> String {
    match e {
        RevocationError::Persist(m) => m,
        other => other.to_string(),
    }
}

fn now_secs() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

impl RevocationList {
    fn with_subjects<T>(&self, f: impl FnOnce(&mut SubjectState) -> T) -> T {
        let mut inner: std::sync::MutexGuard<'_, RevocationInner> = self.inner.lock().unwrap();
        f(&mut inner.subjects)
    }

    /// How many times the subject list has changed in this process. A caller
    /// that read the list, then did something slow, compares this to know a
    /// revocation landed in between.
    pub fn generation(&self) -> u64 {
        self.generation.load(std::sync::atomic::Ordering::SeqCst)
    }

    /// Install the sink every revocation and un-revocation is recorded in
    /// (first call wins). The kernel points it at the chain at boot, so a
    /// revocation applied by the operator, by a mesh notice or by any other
    /// caller is chained without that caller having to remember to.
    pub fn set_audit(&self, sink: AuditSink) -> bool {
        self.audit.set(sink).is_ok()
    }

    fn emit(&self, over: AuditOverride<'_>, kind: &str, p: serde_json::Value) {
        match (over, self.audit.get()) {
            (Some(f), _) => f(kind, p),
            (None, Some(sink)) => sink(kind, p),
            (None, None) => {}
        }
    }

    /// Revoke a package id, signer key or artifact hash and persist it,
    /// recorded as revoked by `local`. See [`Self::revoke_subject_by`].
    pub fn revoke_subject(
        &self,
        kind: RevocationKind,
        id: &str,
        reason: &str,
    ) -> Result<bool, RevocationError> {
        self.revoke_subject_by(kind, id, reason, "local")
    }

    /// [`Self::revoke_subject`] naming who revoked it, for the audit record.
    ///
    /// Returns `Ok(true)` if newly added, `Ok(false)` if already revoked.
    /// A new revocation is audited before the persistence result is
    /// returned: if the write fails the entry still holds in memory
    /// (fail-closed) and the record says `persisted: false`, so the event
    /// is never lost with the write. The caller still gets the error.
    pub fn revoke_subject_by(
        &self,
        kind: RevocationKind,
        id: &str,
        reason: &str,
        by: &str,
    ) -> Result<bool, RevocationError> {
        self.revoke_audited(kind, id, reason, by, None)
    }

    /// [`Self::revoke_subject_by`] recording to `sink` instead of the
    /// installed one.
    pub fn revoke_audited(
        &self,
        kind: RevocationKind,
        id: &str,
        reason: &str,
        by: &str,
        sink: AuditOverride<'_>,
    ) -> Result<bool, RevocationError> {
        let id = kind.normalize(id)?;
        // `Some(None)`: added and saved; `Some(Some(e))`: added, save failed.
        let added: Option<Option<String>> = self.with_subjects(|s| {
            if let Some(p) = &s.poisoned {
                return Err(RevocationError::Poisoned(p.clone()));
            }
            if s.subjects.iter().any(|e| e.kind == kind && e.id == id) {
                return Ok(None);
            }
            s.subjects.push(RevokedSubject {
                kind,
                id: id.clone(),
                revoked_at: now_secs(),
                reason: reason.to_owned(),
            });
            Ok(Some(s.save().err().map(persist_message)))
        })?;
        let Some(save_err) = added else {
            return Ok(false);
        };
        self.generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        info!(%kind, id, reason, "subject revoked");
        self.emit(
            sink,
            AUDIT_REVOKE_KIND,
            serde_json::json!({
                "decision": "revoked",
                "subject_kind": kind,
                "subject_id": id,
                "reason": reason,
                "revoked_by": by,
                "persisted": save_err.is_none(),
                "persist_error": save_err,
            }),
        );
        match save_err {
            None => Ok(true),
            Some(e) => Err(RevocationError::Persist(e)),
        }
    }

    /// Remove a subject revocation, recorded as lifted by `local`.
    /// Returns `Ok(true)` if it was present.
    pub fn unrevoke_subject(&self, kind: RevocationKind, id: &str) -> Result<bool, RevocationError> {
        self.unrevoke_subject_by(kind, id, "local")
    }

    /// [`Self::unrevoke_subject`] naming who lifted it. Chained like a
    /// revocation (`workload.unrevoke`), including when the write fails.
    pub fn unrevoke_subject_by(
        &self,
        kind: RevocationKind,
        id: &str,
        by: &str,
    ) -> Result<bool, RevocationError> {
        self.unrevoke_audited(kind, id, by, None)
    }

    /// [`Self::unrevoke_subject_by`] recording to `sink` instead of the
    /// installed one.
    pub fn unrevoke_audited(
        &self,
        kind: RevocationKind,
        id: &str,
        by: &str,
        sink: AuditOverride<'_>,
    ) -> Result<bool, RevocationError> {
        let id = kind.normalize(id)?;
        let removed: Option<Option<String>> = self.with_subjects(|s| {
            if let Some(p) = &s.poisoned {
                return Err(RevocationError::Poisoned(p.clone()));
            }
            let before = s.subjects.len();
            s.subjects.retain(|e| !(e.kind == kind && e.id == id));
            if s.subjects.len() == before {
                return Ok(None);
            }
            Ok(Some(s.save().err().map(persist_message)))
        })?;
        let Some(save_err) = removed else {
            return Ok(false);
        };
        self.generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        info!(%kind, id, "subject unrevoked");
        self.emit(
            sink,
            AUDIT_UNREVOKE_KIND,
            serde_json::json!({
                "decision": "unrevoked",
                "subject_kind": kind,
                "subject_id": id,
                "unrevoked_by": by,
                "persisted": save_err.is_none(),
                "persist_error": save_err,
            }),
        );
        match save_err {
            None => Ok(true),
            Some(e) => Err(RevocationError::Persist(e)),
        }
    }

    /// Whether a subject is revoked. Invalid ids are never revoked.
    pub fn is_subject_revoked(&self, kind: RevocationKind, id: &str) -> bool {
        self.find_subject(kind, id).is_some()
    }

    /// Look up a revoked subject by kind and id.
    pub fn find_subject(&self, kind: RevocationKind, id: &str) -> Option<RevokedSubject> {
        let id = kind.normalize(id).ok()?;
        self.with_subjects(|s| {
            s.subjects
                .iter()
                .find(|e| e.kind == kind && e.id == id)
                .cloned()
        })
    }

    /// List revoked subjects, optionally filtered by kind.
    pub fn list_subjects(&self, kind: Option<RevocationKind>) -> Vec<RevokedSubject> {
        self.with_subjects(|s| {
            s.subjects
                .iter()
                .filter(|e| kind.is_none_or(|k| e.kind == k))
                .cloned()
                .collect()
        })
    }

    /// First revoked subject among a package id, signers and artifact hashes.
    pub fn first_revoked<'a>(
        &self,
        package_id: Option<&str>,
        signer_keys: impl IntoIterator<Item = &'a String>,
        artifact_hashes: impl IntoIterator<Item = &'a String>,
    ) -> Option<RevokedSubject> {
        package_id
            .and_then(|p| self.find_subject(RevocationKind::Package, p))
            .or_else(|| {
                signer_keys
                    .into_iter()
                    .find_map(|k| self.find_subject(RevocationKind::SignerKey, k))
            })
            .or_else(|| {
                artifact_hashes
                    .into_iter()
                    .find_map(|h| self.find_subject(RevocationKind::ArtifactHash, h))
            })
    }

    /// Load error of the subjects file, if the list is poisoned.
    pub fn subjects_error(&self) -> Option<String> {
        self.with_subjects(|s| s.poisoned.clone())
    }

    /// Path of the subjects file.
    pub fn subjects_path(&self) -> PathBuf {
        self.with_subjects(|s| s.path.clone())
    }
}