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}