Skip to main content

basil_core/core/
revocation.rs

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