use crate::body::Body;
use crate::publish::PublishRequest;
use acdp_primitives::error::AcdpError;
use acdp_primitives::primitives::{AgentDid, ContextType, Visibility};
use acdp_primitives::time::fmt_rfc3339_ms;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const MAX_REASON_CHARS: usize = 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RevocationTrustClass {
ProducerSigned,
RegistryAttested,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeyRevocation {
pub revoked_key_fingerprint: String,
pub compromised_since: DateTime<Utc>,
pub reason: Option<String>,
pub revoked_key_id: Option<String>,
pub revoked_key_controller: AgentDid,
pub publisher: AgentDid,
pub trust_class: RevocationTrustClass,
}
impl KeyRevocation {
pub fn from_body(body: &Body) -> Result<Self, AcdpError> {
Self::from_parts(
&body.context_type,
&body.visibility,
body.metadata.as_ref(),
&body.agent_id,
&body.signature.key_id,
)
}
pub fn from_publish_request(req: &PublishRequest) -> Result<Self, AcdpError> {
Self::from_parts(
&req.context_type,
&req.visibility,
req.metadata.as_ref(),
&req.agent_id,
&req.signature.key_id,
)
}
fn from_parts(
context_type: &ContextType,
visibility: &Visibility,
metadata: Option<&serde_json::Value>,
agent_id: &AgentDid,
signing_key_id: &str,
) -> Result<Self, AcdpError> {
if !context_type.is_key_revocation() {
return Err(AcdpError::SchemaViolation(format!(
"not a key-revocation context: type is '{}' (RFC-ACDP-0014 §4 requires \
'key-revocation', or 'acdp:key-revocation' in the pre-0.3.0 interim form)",
serde_json::to_value(context_type)
.ok()
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_default()
)));
}
if *visibility != Visibility::Public {
return Err(AcdpError::SchemaViolation(
"a key-revocation context MUST be visibility 'public' — it is a safety \
broadcast; an audience-restricted revocation protects nobody outside the \
audience (RFC-ACDP-0014 §4)"
.into(),
));
}
let meta = metadata.and_then(|m| m.as_object()).ok_or_else(|| {
AcdpError::SchemaViolation(
"key-revocation body has no metadata object; \
metadata.revoked_key_fingerprint and metadata.compromised_since are \
REQUIRED (RFC-ACDP-0014 §4)"
.into(),
)
})?;
let fingerprint = required_str(meta, "revoked_key_fingerprint")?;
if !is_sha256_fingerprint(fingerprint) {
return Err(AcdpError::SchemaViolation(format!(
"metadata.revoked_key_fingerprint '{fingerprint}' is not in the \
RFC-ACDP-0010 §6 form 'sha256:' + 64 lowercase hex (RFC-ACDP-0014 §4)"
)));
}
let since_raw = required_str(meta, "compromised_since")?;
let compromised_since = parse_canonical_ms(since_raw).ok_or_else(|| {
AcdpError::SchemaViolation(format!(
"metadata.compromised_since '{since_raw}' is not canonical \
millisecond-precision RFC 3339 UTC (RFC-ACDP-0001 §5.3, RFC-ACDP-0014 §4)"
))
})?;
let reason = optional_str(meta, "reason")?;
if let Some(r) = &reason {
if r.chars().count() > MAX_REASON_CHARS {
return Err(AcdpError::SchemaViolation(format!(
"metadata.reason exceeds {MAX_REASON_CHARS} characters (RFC-ACDP-0014 §4)"
)));
}
}
let revoked_key_id = optional_str(meta, "revoked_key_id")?;
let (revoked_key_controller, trust_class) =
match optional_str(meta, "revoked_key_controller")? {
None => (agent_id.clone(), RevocationTrustClass::ProducerSigned),
Some(c) => {
let controller = AgentDid::parse(&c)?;
if controller == *agent_id {
(controller, RevocationTrustClass::ProducerSigned)
} else {
(controller, RevocationTrustClass::RegistryAttested)
}
}
};
let revocation = KeyRevocation {
revoked_key_fingerprint: fingerprint.to_string(),
compromised_since,
reason,
revoked_key_id,
revoked_key_controller,
publisher: agent_id.clone(),
trust_class,
};
if signing_key_id.starts_with("did:key:") {
if let Ok(material) = acdp_did::key::resolve_did_key_url(signing_key_id) {
if let Ok(fp) = acdp_crypto::fingerprint::fingerprint_did_key_material(&material) {
revocation.check_not_self_signed(&fp)?;
}
}
}
Ok(revocation)
}
pub fn check_not_self_signed(&self, signing_key_fingerprint: &str) -> Result<(), AcdpError> {
if signing_key_fingerprint == self.revoked_key_fingerprint {
return Err(AcdpError::KeyNotAuthorized(format!(
"revocation of key {} is signed by that same key — a key is not \
authorized to attest its own compromise; treat as unverified \
(RFC-ACDP-0014 §5 step 2)",
self.revoked_key_fingerprint
)));
}
Ok(())
}
pub fn revokes(&self, key_fingerprint: &str) -> bool {
self.revoked_key_fingerprint == key_fingerprint
}
pub fn cross_check_registry_binding(
&self,
serving_authority: &str,
capabilities_registry_did: &str,
) -> Result<(), AcdpError> {
let expected_did = acdp_did::web::authority_to_did_web(serving_authority);
if self.publisher.as_str() != expected_did {
return Err(AcdpError::KeyNotAuthorized(format!(
"key-revocation publisher '{}' ≠ serving authority's DID '{expected_did}' \
(RFC-ACDP-0014 §6 steps 2–3)",
self.publisher
)));
}
if self.publisher.as_str() != capabilities_registry_did {
return Err(AcdpError::KeyNotAuthorized(format!(
"key-revocation publisher '{}' ≠ capabilities.registry_did \
'{capabilities_registry_did}' (RFC-ACDP-0014 §6 steps 2–3)",
self.publisher
)));
}
Ok(())
}
}
pub fn effective_boundary<'a>(
revocations: impl IntoIterator<Item = &'a KeyRevocation>,
key_fingerprint: &str,
) -> Option<DateTime<Utc>> {
revocations
.into_iter()
.filter(|r| r.revokes(key_fingerprint))
.map(|r| r.compromised_since)
.min()
}
fn required_str<'m>(
meta: &'m serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Result<&'m str, AcdpError> {
meta.get(key).and_then(|v| v.as_str()).ok_or_else(|| {
AcdpError::SchemaViolation(format!(
"key-revocation metadata.{key} is REQUIRED and must be a string \
(RFC-ACDP-0014 §4)"
))
})
}
fn optional_str(
meta: &serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Result<Option<String>, AcdpError> {
match meta.get(key) {
None => Ok(None),
Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
Some(_) => Err(AcdpError::SchemaViolation(format!(
"key-revocation metadata.{key} must be a string when present (RFC-ACDP-0014 §4)"
))),
}
}
fn is_sha256_fingerprint(s: &str) -> bool {
match s.strip_prefix("sha256:") {
Some(hex) => {
hex.len() == 64
&& hex
.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
}
None => false,
}
}
fn parse_canonical_ms(raw: &str) -> Option<DateTime<Utc>> {
let parsed = DateTime::parse_from_rfc3339(raw).ok()?.with_timezone(&Utc);
(fmt_rfc3339_ms(parsed) == raw).then_some(parsed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::body::Signature;
use acdp_primitives::primitives::{ContentHash, CtxId, LineageId};
const PR_PRODUCER_DID: &str = "did:web:agents.example.com:pr-test-producer";
const PR_COMPROMISED_SINCE: &str = "2026-05-01T00:00:00.000Z";
fn pr_valid_metadata() -> serde_json::Value {
serde_json::json!({
"revoked_key_fingerprint": format!("sha256:{}", "a".repeat(64)),
"compromised_since": PR_COMPROMISED_SINCE,
})
}
fn publish_request_with_metadata(metadata: Option<serde_json::Value>) -> PublishRequest {
PublishRequest {
version: 1,
supersedes: None,
agent_id: AgentDid::new(PR_PRODUCER_DID),
contributors: vec![],
title: "Key revocation — key-1 compromised".into(),
context_type: ContextType::KeyRevocation,
data_refs: vec![],
derived_from: vec![],
visibility: Visibility::Public,
content_hash: ContentHash("sha256:0".into()),
signature: Signature {
algorithm: "ed25519".into(),
key_id: format!("{PR_PRODUCER_DID}#key-1"),
value: "A".repeat(88),
},
audience: None,
acdp_version: Some("0.3.0".into()),
description: None,
summary: None,
lineage_id: None,
tags: None,
domain: None,
expires_at: None,
data_period: None,
metadata,
schema_uri: None,
anchors: None,
}
}
#[test]
fn from_publish_request_valid_case_is_accepted() {
let req = publish_request_with_metadata(Some(pr_valid_metadata()));
let rev =
KeyRevocation::from_publish_request(&req).expect("shape-conformant request must parse");
assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
assert_eq!(rev.publisher.as_str(), PR_PRODUCER_DID);
assert_eq!(rev.revoked_key_controller.as_str(), PR_PRODUCER_DID);
}
#[test]
fn from_publish_request_wrong_context_type_rejected() {
let mut req = publish_request_with_metadata(Some(pr_valid_metadata()));
req.context_type = ContextType::Analysis;
assert!(matches!(
KeyRevocation::from_publish_request(&req),
Err(AcdpError::SchemaViolation(_))
));
}
#[test]
fn from_publish_request_non_public_visibility_rejected() {
let mut req = publish_request_with_metadata(Some(pr_valid_metadata()));
req.visibility = Visibility::Restricted;
assert!(matches!(
KeyRevocation::from_publish_request(&req),
Err(AcdpError::SchemaViolation(_))
));
}
#[test]
fn from_publish_request_missing_metadata_rejected() {
let req = publish_request_with_metadata(None);
assert!(matches!(
KeyRevocation::from_publish_request(&req),
Err(AcdpError::SchemaViolation(_))
));
}
#[test]
fn from_publish_request_missing_fingerprint_rejected() {
let mut meta = pr_valid_metadata();
meta.as_object_mut()
.unwrap()
.remove("revoked_key_fingerprint");
let req = publish_request_with_metadata(Some(meta));
assert!(matches!(
KeyRevocation::from_publish_request(&req),
Err(AcdpError::SchemaViolation(_))
));
}
#[test]
fn from_publish_request_malformed_fingerprint_rejected() {
let mut meta = pr_valid_metadata();
meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
let req = publish_request_with_metadata(Some(meta));
assert!(matches!(
KeyRevocation::from_publish_request(&req),
Err(AcdpError::SchemaViolation(_))
));
}
#[test]
fn from_publish_request_missing_compromised_since_rejected() {
let mut meta = pr_valid_metadata();
meta.as_object_mut().unwrap().remove("compromised_since");
let req = publish_request_with_metadata(Some(meta));
assert!(matches!(
KeyRevocation::from_publish_request(&req),
Err(AcdpError::SchemaViolation(_))
));
}
#[test]
fn from_publish_request_non_canonical_compromised_since_rejected() {
let mut meta = pr_valid_metadata();
meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00Z");
let req = publish_request_with_metadata(Some(meta));
assert!(matches!(
KeyRevocation::from_publish_request(&req),
Err(AcdpError::SchemaViolation(_))
));
}
#[test]
fn from_publish_request_reason_over_limit_rejected() {
let mut meta = pr_valid_metadata();
meta["reason"] = serde_json::json!("x".repeat(MAX_REASON_CHARS + 1));
let req = publish_request_with_metadata(Some(meta));
assert!(matches!(
KeyRevocation::from_publish_request(&req),
Err(AcdpError::SchemaViolation(_))
));
}
#[test]
fn from_body_and_from_publish_request_agree_on_equivalent_input() {
let metadata = Some(pr_valid_metadata());
let req = publish_request_with_metadata(metadata.clone());
let body = body_from_pr_request(&req);
assert_eq!(
KeyRevocation::from_publish_request(&req).unwrap(),
KeyRevocation::from_body(&body).unwrap()
);
}
fn body_from_pr_request(req: &PublishRequest) -> Body {
Body::from_publish_request(
req,
CtxId("acdp://registry.example.com/00000000-0000-4000-8000-000000000000".into()),
LineageId(format!("lin:sha256:{}", "0".repeat(64))),
"registry.example.com",
DateTime::parse_from_rfc3339("2026-05-02T08:00:00.000Z")
.unwrap()
.with_timezone(&Utc),
)
}
#[test]
fn from_body_and_from_publish_request_agree_on_error_message() {
let mut req = publish_request_with_metadata(Some(pr_valid_metadata()));
req.visibility = Visibility::Restricted;
let body = body_from_pr_request(&req);
let pr_err = KeyRevocation::from_publish_request(&req).unwrap_err();
let body_err = KeyRevocation::from_body(&body).unwrap_err();
assert!(matches!(pr_err, AcdpError::SchemaViolation(_)));
assert_eq!(pr_err.to_string(), body_err.to_string());
let mut meta = pr_valid_metadata();
meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
let req = publish_request_with_metadata(Some(meta));
let body = body_from_pr_request(&req);
let pr_err = KeyRevocation::from_publish_request(&req).unwrap_err();
let body_err = KeyRevocation::from_body(&body).unwrap_err();
assert!(matches!(pr_err, AcdpError::SchemaViolation(_)));
assert_eq!(pr_err.to_string(), body_err.to_string());
}
#[test]
fn from_publish_request_controller_equal_to_agent_id_is_producer_signed() {
let mut meta = pr_valid_metadata();
meta["revoked_key_controller"] = serde_json::json!(PR_PRODUCER_DID);
let req = publish_request_with_metadata(Some(meta));
let rev = KeyRevocation::from_publish_request(&req).unwrap();
assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
assert_eq!(rev.revoked_key_controller.as_str(), PR_PRODUCER_DID);
}
#[test]
fn from_publish_request_controller_different_from_agent_id_is_registry_attested() {
const OTHER_PRODUCER: &str = "did:web:agents.example.com:other-producer";
let mut meta = pr_valid_metadata();
meta["revoked_key_controller"] = serde_json::json!(OTHER_PRODUCER);
let req = publish_request_with_metadata(Some(meta));
let rev = KeyRevocation::from_publish_request(&req).unwrap();
assert_eq!(rev.trust_class, RevocationTrustClass::RegistryAttested);
assert_eq!(rev.revoked_key_controller.as_str(), OTHER_PRODUCER);
assert_eq!(rev.publisher.as_str(), PR_PRODUCER_DID);
}
#[test]
fn from_publish_request_controller_not_a_string_rejected() {
let mut meta = pr_valid_metadata();
meta["revoked_key_controller"] = serde_json::json!(42);
let req = publish_request_with_metadata(Some(meta));
assert!(matches!(
KeyRevocation::from_publish_request(&req),
Err(AcdpError::SchemaViolation(_))
));
}
#[test]
fn from_publish_request_controller_invalid_did_rejected() {
let mut meta = pr_valid_metadata();
meta["revoked_key_controller"] = serde_json::json!("not-a-did");
let req = publish_request_with_metadata(Some(meta));
assert!(matches!(
KeyRevocation::from_publish_request(&req),
Err(AcdpError::SchemaViolation(_))
));
}
fn did_key_fixture(seed: [u8; 32]) -> (String, String) {
let signing_key = acdp_crypto::SigningKey::from_bytes(&seed);
let public_key = signing_key.verifying_key_bytes();
let did = acdp_did::key::did_key_from_ed25519(&public_key);
let key_id = acdp_did::key::did_key_url(&did).unwrap();
let fingerprint = acdp_crypto::fingerprint::fingerprint_ed25519(&public_key);
(key_id, fingerprint)
}
#[test]
fn from_publish_request_did_key_self_revocation_rejected() {
let (key_id, fingerprint) = did_key_fixture([1u8; 32]);
let mut meta = pr_valid_metadata();
meta["revoked_key_fingerprint"] = serde_json::json!(fingerprint);
let mut req = publish_request_with_metadata(Some(meta));
req.signature.key_id = key_id;
assert!(matches!(
KeyRevocation::from_publish_request(&req),
Err(AcdpError::KeyNotAuthorized(_))
));
}
#[test]
fn from_publish_request_did_key_different_key_accepted() {
let (key_id, _fingerprint) = did_key_fixture([2u8; 32]);
let meta = pr_valid_metadata(); let mut req = publish_request_with_metadata(Some(meta));
req.signature.key_id = key_id;
let rev = KeyRevocation::from_publish_request(&req)
.expect("did:key signer whose fingerprint differs from the revoked key must pass");
assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
}
#[test]
fn from_publish_request_malformed_did_key_key_id_not_rejected_here() {
let (key_id, fingerprint) = did_key_fixture([3u8; 32]);
let malformed_key_id = format!("{}-not-the-msi", key_id); let mut meta = pr_valid_metadata();
meta["revoked_key_fingerprint"] = serde_json::json!(fingerprint);
let mut req = publish_request_with_metadata(Some(meta));
req.signature.key_id = malformed_key_id;
let rev = KeyRevocation::from_publish_request(&req).expect(
"malformed did:key key_id is left for signature verification, not rejected here",
);
assert_eq!(rev.trust_class, RevocationTrustClass::ProducerSigned);
}
fn registry_attested_rev(publisher: &str) -> KeyRevocation {
KeyRevocation {
revoked_key_fingerprint: format!("sha256:{}", "a1".repeat(32)),
compromised_since: parse_canonical_ms("2026-05-01T00:00:00.000Z").unwrap(),
reason: None,
revoked_key_id: None,
revoked_key_controller: AgentDid::new("did:web:agents.example.com:producer"),
publisher: AgentDid::new(publisher),
trust_class: RevocationTrustClass::RegistryAttested,
}
}
#[test]
fn cross_check_registry_binding_success_and_both_failure_directions() {
let rev = registry_attested_rev("did:web:registry.example.com");
rev.cross_check_registry_binding("registry.example.com", "did:web:registry.example.com")
.expect("serving authority and capabilities.registry_did both match publisher");
assert!(matches!(
rev.cross_check_registry_binding("hostile.example", "did:web:registry.example.com"),
Err(AcdpError::KeyNotAuthorized(_))
));
assert!(matches!(
rev.cross_check_registry_binding("registry.example.com", "did:web:other.example"),
Err(AcdpError::KeyNotAuthorized(_))
));
}
#[test]
fn cross_check_registry_binding_percent_encoded_port_authority() {
let rev = registry_attested_rev("did:web:localhost%3A8443");
rev.cross_check_registry_binding("localhost:8443", "did:web:localhost%3A8443")
.expect("host:port authority round-trips through authority_to_did_web");
assert!(matches!(
rev.cross_check_registry_binding("localhost", "did:web:localhost%3A8443"),
Err(AcdpError::KeyNotAuthorized(_))
));
}
#[test]
fn fingerprint_form_edges() {
assert!(is_sha256_fingerprint(&format!(
"sha256:{}",
"a1".repeat(32)
)));
assert!(!is_sha256_fingerprint(&format!(
"sha256:{}",
"A1".repeat(32)
))); assert!(!is_sha256_fingerprint(&format!(
"sha512:{}",
"a1".repeat(32)
))); assert!(!is_sha256_fingerprint(&format!(
"sha256:{}",
"a1".repeat(31)
))); assert!(!is_sha256_fingerprint("sha256:")); assert!(!is_sha256_fingerprint(&"a1".repeat(32))); }
#[test]
fn canonical_ms_timestamp_edges() {
assert!(parse_canonical_ms("2026-05-01T00:00:00.000Z").is_some());
for bad in [
"2026-05-01T00:00:00Z", "2026-05-01T00:00:00.0Z", "2026-05-01T00:00:00.000000Z", "2026-05-01T00:00:00.000+00:00", "2026-05-01 00:00:00.000Z", "not-a-time",
] {
assert!(
parse_canonical_ms(bad).is_none(),
"{bad:?} must be rejected"
);
}
}
}