1use 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#[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 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 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 #[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 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 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 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 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 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}