Skip to main content

basil_core/core/
revocation.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! JWT-SVID revocation deny-list.
6
7use std::collections::BTreeMap;
8use std::sync::RwLock;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use serde::{Deserialize, Serialize};
12
13use crate::backend::BackendError;
14use crate::catalog::{Class, KeyEntry};
15use crate::manager::{BackendManager, ManagerError};
16
17const STORE_LABEL: &str = "revocation_store";
18const JWT_SVID_STORE: &str = "jwt-svid";
19const STORE_VERSION: u32 = 1;
20
21/// In-memory JWT-SVID deny-list with optional catalog-backed persistence.
22#[derive(Debug)]
23pub struct JwtRevocationStore {
24    storage_key: RwLock<Option<String>>,
25    entries: RwLock<BTreeMap<RevocationKey, u64>>,
26}
27
28impl Default for JwtRevocationStore {
29    fn default() -> Self {
30        Self {
31            storage_key: RwLock::new(None),
32            entries: RwLock::new(BTreeMap::new()),
33        }
34    }
35}
36
37impl JwtRevocationStore {
38    /// Load the configured deny-list from the catalog value key labeled
39    /// `revocation_store=jwt-svid`, if present.
40    ///
41    /// # Errors
42    ///
43    /// Returns [`ManagerError::Backend`] when the configured store is malformed,
44    /// unreadable, or attached to a non-value catalog key.
45    pub async fn load_from_manager(manager: &BackendManager) -> Result<Self, ManagerError> {
46        let Some(storage_key) = jwt_revocation_store_key(manager)? else {
47            return Ok(Self::default());
48        };
49        let routed = manager.resolve(&storage_key)?;
50        let entries = match routed.backend.kv_get(routed.path(), None).await {
51            Ok(value) => stored_entries(&value.value)?,
52            Err(BackendError::KeyNotFound(_)) => BTreeMap::new(),
53            Err(e) => return Err(ManagerError::Backend(e)),
54        };
55        Ok(Self {
56            storage_key: RwLock::new(Some(storage_key)),
57            entries: RwLock::new(prune_expired(entries, now_unix_secs())),
58        })
59    }
60
61    /// Re-read the configured backing store and union it into the live deny-list.
62    ///
63    /// Existing in-memory entries win when they live longer than the reloaded
64    /// value, so a concurrent live revocation is never shortened or removed by a
65    /// reload. A read/load error leaves this store untouched.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`ManagerError`] when the backing store cannot be loaded or the
70    /// live deny-list locks are poisoned.
71    pub async fn refresh_from_manager(&self, manager: &BackendManager) -> Result<(), ManagerError> {
72        let loaded = Self::load_from_manager(manager).await?;
73        self.merge_from(loaded)
74    }
75
76    /// Return true when `jti` is currently denied for `trust_domain`.
77    ///
78    /// Expired entries are pruned before the lookup. Lock poisoning fails closed:
79    /// a validator must not accept a token if the deny-list cannot be read.
80    #[must_use]
81    pub fn is_revoked(&self, trust_domain: &str, jti: &str) -> bool {
82        let Ok(mut entries) = self.entries.write() else {
83            return true;
84        };
85        let now = now_unix_secs();
86        entries.retain(|_, expires_at| *expires_at > now);
87        entries.contains_key(&RevocationKey::new(trust_domain, jti))
88    }
89
90    /// Add or extend a revoked JWT-SVID `jti`.
91    ///
92    /// # Errors
93    ///
94    /// Returns [`ManagerError::Backend`] if the in-memory deny-list lock is
95    /// poisoned.
96    pub fn insert(
97        &self,
98        trust_domain: &str,
99        jti: &str,
100        expires_at_unix: u64,
101    ) -> Result<(), ManagerError> {
102        if expires_at_unix > now_unix_secs() {
103            let mut entries = self.entries.write().map_err(|_| {
104                ManagerError::Backend(BackendError::Backend(
105                    "jwt revocation store lock poisoned".into(),
106                ))
107            })?;
108            entries.insert(RevocationKey::new(trust_domain, jti), expires_at_unix);
109        }
110        Ok(())
111    }
112
113    /// Persist the current deny-list if a catalog-backed store is configured.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`ManagerError`] if the configured backend write fails or the
118    /// in-memory deny-list cannot be serialized.
119    pub async fn persist(&self, manager: &BackendManager) -> Result<(), ManagerError> {
120        let storage_key = {
121            let storage_key = self.storage_key.read().map_err(|_| {
122                ManagerError::Backend(BackendError::Backend(
123                    "jwt revocation store key lock poisoned".into(),
124                ))
125            })?;
126            storage_key.clone()
127        };
128        let Some(storage_key) = storage_key else {
129            return Ok(());
130        };
131        let routed = manager.resolve(&storage_key)?;
132        let payload = self.snapshot_json()?;
133        routed.backend.kv_put(routed.path(), &payload).await?;
134        Ok(())
135    }
136
137    /// Whether live revocations have a configured catalog-backed value store.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`ManagerError::Backend`] if the store-key lock is poisoned.
142    pub fn has_persistent_store(&self) -> Result<bool, ManagerError> {
143        let storage_key = self.storage_key.read().map_err(|_| {
144            ManagerError::Backend(BackendError::Backend(
145                "jwt revocation store key lock poisoned".into(),
146            ))
147        })?;
148        Ok(storage_key.is_some())
149    }
150
151    fn merge_from(&self, loaded: Self) -> Result<(), ManagerError> {
152        let loaded_storage_key = loaded.storage_key.into_inner().map_err(|_| {
153            ManagerError::Backend(BackendError::Backend(
154                "jwt revocation loaded store key lock poisoned".into(),
155            ))
156        })?;
157        let loaded_entries = loaded.entries.into_inner().map_err(|_| {
158            ManagerError::Backend(BackendError::Backend(
159                "jwt revocation loaded entries lock poisoned".into(),
160            ))
161        })?;
162
163        let mut storage_key = self.storage_key.write().map_err(|_| {
164            ManagerError::Backend(BackendError::Backend(
165                "jwt revocation store key lock poisoned".into(),
166            ))
167        })?;
168        *storage_key = loaded_storage_key;
169        drop(storage_key);
170
171        let now = now_unix_secs();
172        let mut entries = self.entries.write().map_err(|_| {
173            ManagerError::Backend(BackendError::Backend(
174                "jwt revocation store lock poisoned".into(),
175            ))
176        })?;
177        entries.retain(|_, expires_at| *expires_at > now);
178        for (key, expires_at) in prune_expired(loaded_entries, now) {
179            entries
180                .entry(key)
181                .and_modify(|current| *current = (*current).max(expires_at))
182                .or_insert(expires_at);
183        }
184        drop(entries);
185        Ok(())
186    }
187
188    fn snapshot_json(&self) -> Result<Vec<u8>, ManagerError> {
189        let entries = self.entries.read().map_err(|_| {
190            ManagerError::Backend(BackendError::Backend(
191                "jwt revocation store lock poisoned".into(),
192            ))
193        })?;
194        let snapshot = entries
195            .iter()
196            .map(|(key, expires_at_unix)| StoredRevocation {
197                trust_domain: key.trust_domain.clone(),
198                jti: key.jti.clone(),
199                expires_at_unix: *expires_at_unix,
200            })
201            .collect();
202        drop(entries);
203        let stored = StoredRevocations {
204            version: STORE_VERSION,
205            entries: snapshot,
206        };
207        serde_json::to_vec(&stored).map_err(|e| {
208            ManagerError::Backend(BackendError::Backend(format!(
209                "jwt revocation store serialize: {e}"
210            )))
211        })
212    }
213}
214
215fn jwt_revocation_store_key(manager: &BackendManager) -> Result<Option<String>, ManagerError> {
216    let mut found = None;
217    for (name, entry) in manager.keys().filter(|(_, entry)| is_jwt_store(entry)) {
218        validate_jwt_store_key(name, entry)?;
219        record_jwt_store_key(&mut found, name)?;
220    }
221    Ok(found)
222}
223
224fn is_jwt_store(entry: &KeyEntry) -> bool {
225    // ubs false positive - not secret comparison
226    /* ubs:ignore */
227    entry.labels.get(STORE_LABEL) == Some(JWT_SVID_STORE)
228}
229
230fn validate_jwt_store_key(name: &str, entry: &KeyEntry) -> Result<(), ManagerError> {
231    if entry.class == Class::Value {
232        return Ok(());
233    }
234    Err(ManagerError::Backend(BackendError::Backend(format!(
235        "jwt revocation store `{name}` must be a value key"
236    ))))
237}
238
239fn record_jwt_store_key(found: &mut Option<String>, name: &str) -> Result<(), ManagerError> {
240    if found.is_some() {
241        return Err(ManagerError::Backend(BackendError::Backend(
242            "multiple jwt revocation stores configured".into(),
243        )));
244    }
245    *found = Some(name.to_string());
246    Ok(())
247}
248
249fn stored_entries(input: &[u8]) -> Result<BTreeMap<RevocationKey, u64>, ManagerError> {
250    let stored: StoredRevocations = serde_json::from_slice(input).map_err(|e| {
251        ManagerError::Backend(BackendError::Backend(format!(
252            "jwt revocation store parse: {e}"
253        )))
254    })?;
255    // ubs false positive - not secret comparison
256    /* ubs:ignore */
257    if stored.version != STORE_VERSION {
258        return Err(ManagerError::Backend(BackendError::Backend(format!(
259            "unsupported jwt revocation store version {}",
260            stored.version
261        ))));
262    }
263    Ok(stored
264        .entries
265        .into_iter()
266        .filter(|entry| !entry.trust_domain.is_empty() && !entry.jti.is_empty())
267        .map(|entry| {
268            (
269                RevocationKey::new(&entry.trust_domain, &entry.jti),
270                entry.expires_at_unix,
271            )
272        })
273        .collect())
274}
275
276fn prune_expired(
277    mut entries: BTreeMap<RevocationKey, u64>,
278    now: u64,
279) -> BTreeMap<RevocationKey, u64> {
280    entries.retain(|_, expires_at| *expires_at > now);
281    entries
282}
283
284fn now_unix_secs() -> u64 {
285    SystemTime::now()
286        .duration_since(UNIX_EPOCH)
287        .map_or(0, |duration| duration.as_secs())
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
291struct RevocationKey {
292    trust_domain: String,
293    jti: String,
294}
295
296impl RevocationKey {
297    fn new(trust_domain: &str, jti: &str) -> Self {
298        Self {
299            trust_domain: trust_domain.to_string(),
300            jti: jti.to_string(),
301        }
302    }
303}
304
305#[derive(Debug, Serialize, Deserialize)]
306struct StoredRevocations {
307    version: u32,
308    entries: Vec<StoredRevocation>,
309}
310
311#[derive(Debug, Serialize, Deserialize)]
312struct StoredRevocation {
313    trust_domain: String,
314    jti: String,
315    expires_at_unix: u64,
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn revoked_entry_matches_until_expiry() {
324        let store = JwtRevocationStore::default();
325        let expires_at = now_unix_secs().saturating_add(60);
326        store
327            .insert("example.org", "jti-1", expires_at)
328            .expect("insert");
329        assert!(store.is_revoked("example.org", "jti-1"));
330        assert!(!store.is_revoked("other.example", "jti-1"));
331        assert!(!store.is_revoked("example.org", "other"));
332    }
333
334    #[test]
335    fn expired_entries_are_pruned_on_load() {
336        let mut entries = BTreeMap::new();
337        entries.insert(RevocationKey::new("example.org", "old"), 1);
338        entries.insert(RevocationKey::new("example.org", "live"), 100);
339        let entries = prune_expired(entries, 50);
340        assert!(!entries.contains_key(&RevocationKey::new("example.org", "old")));
341        assert!(entries.contains_key(&RevocationKey::new("example.org", "live")));
342    }
343
344    #[test]
345    fn merge_refresh_unions_entries_and_preserves_longer_live_expiry() {
346        let now = now_unix_secs();
347        let store = JwtRevocationStore::default();
348        store
349            .insert("example.org", "live-only", now.saturating_add(200))
350            .expect("live insert");
351        store
352            .insert("example.org", "shared", now.saturating_add(300))
353            .expect("live shared insert");
354
355        let mut loaded_entries = BTreeMap::new();
356        loaded_entries.insert(
357            RevocationKey::new("example.org", "loaded-only"),
358            now.saturating_add(250),
359        );
360        loaded_entries.insert(
361            RevocationKey::new("example.org", "shared"),
362            now.saturating_add(100),
363        );
364        let loaded = JwtRevocationStore {
365            storage_key: RwLock::new(Some("revocations".to_string())),
366            entries: RwLock::new(loaded_entries),
367        };
368
369        store.merge_from(loaded).expect("merge");
370        assert!(store.is_revoked("example.org", "live-only"));
371        assert!(store.is_revoked("example.org", "loaded-only"));
372        assert!(store.is_revoked("example.org", "shared"));
373
374        let snapshot = store.snapshot_json().expect("snapshot");
375        let stored: StoredRevocations = serde_json::from_slice(&snapshot).expect("stored json");
376        let shared = stored
377            .entries
378            .iter()
379            .find(|entry| entry.jti == "shared")
380            .expect("shared entry");
381        assert_eq!(shared.expires_at_unix, now.saturating_add(300));
382    }
383}