Skip to main content

agent_mesh_protocol/
guard.rs

1//! [`RevocationGuard`] turns the local user's revocation log into the
2//! [`RevocationState`] every handshake checks a peer's chain against.
3//!
4//! Transport-free (std only), so any verifier can use the one implementation.
5//!
6//! Fail-closed throughout: a log that is missing, unreadable, invalid, owned by
7//! another user, or that does not extend the head last persisted in its `Seen`
8//! file refuses every peer until it is fixed. See `docs/decisions/revocation.md`.
9
10use std::fs;
11use std::io;
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex, MutexGuard};
14
15use crate::{CertChain, Fingerprint, MeshError, RevocationLog, RevocationState, Seen, UserKey};
16
17/// Why a [`RevocationGuard`] refused.
18#[derive(Debug, thiserror::Error)]
19pub enum GuardError {
20    /// The peer's chain, or a link of it, is revoked (agent fingerprint, hex).
21    #[error("peer revoked: agent {0}")]
22    Revoked(String),
23    /// The chain failed verification for a reason other than revocation.
24    #[error("cert chain rejected: {0}")]
25    BadCertChain(String),
26    /// No usable revocation log (missing, unreadable, invalid, another user's,
27    /// or not extending what was last seen). Fail closed.
28    #[error("revocation log unavailable: {0}")]
29    Unavailable(String),
30}
31
32/// Result alias for guard operations.
33pub type Result<T> = std::result::Result<T, GuardError>;
34
35/// The user's revocation log, as a verifier of peer chains.
36///
37/// Cheap to clone; every clone shares one cached view.
38#[derive(Clone, Debug)]
39pub struct RevocationGuard {
40    inner: Arc<Inner>,
41}
42
43#[derive(Debug)]
44struct Inner {
45    local_user: Fingerprint,
46    file: Option<LogFile>,
47    cache: Mutex<Option<Cached>>,
48}
49
50#[derive(Debug)]
51struct LogFile {
52    log: PathBuf,
53    seen: PathBuf,
54    /// Held exclusively across read, check and write of `seen`.
55    lock: PathBuf,
56}
57
58#[derive(Debug)]
59struct Cached {
60    state: Arc<RevocationState>,
61    /// The exact bytes `state` was verified from. The file is read on every
62    /// call and compared with these: identical bytes are the same log (same
63    /// head), anything else is verified afresh. Metadata is never trusted.
64    bytes: Vec<u8>,
65}
66
67impl RevocationGuard {
68    /// A guard over the log file at `log`, for the local user `local_user`.
69    ///
70    /// Reads, verifies and checks the log against the `Seen` record kept
71    /// beside it (`<name>.seen.json`) before returning, so a missing, invalid
72    /// or rolled-back log is refused at startup. The first time a log is
73    /// opened there is no `Seen` yet; it is written then.
74    ///
75    /// # Errors
76    /// [`GuardError::Unavailable`] if the log is missing,
77    /// unreadable, invalid, another user's, or does not extend what was seen.
78    pub fn open(log: impl Into<PathBuf>, local_user: Fingerprint) -> Result<Self> {
79        let log = log.into();
80        let seen = log.with_extension("seen.json");
81        let lock = log.with_extension("seen.lock");
82        let guard = Self {
83            inner: Arc::new(Inner {
84                local_user,
85                file: Some(LogFile { log, seen, lock }),
86                cache: Mutex::new(None),
87            }),
88        };
89        guard.state()?;
90        Ok(guard)
91    }
92
93    /// A guard over a fresh, empty log owned by `user`: nothing is revoked and
94    /// nothing is persisted. For tests and for callers that keep no log;
95    /// production code opens the user's `revocation.json` with [`Self::open`].
96    #[must_use]
97    pub fn in_memory(user: &UserKey) -> Self {
98        let log = RevocationLog::genesis(user).expect("genesis encodes");
99        let state = log.verify().expect("a fresh genesis verifies");
100        Self {
101            inner: Arc::new(Inner {
102                local_user: user.fingerprint(),
103                file: None,
104                cache: Mutex::new(Some(Cached {
105                    state: Arc::new(state),
106                    bytes: Vec::new(),
107                })),
108            }),
109        }
110    }
111
112    /// The current verified state, re-reading the log if its file changed.
113    ///
114    /// # Errors
115    /// [`GuardError::Unavailable`] as for [`Self::open`].
116    pub fn state(&self) -> Result<Arc<RevocationState>> {
117        let mut cache = lock(&self.inner.cache);
118        let Some(file) = &self.inner.file else {
119            return Ok(cache
120                .as_ref()
121                .expect("in-memory state is set")
122                .state
123                .clone());
124        };
125        match fs::read(&file.log) {
126            Ok(now) => {
127                // Unchanged bytes are the same log, but not necessarily still
128                // acceptable: another guard or process may have advanced the
129                // shared `Seen` past it (this file is then a rollback). The
130                // cache only stands while `Seen` is exactly what it persisted;
131                // anything else goes through `load`, which re-checks it under
132                // the lock.
133                if let Some(cached) = cache
134                    .as_ref()
135                    .filter(|c| c.bytes == now && seen_is(&file.seen, &c.state))
136                {
137                    return Ok(cached.state.clone());
138                }
139            }
140            Err(e) => {
141                *cache = None;
142                return Err(unavailable("read revocation log", &e));
143            }
144        }
145        // Refused until fixed: forget the old view so a failure is not masked.
146        *cache = None;
147        let (state, bytes) = self.load(file)?;
148        let state = Arc::new(state);
149        *cache = Some(Cached {
150            state: state.clone(),
151            bytes,
152        });
153        Ok(state)
154    }
155
156    /// Check `peer` against the current state: its user is ours, no link in
157    /// its chain is revoked, and it is valid at the current generation.
158    ///
159    /// # Errors
160    /// [`GuardError::Revoked`] for a revoked link,
161    /// [`GuardError::BadCertChain`] for any other chain failure,
162    /// [`GuardError::Unavailable`] if there is no usable log.
163    pub fn check(&self, peer: &CertChain) -> Result<()> {
164        let state = self.state()?;
165        verify_with(peer, &state)
166    }
167
168    /// Verify the log and advance `Seen` as one transaction under an
169    /// exclusive lock, so independent guards and processes sharing a home
170    /// cannot interleave a read of `Seen` with another's write of it. `Seen`
171    /// is only ever written with a head that strictly extends the one on disk.
172    fn load(&self, file: &LogFile) -> Result<(RevocationState, Vec<u8>)> {
173        let _lock = fs::File::options()
174            .create(true)
175            .append(true)
176            .open(&file.lock)
177            .and_then(|f| f.lock().map(|()| f))
178            .map_err(|e| unavailable("lock seen record", &e))?;
179        // read inside the lock: the bytes verified are the bytes cached
180        let bytes = fs::read(&file.log).map_err(|e| unavailable("read revocation log", &e))?;
181        let log: RevocationLog =
182            serde_json::from_slice(&bytes).map_err(|e| unavailable("parse revocation log", &e))?;
183        let state = log
184            .verify()
185            .map_err(|e| unavailable("verify revocation log", &e))?;
186        if state.user != self.inner.local_user {
187            return Err(unavailable(
188                "revocation log",
189                &"belongs to a different user than the local identity",
190            ));
191        }
192        let advance = match fs::read(&file.seen) {
193            Ok(raw) => {
194                let seen: Seen = serde_json::from_slice(&raw)
195                    .map_err(|e| unavailable("parse seen record", &e))?;
196                log.extends(&seen)
197                    .map_err(|e| unavailable("revocation log", &e))?;
198                state.len > seen.len
199            }
200            Err(e) if e.kind() == io::ErrorKind::NotFound => true,
201            Err(e) => return Err(unavailable("read seen record", &e)),
202        };
203        if advance {
204            persist_seen(&file.seen, &state.seen()).map_err(|e| unavailable("persist seen", &e))?;
205        }
206        Ok((state, bytes))
207    }
208}
209
210/// Whether the persisted `Seen` is exactly the one `state` produced. The
211/// record is replaced by atomic rename, so an unlocked read sees a whole one.
212fn seen_is(path: &Path, state: &RevocationState) -> bool {
213    fs::read(path)
214        .ok()
215        .and_then(|raw| serde_json::from_slice::<Seen>(&raw).ok())
216        .is_some_and(|seen| seen == state.seen())
217}
218
219fn unavailable(what: &str, e: &dyn std::fmt::Display) -> GuardError {
220    GuardError::Unavailable(format!("{what}: {e}"))
221}
222
223/// Check `peer` against an already-verified `state`.
224///
225/// # Errors
226/// [`GuardError::Revoked`] or [`GuardError::BadCertChain`].
227pub fn verify_with(peer: &CertChain, state: &RevocationState) -> Result<()> {
228    peer.verify_with(state).map_err(|e| match e {
229        MeshError::Revoked { agent } => GuardError::Revoked(agent.hex()),
230        other => GuardError::BadCertChain(other.to_string()),
231    })
232}
233
234fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
235    m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
236}
237
238/// Publish `seen` beside the log: exclusive temp file, synced, renamed, with
239/// the directory synced ([`crate::atomic::atomic_write`]).
240fn persist_seen(path: &Path, seen: &Seen) -> io::Result<()> {
241    crate::atomic::atomic_write(path, &serde_json::to_vec(seen).map_err(io::Error::other)?)
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::{AgentKey, AgentMetadata, Caveats, RevocationEntry};
248    use std::time::SystemTime;
249
250    fn agent(user: &UserKey) -> AgentKey {
251        AgentKey::issue(
252            user,
253            AgentMetadata {
254                role: "w".into(),
255                host: "h".into(),
256                capabilities: vec![],
257                issued_at: "2026-10-26T00:00:00Z".into(),
258                expires_at: None,
259                caveats: Caveats::top(),
260            },
261        )
262    }
263
264    /// A user with a persisted genesis log in a temp dir.
265    struct Fixture {
266        user: UserKey,
267        dir: tempfile::TempDir,
268    }
269
270    impl Fixture {
271        fn new() -> Self {
272            let f = Self {
273                user: UserKey::generate(),
274                dir: tempfile::tempdir().unwrap(),
275            };
276            f.write(&RevocationLog::genesis(&f.user).unwrap());
277            f
278        }
279
280        fn path(&self) -> PathBuf {
281            self.dir.path().join("revocation.json")
282        }
283
284        fn write(&self, log: &RevocationLog) {
285            fs::write(self.path(), serde_json::to_vec(log).unwrap()).unwrap();
286        }
287
288        fn read(&self) -> RevocationLog {
289            serde_json::from_slice(&fs::read(self.path()).unwrap()).unwrap()
290        }
291
292        fn revoke(&self, k: &AgentKey) {
293            let mut log = self.read();
294            let agent = k.fingerprint();
295            log.append(&self.user, RevocationEntry::Revoke { agent })
296                .unwrap();
297            self.write(&log);
298        }
299
300        fn open(&self) -> Result<RevocationGuard> {
301            RevocationGuard::open(self.path(), self.user.fingerprint())
302        }
303    }
304
305    fn unavailable<T: std::fmt::Debug>(r: Result<T>) -> bool {
306        matches!(r, Err(GuardError::Unavailable(_)))
307    }
308
309    #[test]
310    fn a_missing_log_refuses() {
311        let user = UserKey::generate();
312        let dir = tempfile::tempdir().unwrap();
313        let r = RevocationGuard::open(dir.path().join("revocation.json"), user.fingerprint());
314        assert!(unavailable(r));
315    }
316
317    #[test]
318    fn a_garbled_log_refuses() {
319        let f = Fixture::new();
320        fs::write(f.path(), b"{not json").unwrap();
321        assert!(unavailable(f.open()));
322    }
323
324    #[test]
325    fn another_users_log_refuses() {
326        let f = Fixture::new();
327        let r = RevocationGuard::open(f.path(), UserKey::generate().fingerprint());
328        assert!(unavailable(r));
329    }
330
331    #[test]
332    fn a_valid_log_admits_its_users_agents_and_persists_seen() {
333        let f = Fixture::new();
334        let guard = f.open().unwrap();
335        guard.check(agent(&f.user).cert()).unwrap();
336        let seen: Seen =
337            serde_json::from_slice(&fs::read(f.path().with_extension("seen.json")).unwrap())
338                .unwrap();
339        assert_eq!(seen, guard.state().unwrap().seen());
340    }
341
342    #[test]
343    fn a_revoked_agent_is_refused() {
344        let f = Fixture::new();
345        let a = agent(&f.user);
346        f.revoke(&a);
347        let guard = f.open().unwrap();
348        assert!(matches!(guard.check(a.cert()), Err(GuardError::Revoked(_))));
349    }
350
351    /// Regression for the rollback hole: a log file swapped back to an older
352    /// copy must be refused at startup, not silently un-revoke the agent.
353    #[test]
354    fn a_rolled_back_log_is_refused_at_startup() {
355        let f = Fixture::new();
356        let old = f.read();
357        f.revoke(&agent(&f.user));
358        f.open().unwrap();
359        f.write(&old);
360        assert!(unavailable(f.open()));
361    }
362
363    #[test]
364    fn a_forked_log_is_refused_at_startup() {
365        let f = Fixture::new();
366        let genesis = f.read();
367        f.revoke(&agent(&f.user));
368        f.open().unwrap();
369        let mut fork = genesis;
370        fork.append(
371            &f.user,
372            RevocationEntry::Revoke {
373                agent: agent(&f.user).fingerprint(),
374            },
375        )
376        .unwrap();
377        f.write(&fork);
378        assert!(unavailable(f.open()));
379    }
380
381    #[test]
382    fn a_log_that_changes_to_a_rollback_while_running_refuses_every_peer() {
383        let f = Fixture::new();
384        let (a, b) = (agent(&f.user), agent(&f.user));
385        let old = f.read();
386        f.revoke(&a);
387        let guard = f.open().unwrap();
388        guard.check(b.cert()).unwrap();
389        f.write(&old);
390        assert!(unavailable(guard.state()));
391        assert!(guard.check(b.cert()).is_err());
392    }
393
394    #[test]
395    fn an_in_memory_guard_admits_its_user_and_refuses_another() {
396        let user = UserKey::generate();
397        let guard = RevocationGuard::in_memory(&user);
398        guard.check(agent(&user).cert()).unwrap();
399        assert!(guard.check(agent(&UserKey::generate()).cert()).is_err());
400    }
401
402    /// Pad `bytes` (JSON) with trailing spaces to exactly `len`.
403    fn padded(mut bytes: Vec<u8>, len: usize) -> Vec<u8> {
404        assert!(bytes.len() <= len);
405        bytes.resize(len, b' ');
406        bytes
407    }
408
409    fn set_mtime(path: &Path, t: SystemTime) {
410        fs::File::options()
411            .write(true)
412            .open(path)
413            .unwrap()
414            .set_modified(t)
415            .unwrap();
416    }
417
418    /// Regression (PR 185 review): file metadata is not the log's identity. A
419    /// newer, valid log written over the old one with the same length and
420    /// mtime must be seen on the next check.
421    #[test]
422    fn an_equal_length_same_mtime_replacement_is_detected() {
423        let f = Fixture::new();
424        let (a, b) = (agent(&f.user), agent(&f.user));
425        let mut old = f.read();
426        old.append(
427            &f.user,
428            RevocationEntry::Revoke {
429                agent: b.fingerprint(),
430            },
431        )
432        .unwrap();
433        let mut new = old.clone();
434        new.append(
435            &f.user,
436            RevocationEntry::Revoke {
437                agent: a.fingerprint(),
438            },
439        )
440        .unwrap();
441        let (old, new) = (
442            serde_json::to_vec(&old).unwrap(),
443            serde_json::to_vec(&new).unwrap(),
444        );
445        let len = old.len().max(new.len());
446        fs::write(f.path(), padded(old, len)).unwrap();
447        let stamp = fs::metadata(f.path()).unwrap().modified().unwrap();
448        let guard = f.open().unwrap();
449        guard.check(a.cert()).unwrap();
450
451        fs::write(f.path(), padded(new, len)).unwrap();
452        set_mtime(&f.path(), stamp);
453        assert!(
454            matches!(guard.check(a.cert()), Err(GuardError::Revoked(_))),
455            "the replacement revokes a, even with identical metadata"
456        );
457    }
458
459    /// An equal-length corrupt replacement with the old mtime fails closed.
460    #[test]
461    fn an_equal_length_same_mtime_corrupt_replacement_fails_closed() {
462        let f = Fixture::new();
463        let guard = f.open().unwrap();
464        let stamp = fs::metadata(f.path()).unwrap().modified().unwrap();
465        let len = fs::metadata(f.path()).unwrap().len() as usize;
466        fs::write(f.path(), vec![b'x'; len]).unwrap();
467        set_mtime(&f.path(), stamp);
468        assert!(unavailable(guard.state()));
469    }
470
471    /// Two guards on one home persist `Seen` under one exclusive lock held
472    /// across read, check and write: while another holder has it, opening
473    /// waits instead of racing the record.
474    #[test]
475    fn opening_waits_for_the_seen_lock() {
476        let f = Fixture::new();
477        f.open().unwrap();
478        let lock = fs::File::options()
479            .create(true)
480            .append(true)
481            .open(f.path().with_extension("seen.lock"))
482            .unwrap();
483        lock.lock().unwrap();
484        let (path, fp) = (f.path(), f.user.fingerprint());
485        let opened = std::thread::spawn(move || RevocationGuard::open(path, fp).is_ok());
486        std::thread::sleep(std::time::Duration::from_millis(300));
487        assert!(!opened.is_finished(), "open must wait for the lock");
488        drop(lock);
489        assert!(opened.join().unwrap());
490    }
491
492    /// `Seen` only advances: a guard whose view is older than what another
493    /// guard persisted is refused and leaves the record where it was.
494    #[test]
495    fn seen_never_goes_backwards_across_guards() {
496        let f = Fixture::new();
497        let l1 = f.read();
498        f.revoke(&agent(&f.user));
499        let l2 = f.read();
500        f.revoke(&agent(&f.user));
501        let l3 = f.read();
502        f.write(&l1);
503        let a = f.open().unwrap();
504        f.write(&l3);
505        let b = f.open().unwrap();
506        let persisted = |f: &Fixture| fs::read(f.path().with_extension("seen.json")).unwrap();
507        let at_l3 = persisted(&f);
508        f.write(&l2);
509        assert!(unavailable(a.state()));
510        assert!(unavailable(b.state()));
511        assert_eq!(persisted(&f), at_l3, "Seen is still L3");
512        f.write(&l3);
513        a.state().unwrap();
514        assert_eq!(persisted(&f), at_l3);
515    }
516
517    /// Regression (PR 185 round 3): a guard that cached L1 must not accept a
518    /// peer on L1's bytes once another guard has persisted a `Seen` that L1
519    /// does not extend: restoring the old file is a rollback, cache or not.
520    #[test]
521    fn a_cached_guard_refuses_a_rollback_to_its_cached_log() {
522        let f = Fixture::new();
523        let (a, victim) = (agent(&f.user), agent(&f.user));
524        let l1 = fs::read(f.path()).unwrap();
525        let guard_a = f.open().unwrap();
526        guard_a.check(victim.cert()).unwrap();
527
528        f.revoke(&victim);
529        let _guard_b = f.open().unwrap(); // persists Seen = L2
530        fs::write(f.path(), &l1).unwrap(); // the exact L1 bytes come back
531        assert!(
532            guard_a.check(victim.cert()).is_err(),
533            "a rollback to L1 must be refused even though A cached L1"
534        );
535        let _ = a;
536    }
537
538    /// Regression (PR 185 round 3): the `Seen` record is published through a
539    /// fresh exclusive temp file; a symlink planted at any guessable temp
540    /// name is never followed or truncated.
541    #[cfg(unix)]
542    #[test]
543    fn a_planted_tmp_symlink_is_never_followed_when_seen_is_written() {
544        let f = Fixture::new();
545        let victim = f.dir.path().join("victim");
546        fs::write(&victim, "precious").unwrap();
547        let seen = f.path().with_extension("seen.json");
548        for name in [
549            format!("{}.tmp", std::process::id()),
550            "tmp".to_string(),
551            "seen.tmp".to_string(),
552        ] {
553            let planted = seen.with_extension(name);
554            let _ = std::os::unix::fs::symlink(&victim, planted);
555        }
556        f.open().unwrap();
557        assert_eq!(fs::read_to_string(&victim).unwrap(), "precious");
558        assert!(fs::metadata(&seen).is_ok(), "Seen was still written");
559    }
560}