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