use crate::body::Body;
use acdp_primitives::error::AcdpError;
use acdp_primitives::primitives::{AgentDid, 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> {
if !body.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(&body.context_type)
.ok()
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_default()
)));
}
if body.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 = body
.metadata
.as_ref()
.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 => (body.agent_id.clone(), RevocationTrustClass::ProducerSigned),
Some(c) => {
let controller = AgentDid::parse(&c)?;
if controller == body.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: body.agent_id.clone(),
trust_class,
};
if body.signature.key_id.starts_with("did:key:") {
if let Ok(material) = acdp_did::key::resolve_did_key_url(&body.signature.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 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::*;
#[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"
);
}
}
}