Skip to main content

clawft_kernel/
revocation.rs

1//! Persistent host revocation (ban list).
2//!
3//! Stores a JSON file at `RuntimePaths::revoked_hosts()` containing
4//! hosts that have been banned from joining the mesh. The ban list is
5//! loaded at kernel boot and checked during mesh peer handshake.
6//!
7//! The list also revokes package ids, signer keys and artifact hashes
8//! (ADR-099 section 7); see [`RevocationKind`] and
9//! [`RevocationList::revoke_subject`]. Those persist in a sibling file.
10
11use std::path::PathBuf;
12use std::sync::Mutex;
13
14use serde::{Deserialize, Serialize};
15use tracing::{debug, info, warn};
16
17mod subjects;
18#[cfg(test)]
19mod subjects_tests;
20pub use subjects::{
21    AUDIT_REVOKE_KIND, AUDIT_UNREVOKE_KIND, AuditSink, MAX_PACKAGE_ID_LEN, RevocationError,
22    RevocationKind, RevokedSubject, SUBJECTS_FILE_NAME,
23};
24
25/// A single revocation entry.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RevokedHost {
28    /// The host/node ID that was revoked.
29    pub host_id: String,
30    /// Unix timestamp (seconds) when the revocation was recorded.
31    pub revoked_at: u64,
32    /// Human-readable reason for revocation.
33    pub reason: String,
34}
35
36/// Persistent host revocation manager.
37///
38/// Thread-safe via internal `Mutex`. Reads and writes a JSON file
39/// on disk so that bans survive kernel restarts.
40pub struct RevocationList {
41    inner: Mutex<RevocationInner>,
42    /// Where every subject revocation and un-revocation is recorded (the
43    /// kernel chain, set at boot). Held here so no caller can skip it.
44    audit: std::sync::OnceLock<AuditSink>,
45    /// Bumped on every subject change (see `generation`).
46    generation: std::sync::atomic::AtomicU64,
47}
48
49struct RevocationInner {
50    hosts: Vec<RevokedHost>,
51    path: PathBuf,
52    subjects: subjects::SubjectState,
53}
54
55impl RevocationList {
56    /// Create a new empty revocation list that persists to `path`.
57    pub fn new(path: PathBuf) -> Self {
58        Self {
59            inner: Mutex::new(RevocationInner {
60                hosts: Vec::new(),
61                subjects: subjects::SubjectState::empty(&path),
62                path,
63            }),
64            audit: std::sync::OnceLock::new(),
65            generation: std::sync::atomic::AtomicU64::new(0),
66        }
67    }
68
69    /// Load the ban list from disk. If the file does not exist or is
70    /// malformed, starts with an empty list.
71    pub fn load(path: PathBuf) -> Self {
72        let hosts = if path.exists() {
73            match std::fs::read_to_string(&path) {
74                Ok(data) => match serde_json::from_str::<Vec<RevokedHost>>(&data) {
75                    Ok(list) => {
76                        info!(count = list.len(), path = %path.display(), "loaded host revocation list");
77                        list
78                    }
79                    Err(e) => {
80                        warn!(error = %e, path = %path.display(), "malformed revocation file, starting empty");
81                        Vec::new()
82                    }
83                },
84                Err(e) => {
85                    warn!(error = %e, path = %path.display(), "failed to read revocation file, starting empty");
86                    Vec::new()
87                }
88            }
89        } else {
90            debug!(path = %path.display(), "no revocation file found, starting empty");
91            Vec::new()
92        };
93
94        Self {
95            inner: Mutex::new(RevocationInner {
96                hosts,
97                subjects: subjects::SubjectState::load(&path),
98                path,
99            }),
100            audit: std::sync::OnceLock::new(),
101            generation: std::sync::atomic::AtomicU64::new(0),
102        }
103    }
104
105    /// Revoke a host. Adds to the ban list and persists to disk.
106    ///
107    /// Returns `true` if newly added, `false` if already revoked.
108    pub fn revoke_host(&self, host_id: &str, reason: &str) -> bool {
109        let mut inner = self.inner.lock().unwrap();
110        if inner.hosts.iter().any(|h| h.host_id == host_id) {
111            return false;
112        }
113
114        let entry = RevokedHost {
115            host_id: host_id.to_owned(),
116            revoked_at: std::time::SystemTime::now()
117                .duration_since(std::time::UNIX_EPOCH)
118                .unwrap_or_default()
119                .as_secs(),
120            reason: reason.to_owned(),
121        };
122        inner.hosts.push(entry);
123        Self::save_inner(&inner);
124        info!(host_id, reason, "host revoked");
125        true
126    }
127
128    /// Check whether a host is currently revoked.
129    pub fn is_revoked(&self, host_id: &str) -> bool {
130        let inner = self.inner.lock().unwrap();
131        inner.hosts.iter().any(|h| h.host_id == host_id)
132    }
133
134    /// List all revoked hosts.
135    pub fn list_revoked(&self) -> Vec<RevokedHost> {
136        let inner = self.inner.lock().unwrap();
137        inner.hosts.clone()
138    }
139
140    /// Remove a host from the ban list. Returns `true` if it was present.
141    pub fn unrevoke_host(&self, host_id: &str) -> bool {
142        let mut inner = self.inner.lock().unwrap();
143        let before = inner.hosts.len();
144        inner.hosts.retain(|h| h.host_id != host_id);
145        let removed = inner.hosts.len() < before;
146        if removed {
147            Self::save_inner(&inner);
148            info!(host_id, "host unrevoked");
149        }
150        removed
151    }
152
153    /// Return the number of revoked hosts.
154    pub fn len(&self) -> usize {
155        self.inner.lock().unwrap().hosts.len()
156    }
157
158    /// Check if the revocation list is empty.
159    pub fn is_empty(&self) -> bool {
160        self.inner.lock().unwrap().hosts.is_empty()
161    }
162
163    /// Get the file path used for persistence.
164    pub fn path(&self) -> PathBuf {
165        self.inner.lock().unwrap().path.clone()
166    }
167
168    /// Persist the current state to disk.
169    fn save_inner(inner: &RevocationInner) {
170        if let Some(parent) = inner.path.parent()
171            && let Err(e) = std::fs::create_dir_all(parent)
172        {
173            warn!(error = %e, "failed to create revocation dir");
174            return;
175        }
176        match serde_json::to_string_pretty(&inner.hosts) {
177            Ok(json) => {
178                if let Err(e) = std::fs::write(&inner.path, json) {
179                    warn!(error = %e, path = %inner.path.display(), "failed to write revocation file");
180                }
181            }
182            Err(e) => {
183                warn!(error = %e, "failed to serialize revocation list");
184            }
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use std::io::Write;
193
194    #[test]
195    fn revoke_and_check() {
196        let dir = tempfile::tempdir().unwrap();
197        let path = dir.path().join("revoked.json");
198        let list = RevocationList::new(path);
199
200        assert!(!list.is_revoked("host-1"));
201        assert!(list.revoke_host("host-1", "misbehaving"));
202        assert!(list.is_revoked("host-1"));
203        assert_eq!(list.len(), 1);
204
205        // Duplicate revoke returns false
206        assert!(!list.revoke_host("host-1", "second attempt"));
207        assert_eq!(list.len(), 1);
208    }
209
210    #[test]
211    fn unrevoke() {
212        let dir = tempfile::tempdir().unwrap();
213        let path = dir.path().join("revoked.json");
214        let list = RevocationList::new(path);
215
216        list.revoke_host("host-1", "test");
217        assert!(list.unrevoke_host("host-1"));
218        assert!(!list.is_revoked("host-1"));
219        assert!(list.is_empty());
220
221        // Unrevoke nonexistent returns false
222        assert!(!list.unrevoke_host("host-1"));
223    }
224
225    #[test]
226    fn persistence_roundtrip() {
227        let dir = tempfile::tempdir().unwrap();
228        let path = dir.path().join("revoked.json");
229
230        // Write with first instance
231        {
232            let list = RevocationList::new(path.clone());
233            list.revoke_host("host-1", "reason-a");
234            list.revoke_host("host-2", "reason-b");
235        }
236
237        // Load with second instance
238        let list = RevocationList::load(path);
239        assert_eq!(list.len(), 2);
240        assert!(list.is_revoked("host-1"));
241        assert!(list.is_revoked("host-2"));
242        assert!(!list.is_revoked("host-3"));
243    }
244
245    #[test]
246    fn load_missing_file() {
247        let dir = tempfile::tempdir().unwrap();
248        let path = dir.path().join("nonexistent.json");
249        let list = RevocationList::load(path);
250        assert!(list.is_empty());
251    }
252
253    #[test]
254    fn load_malformed_file() {
255        let dir = tempfile::tempdir().unwrap();
256        let path = dir.path().join("bad.json");
257        {
258            let mut f = std::fs::File::create(&path).unwrap();
259            f.write_all(b"not valid json{{{").unwrap();
260        }
261        let list = RevocationList::load(path);
262        assert!(list.is_empty());
263    }
264
265    #[test]
266    fn list_revoked() {
267        let dir = tempfile::tempdir().unwrap();
268        let path = dir.path().join("revoked.json");
269        let list = RevocationList::new(path);
270
271        list.revoke_host("a", "reason-a");
272        list.revoke_host("b", "reason-b");
273
274        let entries = list.list_revoked();
275        assert_eq!(entries.len(), 2);
276        assert_eq!(entries[0].host_id, "a");
277        assert_eq!(entries[1].host_id, "b");
278    }
279}