1use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17use tracing::{info, warn};
18
19use super::{RevocationInner, RevocationList};
20
21pub const SUBJECTS_FILE_NAME: &str = "revoked_subjects.json";
23pub const AUDIT_REVOKE_KIND: &str = "workload.revoke";
25pub const AUDIT_UNREVOKE_KIND: &str = "workload.unrevoke";
27
28pub type AuditSink = std::sync::Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
30
31type AuditOverride<'a> = Option<&'a dyn Fn(&str, serde_json::Value)>;
33
34pub const MAX_PACKAGE_ID_LEN: usize = 128;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum RevocationKind {
41 Package,
43 SignerKey,
45 ArtifactHash,
47}
48
49impl RevocationKind {
50 pub fn normalize(self, id: &str) -> Result<String, RevocationError> {
54 match self {
55 RevocationKind::Package => {
56 let ok_len = !id.is_empty() && id.len() <= MAX_PACKAGE_ID_LEN;
57 let ok_first = id.chars().next().is_some_and(|c| c.is_ascii_alphanumeric());
58 let ok_chars = id
59 .chars()
60 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '@' | '/' | ':' | '+'));
61 if ok_len && ok_first && ok_chars {
62 if id.len() == 64 && id.chars().all(|c| c.is_ascii_hexdigit()) {
66 Ok(id.to_ascii_lowercase())
67 } else {
68 Ok(id.to_owned())
69 }
70 } else {
71 Err(RevocationError::InvalidId {
72 kind: self,
73 reason: format!(
74 "package id must be 1..={MAX_PACKAGE_ID_LEN} chars of [A-Za-z0-9._-@/:+] starting alphanumeric"
75 ),
76 })
77 }
78 }
79 RevocationKind::SignerKey | RevocationKind::ArtifactHash => {
80 if id.len() == 64 && id.chars().all(|c| c.is_ascii_hexdigit()) {
81 Ok(id.to_ascii_lowercase())
82 } else {
83 Err(RevocationError::InvalidId {
84 kind: self,
85 reason: "must be exactly 64 hex characters".into(),
86 })
87 }
88 }
89 }
90 }
91}
92
93impl std::fmt::Display for RevocationKind {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 match self {
96 RevocationKind::Package => write!(f, "package"),
97 RevocationKind::SignerKey => write!(f, "signer_key"),
98 RevocationKind::ArtifactHash => write!(f, "artifact_hash"),
99 }
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct RevokedSubject {
106 pub kind: RevocationKind,
108 pub id: String,
110 pub revoked_at: u64,
112 pub reason: String,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
118pub enum RevocationError {
119 #[error("invalid {kind} id: {reason}")]
121 InvalidId {
122 kind: RevocationKind,
124 reason: String,
126 },
127 #[error("subject revocation list is poisoned: {0}")]
129 Poisoned(String),
130 #[error("failed to persist subject revocations: {0}")]
132 Persist(String),
133}
134
135#[derive(Debug, Default)]
137pub(super) struct SubjectState {
138 pub(super) subjects: Vec<RevokedSubject>,
139 pub(super) path: PathBuf,
140 pub(super) poisoned: Option<String>,
141}
142
143impl SubjectState {
144 pub(super) fn path_for(host_path: &Path) -> PathBuf {
146 host_path.with_file_name(SUBJECTS_FILE_NAME)
147 }
148
149 pub(super) fn empty(host_path: &Path) -> Self {
151 Self {
152 subjects: Vec::new(),
153 path: Self::path_for(host_path),
154 poisoned: None,
155 }
156 }
157
158 pub(super) fn load(host_path: &Path) -> Self {
160 let path = Self::path_for(host_path);
161 if !path.exists() {
162 return Self { subjects: Vec::new(), path, poisoned: None };
163 }
164 let parsed = std::fs::read_to_string(&path)
165 .map_err(|e| format!("read {}: {e}", path.display()))
166 .and_then(|data| {
167 serde_json::from_str::<Vec<RevokedSubject>>(&data)
168 .map_err(|e| format!("parse {}: {e}", path.display()))
169 });
170 match parsed {
171 Ok(subjects) => {
172 info!(count = subjects.len(), "loaded subject revocation list");
173 Self { subjects, path, poisoned: None }
174 }
175 Err(e) => {
176 warn!(error = %e, "subject revocation list unreadable; failing closed");
177 Self { subjects: Vec::new(), path, poisoned: Some(e) }
178 }
179 }
180 }
181
182 fn save(&self) -> Result<(), RevocationError> {
183 if let Some(parent) = self.path.parent() {
184 std::fs::create_dir_all(parent).map_err(|e| RevocationError::Persist(e.to_string()))?;
185 }
186 let json = serde_json::to_string_pretty(&self.subjects)
187 .map_err(|e| RevocationError::Persist(e.to_string()))?;
188 let tmp = self.path.with_extension("json.tmp");
190 std::fs::write(&tmp, json).map_err(|e| RevocationError::Persist(e.to_string()))?;
191 std::fs::rename(&tmp, &self.path).map_err(|e| RevocationError::Persist(e.to_string()))
192 }
193}
194
195fn persist_message(e: RevocationError) -> String {
196 match e {
197 RevocationError::Persist(m) => m,
198 other => other.to_string(),
199 }
200}
201
202fn now_secs() -> u64 {
203 std::time::SystemTime::now()
204 .duration_since(std::time::UNIX_EPOCH)
205 .unwrap_or_default()
206 .as_secs()
207}
208
209impl RevocationList {
210 fn with_subjects<T>(&self, f: impl FnOnce(&mut SubjectState) -> T) -> T {
211 let mut inner: std::sync::MutexGuard<'_, RevocationInner> = self.inner.lock().unwrap();
212 f(&mut inner.subjects)
213 }
214
215 pub fn generation(&self) -> u64 {
219 self.generation.load(std::sync::atomic::Ordering::SeqCst)
220 }
221
222 pub fn set_audit(&self, sink: AuditSink) -> bool {
227 self.audit.set(sink).is_ok()
228 }
229
230 fn emit(&self, over: AuditOverride<'_>, kind: &str, p: serde_json::Value) {
231 match (over, self.audit.get()) {
232 (Some(f), _) => f(kind, p),
233 (None, Some(sink)) => sink(kind, p),
234 (None, None) => {}
235 }
236 }
237
238 pub fn revoke_subject(
241 &self,
242 kind: RevocationKind,
243 id: &str,
244 reason: &str,
245 ) -> Result<bool, RevocationError> {
246 self.revoke_subject_by(kind, id, reason, "local")
247 }
248
249 pub fn revoke_subject_by(
257 &self,
258 kind: RevocationKind,
259 id: &str,
260 reason: &str,
261 by: &str,
262 ) -> Result<bool, RevocationError> {
263 self.revoke_audited(kind, id, reason, by, None)
264 }
265
266 pub fn revoke_audited(
269 &self,
270 kind: RevocationKind,
271 id: &str,
272 reason: &str,
273 by: &str,
274 sink: AuditOverride<'_>,
275 ) -> Result<bool, RevocationError> {
276 let id = kind.normalize(id)?;
277 let added: Option<Option<String>> = self.with_subjects(|s| {
279 if let Some(p) = &s.poisoned {
280 return Err(RevocationError::Poisoned(p.clone()));
281 }
282 if s.subjects.iter().any(|e| e.kind == kind && e.id == id) {
283 return Ok(None);
284 }
285 s.subjects.push(RevokedSubject {
286 kind,
287 id: id.clone(),
288 revoked_at: now_secs(),
289 reason: reason.to_owned(),
290 });
291 Ok(Some(s.save().err().map(persist_message)))
292 })?;
293 let Some(save_err) = added else {
294 return Ok(false);
295 };
296 self.generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
297 info!(%kind, id, reason, "subject revoked");
298 self.emit(
299 sink,
300 AUDIT_REVOKE_KIND,
301 serde_json::json!({
302 "decision": "revoked",
303 "subject_kind": kind,
304 "subject_id": id,
305 "reason": reason,
306 "revoked_by": by,
307 "persisted": save_err.is_none(),
308 "persist_error": save_err,
309 }),
310 );
311 match save_err {
312 None => Ok(true),
313 Some(e) => Err(RevocationError::Persist(e)),
314 }
315 }
316
317 pub fn unrevoke_subject(&self, kind: RevocationKind, id: &str) -> Result<bool, RevocationError> {
320 self.unrevoke_subject_by(kind, id, "local")
321 }
322
323 pub fn unrevoke_subject_by(
326 &self,
327 kind: RevocationKind,
328 id: &str,
329 by: &str,
330 ) -> Result<bool, RevocationError> {
331 self.unrevoke_audited(kind, id, by, None)
332 }
333
334 pub fn unrevoke_audited(
337 &self,
338 kind: RevocationKind,
339 id: &str,
340 by: &str,
341 sink: AuditOverride<'_>,
342 ) -> Result<bool, RevocationError> {
343 let id = kind.normalize(id)?;
344 let removed: Option<Option<String>> = self.with_subjects(|s| {
345 if let Some(p) = &s.poisoned {
346 return Err(RevocationError::Poisoned(p.clone()));
347 }
348 let before = s.subjects.len();
349 s.subjects.retain(|e| !(e.kind == kind && e.id == id));
350 if s.subjects.len() == before {
351 return Ok(None);
352 }
353 Ok(Some(s.save().err().map(persist_message)))
354 })?;
355 let Some(save_err) = removed else {
356 return Ok(false);
357 };
358 self.generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
359 info!(%kind, id, "subject unrevoked");
360 self.emit(
361 sink,
362 AUDIT_UNREVOKE_KIND,
363 serde_json::json!({
364 "decision": "unrevoked",
365 "subject_kind": kind,
366 "subject_id": id,
367 "unrevoked_by": by,
368 "persisted": save_err.is_none(),
369 "persist_error": save_err,
370 }),
371 );
372 match save_err {
373 None => Ok(true),
374 Some(e) => Err(RevocationError::Persist(e)),
375 }
376 }
377
378 pub fn is_subject_revoked(&self, kind: RevocationKind, id: &str) -> bool {
380 self.find_subject(kind, id).is_some()
381 }
382
383 pub fn find_subject(&self, kind: RevocationKind, id: &str) -> Option<RevokedSubject> {
385 let id = kind.normalize(id).ok()?;
386 self.with_subjects(|s| {
387 s.subjects
388 .iter()
389 .find(|e| e.kind == kind && e.id == id)
390 .cloned()
391 })
392 }
393
394 pub fn list_subjects(&self, kind: Option<RevocationKind>) -> Vec<RevokedSubject> {
396 self.with_subjects(|s| {
397 s.subjects
398 .iter()
399 .filter(|e| kind.is_none_or(|k| e.kind == k))
400 .cloned()
401 .collect()
402 })
403 }
404
405 pub fn first_revoked<'a>(
407 &self,
408 package_id: Option<&str>,
409 signer_keys: impl IntoIterator<Item = &'a String>,
410 artifact_hashes: impl IntoIterator<Item = &'a String>,
411 ) -> Option<RevokedSubject> {
412 package_id
413 .and_then(|p| self.find_subject(RevocationKind::Package, p))
414 .or_else(|| {
415 signer_keys
416 .into_iter()
417 .find_map(|k| self.find_subject(RevocationKind::SignerKey, k))
418 })
419 .or_else(|| {
420 artifact_hashes
421 .into_iter()
422 .find_map(|h| self.find_subject(RevocationKind::ArtifactHash, h))
423 })
424 }
425
426 pub fn subjects_error(&self) -> Option<String> {
428 self.with_subjects(|s| s.poisoned.clone())
429 }
430
431 pub fn subjects_path(&self) -> PathBuf {
433 self.with_subjects(|s| s.path.clone())
434 }
435}