acdp_types/revocation.rs
1//! Producer key-revocation signal (ACDP 0.3, RFC-ACDP-0014).
2//!
3//! A revocation is not a new wire object: it is an ordinary signed,
4//! permanent, content-addressed [`Body`] of type `key-revocation`
5//! (interim pre-0.3.0 form: `acdp:key-revocation`) whose metadata
6//! declares a key compromised **as of a stated time**. This module is
7//! the typed view over that metadata: [`KeyRevocation::from_body`]
8//! enforces the §4 shape rules and derives the §5/§6 trust class, and
9//! [`effective_boundary`] applies the §4 earliest-`compromised_since`
10//! rule across a set of revocations.
11//!
12//! Parsing a revocation does NOT verify it. A **verified revocation**
13//! additionally requires the strict RFC-ACDP-0001 §5.11 body pipeline
14//! plus the §5 not-self-signed check
15//! ([`KeyRevocation::check_not_self_signed`]) against the *resolved*
16//! signing key's fingerprint — `acdp-client` wires the full pipeline.
17
18use crate::body::Body;
19use acdp_primitives::error::AcdpError;
20use acdp_primitives::primitives::{AgentDid, Visibility};
21use acdp_primitives::time::fmt_rfc3339_ms;
22use chrono::{DateTime, Utc};
23use serde::{Deserialize, Serialize};
24
25/// Maximum length of `metadata.reason` (RFC-ACDP-0014 §4).
26pub const MAX_REASON_CHARS: usize = 1024;
27
28/// The two trust classes of RFC-ACDP-0014 §5–§6. They carry different
29/// authority and MUST be reported distinguishably — never collapsed
30/// (§6).
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum RevocationTrustClass {
34 /// Signed by the producer's own current, non-revoked key (§5): the
35 /// stronger class, backed by the same trust anchor as every ACDP
36 /// body. Consumers act on it without further judgment (§7).
37 ProducerSigned,
38 /// Published under the registry's identity on the producer's
39 /// behalf after an out-of-band identity check (§6): the weaker,
40 /// lost-everything fallback. It imports registry trust — a hostile
41 /// or deceived registry can fabricate one. Strict-profile default:
42 /// apply §7 only for contexts served by or receipted by that same
43 /// registry; seek corroboration before applying it globally.
44 RegistryAttested,
45}
46
47/// Typed, shape-validated view of a `key-revocation` context body
48/// (RFC-ACDP-0014 §4).
49///
50/// Obtain via [`KeyRevocation::from_body`]. Field semantics:
51///
52/// - The **fingerprint is authoritative**; `revoked_key_id` is human
53/// traceability only (§4).
54/// - `compromised_since` is the compromise boundary **T**: signatures
55/// made strictly before T are attributable to the producer; at or
56/// after T they are not (§7). Across a superseding revocation
57/// lineage the *earliest* T is effective (§4, [`effective_boundary`]).
58/// - A revocation is permanent — there is no un-revoking. Consumers
59/// SHOULD cache verified revocations indefinitely (§7); the type is
60/// serde-serializable for exactly that.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct KeyRevocation {
63 /// RFC-ACDP-0010 §6 fingerprint of the revoked public key
64 /// (`sha256:` + 64 lowercase hex), byte-for-byte the encoding
65 /// receipts record. Authoritative over `revoked_key_id`.
66 pub revoked_key_fingerprint: String,
67 /// The compromise boundary T (canonical millisecond RFC 3339 UTC
68 /// on the wire).
69 pub compromised_since: DateTime<Utc>,
70 /// Optional human-readable circumstances (≤ 1024 chars).
71 /// Informational only — apply output hygiene before display
72 /// (RFC-ACDP-0014 §13).
73 pub reason: Option<String>,
74 /// Optional DID URL of the revoked verification method. On any
75 /// disagreement with the fingerprint, the fingerprint governs.
76 pub revoked_key_id: Option<String>,
77 /// The producer DID that controls the revoked key. Defaults to the
78 /// body's `agent_id` when the metadata field is absent
79 /// (producer-signed form); on registry-attested revocations it
80 /// names the affected producer while `agent_id` is the registry.
81 pub revoked_key_controller: AgentDid,
82 /// The body's `agent_id` — the identity the revocation was
83 /// published under (the producer for [`RevocationTrustClass::ProducerSigned`],
84 /// the registry for [`RevocationTrustClass::RegistryAttested`]).
85 pub publisher: AgentDid,
86 /// §5/§6 trust class, derived from the controller binding:
87 /// `revoked_key_controller` absent or equal to `agent_id` ⇒
88 /// producer-signed; different ⇒ registry-attested. MUST NOT be
89 /// collapsed when reporting (§6). For a registry-attested claim the
90 /// caller still owns confirming that `publisher` really is the DID
91 /// of a registry it talks to (`capabilities.registry_did`).
92 pub trust_class: RevocationTrustClass,
93}
94
95impl KeyRevocation {
96 /// Parse and shape-validate a `key-revocation` context body per
97 /// RFC-ACDP-0014 §4.
98 ///
99 /// Enforced here (violations are [`AcdpError::SchemaViolation`], the
100 /// code a 0.3.0 registry rejects them with at publish):
101 ///
102 /// - `type` is `key-revocation` (or the §10 interim
103 /// `acdp:key-revocation`).
104 /// - `visibility` is `public` — an audience-restricted revocation
105 /// protects nobody outside the audience.
106 /// - `metadata.revoked_key_fingerprint` present, in the
107 /// RFC-ACDP-0010 §6 form `sha256:` + 64 lowercase hex.
108 /// - `metadata.compromised_since` present, canonical
109 /// millisecond-precision RFC 3339 UTC (RFC-ACDP-0001 §5.3).
110 /// - `metadata.reason`, when present, ≤ 1024 characters.
111 /// - `metadata.revoked_key_controller`, when present, a valid DID.
112 ///
113 /// Additionally, when the signing key's fingerprint is derivable
114 /// *purely* from the body (a `did:key` signer), the §5 step 2
115 /// not-self-signed rule is enforced here too. For `did:web` signers
116 /// the fingerprint requires DID resolution: callers MUST follow up
117 /// with [`Self::check_not_self_signed`] against the resolved
118 /// fingerprint (`acdp-client`'s revocation pipeline does).
119 ///
120 /// This does NOT verify the body's hash or signature — a parsed
121 /// revocation is untrusted until the strict §5.11 pipeline passes.
122 pub fn from_body(body: &Body) -> Result<Self, AcdpError> {
123 if !body.context_type.is_key_revocation() {
124 return Err(AcdpError::SchemaViolation(format!(
125 "not a key-revocation context: type is '{}' (RFC-ACDP-0014 §4 requires \
126 'key-revocation', or 'acdp:key-revocation' in the pre-0.3.0 interim form)",
127 serde_json::to_value(&body.context_type)
128 .ok()
129 .and_then(|v| v.as_str().map(str::to_owned))
130 .unwrap_or_default()
131 )));
132 }
133 if body.visibility != Visibility::Public {
134 return Err(AcdpError::SchemaViolation(
135 "a key-revocation context MUST be visibility 'public' — it is a safety \
136 broadcast; an audience-restricted revocation protects nobody outside the \
137 audience (RFC-ACDP-0014 §4)"
138 .into(),
139 ));
140 }
141
142 let meta = body
143 .metadata
144 .as_ref()
145 .and_then(|m| m.as_object())
146 .ok_or_else(|| {
147 AcdpError::SchemaViolation(
148 "key-revocation body has no metadata object; \
149 metadata.revoked_key_fingerprint and metadata.compromised_since are \
150 REQUIRED (RFC-ACDP-0014 §4)"
151 .into(),
152 )
153 })?;
154
155 let fingerprint = required_str(meta, "revoked_key_fingerprint")?;
156 if !is_sha256_fingerprint(fingerprint) {
157 return Err(AcdpError::SchemaViolation(format!(
158 "metadata.revoked_key_fingerprint '{fingerprint}' is not in the \
159 RFC-ACDP-0010 §6 form 'sha256:' + 64 lowercase hex (RFC-ACDP-0014 §4)"
160 )));
161 }
162
163 let since_raw = required_str(meta, "compromised_since")?;
164 let compromised_since = parse_canonical_ms(since_raw).ok_or_else(|| {
165 AcdpError::SchemaViolation(format!(
166 "metadata.compromised_since '{since_raw}' is not canonical \
167 millisecond-precision RFC 3339 UTC (RFC-ACDP-0001 §5.3, RFC-ACDP-0014 §4)"
168 ))
169 })?;
170
171 let reason = optional_str(meta, "reason")?;
172 if let Some(r) = &reason {
173 if r.chars().count() > MAX_REASON_CHARS {
174 return Err(AcdpError::SchemaViolation(format!(
175 "metadata.reason exceeds {MAX_REASON_CHARS} characters (RFC-ACDP-0014 §4)"
176 )));
177 }
178 }
179 let revoked_key_id = optional_str(meta, "revoked_key_id")?;
180
181 let (revoked_key_controller, trust_class) =
182 match optional_str(meta, "revoked_key_controller")? {
183 None => (body.agent_id.clone(), RevocationTrustClass::ProducerSigned),
184 Some(c) => {
185 let controller = AgentDid::parse(&c)?;
186 if controller == body.agent_id {
187 // §5 rule 3: present-and-equal is the explicit
188 // producer-signed controller binding.
189 (controller, RevocationTrustClass::ProducerSigned)
190 } else {
191 // §6: published under another identity (the
192 // registry's) on the controller's behalf.
193 (controller, RevocationTrustClass::RegistryAttested)
194 }
195 }
196 };
197
198 let revocation = KeyRevocation {
199 revoked_key_fingerprint: fingerprint.to_string(),
200 compromised_since,
201 reason,
202 revoked_key_id,
203 revoked_key_controller,
204 publisher: body.agent_id.clone(),
205 trust_class,
206 };
207
208 // §5 step 2, pure sub-case: a did:key signer's fingerprint is
209 // derivable from the key_id itself with no resolution. A
210 // malformed did:key key_id is left for signature verification
211 // to reject — this check is best-effort by design.
212 if body.signature.key_id.starts_with("did:key:") {
213 if let Ok(material) = acdp_did::key::resolve_did_key_url(&body.signature.key_id) {
214 if let Ok(fp) = acdp_crypto::fingerprint::fingerprint_did_key_material(&material) {
215 revocation.check_not_self_signed(&fp)?;
216 }
217 }
218 }
219
220 Ok(revocation)
221 }
222
223 /// RFC-ACDP-0014 §5 step 2 — the revocation MUST NOT be signed by
224 /// the very key it revokes: such a statement proves only possession
225 /// of the (by hypothesis, attacker-held) key. Registries at ≥ 0.3.0
226 /// reject the publish with `key_not_authorized`; consumers MUST
227 /// treat one as **unverified** (at most a hint to seek a real
228 /// signal).
229 ///
230 /// `signing_key_fingerprint` is the RFC-ACDP-0010 §6 fingerprint of
231 /// the *resolved* key that signed the revocation body (see
232 /// `acdp_crypto::fingerprint`).
233 pub fn check_not_self_signed(&self, signing_key_fingerprint: &str) -> Result<(), AcdpError> {
234 if signing_key_fingerprint == self.revoked_key_fingerprint {
235 return Err(AcdpError::KeyNotAuthorized(format!(
236 "revocation of key {} is signed by that same key — a key is not \
237 authorized to attest its own compromise; treat as unverified \
238 (RFC-ACDP-0014 §5 step 2)",
239 self.revoked_key_fingerprint
240 )));
241 }
242 Ok(())
243 }
244
245 /// True when this revocation applies to the given signing-key
246 /// fingerprint (RFC-ACDP-0010 §6 encoding, exact match).
247 pub fn revokes(&self, key_fingerprint: &str) -> bool {
248 self.revoked_key_fingerprint == key_fingerprint
249 }
250}
251
252/// The effective compromise boundary for `key_fingerprint` across a set
253/// of (verified) revocations: the **earliest** `compromised_since`
254/// among those that name the fingerprint, or `None` when none does.
255///
256/// This is the RFC-ACDP-0014 §4 monotonicity rule: a superseding
257/// revocation may widen — never narrow — the compromise window, so a
258/// supersession can never quietly shrink it. Feed every revocation of a
259/// lineage (including superseded ones) through this, not just the head.
260pub fn effective_boundary<'a>(
261 revocations: impl IntoIterator<Item = &'a KeyRevocation>,
262 key_fingerprint: &str,
263) -> Option<DateTime<Utc>> {
264 revocations
265 .into_iter()
266 .filter(|r| r.revokes(key_fingerprint))
267 .map(|r| r.compromised_since)
268 .min()
269}
270
271fn required_str<'m>(
272 meta: &'m serde_json::Map<String, serde_json::Value>,
273 key: &str,
274) -> Result<&'m str, AcdpError> {
275 meta.get(key).and_then(|v| v.as_str()).ok_or_else(|| {
276 AcdpError::SchemaViolation(format!(
277 "key-revocation metadata.{key} is REQUIRED and must be a string \
278 (RFC-ACDP-0014 §4)"
279 ))
280 })
281}
282
283fn optional_str(
284 meta: &serde_json::Map<String, serde_json::Value>,
285 key: &str,
286) -> Result<Option<String>, AcdpError> {
287 match meta.get(key) {
288 None => Ok(None),
289 Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
290 Some(_) => Err(AcdpError::SchemaViolation(format!(
291 "key-revocation metadata.{key} must be a string when present (RFC-ACDP-0014 §4)"
292 ))),
293 }
294}
295
296/// `sha256:` + exactly 64 lowercase hex digits (RFC-ACDP-0010 §6).
297fn is_sha256_fingerprint(s: &str) -> bool {
298 match s.strip_prefix("sha256:") {
299 Some(hex) => {
300 hex.len() == 64
301 && hex
302 .chars()
303 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
304 }
305 None => false,
306 }
307}
308
309/// Parse a timestamp REQUIRING the canonical millisecond RFC 3339 UTC
310/// form `YYYY-MM-DDTHH:MM:SS.mmmZ` (RFC-ACDP-0001 §5.3): the string
311/// must round-trip byte-identically through the canonical formatter.
312fn parse_canonical_ms(raw: &str) -> Option<DateTime<Utc>> {
313 let parsed = DateTime::parse_from_rfc3339(raw).ok()?.with_timezone(&Utc);
314 (fmt_rfc3339_ms(parsed) == raw).then_some(parsed)
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn fingerprint_form_edges() {
323 assert!(is_sha256_fingerprint(&format!(
324 "sha256:{}",
325 "a1".repeat(32)
326 )));
327 assert!(!is_sha256_fingerprint(&format!(
328 "sha256:{}",
329 "A1".repeat(32)
330 ))); // uppercase
331 assert!(!is_sha256_fingerprint(&format!(
332 "sha512:{}",
333 "a1".repeat(32)
334 ))); // wrong alg
335 assert!(!is_sha256_fingerprint(&format!(
336 "sha256:{}",
337 "a1".repeat(31)
338 ))); // short
339 assert!(!is_sha256_fingerprint("sha256:")); // empty hex
340 assert!(!is_sha256_fingerprint(&"a1".repeat(32))); // no prefix
341 }
342
343 #[test]
344 fn canonical_ms_timestamp_edges() {
345 assert!(parse_canonical_ms("2026-05-01T00:00:00.000Z").is_some());
346 // Non-canonical forms MUST be rejected even when RFC 3339-valid.
347 for bad in [
348 "2026-05-01T00:00:00Z", // no fractional part
349 "2026-05-01T00:00:00.0Z", // 1 digit
350 "2026-05-01T00:00:00.000000Z", // microseconds
351 "2026-05-01T00:00:00.000+00:00", // offset spelling
352 "2026-05-01 00:00:00.000Z", // space separator
353 "not-a-time",
354 ] {
355 assert!(
356 parse_canonical_ms(bad).is_none(),
357 "{bad:?} must be rejected"
358 );
359 }
360 }
361}