Skip to main content

chio_kernel_core/
revocation_view.rs

1//! Read-only revocation snapshot consulted on every dispatch.
2//!
3//! `RevocationView` is the kernel-core surface that the federation gossip
4//! task updates with the latest signed epoch root; verifiers read the
5//! current snapshot during evaluation without ever blocking a writer. The
6//! cache itself is arc-swap-backed: writers atomically replace the inner
7//! `Arc<RevocationSnapshot>`, readers obtain a `Guard` that holds a
8//! reference for the lifetime of the dispatch call.
9//!
10//! ## Fail-closed contract
11//!
12//! * A freshly-constructed view holds the [`RevocationSnapshot::empty`]
13//!   sentinel: epoch `0`, no revoked subjects, issued-at `0`. Verifiers
14//!   that have never seen a real epoch root MUST treat this snapshot as
15//!   "no revocations known" but MUST still apply their freshness gate
16//!   (`max_staleness_ms`) before allowing dispatch (a snapshot whose
17//!   `issued_at_unix_ms` is too old to satisfy the local freshness window
18//!   denies access).
19//! * Writers MUST never replace the snapshot with one whose `epoch` is
20//!   strictly less than the currently-installed snapshot. The
21//!   [`RevocationView::install_if_newer`] helper enforces that monotone
22//!   advancement so a malicious or stale gossip frame cannot rewind the
23//!   verifier's view.
24//! * Writers MUST also confirm the new snapshot's `epoch` agrees with the
25//!   embedded `signed_root_epoch` field: tampering with the unsigned hint
26//!   surface drops the update.
27//!
28//! ## Module gating
29//!
30//! The module is gated behind the `revocation-view` feature (which itself
31//! implies `std`) because [`arc_swap::ArcSwap`] requires hosted runtime
32//! services. The portable `no_std + alloc` proof does not observe this
33//! surface and falls back to whatever explicit denylist the embedding
34//! kernel passes through `chio-kernel-core::guard::GuardContext`.
35
36extern crate alloc;
37
38use alloc::collections::BTreeSet;
39use alloc::string::{String, ToString};
40use alloc::sync::Arc;
41
42use arc_swap::ArcSwap;
43use serde::{Deserialize, Serialize};
44
45/// Stable identifier for a subject whose credentials may be revoked. Mirrors
46/// `chio_revocation_oracle::SubjectId` but does NOT depend on the oracle
47/// crate, keeping `chio-kernel-core` decoupled from the federation /
48/// revocation-oracle layer above it.
49#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
50#[serde(transparent)]
51pub struct RevocationViewSubject(String);
52
53impl RevocationViewSubject {
54    pub fn new(value: impl Into<String>) -> Self {
55        Self(value.into())
56    }
57
58    pub fn as_str(&self) -> &str {
59        &self.0
60    }
61}
62
63impl From<&str> for RevocationViewSubject {
64    fn from(value: &str) -> Self {
65        Self(value.to_string())
66    }
67}
68
69impl From<String> for RevocationViewSubject {
70    fn from(value: String) -> Self {
71        Self(value)
72    }
73}
74
75/// Immutable snapshot of the revocation set at a particular epoch.
76///
77/// Snapshots are produced by the federation gossip task from the most
78/// recent verified signed epoch root and the leaf set the embedding kernel
79/// has materialised locally. Once installed in a [`RevocationView`] they
80/// are read-only; updates always go through a fresh
81/// [`RevocationView::install_if_newer`] call.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase", deny_unknown_fields)]
84pub struct RevocationSnapshot {
85    /// Monotone epoch counter advertised by the carried signed root.
86    pub epoch: u64,
87    /// Hash of the carried signed root. Verifiers use this for diagnostics
88    /// (e.g. correlating a deny verdict with the snapshot it was evaluated
89    /// against); the kernel-core itself does not re-verify it.
90    pub root_hash: [u8; 32],
91    /// Unix milliseconds at which the carried signed root was issued.
92    /// Verifiers MUST gate against their own freshness window before
93    /// trusting the snapshot for fail-closed dispatch.
94    pub issued_at_unix_ms: u64,
95    /// Sorted set of revoked subjects at this epoch. Ordered for
96    /// deterministic equality and serialisation.
97    pub revoked: BTreeSet<RevocationViewSubject>,
98}
99
100impl RevocationSnapshot {
101    /// The empty sentinel snapshot (`epoch = 0`, no subjects, issued at
102    /// `0`). Returned by [`RevocationView::new`] before any gossip update
103    /// has installed a real epoch root.
104    pub fn empty() -> Self {
105        Self {
106            epoch: 0,
107            root_hash: [0_u8; 32],
108            issued_at_unix_ms: 0,
109            revoked: BTreeSet::new(),
110        }
111    }
112
113    /// Returns `true` when `subject` is present in the snapshot's
114    /// revocation set.
115    pub fn is_revoked(&self, subject: &RevocationViewSubject) -> bool {
116        self.revoked.contains(subject)
117    }
118}
119
120/// Errors surfaced by the revocation-view cache. Every variant is
121/// fail-closed: the embedding kernel MUST refuse to install a snapshot that
122/// triggers any of them and SHOULD treat the active snapshot as the only
123/// safe reference for in-flight dispatch.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum RevocationViewError {
126    /// Candidate snapshot's epoch is not strictly greater than the
127    /// currently-installed snapshot. Required to keep the view monotone so
128    /// a stale or replayed gossip frame cannot rewind dispatch.
129    NonMonotoneEpoch { candidate: u64, installed: u64 },
130}
131
132impl core::fmt::Display for RevocationViewError {
133    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
134        match self {
135            Self::NonMonotoneEpoch {
136                candidate,
137                installed,
138            } => write!(
139                f,
140                "candidate snapshot epoch {candidate} is not strictly greater than installed epoch {installed}"
141            ),
142        }
143    }
144}
145
146impl core::error::Error for RevocationViewError {}
147
148/// Arc-swap-backed read-only snapshot store. Cheap to clone (it is itself
149/// an `Arc` internally) so embedding kernels can hand a clone to every
150/// dispatch worker.
151#[derive(Debug)]
152pub struct RevocationView {
153    inner: ArcSwap<RevocationSnapshot>,
154}
155
156impl Default for RevocationView {
157    fn default() -> Self {
158        Self::new()
159    }
160}
161
162impl RevocationView {
163    /// Create a new view holding the [`RevocationSnapshot::empty`]
164    /// sentinel.
165    pub fn new() -> Self {
166        Self {
167            inner: ArcSwap::from_pointee(RevocationSnapshot::empty()),
168        }
169    }
170
171    /// Borrow the currently-installed snapshot for the lifetime of the
172    /// returned guard. Readers never block writers and vice versa.
173    pub fn load(&self) -> Arc<RevocationSnapshot> {
174        ArcSwap::load_full(&self.inner)
175    }
176
177    /// Replace the snapshot iff `candidate.epoch > current.epoch`.
178    ///
179    /// Returns the previously-installed snapshot on success. Returns
180    /// [`RevocationViewError::NonMonotoneEpoch`] when the candidate's
181    /// epoch does not strictly advance the current one; the active
182    /// snapshot is left unchanged in that case (fail-closed).
183    pub fn install_if_newer(
184        &self,
185        candidate: RevocationSnapshot,
186    ) -> Result<Arc<RevocationSnapshot>, RevocationViewError> {
187        let candidate_epoch = candidate.epoch;
188        let candidate = Arc::new(candidate);
189
190        loop {
191            let current = self.load();
192            if candidate_epoch <= current.epoch {
193                return Err(RevocationViewError::NonMonotoneEpoch {
194                    candidate: candidate_epoch,
195                    installed: current.epoch,
196                });
197            }
198
199            let previous = self
200                .inner
201                .compare_and_swap(&current, Arc::clone(&candidate));
202            if Arc::ptr_eq(&current, &*previous) {
203                return Ok(current);
204            }
205        }
206    }
207
208    /// Convenience for the most common dispatch lookup.
209    pub fn is_revoked(&self, subject: &RevocationViewSubject) -> bool {
210        self.load().is_revoked(subject)
211    }
212
213    /// Currently-installed epoch counter. Cheap (no clone of the snapshot
214    /// body).
215    pub fn current_epoch(&self) -> u64 {
216        self.load().epoch
217    }
218}
219
220#[cfg(test)]
221#[allow(clippy::unwrap_used, clippy::expect_used)]
222mod tests {
223    extern crate std;
224
225    use self::std::sync::Barrier;
226    use self::std::thread;
227    use super::*;
228    use alloc::vec;
229    use alloc::vec::Vec;
230
231    fn snapshot(epoch: u64, revoked: &[&str]) -> RevocationSnapshot {
232        let revoked_set = revoked
233            .iter()
234            .copied()
235            .map(RevocationViewSubject::from)
236            .collect();
237        RevocationSnapshot {
238            epoch,
239            root_hash: [(epoch as u8); 32],
240            issued_at_unix_ms: 1_700_000_000_000 + epoch,
241            revoked: revoked_set,
242        }
243    }
244
245    #[test]
246    fn new_view_holds_empty_sentinel() {
247        let view = RevocationView::new();
248        let loaded = view.load();
249        assert_eq!(*loaded, RevocationSnapshot::empty());
250        assert_eq!(view.current_epoch(), 0);
251    }
252
253    #[test]
254    fn install_if_newer_advances_monotonically() {
255        let view = RevocationView::new();
256        let prev = view.install_if_newer(snapshot(1, &["alice"])).unwrap();
257        assert_eq!(prev.epoch, 0);
258        let prev2 = view
259            .install_if_newer(snapshot(5, &["alice", "bob"]))
260            .unwrap();
261        assert_eq!(prev2.epoch, 1);
262        assert_eq!(view.current_epoch(), 5);
263        assert!(view.is_revoked(&RevocationViewSubject::from("alice")));
264        assert!(view.is_revoked(&RevocationViewSubject::from("bob")));
265        assert!(!view.is_revoked(&RevocationViewSubject::from("carol")));
266    }
267
268    #[test]
269    fn install_if_newer_rejects_equal_epoch() {
270        let view = RevocationView::new();
271        view.install_if_newer(snapshot(1, &["alice"])).unwrap();
272        let err = view
273            .install_if_newer(snapshot(1, &["alice", "bob"]))
274            .expect_err("equal epoch must fail closed");
275        assert_eq!(
276            err,
277            RevocationViewError::NonMonotoneEpoch {
278                candidate: 1,
279                installed: 1
280            }
281        );
282        // Active snapshot unchanged.
283        assert!(!view.is_revoked(&RevocationViewSubject::from("bob")));
284    }
285
286    #[test]
287    fn install_if_newer_rejects_stale_epoch() {
288        let view = RevocationView::new();
289        view.install_if_newer(snapshot(5, &["alice"])).unwrap();
290        let err = view
291            .install_if_newer(snapshot(3, &["dave"]))
292            .expect_err("stale epoch must fail closed");
293        assert_eq!(
294            err,
295            RevocationViewError::NonMonotoneEpoch {
296                candidate: 3,
297                installed: 5
298            }
299        );
300        assert!(!view.is_revoked(&RevocationViewSubject::from("dave")));
301    }
302
303    #[test]
304    fn install_if_newer_is_atomic_across_concurrent_writers() {
305        let view = Arc::new(RevocationView::new());
306        let barrier = Arc::new(Barrier::new(8));
307        let mut handles = Vec::new();
308
309        for epoch in 1..=8_u64 {
310            let view = Arc::clone(&view);
311            let barrier = Arc::clone(&barrier);
312            handles.push(thread::spawn(move || {
313                barrier.wait();
314                let _ = view.install_if_newer(snapshot(epoch, &["alice"]));
315            }));
316        }
317
318        for handle in handles {
319            handle.join().unwrap();
320        }
321
322        assert_eq!(view.current_epoch(), 8);
323        assert!(view.is_revoked(&RevocationViewSubject::from("alice")));
324        let err = view
325            .install_if_newer(snapshot(7, &["bob"]))
326            .expect_err("stale concurrent write must fail closed");
327        assert_eq!(
328            err,
329            RevocationViewError::NonMonotoneEpoch {
330                candidate: 7,
331                installed: 8
332            }
333        );
334        assert!(!view.is_revoked(&RevocationViewSubject::from("bob")));
335    }
336
337    #[test]
338    fn snapshot_round_trips_via_serde() {
339        let s = snapshot(7, &["alice", "bob"]);
340        let bytes = serde_json::to_vec(&s).unwrap();
341        let decoded: RevocationSnapshot = serde_json::from_slice(&bytes).unwrap();
342        assert_eq!(decoded, s);
343    }
344
345    #[test]
346    fn snapshot_deny_unknown_fields() {
347        let s = snapshot(2, &["alice"]);
348        let mut value: serde_json::Value = serde_json::to_value(&s).unwrap();
349        value
350            .as_object_mut()
351            .unwrap()
352            .insert("extra".to_string(), serde_json::Value::Bool(true));
353        let bytes = serde_json::to_vec(&value).unwrap();
354        let parsed: Result<RevocationSnapshot, _> = serde_json::from_slice(&bytes);
355        assert!(parsed.is_err(), "unknown fields must be rejected");
356    }
357
358    #[test]
359    fn empty_sentinel_is_default_install_target() {
360        let view = RevocationView::default();
361        assert_eq!(*view.load(), RevocationSnapshot::empty());
362    }
363
364    #[test]
365    fn snapshot_preserves_revoked_ordering() {
366        let s = snapshot(1, &["delta", "alpha", "charlie", "bravo"]);
367        let collected: Vec<String> = s.revoked.iter().map(|k| k.as_str().to_string()).collect();
368        assert_eq!(
369            collected,
370            vec![
371                String::from("alpha"),
372                String::from("bravo"),
373                String::from("charlie"),
374                String::from("delta"),
375            ]
376        );
377    }
378}