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 crate::publish::PublishRequest;
20use acdp_primitives::error::AcdpError;
21use acdp_primitives::primitives::{AgentDid, ContextType, CtxId, Visibility};
22pub use acdp_primitives::revocation::{
23    RevocationEffect, RevocationRefusal, RevocationSource, SourcedRevocation, UnknownReason,
24};
25use acdp_primitives::time::fmt_rfc3339_ms;
26use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28
29/// Maximum length of `metadata.reason` (RFC-ACDP-0014 §4).
30pub const MAX_REASON_CHARS: usize = 1024;
31
32pub use acdp_primitives::revocation::RevocationTrustClass;
33
34/// Typed, shape-validated view of a `key-revocation` context body
35/// (RFC-ACDP-0014 §4).
36///
37/// Obtain via [`KeyRevocation::from_body`]. Field semantics:
38///
39/// - The **fingerprint is authoritative**; `revoked_key_id` is human
40///   traceability only (§4).
41/// - `compromised_since` is the compromise boundary **T**: signatures
42///   made strictly before T are attributable to the producer; at or
43///   after T they are not (§7). Across a superseding revocation
44///   lineage the *earliest* T is effective (§4, [`effective_boundary`]).
45/// - A revocation is permanent — there is no un-revoking. Consumers
46///   SHOULD cache verified revocations indefinitely (§7); the type is
47///   serde-serializable for exactly that.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct KeyRevocation {
50    /// RFC-ACDP-0010 §6 fingerprint of the revoked public key
51    /// (`sha256:` + 64 lowercase hex), byte-for-byte the encoding
52    /// receipts record. Authoritative over `revoked_key_id`.
53    pub revoked_key_fingerprint: String,
54    /// The compromise boundary T (canonical millisecond RFC 3339 UTC
55    /// on the wire).
56    pub compromised_since: DateTime<Utc>,
57    /// Optional human-readable circumstances (≤ 1024 chars).
58    /// Informational only — apply output hygiene before display
59    /// (RFC-ACDP-0014 §13).
60    pub reason: Option<String>,
61    /// Optional DID URL of the revoked verification method. On any
62    /// disagreement with the fingerprint, the fingerprint governs.
63    pub revoked_key_id: Option<String>,
64    /// The producer DID that controls the revoked key. Defaults to the
65    /// body's `agent_id` when the metadata field is absent
66    /// (producer-signed form); on registry-attested revocations it
67    /// names the affected producer while `agent_id` is the registry.
68    pub revoked_key_controller: AgentDid,
69    /// The body's `agent_id` — the identity the revocation was
70    /// published under (the producer for [`RevocationTrustClass::ProducerSigned`],
71    /// the registry for [`RevocationTrustClass::RegistryAttested`]).
72    pub publisher: AgentDid,
73    /// §5/§6 trust class, derived from the controller binding:
74    /// `revoked_key_controller` absent or equal to `agent_id` ⇒
75    /// producer-signed; different ⇒ registry-attested. MUST NOT be
76    /// collapsed when reporting (§6). For a registry-attested claim the
77    /// caller still owns confirming that `publisher` really is the DID
78    /// of a registry it talks to — see
79    /// [`Self::cross_check_registry_binding`].
80    pub trust_class: RevocationTrustClass,
81    /// The revocation context's `ctx_id` (0.15.0, RFC-ACDP-0014 §7
82    /// "Attribution"): `Some` when parsed from a [`Body`], `None` from a
83    /// [`PublishRequest`] (not yet assigned). Registry-assigned and outside
84    /// the signature: it identifies the revocation in reports, it does not
85    /// authenticate it.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub ctx_id: Option<CtxId>,
88}
89
90impl KeyRevocation {
91    /// Parse and shape-validate a `key-revocation` context body per
92    /// RFC-ACDP-0014 §4.
93    ///
94    /// Enforced here (violations are [`AcdpError::SchemaViolation`], the
95    /// code a 0.3.0 registry rejects them with at publish):
96    ///
97    /// - `type` is `key-revocation` (or the §10 interim
98    ///   `acdp:key-revocation`).
99    /// - `visibility` is `public` — an audience-restricted revocation
100    ///   protects nobody outside the audience.
101    /// - `metadata.revoked_key_fingerprint` present, in the
102    ///   RFC-ACDP-0010 §6 form `sha256:` + 64 lowercase hex.
103    /// - `metadata.compromised_since` present, canonical
104    ///   millisecond-precision RFC 3339 UTC (RFC-ACDP-0001 §5.3).
105    /// - `metadata.reason`, when present, ≤ 1024 characters.
106    /// - `metadata.revoked_key_controller`, when present, a valid DID.
107    ///
108    /// Additionally, when the signing key's fingerprint is derivable
109    /// *purely* from the body (a `did:key` signer), the §5 step 2
110    /// not-self-signed rule is enforced here too. For `did:web` signers
111    /// the fingerprint requires DID resolution: callers MUST follow up
112    /// with [`Self::check_not_self_signed`] against the resolved
113    /// fingerprint (`acdp-client`'s revocation pipeline does).
114    ///
115    /// This does NOT verify the body's hash or signature — a parsed
116    /// revocation is untrusted until the strict §5.11 pipeline passes.
117    pub fn from_body(body: &Body) -> Result<Self, AcdpError> {
118        let mut revocation = Self::from_parts(
119            &body.context_type,
120            &body.visibility,
121            body.metadata.as_ref(),
122            &body.agent_id,
123            &body.signature.key_id,
124        )?;
125        revocation.ctx_id = Some(body.ctx_id.clone());
126        Ok(revocation)
127    }
128
129    /// Attribute this revocation (RFC-ACDP-0014 §7 "Attribution", 0.5.0)
130    /// with where the consumer obtained it.
131    #[must_use]
132    pub fn sourced(&self, source: RevocationSource) -> SourcedRevocation {
133        SourcedRevocation::new(
134            self.revoked_key_fingerprint.clone(),
135            self.compromised_since,
136            self.ctx_id.clone(),
137            self.publisher.clone(),
138            self.revoked_key_controller.clone(),
139            self.trust_class,
140            source,
141        )
142    }
143
144    /// Parse and shape-validate a `key-revocation` context carried as a
145    /// producer-submitted [`PublishRequest`] — i.e. *before* the registry
146    /// has assigned `ctx_id`/`lineage_id`/`origin_registry`/`created_at`.
147    /// Enforces exactly the same RFC-ACDP-0014 §4 shape table as
148    /// [`Self::from_body`] (see its doc comment for the itemized list),
149    /// because none of those checks touch a registry-assigned field.
150    ///
151    /// This is the entry point a `PublishValidator` — which sees a
152    /// `PublishRequest`, never a `Body` — uses to run the §4 checks at
153    /// publish time.
154    pub fn from_publish_request(req: &PublishRequest) -> Result<Self, AcdpError> {
155        Self::from_parts(
156            &req.context_type,
157            &req.visibility,
158            req.metadata.as_ref(),
159            &req.agent_id,
160            &req.signature.key_id,
161        )
162    }
163
164    /// Shared RFC-ACDP-0014 §4 shape-validation core over the five fields
165    /// the constraint table actually touches. Identical on `Body` and
166    /// `PublishRequest`, which is why [`Self::from_body`] and
167    /// [`Self::from_publish_request`] both delegate here instead of each
168    /// carrying their own copy — see [`Self::from_body`]'s doc comment
169    /// for the itemized list of what is enforced.
170    fn from_parts(
171        context_type: &ContextType,
172        visibility: &Visibility,
173        metadata: Option<&serde_json::Value>,
174        agent_id: &AgentDid,
175        signing_key_id: &str,
176    ) -> Result<Self, AcdpError> {
177        if !context_type.is_key_revocation() {
178            return Err(AcdpError::SchemaViolation(format!(
179                "not a key-revocation context: type is '{}' (RFC-ACDP-0014 §4 requires \
180                 'key-revocation', or 'acdp:key-revocation' in the pre-0.3.0 interim form)",
181                serde_json::to_value(context_type)
182                    .ok()
183                    .and_then(|v| v.as_str().map(str::to_owned))
184                    .unwrap_or_default()
185            )));
186        }
187        if *visibility != Visibility::Public {
188            return Err(AcdpError::SchemaViolation(
189                "a key-revocation context MUST be visibility 'public' — it is a safety \
190                 broadcast; an audience-restricted revocation protects nobody outside the \
191                 audience (RFC-ACDP-0014 §4)"
192                    .into(),
193            ));
194        }
195
196        let meta = metadata.and_then(|m| m.as_object()).ok_or_else(|| {
197            AcdpError::SchemaViolation(
198                "key-revocation body has no metadata object; \
199                 metadata.revoked_key_fingerprint and metadata.compromised_since are \
200                 REQUIRED (RFC-ACDP-0014 §4)"
201                    .into(),
202            )
203        })?;
204
205        let fingerprint = required_str(meta, "revoked_key_fingerprint")?;
206        if !is_sha256_fingerprint(fingerprint) {
207            return Err(AcdpError::SchemaViolation(format!(
208                "metadata.revoked_key_fingerprint '{fingerprint}' is not in the \
209                 RFC-ACDP-0010 §6 form 'sha256:' + 64 lowercase hex (RFC-ACDP-0014 §4)"
210            )));
211        }
212
213        let since_raw = required_str(meta, "compromised_since")?;
214        let compromised_since = parse_canonical_ms(since_raw).ok_or_else(|| {
215            AcdpError::SchemaViolation(format!(
216                "metadata.compromised_since '{since_raw}' is not canonical \
217                 millisecond-precision RFC 3339 UTC (RFC-ACDP-0001 §5.3, RFC-ACDP-0014 §4)"
218            ))
219        })?;
220
221        let reason = optional_str(meta, "reason")?;
222        if let Some(r) = &reason {
223            if r.chars().count() > MAX_REASON_CHARS {
224                return Err(AcdpError::SchemaViolation(format!(
225                    "metadata.reason exceeds {MAX_REASON_CHARS} characters (RFC-ACDP-0014 §4)"
226                )));
227            }
228        }
229        let revoked_key_id = optional_str(meta, "revoked_key_id")?;
230
231        let (revoked_key_controller, trust_class) =
232            match optional_str(meta, "revoked_key_controller")? {
233                None => (agent_id.clone(), RevocationTrustClass::ProducerSigned),
234                Some(c) => {
235                    let controller = AgentDid::parse(&c)?;
236                    if controller == *agent_id {
237                        // §5 rule 3: present-and-equal is the explicit
238                        // producer-signed controller binding.
239                        (controller, RevocationTrustClass::ProducerSigned)
240                    } else {
241                        // §6: published under another identity (the
242                        // registry's) on the controller's behalf.
243                        (controller, RevocationTrustClass::RegistryAttested)
244                    }
245                }
246            };
247
248        let revocation = KeyRevocation {
249            revoked_key_fingerprint: fingerprint.to_string(),
250            compromised_since,
251            reason,
252            revoked_key_id,
253            revoked_key_controller,
254            publisher: agent_id.clone(),
255            trust_class,
256            ctx_id: None,
257        };
258
259        // §5 step 2, pure sub-case: a did:key signer's fingerprint is
260        // derivable from the key_id itself with no resolution. A
261        // malformed did:key key_id is left for signature verification
262        // to reject — this check is best-effort by design.
263        if signing_key_id.starts_with("did:key:") {
264            if let Ok(material) = acdp_did::key::resolve_did_key_url(signing_key_id) {
265                if let Ok(fp) = acdp_crypto::fingerprint::fingerprint_did_key_material(&material) {
266                    revocation.check_not_self_signed(&fp)?;
267                }
268            }
269        }
270
271        Ok(revocation)
272    }
273
274    /// RFC-ACDP-0014 §5 step 2 — the revocation MUST NOT be signed by
275    /// the very key it revokes: such a statement proves only possession
276    /// of the (by hypothesis, attacker-held) key. Registries at ≥ 0.3.0
277    /// reject the publish with `key_not_authorized`; consumers MUST
278    /// treat one as **unverified** (at most a hint to seek a real
279    /// signal).
280    ///
281    /// `signing_key_fingerprint` is the RFC-ACDP-0010 §6 fingerprint of
282    /// the *resolved* key that signed the revocation body (see
283    /// `acdp_crypto::fingerprint`).
284    pub fn check_not_self_signed(&self, signing_key_fingerprint: &str) -> Result<(), AcdpError> {
285        if signing_key_fingerprint == self.revoked_key_fingerprint {
286            return Err(AcdpError::KeyNotAuthorized(format!(
287                "revocation of key {} is signed by that same key — a key is not \
288                 authorized to attest its own compromise; treat as unverified \
289                 (RFC-ACDP-0014 §5 step 2)",
290                self.revoked_key_fingerprint
291            )));
292        }
293        Ok(())
294    }
295
296    /// True when this revocation applies to the given signing-key
297    /// fingerprint (RFC-ACDP-0010 §6 encoding, exact match).
298    pub fn revokes(&self, key_fingerprint: &str) -> bool {
299        self.revoked_key_fingerprint == key_fingerprint
300    }
301
302    /// RFC-ACDP-0014 §5 step 2, decoupled from full §4 shape validation
303    /// (contrast [`Self::check_not_self_signed`], which needs an already
304    /// shape-validated [`KeyRevocation`]). §10's interim-form carve-out
305    /// is scoped explicitly to "§4 shape validation" — it says nothing
306    /// about §5, whose own MUST-reject text is gated on `acdp_version`
307    /// alone, not on which spelling of the context type was used. So a
308    /// registry on `[0.3.0, 0.5.0)` that must NOT §4-validate the
309    /// interim `acdp:key-revocation` form still MUST enforce this check
310    /// against it — this is the primitive that lets it do so without
311    /// pulling in the rest of §4.
312    ///
313    /// Tolerant by design: a missing or non-string
314    /// `metadata.revoked_key_fingerprint` can't be evaluated, so this
315    /// returns `Ok(())` rather than rejecting on shape grounds — full §4
316    /// validation (standard type) or §5.11 signature verification is
317    /// what catches those cases instead. `signing_key_fingerprint` is
318    /// the RFC-ACDP-0010 §6 fingerprint of the *resolved* signing key.
319    pub fn check_not_self_signed_lenient(
320        req: &PublishRequest,
321        signing_key_fingerprint: &str,
322    ) -> Result<(), AcdpError> {
323        let Some(fingerprint) = req
324            .metadata
325            .as_ref()
326            .and_then(|m| m.as_object())
327            .and_then(|m| m.get("revoked_key_fingerprint"))
328            .and_then(|v| v.as_str())
329        else {
330            return Ok(());
331        };
332        if fingerprint == signing_key_fingerprint {
333            return Err(AcdpError::KeyNotAuthorized(format!(
334                "revocation of key {fingerprint} is signed by that same key — a key is not \
335                 authorized to attest its own compromise; treat as unverified \
336                 (RFC-ACDP-0014 §5 step 2)"
337            )));
338        }
339        Ok(())
340    }
341
342    /// [`Self::check_not_self_signed_lenient`]'s did:key sub-case: the
343    /// signing key's fingerprint is derivable from `signature.key_id`
344    /// alone, with no DID resolution — the same synchronous check
345    /// `from_parts` used to run unconditionally as a side effect of full
346    /// §4 parsing (before the interim form was carved out of that), now
347    /// exposed standalone so a caller (like `PublishValidator`) can run
348    /// it on a body it deliberately is NOT §4-shape-validating.
349    ///
350    /// Tolerant by design: a non-did:key signer, an unresolvable
351    /// did:key `key_id`, or a fingerprint that fails to derive all leave
352    /// this a no-op — see [`Self::check_not_self_signed_lenient`] for
353    /// why that's the right default.
354    pub fn check_not_self_signed_did_key_lenient(req: &PublishRequest) -> Result<(), AcdpError> {
355        if !req.signature.key_id.starts_with("did:key:") {
356            return Ok(());
357        }
358        let Ok(material) = acdp_did::key::resolve_did_key_url(&req.signature.key_id) else {
359            return Ok(());
360        };
361        let Ok(signer_fingerprint) =
362            acdp_crypto::fingerprint::fingerprint_did_key_material(&material)
363        else {
364            return Ok(());
365        };
366        Self::check_not_self_signed_lenient(req, &signer_fingerprint)
367    }
368
369    /// Registry-attestation binding (pure): `publisher` — the identity
370    /// this revocation was actually published under — MUST equal both
371    /// `did:web:<serving_authority>` (the authority the context was
372    /// actually fetched from, not whatever the body claims) AND the
373    /// serving registry's advertised `capabilities.registry_did`. The
374    /// two halves have different citations: the `registry_did` half is
375    /// RFC-ACDP-0014 §6 step 2; the `serving_authority` half is not a
376    /// §6 step at all — it is the ACDP-wide `registry_did`↔authority
377    /// invariant of RFC-ACDP-0011 §7 step 3 / RFC-ACDP-0012 §9.3 step 3
378    /// (the two house-pattern siblings), applied here to key
379    /// revocations.
380    ///
381    /// A [`RevocationTrustClass::RegistryAttested`] revocation imports
382    /// its authority entirely from *who published it* — §5 body
383    /// verification alone only proves the body is genuinely signed by
384    /// `publisher`'s current key, not that `publisher` is the specific
385    /// registry a caller actually talks to. Without this check a
386    /// consumer could apply a registry-attested revocation on the say-so
387    /// of any producer willing to name someone else as
388    /// `revoked_key_controller`; this pins `publisher` to the one
389    /// registry both the transport (`serving_authority`) and the
390    /// registry's own self-description (`capabilities_registry_did`)
391    /// agree on.
392    ///
393    /// Pure — no DID resolution or network I/O — so it stays exposable
394    /// from the language bindings.
395    pub fn cross_check_registry_binding(
396        &self,
397        serving_authority: &str,
398        capabilities_registry_did: &str,
399    ) -> Result<(), AcdpError> {
400        let expected_did = acdp_did::web::authority_to_did_web(serving_authority);
401        if self.publisher.as_str() != expected_did {
402            return Err(AcdpError::KeyNotAuthorized(format!(
403                "key-revocation publisher '{}' ≠ serving authority's DID '{expected_did}' \
404                 (RFC-ACDP-0014 §6 steps 2–3)",
405                self.publisher
406            )));
407        }
408        if self.publisher.as_str() != capabilities_registry_did {
409            return Err(AcdpError::KeyNotAuthorized(format!(
410                "key-revocation publisher '{}' ≠ capabilities.registry_did \
411                 '{capabilities_registry_did}' (RFC-ACDP-0014 §6 steps 2–3)",
412                self.publisher
413            )));
414        }
415        Ok(())
416    }
417}
418
419/// The effective compromise boundary for `key_fingerprint` across a set
420/// of (verified) revocations: the **earliest** `compromised_since`
421/// among those that name the fingerprint, or `None` when none does.
422///
423/// This is the RFC-ACDP-0014 §4 monotonicity rule: a superseding
424/// revocation may widen — never narrow — the compromise window, so a
425/// supersession can never quietly shrink it. Feed every revocation of a
426/// lineage (including superseded ones) through this, not just the head.
427pub fn effective_boundary<'a>(
428    revocations: impl IntoIterator<Item = &'a KeyRevocation>,
429    key_fingerprint: &str,
430) -> Option<DateTime<Utc>> {
431    revocations
432        .into_iter()
433        .filter(|r| r.revokes(key_fingerprint))
434        .map(|r| r.compromised_since)
435        .min()
436}
437
438fn required_str<'m>(
439    meta: &'m serde_json::Map<String, serde_json::Value>,
440    key: &str,
441) -> Result<&'m str, AcdpError> {
442    meta.get(key).and_then(|v| v.as_str()).ok_or_else(|| {
443        AcdpError::SchemaViolation(format!(
444            "key-revocation metadata.{key} is REQUIRED and must be a string \
445             (RFC-ACDP-0014 §4)"
446        ))
447    })
448}
449
450fn optional_str(
451    meta: &serde_json::Map<String, serde_json::Value>,
452    key: &str,
453) -> Result<Option<String>, AcdpError> {
454    match meta.get(key) {
455        None => Ok(None),
456        Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
457        Some(_) => Err(AcdpError::SchemaViolation(format!(
458            "key-revocation metadata.{key} must be a string when present (RFC-ACDP-0014 §4)"
459        ))),
460    }
461}
462
463/// `sha256:` + exactly 64 lowercase hex digits (RFC-ACDP-0010 §6).
464fn is_sha256_fingerprint(s: &str) -> bool {
465    match s.strip_prefix("sha256:") {
466        Some(hex) => {
467            hex.len() == 64
468                && hex
469                    .chars()
470                    .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
471        }
472        None => false,
473    }
474}
475
476/// Parse a timestamp REQUIRING the canonical millisecond RFC 3339 UTC
477/// form `YYYY-MM-DDTHH:MM:SS.mmmZ` (RFC-ACDP-0001 §5.3): the string
478/// must round-trip byte-identically through the canonical formatter.
479fn parse_canonical_ms(raw: &str) -> Option<DateTime<Utc>> {
480    let parsed = DateTime::parse_from_rfc3339(raw).ok()?.with_timezone(&Utc);
481    (fmt_rfc3339_ms(parsed) == raw).then_some(parsed)
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use crate::body::Signature;
488    use acdp_primitives::primitives::{ContentHash, CtxId, LineageId};
489
490    // ── from_publish_request: mirrors the from_body shape-violation
491    // coverage above, over the PublishRequest-shaped entry point Phase 5
492    // adds (RFC-ACDP-0014 §4). ──────────────────────────────────────────
493
494    const PR_PRODUCER_DID: &str = "did:web:agents.example.com:pr-test-producer";
495    const PR_COMPROMISED_SINCE: &str = "2026-05-01T00:00:00.000Z";
496
497    fn pr_valid_metadata() -> serde_json::Value {
498        serde_json::json!({
499            "revoked_key_fingerprint": format!("sha256:{}", "a".repeat(64)),
500            "compromised_since": PR_COMPROMISED_SINCE,
501        })
502    }
503
504    fn publish_request_with_metadata(metadata: Option<serde_json::Value>) -> PublishRequest {
505        PublishRequest {
506            version: 1,
507            supersedes: None,
508            agent_id: AgentDid::new(PR_PRODUCER_DID),
509            contributors: vec![],
510            title: "Key revocation — key-1 compromised".into(),
511            context_type: ContextType::KeyRevocation,
512            data_refs: vec![],
513            derived_from: vec![],
514            visibility: Visibility::Public,
515            content_hash: ContentHash("sha256:0".into()),
516            signature: Signature {
517                algorithm: "ed25519".into(),
518                key_id: format!("{PR_PRODUCER_DID}#key-1"),
519                value: "A".repeat(88),
520            },
521            audience: None,
522            acdp_version: Some("0.3.0".into()),
523            description: None,
524            summary: None,
525            lineage_id: None,
526            tags: None,
527            domain: None,
528            expires_at: None,
529            data_period: None,
530            metadata,
531            schema_uri: None,
532            anchors: None,
533            key_attestation: None,
534        }
535    }
536
537    /// Positive control: a shape-conformant request is accepted, and
538    /// classifies as producer-signed — proving the rejection tests below
539    /// aren't passing vacuously.
540    #[test]
541    fn from_publish_request_valid_case_is_accepted() {
542        let req = publish_request_with_metadata(Some(pr_valid_metadata()));
543        let rev =
544            KeyRevocation::from_publish_request(&req).expect("shape-conformant request must parse");
545        assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
546        assert_eq!(rev.publisher.as_str(), PR_PRODUCER_DID);
547        assert_eq!(rev.revoked_key_controller.as_str(), PR_PRODUCER_DID);
548    }
549
550    #[test]
551    fn from_publish_request_wrong_context_type_rejected() {
552        let mut req = publish_request_with_metadata(Some(pr_valid_metadata()));
553        req.context_type = ContextType::Analysis;
554        assert!(matches!(
555            KeyRevocation::from_publish_request(&req),
556            Err(AcdpError::SchemaViolation(_))
557        ));
558    }
559
560    #[test]
561    fn from_publish_request_non_public_visibility_rejected() {
562        let mut req = publish_request_with_metadata(Some(pr_valid_metadata()));
563        req.visibility = Visibility::Restricted;
564        assert!(matches!(
565            KeyRevocation::from_publish_request(&req),
566            Err(AcdpError::SchemaViolation(_))
567        ));
568    }
569
570    #[test]
571    fn from_publish_request_missing_metadata_rejected() {
572        let req = publish_request_with_metadata(None);
573        assert!(matches!(
574            KeyRevocation::from_publish_request(&req),
575            Err(AcdpError::SchemaViolation(_))
576        ));
577    }
578
579    #[test]
580    fn from_publish_request_missing_fingerprint_rejected() {
581        let mut meta = pr_valid_metadata();
582        meta.as_object_mut()
583            .unwrap()
584            .remove("revoked_key_fingerprint");
585        let req = publish_request_with_metadata(Some(meta));
586        assert!(matches!(
587            KeyRevocation::from_publish_request(&req),
588            Err(AcdpError::SchemaViolation(_))
589        ));
590    }
591
592    #[test]
593    fn from_publish_request_malformed_fingerprint_rejected() {
594        let mut meta = pr_valid_metadata();
595        meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
596        let req = publish_request_with_metadata(Some(meta));
597        assert!(matches!(
598            KeyRevocation::from_publish_request(&req),
599            Err(AcdpError::SchemaViolation(_))
600        ));
601    }
602
603    #[test]
604    fn from_publish_request_missing_compromised_since_rejected() {
605        let mut meta = pr_valid_metadata();
606        meta.as_object_mut().unwrap().remove("compromised_since");
607        let req = publish_request_with_metadata(Some(meta));
608        assert!(matches!(
609            KeyRevocation::from_publish_request(&req),
610            Err(AcdpError::SchemaViolation(_))
611        ));
612    }
613
614    #[test]
615    fn from_publish_request_non_canonical_compromised_since_rejected() {
616        let mut meta = pr_valid_metadata();
617        // No fractional-seconds component — RFC 3339-valid but not the
618        // canonical millisecond form RFC-ACDP-0001 §5.3 requires.
619        meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00Z");
620        let req = publish_request_with_metadata(Some(meta));
621        assert!(matches!(
622            KeyRevocation::from_publish_request(&req),
623            Err(AcdpError::SchemaViolation(_))
624        ));
625    }
626
627    #[test]
628    fn from_publish_request_reason_over_limit_rejected() {
629        let mut meta = pr_valid_metadata();
630        meta["reason"] = serde_json::json!("x".repeat(MAX_REASON_CHARS + 1));
631        let req = publish_request_with_metadata(Some(meta));
632        assert!(matches!(
633            KeyRevocation::from_publish_request(&req),
634            Err(AcdpError::SchemaViolation(_))
635        ));
636    }
637
638    /// `from_body` and `from_publish_request` share `from_parts`: over
639    /// the five fields the §4 table touches, equivalent input must
640    /// produce an identical parsed `KeyRevocation`, not merely the same
641    /// pass/fail verdict.
642    #[test]
643    fn from_body_and_from_publish_request_agree_on_equivalent_input() {
644        let metadata = Some(pr_valid_metadata());
645        let req = publish_request_with_metadata(metadata.clone());
646        let body = body_from_pr_request(&req);
647
648        let from_body = KeyRevocation::from_body(&body).unwrap();
649        // Only a stored body has a registry-assigned ctx_id.
650        assert_eq!(from_body.ctx_id.as_ref(), Some(&body.ctx_id));
651        let mut from_request = KeyRevocation::from_publish_request(&req).unwrap();
652        assert_eq!(from_request.ctx_id, None);
653        from_request.ctx_id = Some(body.ctx_id.clone());
654        assert_eq!(from_request, from_body);
655    }
656
657    /// Builds the `Body` a registry would derive from `req`, mirroring
658    /// `from_body_and_from_publish_request_agree_on_equivalent_input`'s
659    /// fixture so error-path equivalence tests can reuse it verbatim.
660    fn body_from_pr_request(req: &PublishRequest) -> Body {
661        Body::from_publish_request(
662            req,
663            CtxId("acdp://registry.example.com/00000000-0000-4000-8000-000000000000".into()),
664            LineageId(format!("lin:sha256:{}", "0".repeat(64))),
665            "registry.example.com",
666            DateTime::parse_from_rfc3339("2026-05-02T08:00:00.000Z")
667                .unwrap()
668                .with_timezone(&Utc),
669        )
670    }
671
672    /// Gap E: agreement must hold on the ERROR path too, and not merely
673    /// at the variant level — every §4 shape violation returns
674    /// `SchemaViolation`, so comparing variants alone would pass even if
675    /// `from_body` and `from_publish_request` disagreed on the message.
676    /// Covers two distinct violations: non-public visibility (a
677    /// top-level-field check) and a malformed fingerprint (a
678    /// metadata-field check).
679    #[test]
680    fn from_body_and_from_publish_request_agree_on_error_message() {
681        // Violation 1: non-public visibility.
682        let mut req = publish_request_with_metadata(Some(pr_valid_metadata()));
683        req.visibility = Visibility::Restricted;
684        let body = body_from_pr_request(&req);
685        let pr_err = KeyRevocation::from_publish_request(&req).unwrap_err();
686        let body_err = KeyRevocation::from_body(&body).unwrap_err();
687        assert!(matches!(pr_err, AcdpError::SchemaViolation(_)));
688        assert_eq!(pr_err.to_string(), body_err.to_string());
689
690        // Violation 2: malformed fingerprint.
691        let mut meta = pr_valid_metadata();
692        meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
693        let req = publish_request_with_metadata(Some(meta));
694        let body = body_from_pr_request(&req);
695        let pr_err = KeyRevocation::from_publish_request(&req).unwrap_err();
696        let body_err = KeyRevocation::from_body(&body).unwrap_err();
697        assert!(matches!(pr_err, AcdpError::SchemaViolation(_)));
698        assert_eq!(pr_err.to_string(), body_err.to_string());
699    }
700
701    // ── from_publish_request: revoked_key_controller classification ────
702
703    /// Controller present and equal to `agent_id` is the explicit form
704    /// of the producer-signed binding (distinct from the
705    /// controller-absent case `from_publish_request_valid_case_is_accepted`
706    /// already covers).
707    #[test]
708    fn from_publish_request_controller_equal_to_agent_id_is_producer_signed() {
709        let mut meta = pr_valid_metadata();
710        meta["revoked_key_controller"] = serde_json::json!(PR_PRODUCER_DID);
711        let req = publish_request_with_metadata(Some(meta));
712        let rev = KeyRevocation::from_publish_request(&req).unwrap();
713        assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
714        assert_eq!(rev.revoked_key_controller.as_str(), PR_PRODUCER_DID);
715    }
716
717    /// Controller present and different from `agent_id` classifies as
718    /// registry-attested. Classification only — Phase 6 owns enforcing
719    /// that the publisher really is a trusted registry.
720    #[test]
721    fn from_publish_request_controller_different_from_agent_id_is_registry_attested() {
722        const OTHER_PRODUCER: &str = "did:web:agents.example.com:other-producer";
723        let mut meta = pr_valid_metadata();
724        meta["revoked_key_controller"] = serde_json::json!(OTHER_PRODUCER);
725        let req = publish_request_with_metadata(Some(meta));
726        let rev = KeyRevocation::from_publish_request(&req).unwrap();
727        assert_eq!(rev.trust_class, RevocationTrustClass::RegistryAttested);
728        assert_eq!(rev.revoked_key_controller.as_str(), OTHER_PRODUCER);
729        assert_eq!(rev.publisher.as_str(), PR_PRODUCER_DID);
730    }
731
732    #[test]
733    fn from_publish_request_controller_not_a_string_rejected() {
734        let mut meta = pr_valid_metadata();
735        meta["revoked_key_controller"] = serde_json::json!(42);
736        let req = publish_request_with_metadata(Some(meta));
737        assert!(matches!(
738            KeyRevocation::from_publish_request(&req),
739            Err(AcdpError::SchemaViolation(_))
740        ));
741    }
742
743    #[test]
744    fn from_publish_request_controller_invalid_did_rejected() {
745        let mut meta = pr_valid_metadata();
746        meta["revoked_key_controller"] = serde_json::json!("not-a-did");
747        let req = publish_request_with_metadata(Some(meta));
748        assert!(matches!(
749            KeyRevocation::from_publish_request(&req),
750            Err(AcdpError::SchemaViolation(_))
751        ));
752    }
753
754    // ── from_publish_request: §5 step 2 did:key self-sign tail ─────────
755    // All tests above use a did:web key_id, leaving `from_parts`' pure
756    // did:key self-sign check (revocation.rs ~252-258) dead in every one
757    // of them. These drive it explicitly through `from_publish_request`,
758    // reusing the fixture approach of
759    // `tests/key_revocation.rs::rev_001_did_key_self_revocation_rejected_at_parse`
760    // but built from primitives already in acdp-types's dependency graph
761    // (acdp-crypto and acdp-did are ordinary, non-dev dependencies —
762    // `from_parts` itself already calls into them) rather than
763    // `acdp-producer`'s `Producer`, which sits above acdp-types in the
764    // crate stack and is unavailable here.
765
766    /// Builds a did:key `signature.key_id` and its RFC-ACDP-0010 §6
767    /// fingerprint from an Ed25519 seed, mirroring
768    /// `rev_001_did_key_self_revocation_rejected_at_parse`'s fixture.
769    fn did_key_fixture(seed: [u8; 32]) -> (String, String) {
770        let signing_key = acdp_crypto::SigningKey::from_bytes(&seed);
771        let public_key = signing_key.verifying_key_bytes();
772        let did = acdp_did::key::did_key_from_ed25519(&public_key);
773        let key_id = acdp_did::key::did_key_url(&did).unwrap();
774        let fingerprint = acdp_crypto::fingerprint::fingerprint_ed25519(&public_key);
775        (key_id, fingerprint)
776    }
777
778    /// Negative: `signature.key_id` is a did:key URL whose derived
779    /// fingerprint EQUALS `metadata.revoked_key_fingerprint` — the
780    /// revocation is signed by the very key it revokes (RFC-ACDP-0014 §5
781    /// step 2) — rejected even though the request never goes through
782    /// `from_body`.
783    #[test]
784    fn from_publish_request_did_key_self_revocation_rejected() {
785        let (key_id, fingerprint) = did_key_fixture([1u8; 32]);
786        let mut meta = pr_valid_metadata();
787        meta["revoked_key_fingerprint"] = serde_json::json!(fingerprint);
788        let mut req = publish_request_with_metadata(Some(meta));
789        req.signature.key_id = key_id;
790        assert!(matches!(
791            KeyRevocation::from_publish_request(&req),
792            Err(AcdpError::KeyNotAuthorized(_))
793        ));
794    }
795
796    /// Positive control for the test above: same did:key shape, but the
797    /// signing key's fingerprint DIFFERS from `revoked_key_fingerprint`
798    /// — accepted. Without this, the negative test could be passing for
799    /// an unrelated reason (e.g. a bug that always rejects did:key
800    /// signers).
801    #[test]
802    fn from_publish_request_did_key_different_key_accepted() {
803        let (key_id, _fingerprint) = did_key_fixture([2u8; 32]);
804        let meta = pr_valid_metadata(); // fingerprint is all-'a', unrelated to key [2u8; 32]
805        let mut req = publish_request_with_metadata(Some(meta));
806        req.signature.key_id = key_id;
807        let rev = KeyRevocation::from_publish_request(&req)
808            .expect("did:key signer whose fingerprint differs from the revoked key must pass");
809        assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
810    }
811
812    /// A malformed did:key `signature.key_id` (fragment does not match
813    /// the method-specific identifier) makes fingerprint derivation fail
814    /// `Ok(...)`-checked inside `from_parts`, which by design leaves the
815    /// self-sign check unrun rather than rejecting here — signature
816    /// verification is expected to reject the body instead. Pinning this
817    /// deliberate leniency so a future change to it is visible.
818    #[test]
819    fn from_publish_request_malformed_did_key_key_id_not_rejected_here() {
820        let (key_id, fingerprint) = did_key_fixture([3u8; 32]);
821        let malformed_key_id = format!("{}-not-the-msi", key_id); // breaks the #fragment == msi rule
822        let mut meta = pr_valid_metadata();
823        meta["revoked_key_fingerprint"] = serde_json::json!(fingerprint);
824        let mut req = publish_request_with_metadata(Some(meta));
825        req.signature.key_id = malformed_key_id;
826        let rev = KeyRevocation::from_publish_request(&req).expect(
827            "malformed did:key key_id is left for signature verification, not rejected here",
828        );
829        assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
830    }
831
832    fn registry_attested_rev(publisher: &str) -> KeyRevocation {
833        KeyRevocation {
834            revoked_key_fingerprint: format!("sha256:{}", "a1".repeat(32)),
835            compromised_since: parse_canonical_ms("2026-05-01T00:00:00.000Z").unwrap(),
836            reason: None,
837            revoked_key_id: None,
838            revoked_key_controller: AgentDid::new("did:web:agents.example.com:producer"),
839            publisher: AgentDid::new(publisher),
840            trust_class: RevocationTrustClass::RegistryAttested,
841            ctx_id: None,
842        }
843    }
844
845    /// RFC-ACDP-0014 §6 steps 2–3: `publisher` must equal both the
846    /// serving authority's DID and `capabilities.registry_did`. All
847    /// aligned ⇒ `Ok`; either mismatch ⇒ `Err(KeyNotAuthorized)`.
848    #[test]
849    fn cross_check_registry_binding_success_and_both_failure_directions() {
850        let rev = registry_attested_rev("did:web:registry.example.com");
851
852        rev.cross_check_registry_binding("registry.example.com", "did:web:registry.example.com")
853            .expect("serving authority and capabilities.registry_did both match publisher");
854
855        // Wrong serving authority.
856        assert!(matches!(
857            rev.cross_check_registry_binding("hostile.example", "did:web:registry.example.com"),
858            Err(AcdpError::KeyNotAuthorized(_))
859        ));
860
861        // Wrong capabilities.registry_did.
862        assert!(matches!(
863            rev.cross_check_registry_binding("registry.example.com", "did:web:other.example"),
864            Err(AcdpError::KeyNotAuthorized(_))
865        ));
866    }
867
868    /// `did:web:localhost%3A8443` — the percent-encoded-port form
869    /// `authority_to_did_web` produces for a `host:port` authority
870    /// (RFC-ACDP-0014 §6 steps 2–3; live in this codebase's own test
871    /// harness, which binds ephemeral ports, not merely hypothetical).
872    #[test]
873    fn cross_check_registry_binding_percent_encoded_port_authority() {
874        let rev = registry_attested_rev("did:web:localhost%3A8443");
875
876        rev.cross_check_registry_binding("localhost:8443", "did:web:localhost%3A8443")
877            .expect("host:port authority round-trips through authority_to_did_web");
878
879        // A bare-hostname serving authority (no port) must NOT match a
880        // publisher bound to the port-bearing form.
881        assert!(matches!(
882            rev.cross_check_registry_binding("localhost", "did:web:localhost%3A8443"),
883            Err(AcdpError::KeyNotAuthorized(_))
884        ));
885    }
886
887    #[test]
888    fn fingerprint_form_edges() {
889        assert!(is_sha256_fingerprint(&format!(
890            "sha256:{}",
891            "a1".repeat(32)
892        )));
893        assert!(!is_sha256_fingerprint(&format!(
894            "sha256:{}",
895            "A1".repeat(32)
896        ))); // uppercase
897        assert!(!is_sha256_fingerprint(&format!(
898            "sha512:{}",
899            "a1".repeat(32)
900        ))); // wrong alg
901        assert!(!is_sha256_fingerprint(&format!(
902            "sha256:{}",
903            "a1".repeat(31)
904        ))); // short
905        assert!(!is_sha256_fingerprint("sha256:")); // empty hex
906        assert!(!is_sha256_fingerprint(&"a1".repeat(32))); // no prefix
907    }
908
909    #[test]
910    fn canonical_ms_timestamp_edges() {
911        assert!(parse_canonical_ms("2026-05-01T00:00:00.000Z").is_some());
912        // Non-canonical forms MUST be rejected even when RFC 3339-valid.
913        for bad in [
914            "2026-05-01T00:00:00Z",          // no fractional part
915            "2026-05-01T00:00:00.0Z",        // 1 digit
916            "2026-05-01T00:00:00.000000Z",   // microseconds
917            "2026-05-01T00:00:00.000+00:00", // offset spelling
918            "2026-05-01 00:00:00.000Z",      // space separator
919            "not-a-time",
920        ] {
921            assert!(
922                parse_canonical_ms(bad).is_none(),
923                "{bad:?} must be rejected"
924            );
925        }
926    }
927
928    // ── effective_boundary: issue #226 Phase 5 — zero direct unit tests
929    // existed for this fold before this block; `rev_002_earliest_boundary_across_lineage`
930    // (tests/key_revocation.rs) and `earliest_boundary_wins`
931    // (crates/acdp-client/src/revocation.rs) exercise it only indirectly,
932    // through `classify_under_revocation`. ─────────────────────────────
933
934    const EB_FP: &str = "sha256:139e3940e64b5491722088d9a0d741628fc826e09475d341a780acde3c4b8070";
935    const EB_OTHER_FP: &str =
936        "sha256:3097e2dee2cb4a34b53840cdb705aed71067c36f68db0e0f559c3f3fa043315f";
937
938    fn eb_rev(fp: &str, t: &str) -> KeyRevocation {
939        KeyRevocation {
940            revoked_key_fingerprint: fp.into(),
941            compromised_since: DateTime::parse_from_rfc3339(t).unwrap().with_timezone(&Utc),
942            reason: None,
943            revoked_key_id: None,
944            revoked_key_controller: AgentDid::new("did:web:agents.example.com:p"),
945            publisher: AgentDid::new("did:web:agents.example.com:p"),
946            trust_class: RevocationTrustClass::ProducerSigned,
947            ctx_id: None,
948        }
949    }
950
951    #[test]
952    fn effective_boundary_empty_slice_is_none() {
953        let revs: [KeyRevocation; 0] = [];
954        assert_eq!(effective_boundary(&revs, EB_FP), None);
955    }
956
957    #[test]
958    fn effective_boundary_single_match() {
959        let revs = [eb_rev(EB_FP, "2026-05-01T00:00:00.000Z")];
960        assert_eq!(
961            effective_boundary(&revs, EB_FP),
962            Some(
963                DateTime::parse_from_rfc3339("2026-05-01T00:00:00.000Z")
964                    .unwrap()
965                    .with_timezone(&Utc)
966            )
967        );
968    }
969
970    /// The §4 monotonicity rule: the EARLIEST `compromised_since` among
971    /// several entries naming the same fingerprint wins, regardless of
972    /// input order or which one is the lineage head.
973    #[test]
974    fn effective_boundary_min_folds_across_multiple_entries_same_fingerprint() {
975        let revs = [
976            eb_rev(EB_FP, "2026-06-01T00:00:00.000Z"),
977            eb_rev(EB_FP, "2026-04-01T00:00:00.000Z"), // earliest
978            eb_rev(EB_FP, "2026-05-01T00:00:00.000Z"),
979        ];
980        assert_eq!(
981            effective_boundary(&revs, EB_FP),
982            Some(
983                DateTime::parse_from_rfc3339("2026-04-01T00:00:00.000Z")
984                    .unwrap()
985                    .with_timezone(&Utc)
986            )
987        );
988    }
989
990    /// An entry naming a different fingerprint is inert: it neither
991    /// contributes to nor blocks the fold for the fingerprint under
992    /// test.
993    #[test]
994    fn effective_boundary_ignores_non_matching_fingerprints() {
995        let revs = [
996            eb_rev(EB_OTHER_FP, "2026-01-01T00:00:00.000Z"),
997            eb_rev(EB_FP, "2026-05-01T00:00:00.000Z"),
998            eb_rev(EB_OTHER_FP, "2026-02-01T00:00:00.000Z"),
999        ];
1000        assert_eq!(
1001            effective_boundary(&revs, EB_FP),
1002            Some(
1003                DateTime::parse_from_rfc3339("2026-05-01T00:00:00.000Z")
1004                    .unwrap()
1005                    .with_timezone(&Utc)
1006            )
1007        );
1008        // And the converse: querying a fingerprint no entry names at
1009        // all is None, not a false match against the non-matching
1010        // entries present.
1011        const UNRELATED_FP: &str =
1012            "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1013        assert_eq!(effective_boundary(&revs, UNRELATED_FP), None);
1014    }
1015}