Skip to main content

acdp_primitives/
revocation.rs

1//! Attributable revocation verdict vocabulary (RFC-ACDP-0014 §7/§8,
2//! 0.5.0 "Attribution" and "Discovery outcome classes").
3//!
4//! These types live at the bottom of the workspace because
5//! [`AcdpError::KeyRevoked`](crate::AcdpError::KeyRevoked) and
6//! [`AcdpError::RevocationUnknown`](crate::AcdpError::RevocationUnknown)
7//! carry them. `acdp-types` re-exports [`RevocationTrustClass`] from its
8//! historical `acdp::types::revocation` path; `acdp-client` builds the
9//! rest from verified `KeyRevocation`s.
10
11use crate::primitives::{AgentDid, CtxId};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14
15/// The two trust classes of RFC-ACDP-0014 §5–§6. They carry different
16/// authority and MUST be reported distinguishably — never collapsed
17/// (§6).
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum RevocationTrustClass {
21    /// Signed by the producer's own current, non-revoked key (§5): the
22    /// stronger class, backed by the same trust anchor as every ACDP
23    /// body. Consumers act on it without further judgment (§7).
24    ProducerSigned,
25    /// Published under the registry's identity on the producer's
26    /// behalf after an out-of-band identity check (§6): the weaker,
27    /// lost-everything fallback. It imports registry trust — a hostile
28    /// or deceived registry can fabricate one. Strict-profile default:
29    /// apply §7 only for contexts served by or receipted by that same
30    /// registry; seek corroboration before applying it globally.
31    RegistryAttested,
32}
33
34/// Where a consumer obtained a revocation it acted on.
35#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
36#[serde(tag = "kind", rename_all = "snake_case")]
37#[non_exhaustive]
38pub enum RevocationSource {
39    /// Supplied by the caller (`RevocationPolicy::known`) — the §8
40    /// "out of band" source.
41    Known,
42    /// Found by this call's §8 discovery at registry `authority`.
43    Discovered {
44        /// The registry authority discovery queried.
45        authority: String,
46    },
47    /// Read from a `RevocationCache`; first verified at `origin`.
48    Cached {
49        /// The registry authority whose discovery first verified it.
50        origin: String,
51    },
52}
53
54/// A verified revocation, attributed (RFC-ACDP-0014 §7 "Attribution",
55/// 0.5.0): which context it is, who signed it, its trust class, which
56/// producer's key it names, and where the consumer got it.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[non_exhaustive]
59pub struct SourcedRevocation {
60    /// RFC-ACDP-0010 §6 fingerprint of the revoked key.
61    pub revoked_key_fingerprint: String,
62    /// This revocation's compromise boundary T (the *effective* boundary
63    /// across a lineage is [`RevocationRefusal::boundary`]).
64    pub compromised_since: DateTime<Utc>,
65    /// The revocation context's `ctx_id`, when known. `None` only for a
66    /// caller-supplied revocation built from a publish request.
67    /// Registry-assigned — identifies, does not authenticate.
68    pub ctx_id: Option<CtxId>,
69    /// The revocation's signer (`body.agent_id`): the producer for the
70    /// producer-signed class, the registry for the registry-attested one.
71    pub issuer: AgentDid,
72    /// `metadata.revoked_key_controller` (defaults to `issuer`).
73    pub revoked_key_controller: AgentDid,
74    /// §5/§6 trust class.
75    pub trust_class: RevocationTrustClass,
76    /// Where the consumer obtained it.
77    pub source: RevocationSource,
78}
79
80impl SourcedRevocation {
81    /// Assemble an attributed revocation. `acdp-client` builds these from
82    /// verified `KeyRevocation`s; there is no other sound source.
83    #[must_use]
84    pub fn new(
85        revoked_key_fingerprint: String,
86        compromised_since: DateTime<Utc>,
87        ctx_id: Option<CtxId>,
88        issuer: AgentDid,
89        revoked_key_controller: AgentDid,
90        trust_class: RevocationTrustClass,
91        source: RevocationSource,
92    ) -> Self {
93        Self {
94            revoked_key_fingerprint,
95            compromised_since,
96            ctx_id,
97            issuer,
98            revoked_key_controller,
99            trust_class,
100            source,
101        }
102    }
103
104    /// Whether this revocation speaks for `producer` (RFC-ACDP-0014 §7
105    /// "Producer scope", 0.5.0): producer-signed ⇒ `issuer == producer`;
106    /// registry-attested ⇒ `revoked_key_controller == producer` (§6
107    /// vantage scoping is applied separately). A revocation outside this
108    /// scope MUST NOT change the producer's verdict; it is a hint only.
109    #[must_use]
110    pub fn speaks_for(&self, producer: &AgentDid) -> bool {
111        match self.trust_class {
112            RevocationTrustClass::ProducerSigned => self.issuer == *producer,
113            RevocationTrustClass::RegistryAttested => self.revoked_key_controller == *producer,
114        }
115    }
116}
117
118/// Which RFC-ACDP-0014 §7 step a revocation verdict came from.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121#[non_exhaustive]
122pub enum RevocationEffect {
123    /// §7 step 2: placed strictly before T — the context stays
124    /// attributable (accepted under the historical rule).
125    PreCompromise,
126    /// §7 step 3: placed at or after T — final.
127    Revoked,
128    /// §7 step 4: no verifiable publish time (no verified receipt, or a
129    /// receipt under a historical receipt key in the strict profile) —
130    /// curable by a receipt.
131    Unplaceable,
132}
133
134/// The attributed refusal carried by
135/// [`AcdpError::KeyRevoked`](crate::AcdpError::KeyRevoked): a verified,
136/// in-scope revocation made the context's signing key not authorized.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[non_exhaustive]
139pub struct RevocationRefusal {
140    /// [`RevocationEffect::Revoked`] or [`RevocationEffect::Unplaceable`].
141    pub effect: RevocationEffect,
142    /// Fingerprint of the context's signing key.
143    pub signing_key_fingerprint: String,
144    /// Effective boundary T: the earliest `compromised_since` across
145    /// every in-scope revocation naming the key (§4).
146    pub boundary: DateTime<Utc>,
147    /// The revocation that fixes `boundary`.
148    pub revocation: SourcedRevocation,
149    /// The receipt-attested publish time compared against `boundary`, if
150    /// any.
151    pub placement: Option<DateTime<Utc>>,
152    /// Human-readable explanation.
153    pub detail: String,
154}
155
156impl RevocationRefusal {
157    /// Assemble a refusal.
158    #[must_use]
159    pub fn new(
160        effect: RevocationEffect,
161        signing_key_fingerprint: String,
162        boundary: DateTime<Utc>,
163        revocation: SourcedRevocation,
164        placement: Option<DateTime<Utc>>,
165        detail: String,
166    ) -> Self {
167        Self {
168            effect,
169            signing_key_fingerprint,
170            boundary,
171            revocation,
172            placement,
173            detail,
174        }
175    }
176}
177
178impl std::fmt::Display for RevocationRefusal {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        let effect = match self.effect {
181            RevocationEffect::PreCompromise => "pre-compromise",
182            RevocationEffect::Revoked => "revoked",
183            RevocationEffect::Unplaceable => "unplaceable",
184        };
185        write!(
186            f,
187            "{effect}: key {} revoked as of {} by {} ({:?}",
188            self.signing_key_fingerprint,
189            crate::time::fmt_rfc3339_ms(self.boundary),
190            self.revocation.issuer,
191            self.revocation.trust_class,
192        )?;
193        if let Some(ctx) = &self.revocation.ctx_id {
194            write!(f, ", {ctx}")?;
195        }
196        write!(f, "): {}", self.detail)
197    }
198}
199
200/// Why a revocation-discovery outcome is `unknown` (RFC-ACDP-0014 §8,
201/// 0.5.0). `unknown` is never folded into not-revoked.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
203#[serde(rename_all = "snake_case")]
204#[non_exhaustive]
205pub enum UnknownReason {
206    /// A search or lineage walk was cut short by a page, walk, or budget
207    /// cap (§8 "Truncation is not completeness").
208    Truncated,
209    /// The vantage cannot answer: `not_authorized` (e.g.
210    /// `anonymous_public_reads: false`), `not_implemented`, or a
211    /// malformed discovery response.
212    Unanswerable,
213    /// A lookup failed transiently (DID host, registry, transport, or the
214    /// discovery deadline).
215    Transient,
216    /// A candidate could be neither verified nor proven invalid (the §5
217    /// step 1 historical arm had no usable receipt).
218    UnverifiableCandidate,
219}
220
221impl UnknownReason {
222    /// The report token (`truncated`, `unanswerable`, `transient`,
223    /// `unverifiable_candidate`).
224    #[must_use]
225    pub fn as_str(self) -> &'static str {
226        match self {
227            UnknownReason::Truncated => "truncated",
228            UnknownReason::Unanswerable => "unanswerable",
229            UnknownReason::Transient => "transient",
230            UnknownReason::UnverifiableCandidate => "unverifiable_candidate",
231        }
232    }
233}
234
235impl std::fmt::Display for UnknownReason {
236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        f.write_str(self.as_str())
238    }
239}