Skip to main content

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}