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 /// RFC-ACDP-0014 §5 step 2, decoupled from full §4 shape validation
292 /// (contrast [`Self::check_not_self_signed`], which needs an already
293 /// shape-validated [`KeyRevocation`]). §10's interim-form carve-out
294 /// is scoped explicitly to "§4 shape validation" — it says nothing
295 /// about §5, whose own MUST-reject text is gated on `acdp_version`
296 /// alone, not on which spelling of the context type was used. So a
297 /// registry on `[0.3.0, 0.5.0)` that must NOT §4-validate the
298 /// interim `acdp:key-revocation` form still MUST enforce this check
299 /// against it — this is the primitive that lets it do so without
300 /// pulling in the rest of §4.
301 ///
302 /// Tolerant by design: a missing or non-string
303 /// `metadata.revoked_key_fingerprint` can't be evaluated, so this
304 /// returns `Ok(())` rather than rejecting on shape grounds — full §4
305 /// validation (standard type) or §5.11 signature verification is
306 /// what catches those cases instead. `signing_key_fingerprint` is
307 /// the RFC-ACDP-0010 §6 fingerprint of the *resolved* signing key.
308 pub fn check_not_self_signed_lenient(
309 req: &PublishRequest,
310 signing_key_fingerprint: &str,
311 ) -> Result<(), AcdpError> {
312 let Some(fingerprint) = req
313 .metadata
314 .as_ref()
315 .and_then(|m| m.as_object())
316 .and_then(|m| m.get("revoked_key_fingerprint"))
317 .and_then(|v| v.as_str())
318 else {
319 return Ok(());
320 };
321 if fingerprint == signing_key_fingerprint {
322 return Err(AcdpError::KeyNotAuthorized(format!(
323 "revocation of key {fingerprint} is signed by that same key — a key is not \
324 authorized to attest its own compromise; treat as unverified \
325 (RFC-ACDP-0014 §5 step 2)"
326 )));
327 }
328 Ok(())
329 }
330
331 /// [`Self::check_not_self_signed_lenient`]'s did:key sub-case: the
332 /// signing key's fingerprint is derivable from `signature.key_id`
333 /// alone, with no DID resolution — the same synchronous check
334 /// `from_parts` used to run unconditionally as a side effect of full
335 /// §4 parsing (before the interim form was carved out of that), now
336 /// exposed standalone so a caller (like `PublishValidator`) can run
337 /// it on a body it deliberately is NOT §4-shape-validating.
338 ///
339 /// Tolerant by design: a non-did:key signer, an unresolvable
340 /// did:key `key_id`, or a fingerprint that fails to derive all leave
341 /// this a no-op — see [`Self::check_not_self_signed_lenient`] for
342 /// why that's the right default.
343 pub fn check_not_self_signed_did_key_lenient(req: &PublishRequest) -> Result<(), AcdpError> {
344 if !req.signature.key_id.starts_with("did:key:") {
345 return Ok(());
346 }
347 let Ok(material) = acdp_did::key::resolve_did_key_url(&req.signature.key_id) else {
348 return Ok(());
349 };
350 let Ok(signer_fingerprint) =
351 acdp_crypto::fingerprint::fingerprint_did_key_material(&material)
352 else {
353 return Ok(());
354 };
355 Self::check_not_self_signed_lenient(req, &signer_fingerprint)
356 }
357
358 /// Registry-attestation binding (pure): `publisher` — the identity
359 /// this revocation was actually published under — MUST equal both
360 /// `did:web:<serving_authority>` (the authority the context was
361 /// actually fetched from, not whatever the body claims) AND the
362 /// serving registry's advertised `capabilities.registry_did`. The
363 /// two halves have different citations: the `registry_did` half is
364 /// RFC-ACDP-0014 §6 step 2; the `serving_authority` half is not a
365 /// §6 step at all — it is the ACDP-wide `registry_did`↔authority
366 /// invariant of RFC-ACDP-0011 §7 step 3 / RFC-ACDP-0012 §9.3 step 3
367 /// (the two house-pattern siblings), applied here to key
368 /// revocations.
369 ///
370 /// A [`RevocationTrustClass::RegistryAttested`] revocation imports
371 /// its authority entirely from *who published it* — §5 body
372 /// verification alone only proves the body is genuinely signed by
373 /// `publisher`'s current key, not that `publisher` is the specific
374 /// registry a caller actually talks to. Without this check a
375 /// consumer could apply a registry-attested revocation on the say-so
376 /// of any producer willing to name someone else as
377 /// `revoked_key_controller`; this pins `publisher` to the one
378 /// registry both the transport (`serving_authority`) and the
379 /// registry's own self-description (`capabilities_registry_did`)
380 /// agree on.
381 ///
382 /// Pure — no DID resolution or network I/O — so it stays exposable
383 /// from the language bindings.
384 pub fn cross_check_registry_binding(
385 &self,
386 serving_authority: &str,
387 capabilities_registry_did: &str,
388 ) -> Result<(), AcdpError> {
389 let expected_did = acdp_did::web::authority_to_did_web(serving_authority);
390 if self.publisher.as_str() != expected_did {
391 return Err(AcdpError::KeyNotAuthorized(format!(
392 "key-revocation publisher '{}' ≠ serving authority's DID '{expected_did}' \
393 (RFC-ACDP-0014 §6 steps 2–3)",
394 self.publisher
395 )));
396 }
397 if self.publisher.as_str() != capabilities_registry_did {
398 return Err(AcdpError::KeyNotAuthorized(format!(
399 "key-revocation publisher '{}' ≠ capabilities.registry_did \
400 '{capabilities_registry_did}' (RFC-ACDP-0014 §6 steps 2–3)",
401 self.publisher
402 )));
403 }
404 Ok(())
405 }
406}
407
408/// The effective compromise boundary for `key_fingerprint` across a set
409/// of (verified) revocations: the **earliest** `compromised_since`
410/// among those that name the fingerprint, or `None` when none does.
411///
412/// This is the RFC-ACDP-0014 §4 monotonicity rule: a superseding
413/// revocation may widen — never narrow — the compromise window, so a
414/// supersession can never quietly shrink it. Feed every revocation of a
415/// lineage (including superseded ones) through this, not just the head.
416pub fn effective_boundary<'a>(
417 revocations: impl IntoIterator<Item = &'a KeyRevocation>,
418 key_fingerprint: &str,
419) -> Option<DateTime<Utc>> {
420 revocations
421 .into_iter()
422 .filter(|r| r.revokes(key_fingerprint))
423 .map(|r| r.compromised_since)
424 .min()
425}
426
427fn required_str<'m>(
428 meta: &'m serde_json::Map<String, serde_json::Value>,
429 key: &str,
430) -> Result<&'m str, AcdpError> {
431 meta.get(key).and_then(|v| v.as_str()).ok_or_else(|| {
432 AcdpError::SchemaViolation(format!(
433 "key-revocation metadata.{key} is REQUIRED and must be a string \
434 (RFC-ACDP-0014 §4)"
435 ))
436 })
437}
438
439fn optional_str(
440 meta: &serde_json::Map<String, serde_json::Value>,
441 key: &str,
442) -> Result<Option<String>, AcdpError> {
443 match meta.get(key) {
444 None => Ok(None),
445 Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
446 Some(_) => Err(AcdpError::SchemaViolation(format!(
447 "key-revocation metadata.{key} must be a string when present (RFC-ACDP-0014 §4)"
448 ))),
449 }
450}
451
452/// `sha256:` + exactly 64 lowercase hex digits (RFC-ACDP-0010 §6).
453fn is_sha256_fingerprint(s: &str) -> bool {
454 match s.strip_prefix("sha256:") {
455 Some(hex) => {
456 hex.len() == 64
457 && hex
458 .chars()
459 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
460 }
461 None => false,
462 }
463}
464
465/// Parse a timestamp REQUIRING the canonical millisecond RFC 3339 UTC
466/// form `YYYY-MM-DDTHH:MM:SS.mmmZ` (RFC-ACDP-0001 §5.3): the string
467/// must round-trip byte-identically through the canonical formatter.
468fn parse_canonical_ms(raw: &str) -> Option<DateTime<Utc>> {
469 let parsed = DateTime::parse_from_rfc3339(raw).ok()?.with_timezone(&Utc);
470 (fmt_rfc3339_ms(parsed) == raw).then_some(parsed)
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476 use crate::body::Signature;
477 use acdp_primitives::primitives::{ContentHash, CtxId, LineageId};
478
479 // ── from_publish_request: mirrors the from_body shape-violation
480 // coverage above, over the PublishRequest-shaped entry point Phase 5
481 // adds (RFC-ACDP-0014 §4). ──────────────────────────────────────────
482
483 const PR_PRODUCER_DID: &str = "did:web:agents.example.com:pr-test-producer";
484 const PR_COMPROMISED_SINCE: &str = "2026-05-01T00:00:00.000Z";
485
486 fn pr_valid_metadata() -> serde_json::Value {
487 serde_json::json!({
488 "revoked_key_fingerprint": format!("sha256:{}", "a".repeat(64)),
489 "compromised_since": PR_COMPROMISED_SINCE,
490 })
491 }
492
493 fn publish_request_with_metadata(metadata: Option<serde_json::Value>) -> PublishRequest {
494 PublishRequest {
495 version: 1,
496 supersedes: None,
497 agent_id: AgentDid::new(PR_PRODUCER_DID),
498 contributors: vec![],
499 title: "Key revocation — key-1 compromised".into(),
500 context_type: ContextType::KeyRevocation,
501 data_refs: vec![],
502 derived_from: vec![],
503 visibility: Visibility::Public,
504 content_hash: ContentHash("sha256:0".into()),
505 signature: Signature {
506 algorithm: "ed25519".into(),
507 key_id: format!("{PR_PRODUCER_DID}#key-1"),
508 value: "A".repeat(88),
509 },
510 audience: None,
511 acdp_version: Some("0.3.0".into()),
512 description: None,
513 summary: None,
514 lineage_id: None,
515 tags: None,
516 domain: None,
517 expires_at: None,
518 data_period: None,
519 metadata,
520 schema_uri: None,
521 anchors: None,
522 }
523 }
524
525 /// Positive control: a shape-conformant request is accepted, and
526 /// classifies as producer-signed — proving the rejection tests below
527 /// aren't passing vacuously.
528 #[test]
529 fn from_publish_request_valid_case_is_accepted() {
530 let req = publish_request_with_metadata(Some(pr_valid_metadata()));
531 let rev =
532 KeyRevocation::from_publish_request(&req).expect("shape-conformant request must parse");
533 assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
534 assert_eq!(rev.publisher.as_str(), PR_PRODUCER_DID);
535 assert_eq!(rev.revoked_key_controller.as_str(), PR_PRODUCER_DID);
536 }
537
538 #[test]
539 fn from_publish_request_wrong_context_type_rejected() {
540 let mut req = publish_request_with_metadata(Some(pr_valid_metadata()));
541 req.context_type = ContextType::Analysis;
542 assert!(matches!(
543 KeyRevocation::from_publish_request(&req),
544 Err(AcdpError::SchemaViolation(_))
545 ));
546 }
547
548 #[test]
549 fn from_publish_request_non_public_visibility_rejected() {
550 let mut req = publish_request_with_metadata(Some(pr_valid_metadata()));
551 req.visibility = Visibility::Restricted;
552 assert!(matches!(
553 KeyRevocation::from_publish_request(&req),
554 Err(AcdpError::SchemaViolation(_))
555 ));
556 }
557
558 #[test]
559 fn from_publish_request_missing_metadata_rejected() {
560 let req = publish_request_with_metadata(None);
561 assert!(matches!(
562 KeyRevocation::from_publish_request(&req),
563 Err(AcdpError::SchemaViolation(_))
564 ));
565 }
566
567 #[test]
568 fn from_publish_request_missing_fingerprint_rejected() {
569 let mut meta = pr_valid_metadata();
570 meta.as_object_mut()
571 .unwrap()
572 .remove("revoked_key_fingerprint");
573 let req = publish_request_with_metadata(Some(meta));
574 assert!(matches!(
575 KeyRevocation::from_publish_request(&req),
576 Err(AcdpError::SchemaViolation(_))
577 ));
578 }
579
580 #[test]
581 fn from_publish_request_malformed_fingerprint_rejected() {
582 let mut meta = pr_valid_metadata();
583 meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
584 let req = publish_request_with_metadata(Some(meta));
585 assert!(matches!(
586 KeyRevocation::from_publish_request(&req),
587 Err(AcdpError::SchemaViolation(_))
588 ));
589 }
590
591 #[test]
592 fn from_publish_request_missing_compromised_since_rejected() {
593 let mut meta = pr_valid_metadata();
594 meta.as_object_mut().unwrap().remove("compromised_since");
595 let req = publish_request_with_metadata(Some(meta));
596 assert!(matches!(
597 KeyRevocation::from_publish_request(&req),
598 Err(AcdpError::SchemaViolation(_))
599 ));
600 }
601
602 #[test]
603 fn from_publish_request_non_canonical_compromised_since_rejected() {
604 let mut meta = pr_valid_metadata();
605 // No fractional-seconds component — RFC 3339-valid but not the
606 // canonical millisecond form RFC-ACDP-0001 §5.3 requires.
607 meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00Z");
608 let req = publish_request_with_metadata(Some(meta));
609 assert!(matches!(
610 KeyRevocation::from_publish_request(&req),
611 Err(AcdpError::SchemaViolation(_))
612 ));
613 }
614
615 #[test]
616 fn from_publish_request_reason_over_limit_rejected() {
617 let mut meta = pr_valid_metadata();
618 meta["reason"] = serde_json::json!("x".repeat(MAX_REASON_CHARS + 1));
619 let req = publish_request_with_metadata(Some(meta));
620 assert!(matches!(
621 KeyRevocation::from_publish_request(&req),
622 Err(AcdpError::SchemaViolation(_))
623 ));
624 }
625
626 /// `from_body` and `from_publish_request` share `from_parts`: over
627 /// the five fields the §4 table touches, equivalent input must
628 /// produce an identical parsed `KeyRevocation`, not merely the same
629 /// pass/fail verdict.
630 #[test]
631 fn from_body_and_from_publish_request_agree_on_equivalent_input() {
632 let metadata = Some(pr_valid_metadata());
633 let req = publish_request_with_metadata(metadata.clone());
634 let body = body_from_pr_request(&req);
635
636 assert_eq!(
637 KeyRevocation::from_publish_request(&req).unwrap(),
638 KeyRevocation::from_body(&body).unwrap()
639 );
640 }
641
642 /// Builds the `Body` a registry would derive from `req`, mirroring
643 /// `from_body_and_from_publish_request_agree_on_equivalent_input`'s
644 /// fixture so error-path equivalence tests can reuse it verbatim.
645 fn body_from_pr_request(req: &PublishRequest) -> Body {
646 Body::from_publish_request(
647 req,
648 CtxId("acdp://registry.example.com/00000000-0000-4000-8000-000000000000".into()),
649 LineageId(format!("lin:sha256:{}", "0".repeat(64))),
650 "registry.example.com",
651 DateTime::parse_from_rfc3339("2026-05-02T08:00:00.000Z")
652 .unwrap()
653 .with_timezone(&Utc),
654 )
655 }
656
657 /// Gap E: agreement must hold on the ERROR path too, and not merely
658 /// at the variant level — every §4 shape violation returns
659 /// `SchemaViolation`, so comparing variants alone would pass even if
660 /// `from_body` and `from_publish_request` disagreed on the message.
661 /// Covers two distinct violations: non-public visibility (a
662 /// top-level-field check) and a malformed fingerprint (a
663 /// metadata-field check).
664 #[test]
665 fn from_body_and_from_publish_request_agree_on_error_message() {
666 // Violation 1: non-public visibility.
667 let mut req = publish_request_with_metadata(Some(pr_valid_metadata()));
668 req.visibility = Visibility::Restricted;
669 let body = body_from_pr_request(&req);
670 let pr_err = KeyRevocation::from_publish_request(&req).unwrap_err();
671 let body_err = KeyRevocation::from_body(&body).unwrap_err();
672 assert!(matches!(pr_err, AcdpError::SchemaViolation(_)));
673 assert_eq!(pr_err.to_string(), body_err.to_string());
674
675 // Violation 2: malformed fingerprint.
676 let mut meta = pr_valid_metadata();
677 meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
678 let req = publish_request_with_metadata(Some(meta));
679 let body = body_from_pr_request(&req);
680 let pr_err = KeyRevocation::from_publish_request(&req).unwrap_err();
681 let body_err = KeyRevocation::from_body(&body).unwrap_err();
682 assert!(matches!(pr_err, AcdpError::SchemaViolation(_)));
683 assert_eq!(pr_err.to_string(), body_err.to_string());
684 }
685
686 // ── from_publish_request: revoked_key_controller classification ────
687
688 /// Controller present and equal to `agent_id` is the explicit form
689 /// of the producer-signed binding (distinct from the
690 /// controller-absent case `from_publish_request_valid_case_is_accepted`
691 /// already covers).
692 #[test]
693 fn from_publish_request_controller_equal_to_agent_id_is_producer_signed() {
694 let mut meta = pr_valid_metadata();
695 meta["revoked_key_controller"] = serde_json::json!(PR_PRODUCER_DID);
696 let req = publish_request_with_metadata(Some(meta));
697 let rev = KeyRevocation::from_publish_request(&req).unwrap();
698 assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
699 assert_eq!(rev.revoked_key_controller.as_str(), PR_PRODUCER_DID);
700 }
701
702 /// Controller present and different from `agent_id` classifies as
703 /// registry-attested. Classification only — Phase 6 owns enforcing
704 /// that the publisher really is a trusted registry.
705 #[test]
706 fn from_publish_request_controller_different_from_agent_id_is_registry_attested() {
707 const OTHER_PRODUCER: &str = "did:web:agents.example.com:other-producer";
708 let mut meta = pr_valid_metadata();
709 meta["revoked_key_controller"] = serde_json::json!(OTHER_PRODUCER);
710 let req = publish_request_with_metadata(Some(meta));
711 let rev = KeyRevocation::from_publish_request(&req).unwrap();
712 assert_eq!(rev.trust_class, RevocationTrustClass::RegistryAttested);
713 assert_eq!(rev.revoked_key_controller.as_str(), OTHER_PRODUCER);
714 assert_eq!(rev.publisher.as_str(), PR_PRODUCER_DID);
715 }
716
717 #[test]
718 fn from_publish_request_controller_not_a_string_rejected() {
719 let mut meta = pr_valid_metadata();
720 meta["revoked_key_controller"] = serde_json::json!(42);
721 let req = publish_request_with_metadata(Some(meta));
722 assert!(matches!(
723 KeyRevocation::from_publish_request(&req),
724 Err(AcdpError::SchemaViolation(_))
725 ));
726 }
727
728 #[test]
729 fn from_publish_request_controller_invalid_did_rejected() {
730 let mut meta = pr_valid_metadata();
731 meta["revoked_key_controller"] = serde_json::json!("not-a-did");
732 let req = publish_request_with_metadata(Some(meta));
733 assert!(matches!(
734 KeyRevocation::from_publish_request(&req),
735 Err(AcdpError::SchemaViolation(_))
736 ));
737 }
738
739 // ── from_publish_request: §5 step 2 did:key self-sign tail ─────────
740 // All tests above use a did:web key_id, leaving `from_parts`' pure
741 // did:key self-sign check (revocation.rs ~252-258) dead in every one
742 // of them. These drive it explicitly through `from_publish_request`,
743 // reusing the fixture approach of
744 // `tests/key_revocation.rs::rev_001_did_key_self_revocation_rejected_at_parse`
745 // but built from primitives already in acdp-types's dependency graph
746 // (acdp-crypto and acdp-did are ordinary, non-dev dependencies —
747 // `from_parts` itself already calls into them) rather than
748 // `acdp-producer`'s `Producer`, which sits above acdp-types in the
749 // crate stack and is unavailable here.
750
751 /// Builds a did:key `signature.key_id` and its RFC-ACDP-0010 §6
752 /// fingerprint from an Ed25519 seed, mirroring
753 /// `rev_001_did_key_self_revocation_rejected_at_parse`'s fixture.
754 fn did_key_fixture(seed: [u8; 32]) -> (String, String) {
755 let signing_key = acdp_crypto::SigningKey::from_bytes(&seed);
756 let public_key = signing_key.verifying_key_bytes();
757 let did = acdp_did::key::did_key_from_ed25519(&public_key);
758 let key_id = acdp_did::key::did_key_url(&did).unwrap();
759 let fingerprint = acdp_crypto::fingerprint::fingerprint_ed25519(&public_key);
760 (key_id, fingerprint)
761 }
762
763 /// Negative: `signature.key_id` is a did:key URL whose derived
764 /// fingerprint EQUALS `metadata.revoked_key_fingerprint` — the
765 /// revocation is signed by the very key it revokes (RFC-ACDP-0014 §5
766 /// step 2) — rejected even though the request never goes through
767 /// `from_body`.
768 #[test]
769 fn from_publish_request_did_key_self_revocation_rejected() {
770 let (key_id, fingerprint) = did_key_fixture([1u8; 32]);
771 let mut meta = pr_valid_metadata();
772 meta["revoked_key_fingerprint"] = serde_json::json!(fingerprint);
773 let mut req = publish_request_with_metadata(Some(meta));
774 req.signature.key_id = key_id;
775 assert!(matches!(
776 KeyRevocation::from_publish_request(&req),
777 Err(AcdpError::KeyNotAuthorized(_))
778 ));
779 }
780
781 /// Positive control for the test above: same did:key shape, but the
782 /// signing key's fingerprint DIFFERS from `revoked_key_fingerprint`
783 /// — accepted. Without this, the negative test could be passing for
784 /// an unrelated reason (e.g. a bug that always rejects did:key
785 /// signers).
786 #[test]
787 fn from_publish_request_did_key_different_key_accepted() {
788 let (key_id, _fingerprint) = did_key_fixture([2u8; 32]);
789 let meta = pr_valid_metadata(); // fingerprint is all-'a', unrelated to key [2u8; 32]
790 let mut req = publish_request_with_metadata(Some(meta));
791 req.signature.key_id = key_id;
792 let rev = KeyRevocation::from_publish_request(&req)
793 .expect("did:key signer whose fingerprint differs from the revoked key must pass");
794 assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
795 }
796
797 /// A malformed did:key `signature.key_id` (fragment does not match
798 /// the method-specific identifier) makes fingerprint derivation fail
799 /// `Ok(...)`-checked inside `from_parts`, which by design leaves the
800 /// self-sign check unrun rather than rejecting here — signature
801 /// verification is expected to reject the body instead. Pinning this
802 /// deliberate leniency so a future change to it is visible.
803 #[test]
804 fn from_publish_request_malformed_did_key_key_id_not_rejected_here() {
805 let (key_id, fingerprint) = did_key_fixture([3u8; 32]);
806 let malformed_key_id = format!("{}-not-the-msi", key_id); // breaks the #fragment == msi rule
807 let mut meta = pr_valid_metadata();
808 meta["revoked_key_fingerprint"] = serde_json::json!(fingerprint);
809 let mut req = publish_request_with_metadata(Some(meta));
810 req.signature.key_id = malformed_key_id;
811 let rev = KeyRevocation::from_publish_request(&req).expect(
812 "malformed did:key key_id is left for signature verification, not rejected here",
813 );
814 assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
815 }
816
817 fn registry_attested_rev(publisher: &str) -> KeyRevocation {
818 KeyRevocation {
819 revoked_key_fingerprint: format!("sha256:{}", "a1".repeat(32)),
820 compromised_since: parse_canonical_ms("2026-05-01T00:00:00.000Z").unwrap(),
821 reason: None,
822 revoked_key_id: None,
823 revoked_key_controller: AgentDid::new("did:web:agents.example.com:producer"),
824 publisher: AgentDid::new(publisher),
825 trust_class: RevocationTrustClass::RegistryAttested,
826 }
827 }
828
829 /// RFC-ACDP-0014 §6 steps 2–3: `publisher` must equal both the
830 /// serving authority's DID and `capabilities.registry_did`. All
831 /// aligned ⇒ `Ok`; either mismatch ⇒ `Err(KeyNotAuthorized)`.
832 #[test]
833 fn cross_check_registry_binding_success_and_both_failure_directions() {
834 let rev = registry_attested_rev("did:web:registry.example.com");
835
836 rev.cross_check_registry_binding("registry.example.com", "did:web:registry.example.com")
837 .expect("serving authority and capabilities.registry_did both match publisher");
838
839 // Wrong serving authority.
840 assert!(matches!(
841 rev.cross_check_registry_binding("hostile.example", "did:web:registry.example.com"),
842 Err(AcdpError::KeyNotAuthorized(_))
843 ));
844
845 // Wrong capabilities.registry_did.
846 assert!(matches!(
847 rev.cross_check_registry_binding("registry.example.com", "did:web:other.example"),
848 Err(AcdpError::KeyNotAuthorized(_))
849 ));
850 }
851
852 /// `did:web:localhost%3A8443` — the percent-encoded-port form
853 /// `authority_to_did_web` produces for a `host:port` authority
854 /// (RFC-ACDP-0014 §6 steps 2–3; live in this codebase's own test
855 /// harness, which binds ephemeral ports, not merely hypothetical).
856 #[test]
857 fn cross_check_registry_binding_percent_encoded_port_authority() {
858 let rev = registry_attested_rev("did:web:localhost%3A8443");
859
860 rev.cross_check_registry_binding("localhost:8443", "did:web:localhost%3A8443")
861 .expect("host:port authority round-trips through authority_to_did_web");
862
863 // A bare-hostname serving authority (no port) must NOT match a
864 // publisher bound to the port-bearing form.
865 assert!(matches!(
866 rev.cross_check_registry_binding("localhost", "did:web:localhost%3A8443"),
867 Err(AcdpError::KeyNotAuthorized(_))
868 ));
869 }
870
871 #[test]
872 fn fingerprint_form_edges() {
873 assert!(is_sha256_fingerprint(&format!(
874 "sha256:{}",
875 "a1".repeat(32)
876 )));
877 assert!(!is_sha256_fingerprint(&format!(
878 "sha256:{}",
879 "A1".repeat(32)
880 ))); // uppercase
881 assert!(!is_sha256_fingerprint(&format!(
882 "sha512:{}",
883 "a1".repeat(32)
884 ))); // wrong alg
885 assert!(!is_sha256_fingerprint(&format!(
886 "sha256:{}",
887 "a1".repeat(31)
888 ))); // short
889 assert!(!is_sha256_fingerprint("sha256:")); // empty hex
890 assert!(!is_sha256_fingerprint(&"a1".repeat(32))); // no prefix
891 }
892
893 #[test]
894 fn canonical_ms_timestamp_edges() {
895 assert!(parse_canonical_ms("2026-05-01T00:00:00.000Z").is_some());
896 // Non-canonical forms MUST be rejected even when RFC 3339-valid.
897 for bad in [
898 "2026-05-01T00:00:00Z", // no fractional part
899 "2026-05-01T00:00:00.0Z", // 1 digit
900 "2026-05-01T00:00:00.000000Z", // microseconds
901 "2026-05-01T00:00:00.000+00:00", // offset spelling
902 "2026-05-01 00:00:00.000Z", // space separator
903 "not-a-time",
904 ] {
905 assert!(
906 parse_canonical_ms(bad).is_none(),
907 "{bad:?} must be rejected"
908 );
909 }
910 }
911
912 // ── effective_boundary: issue #226 Phase 5 — zero direct unit tests
913 // existed for this fold before this block; `rev_002_earliest_boundary_across_lineage`
914 // (tests/key_revocation.rs) and `earliest_boundary_wins`
915 // (crates/acdp-client/src/revocation.rs) exercise it only indirectly,
916 // through `classify_under_revocation`. ─────────────────────────────
917
918 const EB_FP: &str = "sha256:139e3940e64b5491722088d9a0d741628fc826e09475d341a780acde3c4b8070";
919 const EB_OTHER_FP: &str =
920 "sha256:3097e2dee2cb4a34b53840cdb705aed71067c36f68db0e0f559c3f3fa043315f";
921
922 fn eb_rev(fp: &str, t: &str) -> KeyRevocation {
923 KeyRevocation {
924 revoked_key_fingerprint: fp.into(),
925 compromised_since: DateTime::parse_from_rfc3339(t).unwrap().with_timezone(&Utc),
926 reason: None,
927 revoked_key_id: None,
928 revoked_key_controller: AgentDid::new("did:web:agents.example.com:p"),
929 publisher: AgentDid::new("did:web:agents.example.com:p"),
930 trust_class: RevocationTrustClass::ProducerSigned,
931 }
932 }
933
934 #[test]
935 fn effective_boundary_empty_slice_is_none() {
936 let revs: [KeyRevocation; 0] = [];
937 assert_eq!(effective_boundary(&revs, EB_FP), None);
938 }
939
940 #[test]
941 fn effective_boundary_single_match() {
942 let revs = [eb_rev(EB_FP, "2026-05-01T00:00:00.000Z")];
943 assert_eq!(
944 effective_boundary(&revs, EB_FP),
945 Some(
946 DateTime::parse_from_rfc3339("2026-05-01T00:00:00.000Z")
947 .unwrap()
948 .with_timezone(&Utc)
949 )
950 );
951 }
952
953 /// The §4 monotonicity rule: the EARLIEST `compromised_since` among
954 /// several entries naming the same fingerprint wins, regardless of
955 /// input order or which one is the lineage head.
956 #[test]
957 fn effective_boundary_min_folds_across_multiple_entries_same_fingerprint() {
958 let revs = [
959 eb_rev(EB_FP, "2026-06-01T00:00:00.000Z"),
960 eb_rev(EB_FP, "2026-04-01T00:00:00.000Z"), // earliest
961 eb_rev(EB_FP, "2026-05-01T00:00:00.000Z"),
962 ];
963 assert_eq!(
964 effective_boundary(&revs, EB_FP),
965 Some(
966 DateTime::parse_from_rfc3339("2026-04-01T00:00:00.000Z")
967 .unwrap()
968 .with_timezone(&Utc)
969 )
970 );
971 }
972
973 /// An entry naming a different fingerprint is inert: it neither
974 /// contributes to nor blocks the fold for the fingerprint under
975 /// test.
976 #[test]
977 fn effective_boundary_ignores_non_matching_fingerprints() {
978 let revs = [
979 eb_rev(EB_OTHER_FP, "2026-01-01T00:00:00.000Z"),
980 eb_rev(EB_FP, "2026-05-01T00:00:00.000Z"),
981 eb_rev(EB_OTHER_FP, "2026-02-01T00:00:00.000Z"),
982 ];
983 assert_eq!(
984 effective_boundary(&revs, EB_FP),
985 Some(
986 DateTime::parse_from_rfc3339("2026-05-01T00:00:00.000Z")
987 .unwrap()
988 .with_timezone(&Utc)
989 )
990 );
991 // And the converse: querying a fingerprint no entry names at
992 // all is None, not a false match against the non-matching
993 // entries present.
994 const UNRELATED_FP: &str =
995 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
996 assert_eq!(effective_boundary(&revs, UNRELATED_FP), None);
997 }
998}