Skip to main content

atd_runtime/ucan/
revocation.rs

1//! SP-capability-v2 revocation-store trait + reference impl.
2//!
3//! - Trait [`UcanRevocationStore`] (read-only `is_revoked`) is the
4//!   contract the verifier (Phase B.2) consults on every chain link.
5//! - [`InMemoryUcanRevocationStore`] (Phase E) is the reference impl
6//!   for tests + small deployments. Adopters wrap their own revocation
7//!   table behind the same trait (celia's `consent.status='revoked'`
8//!   rows + new `ucan_cid` index — spec §6).
9//!
10//! Design choice: `revoke()` is **inherent** on `InMemoryUcanRevocationStore`
11//! rather than required on the trait. Rationale — production adopters
12//! revoke via adopter-specific paths (celia Tauri command + recursive
13//! SQL cascade; future atd-ref-server admin CLI; etc.) whose signatures
14//! differ. Keeping the trait read-only matches the verifier's actual
15//! need, and avoids forcing every wrapper to expose a mutator it
16//! doesn't have. SP-capability-v2 §4.7 leaves "how revocations get
17//! recorded" deliberately adopter-side.
18//!
19//! Spec: `docs/archive/superpowers/specs/2026-05-11-sp-capability-v2-design.md` §4.7
20
21use std::collections::HashSet;
22use std::fmt::Debug;
23use std::sync::{Arc, RwLock};
24
25/// A store that can answer "has this UCAN been revoked?" for a CID.
26///
27/// CID format in v1: SHA-256(jwt_compact) hex-encoded (see
28/// [`super::compute_cid`]). The verifier computes this on every link;
29/// the store decides authoritatively.
30///
31/// Implementations must be `Send + Sync` because the verifier runs on
32/// the per-connection task and the store may be shared via
33/// `Arc<dyn UcanRevocationStore>`. `Debug` because [`super::VerifyConfig`]
34/// is `Clone` and propagates the trait object through error contexts.
35pub trait UcanRevocationStore: Send + Sync + Debug {
36    /// Returns `true` if the CID is in the revocation set.
37    fn is_revoked(&self, ucan_cid: &str) -> bool;
38}
39
40/// Reference revocation store: an `Arc<RwLock<HashSet<String>>>` of
41/// revoked CIDs. Suitable for tests and small in-process deployments.
42///
43/// Concurrency: every read takes a shared lock; revocations take an
44/// exclusive lock and run in O(1) average. The whole structure clones
45/// cheaply (`Arc`) so multiple subsystems (broker, dispatch, Tauri
46/// command) can share one revocation surface.
47///
48/// **Not** persistent — restarting the host process empties the set.
49/// Adopters that need durable revocation (celia) back the trait with
50/// their own SQLite-or-similar store.
51#[derive(Debug, Default, Clone)]
52pub struct InMemoryUcanRevocationStore {
53    revoked: Arc<RwLock<HashSet<String>>>,
54}
55
56impl InMemoryUcanRevocationStore {
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Mark a CID as revoked. Idempotent — revoking the same CID twice
62    /// is a no-op. Once revoked, [`Self::is_revoked`] returns `true`
63    /// for the lifetime of the store (no un-revoke API by design;
64    /// administrators issue a fresh UCAN instead).
65    pub fn revoke(&self, ucan_cid: impl Into<String>) {
66        self.revoked
67            .write()
68            .expect("InMemoryUcanRevocationStore lock poisoned")
69            .insert(ucan_cid.into());
70    }
71
72    /// Number of revoked CIDs currently in the store. Mostly useful
73    /// for test assertions + diagnostics; production code should not
74    /// rely on this for policy.
75    pub fn len(&self) -> usize {
76        self.revoked
77            .read()
78            .expect("InMemoryUcanRevocationStore lock poisoned")
79            .len()
80    }
81
82    pub fn is_empty(&self) -> bool {
83        self.len() == 0
84    }
85}
86
87impl UcanRevocationStore for InMemoryUcanRevocationStore {
88    fn is_revoked(&self, ucan_cid: &str) -> bool {
89        self.revoked
90            .read()
91            .expect("InMemoryUcanRevocationStore lock poisoned")
92            .contains(ucan_cid)
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    //! Phase E unit tests. End-to-end propagation (revoke a chain link
99    //! → verifier rejects descendant chain) is covered by
100    //! `verify::tests::revoked_cid_rejects` (uses `MockRevocationStore`)
101    //! plus a new propagation test below that exercises the real
102    //! `InMemoryUcanRevocationStore` against a 3-link chain.
103
104    use super::*;
105
106    #[test]
107    fn empty_store_reports_nothing_revoked() {
108        let s = InMemoryUcanRevocationStore::new();
109        assert!(!s.is_revoked("bafy-cid-anything"));
110        assert!(s.is_empty());
111    }
112
113    #[test]
114    fn revoke_then_is_revoked_returns_true() {
115        let s = InMemoryUcanRevocationStore::new();
116        s.revoke("cid-abc-123");
117        assert!(s.is_revoked("cid-abc-123"));
118        assert!(!s.is_revoked("cid-xyz-999"));
119        assert_eq!(s.len(), 1);
120    }
121
122    #[test]
123    fn revoke_is_idempotent() {
124        let s = InMemoryUcanRevocationStore::new();
125        s.revoke("cid-A");
126        s.revoke("cid-A");
127        s.revoke("cid-A");
128        assert_eq!(s.len(), 1);
129        assert!(s.is_revoked("cid-A"));
130    }
131
132    #[test]
133    fn store_clones_share_state_via_arc() {
134        // Cloning the store should share the underlying revocation set
135        // (Arc semantics) — a revoke on one handle is visible on the
136        // other. Important for the dispatch ↔ broker sharing pattern.
137        let s = InMemoryUcanRevocationStore::new();
138        let s2 = s.clone();
139        s.revoke("cid-X");
140        assert!(s2.is_revoked("cid-X"));
141    }
142
143    #[test]
144    fn store_works_through_trait_object() {
145        // The verifier holds `Option<Arc<dyn UcanRevocationStore>>`.
146        // Confirm dispatch through the dyn boundary.
147        let inner = Arc::new(InMemoryUcanRevocationStore::new());
148        inner.revoke("cid-T");
149        let dyn_store: Arc<dyn UcanRevocationStore> = inner;
150        assert!(dyn_store.is_revoked("cid-T"));
151        assert!(!dyn_store.is_revoked("cid-other"));
152    }
153}