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