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 — see
92    /// [`Self::cross_check_registry_binding`].
93    pub trust_class: RevocationTrustClass,
94}
95
96impl KeyRevocation {
97    /// Parse and shape-validate a `key-revocation` context body per
98    /// RFC-ACDP-0014 §4.
99    ///
100    /// Enforced here (violations are [`AcdpError::SchemaViolation`], the
101    /// code a 0.3.0 registry rejects them with at publish):
102    ///
103    /// - `type` is `key-revocation` (or the §10 interim
104    ///   `acdp:key-revocation`).
105    /// - `visibility` is `public` — an audience-restricted revocation
106    ///   protects nobody outside the audience.
107    /// - `metadata.revoked_key_fingerprint` present, in the
108    ///   RFC-ACDP-0010 §6 form `sha256:` + 64 lowercase hex.
109    /// - `metadata.compromised_since` present, canonical
110    ///   millisecond-precision RFC 3339 UTC (RFC-ACDP-0001 §5.3).
111    /// - `metadata.reason`, when present, ≤ 1024 characters.
112    /// - `metadata.revoked_key_controller`, when present, a valid DID.
113    ///
114    /// Additionally, when the signing key's fingerprint is derivable
115    /// *purely* from the body (a `did:key` signer), the §5 step 2
116    /// not-self-signed rule is enforced here too. For `did:web` signers
117    /// the fingerprint requires DID resolution: callers MUST follow up
118    /// with [`Self::check_not_self_signed`] against the resolved
119    /// fingerprint (`acdp-client`'s revocation pipeline does).
120    ///
121    /// This does NOT verify the body's hash or signature — a parsed
122    /// revocation is untrusted until the strict §5.11 pipeline passes.
123    pub fn from_body(body: &Body) -> Result<Self, AcdpError> {
124        if !body.context_type.is_key_revocation() {
125            return Err(AcdpError::SchemaViolation(format!(
126                "not a key-revocation context: type is '{}' (RFC-ACDP-0014 §4 requires \
127                 'key-revocation', or 'acdp:key-revocation' in the pre-0.3.0 interim form)",
128                serde_json::to_value(&body.context_type)
129                    .ok()
130                    .and_then(|v| v.as_str().map(str::to_owned))
131                    .unwrap_or_default()
132            )));
133        }
134        if body.visibility != Visibility::Public {
135            return Err(AcdpError::SchemaViolation(
136                "a key-revocation context MUST be visibility 'public' — it is a safety \
137                 broadcast; an audience-restricted revocation protects nobody outside the \
138                 audience (RFC-ACDP-0014 §4)"
139                    .into(),
140            ));
141        }
142
143        let meta = body
144            .metadata
145            .as_ref()
146            .and_then(|m| m.as_object())
147            .ok_or_else(|| {
148                AcdpError::SchemaViolation(
149                    "key-revocation body has no metadata object; \
150                     metadata.revoked_key_fingerprint and metadata.compromised_since are \
151                     REQUIRED (RFC-ACDP-0014 §4)"
152                        .into(),
153                )
154            })?;
155
156        let fingerprint = required_str(meta, "revoked_key_fingerprint")?;
157        if !is_sha256_fingerprint(fingerprint) {
158            return Err(AcdpError::SchemaViolation(format!(
159                "metadata.revoked_key_fingerprint '{fingerprint}' is not in the \
160                 RFC-ACDP-0010 §6 form 'sha256:' + 64 lowercase hex (RFC-ACDP-0014 §4)"
161            )));
162        }
163
164        let since_raw = required_str(meta, "compromised_since")?;
165        let compromised_since = parse_canonical_ms(since_raw).ok_or_else(|| {
166            AcdpError::SchemaViolation(format!(
167                "metadata.compromised_since '{since_raw}' is not canonical \
168                 millisecond-precision RFC 3339 UTC (RFC-ACDP-0001 §5.3, RFC-ACDP-0014 §4)"
169            ))
170        })?;
171
172        let reason = optional_str(meta, "reason")?;
173        if let Some(r) = &reason {
174            if r.chars().count() > MAX_REASON_CHARS {
175                return Err(AcdpError::SchemaViolation(format!(
176                    "metadata.reason exceeds {MAX_REASON_CHARS} characters (RFC-ACDP-0014 §4)"
177                )));
178            }
179        }
180        let revoked_key_id = optional_str(meta, "revoked_key_id")?;
181
182        let (revoked_key_controller, trust_class) =
183            match optional_str(meta, "revoked_key_controller")? {
184                None => (body.agent_id.clone(), RevocationTrustClass::ProducerSigned),
185                Some(c) => {
186                    let controller = AgentDid::parse(&c)?;
187                    if controller == body.agent_id {
188                        // §5 rule 3: present-and-equal is the explicit
189                        // producer-signed controller binding.
190                        (controller, RevocationTrustClass::ProducerSigned)
191                    } else {
192                        // §6: published under another identity (the
193                        // registry's) on the controller's behalf.
194                        (controller, RevocationTrustClass::RegistryAttested)
195                    }
196                }
197            };
198
199        let revocation = KeyRevocation {
200            revoked_key_fingerprint: fingerprint.to_string(),
201            compromised_since,
202            reason,
203            revoked_key_id,
204            revoked_key_controller,
205            publisher: body.agent_id.clone(),
206            trust_class,
207        };
208
209        // §5 step 2, pure sub-case: a did:key signer's fingerprint is
210        // derivable from the key_id itself with no resolution. A
211        // malformed did:key key_id is left for signature verification
212        // to reject — this check is best-effort by design.
213        if body.signature.key_id.starts_with("did:key:") {
214            if let Ok(material) = acdp_did::key::resolve_did_key_url(&body.signature.key_id) {
215                if let Ok(fp) = acdp_crypto::fingerprint::fingerprint_did_key_material(&material) {
216                    revocation.check_not_self_signed(&fp)?;
217                }
218            }
219        }
220
221        Ok(revocation)
222    }
223
224    /// RFC-ACDP-0014 §5 step 2 — the revocation MUST NOT be signed by
225    /// the very key it revokes: such a statement proves only possession
226    /// of the (by hypothesis, attacker-held) key. Registries at ≥ 0.3.0
227    /// reject the publish with `key_not_authorized`; consumers MUST
228    /// treat one as **unverified** (at most a hint to seek a real
229    /// signal).
230    ///
231    /// `signing_key_fingerprint` is the RFC-ACDP-0010 §6 fingerprint of
232    /// the *resolved* key that signed the revocation body (see
233    /// `acdp_crypto::fingerprint`).
234    pub fn check_not_self_signed(&self, signing_key_fingerprint: &str) -> Result<(), AcdpError> {
235        if signing_key_fingerprint == self.revoked_key_fingerprint {
236            return Err(AcdpError::KeyNotAuthorized(format!(
237                "revocation of key {} is signed by that same key — a key is not \
238                 authorized to attest its own compromise; treat as unverified \
239                 (RFC-ACDP-0014 §5 step 2)",
240                self.revoked_key_fingerprint
241            )));
242        }
243        Ok(())
244    }
245
246    /// True when this revocation applies to the given signing-key
247    /// fingerprint (RFC-ACDP-0010 §6 encoding, exact match).
248    pub fn revokes(&self, key_fingerprint: &str) -> bool {
249        self.revoked_key_fingerprint == key_fingerprint
250    }
251
252    /// Registry-attestation binding (pure): `publisher` — the identity
253    /// this revocation was actually published under — MUST equal both
254    /// `did:web:<serving_authority>` (the authority the context was
255    /// actually fetched from, not whatever the body claims) AND the
256    /// serving registry's advertised `capabilities.registry_did`. The
257    /// two halves have different citations: the `registry_did` half is
258    /// RFC-ACDP-0014 §6 step 2; the `serving_authority` half is not a
259    /// §6 step at all — it is the ACDP-wide `registry_did`↔authority
260    /// invariant of RFC-ACDP-0011 §7 step 3 / RFC-ACDP-0012 §9.3 step 3
261    /// (the two house-pattern siblings), applied here to key
262    /// revocations.
263    ///
264    /// A [`RevocationTrustClass::RegistryAttested`] revocation imports
265    /// its authority entirely from *who published it* — §5 body
266    /// verification alone only proves the body is genuinely signed by
267    /// `publisher`'s current key, not that `publisher` is the specific
268    /// registry a caller actually talks to. Without this check a
269    /// consumer could apply a registry-attested revocation on the say-so
270    /// of any producer willing to name someone else as
271    /// `revoked_key_controller`; this pins `publisher` to the one
272    /// registry both the transport (`serving_authority`) and the
273    /// registry's own self-description (`capabilities_registry_did`)
274    /// agree on.
275    ///
276    /// Pure — no DID resolution or network I/O — so it stays exposable
277    /// from the language bindings.
278    pub fn cross_check_registry_binding(
279        &self,
280        serving_authority: &str,
281        capabilities_registry_did: &str,
282    ) -> Result<(), AcdpError> {
283        let expected_did = acdp_did::web::authority_to_did_web(serving_authority);
284        if self.publisher.as_str() != expected_did {
285            return Err(AcdpError::KeyNotAuthorized(format!(
286                "key-revocation publisher '{}' ≠ serving authority's DID '{expected_did}' \
287                 (RFC-ACDP-0014 §6 steps 2–3)",
288                self.publisher
289            )));
290        }
291        if self.publisher.as_str() != capabilities_registry_did {
292            return Err(AcdpError::KeyNotAuthorized(format!(
293                "key-revocation publisher '{}' ≠ capabilities.registry_did \
294                 '{capabilities_registry_did}' (RFC-ACDP-0014 §6 steps 2–3)",
295                self.publisher
296            )));
297        }
298        Ok(())
299    }
300}
301
302/// The effective compromise boundary for `key_fingerprint` across a set
303/// of (verified) revocations: the **earliest** `compromised_since`
304/// among those that name the fingerprint, or `None` when none does.
305///
306/// This is the RFC-ACDP-0014 §4 monotonicity rule: a superseding
307/// revocation may widen — never narrow — the compromise window, so a
308/// supersession can never quietly shrink it. Feed every revocation of a
309/// lineage (including superseded ones) through this, not just the head.
310pub fn effective_boundary<'a>(
311    revocations: impl IntoIterator<Item = &'a KeyRevocation>,
312    key_fingerprint: &str,
313) -> Option<DateTime<Utc>> {
314    revocations
315        .into_iter()
316        .filter(|r| r.revokes(key_fingerprint))
317        .map(|r| r.compromised_since)
318        .min()
319}
320
321fn required_str<'m>(
322    meta: &'m serde_json::Map<String, serde_json::Value>,
323    key: &str,
324) -> Result<&'m str, AcdpError> {
325    meta.get(key).and_then(|v| v.as_str()).ok_or_else(|| {
326        AcdpError::SchemaViolation(format!(
327            "key-revocation metadata.{key} is REQUIRED and must be a string \
328             (RFC-ACDP-0014 §4)"
329        ))
330    })
331}
332
333fn optional_str(
334    meta: &serde_json::Map<String, serde_json::Value>,
335    key: &str,
336) -> Result<Option<String>, AcdpError> {
337    match meta.get(key) {
338        None => Ok(None),
339        Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
340        Some(_) => Err(AcdpError::SchemaViolation(format!(
341            "key-revocation metadata.{key} must be a string when present (RFC-ACDP-0014 §4)"
342        ))),
343    }
344}
345
346/// `sha256:` + exactly 64 lowercase hex digits (RFC-ACDP-0010 §6).
347fn is_sha256_fingerprint(s: &str) -> bool {
348    match s.strip_prefix("sha256:") {
349        Some(hex) => {
350            hex.len() == 64
351                && hex
352                    .chars()
353                    .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
354        }
355        None => false,
356    }
357}
358
359/// Parse a timestamp REQUIRING the canonical millisecond RFC 3339 UTC
360/// form `YYYY-MM-DDTHH:MM:SS.mmmZ` (RFC-ACDP-0001 §5.3): the string
361/// must round-trip byte-identically through the canonical formatter.
362fn parse_canonical_ms(raw: &str) -> Option<DateTime<Utc>> {
363    let parsed = DateTime::parse_from_rfc3339(raw).ok()?.with_timezone(&Utc);
364    (fmt_rfc3339_ms(parsed) == raw).then_some(parsed)
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    fn registry_attested_rev(publisher: &str) -> KeyRevocation {
372        KeyRevocation {
373            revoked_key_fingerprint: format!("sha256:{}", "a1".repeat(32)),
374            compromised_since: parse_canonical_ms("2026-05-01T00:00:00.000Z").unwrap(),
375            reason: None,
376            revoked_key_id: None,
377            revoked_key_controller: AgentDid::new("did:web:agents.example.com:producer"),
378            publisher: AgentDid::new(publisher),
379            trust_class: RevocationTrustClass::RegistryAttested,
380        }
381    }
382
383    /// RFC-ACDP-0014 §6 steps 2–3: `publisher` must equal both the
384    /// serving authority's DID and `capabilities.registry_did`. All
385    /// aligned ⇒ `Ok`; either mismatch ⇒ `Err(KeyNotAuthorized)`.
386    #[test]
387    fn cross_check_registry_binding_success_and_both_failure_directions() {
388        let rev = registry_attested_rev("did:web:registry.example.com");
389
390        rev.cross_check_registry_binding("registry.example.com", "did:web:registry.example.com")
391            .expect("serving authority and capabilities.registry_did both match publisher");
392
393        // Wrong serving authority.
394        assert!(matches!(
395            rev.cross_check_registry_binding("hostile.example", "did:web:registry.example.com"),
396            Err(AcdpError::KeyNotAuthorized(_))
397        ));
398
399        // Wrong capabilities.registry_did.
400        assert!(matches!(
401            rev.cross_check_registry_binding("registry.example.com", "did:web:other.example"),
402            Err(AcdpError::KeyNotAuthorized(_))
403        ));
404    }
405
406    /// `did:web:localhost%3A8443` — the percent-encoded-port form
407    /// `authority_to_did_web` produces for a `host:port` authority
408    /// (RFC-ACDP-0014 §6 steps 2–3; live in this codebase's own test
409    /// harness, which binds ephemeral ports, not merely hypothetical).
410    #[test]
411    fn cross_check_registry_binding_percent_encoded_port_authority() {
412        let rev = registry_attested_rev("did:web:localhost%3A8443");
413
414        rev.cross_check_registry_binding("localhost:8443", "did:web:localhost%3A8443")
415            .expect("host:port authority round-trips through authority_to_did_web");
416
417        // A bare-hostname serving authority (no port) must NOT match a
418        // publisher bound to the port-bearing form.
419        assert!(matches!(
420            rev.cross_check_registry_binding("localhost", "did:web:localhost%3A8443"),
421            Err(AcdpError::KeyNotAuthorized(_))
422        ));
423    }
424
425    #[test]
426    fn fingerprint_form_edges() {
427        assert!(is_sha256_fingerprint(&format!(
428            "sha256:{}",
429            "a1".repeat(32)
430        )));
431        assert!(!is_sha256_fingerprint(&format!(
432            "sha256:{}",
433            "A1".repeat(32)
434        ))); // uppercase
435        assert!(!is_sha256_fingerprint(&format!(
436            "sha512:{}",
437            "a1".repeat(32)
438        ))); // wrong alg
439        assert!(!is_sha256_fingerprint(&format!(
440            "sha256:{}",
441            "a1".repeat(31)
442        ))); // short
443        assert!(!is_sha256_fingerprint("sha256:")); // empty hex
444        assert!(!is_sha256_fingerprint(&"a1".repeat(32))); // no prefix
445    }
446
447    #[test]
448    fn canonical_ms_timestamp_edges() {
449        assert!(parse_canonical_ms("2026-05-01T00:00:00.000Z").is_some());
450        // Non-canonical forms MUST be rejected even when RFC 3339-valid.
451        for bad in [
452            "2026-05-01T00:00:00Z",          // no fractional part
453            "2026-05-01T00:00:00.0Z",        // 1 digit
454            "2026-05-01T00:00:00.000000Z",   // microseconds
455            "2026-05-01T00:00:00.000+00:00", // offset spelling
456            "2026-05-01 00:00:00.000Z",      // space separator
457            "not-a-time",
458        ] {
459            assert!(
460                parse_canonical_ms(bad).is_none(),
461                "{bad:?} must be rejected"
462            );
463        }
464    }
465}