Skip to main content

confium_signatif/
revocation.rs

1//! Revocation semantics (SIGNATIF §12).
2//!
3//! Four pieces:
4//!
5//! - [`Crl`] — the signed, time-stamped list of revoked authority
6//!   credentials with reason, validity period, and a transparency-log
7//!   reference for its own publication;
8//! - [`AuthorityStateBinding`] — the hash-binding that ties an
9//!   artifact to the authority states under which it was produced;
10//! - [`RevocationIndex`] — propagation and query: when a state is
11//!   revoked, every transitively bound artifact is *marked* (never
12//!   deleted), reversibly if the revocation is corrected;
13//! - [`RevocationView`] — the offline-verifier lens consumed by the
14//!   pipeline, including the CRL grace-period policy.
15
16use std::collections::BTreeMap;
17
18use chrono::{DateTime, Duration, Utc};
19use serde::{Deserialize, Serialize};
20
21/// Default offline CRL grace period before a stale CRL hard-rejects.
22pub const DEFAULT_GRACE_PERIOD: Duration = Duration::hours(24);
23
24/// Why a credential was revoked.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum RevocationReason {
28    /// Cryptographic key compromise.
29    KeyCompromise,
30    /// The authority ceased operation or affiliation ended.
31    CessationOfOperation,
32    /// Issued in error or superseded.
33    Superseded,
34    /// Withdrawn by the authority (policy).
35    Withdrawn,
36}
37
38/// One entry in a CRL.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct RevokedEntry {
41    /// Fingerprint of the revoked credential.
42    pub fingerprint: String,
43    /// When it was revoked.
44    pub revoked_at: DateTime<Utc>,
45    /// Why.
46    pub reason: RevocationReason,
47}
48
49/// A certificate revocation list: signed by the issuing trust
50/// authority, timestamped, validity-bounded, log-recorded.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Crl {
53    /// The issuing trust authority's identifier.
54    pub issuer: String,
55    /// Revoked credentials.
56    pub revoked: Vec<RevokedEntry>,
57    /// CRL validity start.
58    pub this_update: DateTime<Utc>,
59    /// CRL validity end.
60    pub next_update: DateTime<Utc>,
61    /// Transparency-log sequence of this CRL's own publication.
62    pub log_sequence: u64,
63    /// The issuer's signature over the canonical CRL body.
64    pub signature: Vec<u8>,
65}
66
67impl Crl {
68    /// The canonical signing bytes (JCS of the CRL without signature).
69    ///
70    /// # Errors
71    ///
72    /// Propagates canonicalization errors.
73    pub fn signing_bytes(&self) -> crate::error::SignatifResult<Vec<u8>> {
74        let mut copy = self.clone();
75        copy.signature = Vec::new();
76        Ok(
77            crate::jcs::canonicalize(&serde_json::to_value(&copy).expect("crl serializes"))?
78                .into_bytes(),
79        )
80    }
81
82    /// Whether the CRL covers `fingerprint` with a revocation time at
83    /// or before `at`.
84    pub fn revokes(&self, fingerprint: &str, at: DateTime<Utc>) -> Option<&RevokedEntry> {
85        self.revoked
86            .iter()
87            .find(|e| e.fingerprint == fingerprint && e.revoked_at <= at)
88    }
89
90    /// Whether the CRL is stale: `now` past `next_update`.
91    pub fn is_stale(&self, now: DateTime<Utc>) -> bool {
92        now > self.next_update
93    }
94}
95
96/// The hash-binding of an artifact to the authority states under
97/// which it was produced (`hash-binding` requirement): the artifact's
98/// canonical payload hash plus the fingerprints of every authority on
99/// every verified path.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct AuthorityStateBinding {
102    /// The bound artifact's canonical payload hash (hex).
103    pub artifact_hash: String,
104    /// Fingerprints of authority states the artifact depends on.
105    pub authority_fingerprints: Vec<String>,
106    /// When the binding was recorded.
107    pub bound_at: DateTime<Utc>,
108}
109
110/// Revocation status of a signer as seen by the pipeline.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum RevocationStatus {
113    /// Not revoked; CRL fresh.
114    Good,
115    /// Not revoked, but the CRL is within the grace window — soft.
116    GraceDowngrade,
117    /// Revoked — hard failure.
118    Revoked,
119}
120
121/// The offline-verifier revocation lens.
122pub trait RevocationView {
123    /// Status of the authority identified by `id` at time `now`.
124    fn authority_status(&self, id: &str, now: DateTime<Utc>) -> RevocationStatus;
125
126    /// Age of the freshest CRL consulted (zero when none exist).
127    fn max_crl_age(&self, now: DateTime<Utc>) -> Duration;
128}
129
130/// A view with no revocations — for sealed offline bundles with empty
131/// CRLs and for tests.
132#[derive(Debug, Clone, Copy)]
133pub struct NoRevocations;
134
135impl RevocationView for NoRevocations {
136    fn authority_status(&self, _id: &str, _now: DateTime<Utc>) -> RevocationStatus {
137        RevocationStatus::Good
138    }
139
140    fn max_crl_age(&self, _now: DateTime<Utc>) -> Duration {
141        Duration::zero()
142    }
143}
144
145/// A concrete view over a set of CRLs.
146#[derive(Debug, Clone, Default)]
147pub struct CrlView {
148    /// Fingerprints of the authorities whose status is being asked.
149    pub authority_fingerprints: BTreeMap<String, String>,
150    /// The CRLs held (cached for offline verification).
151    pub crls: Vec<Crl>,
152}
153
154impl RevocationView for CrlView {
155    fn authority_status(&self, id: &str, now: DateTime<Utc>) -> RevocationStatus {
156        let Some(fp) = self.authority_fingerprints.get(id) else {
157            return RevocationStatus::Good;
158        };
159        let stale = self.crls.iter().all(|c| c.is_stale(now));
160        for crl in &self.crls {
161            if crl.revokes(fp, now).is_some() {
162                return RevocationStatus::Revoked;
163            }
164        }
165        if stale && !self.crls.is_empty() {
166            RevocationStatus::GraceDowngrade
167        } else {
168            RevocationStatus::Good
169        }
170    }
171
172    fn max_crl_age(&self, now: DateTime<Utc>) -> Duration {
173        self.crls
174            .iter()
175            .map(|c| now.signed_duration_since(c.this_update))
176            .max()
177            .unwrap_or_else(Duration::zero)
178    }
179}
180
181/// Artifact marking: revoked-but-not-deleted, reversible.
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum ArtifactMark {
185    /// Bound to a revoked authority state.
186    Marked,
187    /// Mark cleared after a corrected revocation.
188    Cleared,
189}
190
191/// The propagation and query index: artifacts ↔ authority states.
192#[derive(Debug, Clone, Default)]
193pub struct RevocationIndex {
194    bindings: Vec<AuthorityStateBinding>,
195    marks: BTreeMap<String, ArtifactMark>,
196    revoked_states: Vec<String>,
197}
198
199impl RevocationIndex {
200    /// An empty index.
201    pub fn new() -> Self {
202        Self::default()
203    }
204
205    /// Record an artifact's binding to authority states.
206    pub fn bind(&mut self, binding: AuthorityStateBinding) {
207        self.bindings.push(binding);
208    }
209
210    /// Revoke an authority state (by fingerprint) and propagate: every
211    /// artifact transitively bound to that state is **marked**, not
212    /// deleted; the marking is queryable and reversible.
213    pub fn revoke_state(&mut self, fingerprint: &str) {
214        self.revoked_states.push(fingerprint.to_string());
215        for b in &self.bindings {
216            if b.authority_fingerprints.iter().any(|f| f == fingerprint) {
217                self.marks
218                    .insert(b.artifact_hash.clone(), ArtifactMark::Marked);
219            }
220        }
221    }
222
223    /// Correct a revocation: un-mark artifacts bound to the state.
224    pub fn correct_state(&mut self, fingerprint: &str) {
225        self.revoked_states.retain(|f| f != fingerprint);
226        for b in &self.bindings {
227            if b.authority_fingerprints.iter().any(|f| f == fingerprint) {
228                self.marks
229                    .insert(b.artifact_hash.clone(), ArtifactMark::Cleared);
230            }
231        }
232    }
233
234    /// Forward query: the states an artifact is bound to and its
235    /// current marking.
236    pub fn artifact_status(&self, artifact_hash: &str) -> (Vec<String>, Option<ArtifactMark>) {
237        let states = self
238            .bindings
239            .iter()
240            .find(|b| b.artifact_hash == artifact_hash)
241            .map(|b| b.authority_fingerprints.clone())
242            .unwrap_or_default();
243        (states, self.marks.get(artifact_hash).copied())
244    }
245
246    /// Reverse query: artifacts bound to a given state.
247    pub fn artifacts_for_state(&self, fingerprint: &str) -> Vec<String> {
248        self.bindings
249            .iter()
250            .filter(|b| b.authority_fingerprints.iter().any(|f| f == fingerprint))
251            .map(|b| b.artifact_hash.clone())
252            .collect()
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    fn crl(revoked: Vec<RevokedEntry>) -> Crl {
261        Crl {
262            issuer: "root".into(),
263            revoked,
264            this_update: Utc::now() - Duration::hours(1),
265            next_update: Utc::now() + Duration::days(7),
266            log_sequence: 42,
267            signature: vec![],
268        }
269    }
270
271    #[test]
272    fn crl_matches_by_fingerprint_and_time() {
273        let fp = "abc";
274        let c = crl(vec![RevokedEntry {
275            fingerprint: fp.into(),
276            revoked_at: Utc::now() - Duration::minutes(5),
277            reason: RevocationReason::KeyCompromise,
278        }]);
279        assert!(c.revokes(fp, Utc::now()).is_some());
280        assert!(c.revokes(fp, Utc::now() - Duration::hours(1)).is_none());
281        assert!(c.revokes("other", Utc::now()).is_none());
282    }
283
284    #[test]
285    fn view_reports_revoked_and_stale_grace() {
286        let fp = "abc";
287        let mut view = CrlView::default();
288        view.authority_fingerprints.insert("end".into(), fp.into());
289        view.crls.push(crl(vec![RevokedEntry {
290            fingerprint: fp.into(),
291            revoked_at: Utc::now(),
292            reason: RevocationReason::Withdrawn,
293        }]));
294        assert_eq!(
295            view.authority_status("end", Utc::now()),
296            RevocationStatus::Revoked
297        );
298
299        let fresh_view = CrlView {
300            authority_fingerprints: [("end".to_string(), "abc".to_string())]
301                .into_iter()
302                .collect(),
303            crls: vec![crl(vec![])],
304        };
305        assert_eq!(
306            fresh_view.authority_status("end", Utc::now()),
307            RevocationStatus::Good
308        );
309
310        let mut stale = fresh_view.clone();
311        stale.crls[0].next_update = Utc::now() - Duration::hours(1);
312        assert_eq!(
313            stale.authority_status("end", Utc::now()),
314            RevocationStatus::GraceDowngrade
315        );
316    }
317
318    #[test]
319    fn propagation_marks_reversibly() {
320        let mut idx = RevocationIndex::new();
321        idx.bind(AuthorityStateBinding {
322            artifact_hash: "h1".into(),
323            authority_fingerprints: vec!["fp-root".into(), "fp-end".into()],
324            bound_at: Utc::now(),
325        });
326        idx.bind(AuthorityStateBinding {
327            artifact_hash: "h2".into(),
328            authority_fingerprints: vec!["fp-other".into()],
329            bound_at: Utc::now(),
330        });
331
332        idx.revoke_state("fp-end");
333        assert_eq!(idx.artifact_status("h1").1, Some(ArtifactMark::Marked));
334        assert_eq!(idx.artifact_status("h2").1, None);
335        assert_eq!(idx.artifacts_for_state("fp-end"), vec!["h1".to_string()]);
336
337        idx.correct_state("fp-end");
338        assert_eq!(idx.artifact_status("h1").1, Some(ArtifactMark::Cleared));
339    }
340}