use acdp_crypto::{verify_content_hash, verify_ecdsa_p256, verify_ed25519};
use acdp_primitives::error::AcdpError;
use acdp_types::body::{Body, Signature};
use acdp_types::lifecycle::LifecycleEvent;
use acdp_types::primitives::{AgentDid, ContentHash, CtxId};
use acdp_types::publish::PublishRequest;
#[cfg(feature = "client")]
use acdp_did::web::WebResolver;
#[cfg(feature = "client")]
pub struct Verifier<'a> {
resolver: &'a WebResolver,
}
#[cfg(feature = "client")]
impl<'a> Verifier<'a> {
pub fn new(resolver: &'a WebResolver) -> Self {
Self { resolver }
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(
name = "acdp.verify_body",
skip_all,
fields(ctx_id = %body.ctx_id.0, agent_id = body.agent_id.as_str()),
err(Display)
)
)]
pub async fn verify_body(&self, body: &Body) -> Result<(), AcdpError> {
acdp_validation::validate_body(body)?;
self.verify_body_signed(body).await
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(
name = "acdp.verify_body_signed",
skip_all,
fields(ctx_id = %body.ctx_id.0),
err(Display)
)
)]
pub async fn verify_body_signed(&self, body: &Body) -> Result<(), AcdpError> {
self.verify_body_hash(body)?;
#[cfg(feature = "tracing")]
tracing::debug!(
stage = "content_hash",
"content hash recomputed and matched"
);
self.verify_body_signature(body).await?;
#[cfg(feature = "tracing")]
tracing::debug!(stage = "signature", "producer signature verified");
Ok(())
}
pub fn verify_body_hash(&self, body: &Body) -> Result<(), AcdpError> {
let body_val = serde_json::to_value(body)?;
verify_content_hash(&body_val, &body.content_hash)
}
pub async fn verify_body_signature(&self, body: &Body) -> Result<(), AcdpError> {
verify_signature_envelope(
&body.agent_id,
&body.signature,
&body.content_hash,
self.resolver,
)
.await
}
}
#[cfg(feature = "client")]
#[cfg_attr(
feature = "tracing",
tracing::instrument(
name = "acdp.verify_publish_request_signature",
skip_all,
fields(agent_id = req.agent_id.as_str(), key_id = %req.signature.key_id),
err(Display)
)
)]
pub async fn verify_publish_request_signature(
req: &PublishRequest,
resolver: &WebResolver,
) -> Result<(), AcdpError> {
verify_signature_envelope(&req.agent_id, &req.signature, &req.content_hash, resolver).await
}
#[cfg(feature = "client")]
async fn verify_signature_envelope(
agent_id: &AgentDid,
signature: &Signature,
content_hash: &ContentHash,
resolver: &WebResolver,
) -> Result<(), AcdpError> {
let key_id = &signature.key_id;
let (did_part, fragment) = key_id.split_once('#').ok_or_else(|| {
AcdpError::KeyResolution(format!("signature.key_id '{key_id}' has no '#fragment'"))
})?;
if fragment.is_empty() {
return Err(AcdpError::KeyResolution(format!(
"signature.key_id '{key_id}' has an empty '#fragment'"
)));
}
if did_part != agent_id.as_str() {
return Err(AcdpError::KeyNotAuthorized(format!(
"key_id DID '{did_part}' ≠ agent_id '{agent_id}'"
)));
}
if did_part.starts_with("did:key:") {
return verify_did_key_envelope(signature, content_hash);
}
if !did_part.starts_with("did:web:") {
return Err(AcdpError::KeyNotAuthorized(format!(
"signatures require a did:web or did:key key_id; got '{did_part}'"
)));
}
let doc = resolver.resolve(did_part).await?;
let method = doc.find_by_fragment(fragment).ok_or_else(|| {
AcdpError::KeyResolution(format!(
"no verification method with fragment '#{fragment}'"
))
})?;
if !doc.is_assertion_method(&method.id) {
return Err(AcdpError::KeyNotAuthorized(format!(
"'{}' is not in assertionMethod",
method.id
)));
}
if let Some(declared) = method.declared_algorithm() {
if declared != signature.algorithm {
return Err(AcdpError::InvalidSignature(format!(
"signature.algorithm '{}' does not match verification method type \
(resolved key declares '{declared}')",
signature.algorithm
)));
}
}
match signature.algorithm.as_str() {
"ed25519" => {
let pub_bytes = method.ed25519_public_key_bytes()?;
verify_ed25519(&pub_bytes, &signature.value, content_hash.as_str())
}
"ecdsa-p256" => {
let pub_sec1 = method.ecdsa_p256_public_key_sec1()?;
verify_ecdsa_p256(&pub_sec1, &signature.value, content_hash.as_str())
}
other => Err(AcdpError::UnsupportedAlgorithm(format!(
"verifier does not support signature algorithm '{other}'"
))),
}
}
pub fn verify_did_key_envelope(
signature: &Signature,
content_hash: &ContentHash,
) -> Result<(), AcdpError> {
let material = acdp_did::key::resolve_did_key_url(&signature.key_id)?;
if material.algorithm() != signature.algorithm {
return Err(AcdpError::InvalidSignature(format!(
"signature.algorithm '{}' does not match the did:key multicodec \
(key implies '{}')",
signature.algorithm,
material.algorithm()
)));
}
match material {
acdp_did::key::DidKeyMaterial::Ed25519(pub_bytes) => {
verify_ed25519(&pub_bytes, &signature.value, content_hash.as_str())
}
acdp_did::key::DidKeyMaterial::EcdsaP256(sec1_compressed) => {
verify_ecdsa_p256(&sec1_compressed, &signature.value, content_hash.as_str())
}
}
}
pub fn verify_body_offline(body: &Body) -> Result<(), AcdpError> {
acdp_validation::validate_body(body)?;
if !body.agent_id.as_str().starts_with("did:key:") {
return Err(AcdpError::KeyResolution(format!(
"verify_body_offline supports did:key producers only; '{}' requires \
the resolver-backed Verifier (client feature)",
body.agent_id
)));
}
let body_val = serde_json::to_value(body)?;
verify_content_hash(&body_val, &body.content_hash)?;
let did_part = body
.signature
.key_id
.split_once('#')
.map(|(d, _)| d)
.unwrap_or(body.signature.key_id.as_str());
if did_part != body.agent_id.as_str() {
return Err(AcdpError::KeyNotAuthorized(format!(
"key_id DID '{did_part}' ≠ agent_id '{}'",
body.agent_id
)));
}
verify_did_key_envelope(&body.signature, &body.content_hash)
}
pub fn verify_publish_request_signature_offline(req: &PublishRequest) -> Result<(), AcdpError> {
let key_id = req.signature.key_id.as_str();
let did_part = key_id.split_once('#').map(|(d, _)| d).unwrap_or(key_id);
if did_part != req.agent_id.as_str() {
return Err(AcdpError::KeyNotAuthorized(format!(
"key_id DID '{did_part}' ≠ agent_id '{}'",
req.agent_id
)));
}
if !did_part.starts_with("did:key:") {
return Err(AcdpError::KeyResolution(format!(
"offline verification supports did:key only; got '{did_part}'"
)));
}
verify_did_key_envelope(&req.signature, &req.content_hash)
}
#[cfg(feature = "client")]
pub async fn verify_body_signature_historical(
body: &Body,
resolver: &WebResolver,
) -> Result<(), AcdpError> {
let key_id = &body.signature.key_id;
let (did_part, fragment) = key_id.split_once('#').ok_or_else(|| {
AcdpError::KeyResolution(format!("signature.key_id '{key_id}' has no '#fragment'"))
})?;
if did_part != body.agent_id.as_str() {
return Err(AcdpError::KeyNotAuthorized(format!(
"key_id DID '{did_part}' ≠ agent_id '{}'",
body.agent_id
)));
}
if !did_part.starts_with("did:web:") {
return Err(AcdpError::KeyResolution(format!(
"historical-key verification applies to did:web only; got '{did_part}'"
)));
}
let doc = resolver.resolve(did_part).await?;
let method = doc.find_by_fragment(fragment).ok_or_else(|| {
AcdpError::KeyResolution(format!(
"no verification method with fragment '#{fragment}' — the key was \
removed from the DID document, not just rotated out of assertionMethod"
))
})?;
if let Some(declared) = method.declared_algorithm() {
if declared != body.signature.algorithm {
return Err(AcdpError::InvalidSignature(format!(
"signature.algorithm '{}' does not match verification method type \
(resolved key declares '{declared}')",
body.signature.algorithm
)));
}
}
match body.signature.algorithm.as_str() {
"ed25519" => verify_ed25519(
&method.ed25519_public_key_bytes()?,
&body.signature.value,
body.content_hash.as_str(),
),
"ecdsa-p256" => verify_ecdsa_p256(
&method.ecdsa_p256_public_key_sec1()?,
&body.signature.value,
body.content_hash.as_str(),
),
other => Err(AcdpError::UnsupportedAlgorithm(format!(
"verifier does not support signature algorithm '{other}'"
))),
}
}
fn lifecycle_event_prechecks(
raw_event: &serde_json::Value,
expected_ctx_id: &CtxId,
producer_did: &AgentDid,
registry_did: Option<&str>,
) -> Result<(LifecycleEvent, ContentHash), AcdpError> {
let hash = LifecycleEvent::preimage_hash_of_value(raw_event)?;
let event = LifecycleEvent::from_value(raw_event)?;
if &event.ctx_id != expected_ctx_id {
return Err(AcdpError::SchemaViolation(format!(
"lifecycle event ctx_id '{}' ≠ the context's ctx_id '{expected_ctx_id}' \
(RFC-ACDP-0013 §4: an event binds to exactly one context)",
event.ctx_id
)));
}
let is_producer = event.actor.as_str() == producer_did.as_str();
let is_registry = registry_did.is_some_and(|did| event.actor.as_str() == did);
if !is_producer && !is_registry {
return Err(AcdpError::NotAuthorized(format!(
"lifecycle event actor '{}' is neither the producer '{producer_did}' nor the \
registry DID — only the producer and the serving registry can record \
lifecycle events (RFC-ACDP-0013 §4, §12)",
event.actor
)));
}
event.actor_bound_signature()?;
Ok((event, hash))
}
#[cfg(feature = "client")]
pub async fn verify_lifecycle_event(
raw_event: &serde_json::Value,
expected_ctx_id: &CtxId,
producer_did: &AgentDid,
registry_did: Option<&str>,
resolver: &WebResolver,
) -> Result<LifecycleEvent, AcdpError> {
let (event, hash) =
lifecycle_event_prechecks(raw_event, expected_ctx_id, producer_did, registry_did)?;
let signature = event.actor_bound_signature()?.clone();
verify_signature_envelope(&event.actor, &signature, &hash, resolver).await?;
Ok(event)
}
pub fn verify_lifecycle_event_offline(
raw_event: &serde_json::Value,
expected_ctx_id: &CtxId,
producer_did: &AgentDid,
registry_did: Option<&str>,
) -> Result<LifecycleEvent, AcdpError> {
let (event, hash) =
lifecycle_event_prechecks(raw_event, expected_ctx_id, producer_did, registry_did)?;
if !event.actor.as_str().starts_with("did:key:") {
return Err(AcdpError::KeyResolution(format!(
"offline lifecycle-event verification supports did:key actors only; '{}' \
requires the resolver-backed verify_lifecycle_event (client feature)",
event.actor
)));
}
let signature = event.actor_bound_signature()?;
verify_did_key_envelope(signature, &hash)?;
Ok(event)
}
#[cfg(test)]
mod offline_tests {
use super::*;
use acdp_crypto::{P256SigningKey, SigningKey};
use acdp_producer::Producer;
use acdp_types::body::{Body, DataPeriod};
use acdp_types::lifecycle::{LifecycleEvent, LifecycleEventType};
use acdp_types::{AgentDid, ContextType, CtxId, LineageId, Visibility};
const CTX: &str = "acdp://registry.example.com/00000000-0000-4000-8000-000000000000";
const LIN: &str = "lin:sha256:0000000000000000000000000000000000000000000000000000000000000000";
const EVENT_ID: &str = "00000000-0000-4000-8000-0000000000aa";
fn ts() -> chrono::DateTime<chrono::Utc> {
chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap()
}
fn body_of(producer: &Producer) -> Body {
let req = producer
.publish_request()
.title("offline verify test")
.context_type(ContextType::DataSnapshot)
.visibility(Visibility::Public)
.build()
.expect("valid request");
Body::from_publish_request(
&req,
CtxId(CTX.into()),
LineageId(LIN.into()),
"registry.example.com",
ts(),
)
}
fn ed25519_body() -> Body {
body_of(&Producer::new_did_key(SigningKey::from_bytes(&[1u8; 32])))
}
fn p256_body() -> Body {
let key = P256SigningKey::from_bytes(&[2u8; 32]).expect("valid p256 scalar");
body_of(&Producer::new_did_key_p256(key).expect("did:key p256 producer"))
}
fn didweb_body() -> Body {
body_of(&Producer::new(
SigningKey::from_bytes(&[3u8; 32]),
AgentDid::new("did:web:agents.example.com:p"),
"did:web:agents.example.com:p#key-1",
))
}
#[test]
fn did_key_envelope_ed25519_and_p256_happy() {
let b = ed25519_body();
assert!(verify_did_key_envelope(&b.signature, &b.content_hash).is_ok());
let p = p256_body();
assert!(verify_did_key_envelope(&p.signature, &p.content_hash).is_ok());
}
#[test]
fn did_key_envelope_algorithm_downgrade_rejected() {
let b = ed25519_body();
let mut sig = b.signature.clone();
sig.algorithm = "ecdsa-p256".into();
assert!(matches!(
verify_did_key_envelope(&sig, &b.content_hash),
Err(AcdpError::InvalidSignature(_))
));
}
#[test]
fn did_key_envelope_malformed_key_id_errors() {
let b = ed25519_body();
let mut sig = b.signature.clone();
sig.key_id = sig.key_id.split('#').next().unwrap().to_string();
assert!(verify_did_key_envelope(&sig, &b.content_hash).is_err());
}
#[test]
fn did_key_envelope_tampered_signature_rejected() {
let b = ed25519_body();
let other = body_of(&Producer::new_did_key(SigningKey::from_bytes(&[9u8; 32])));
let mut sig = b.signature.clone();
sig.value = other.signature.value.clone();
assert!(matches!(
verify_did_key_envelope(&sig, &b.content_hash),
Err(AcdpError::InvalidSignature(_))
));
}
#[test]
fn body_offline_happy_ed25519_and_p256() {
assert!(verify_body_offline(&ed25519_body()).is_ok());
assert!(verify_body_offline(&p256_body()).is_ok());
}
#[test]
fn body_offline_structural_failure_precedes_hash() {
let mut b = ed25519_body();
b.data_period = Some(DataPeriod {
start: ts(),
end: ts() - chrono::Duration::days(1),
});
assert!(matches!(
verify_body_offline(&b),
Err(AcdpError::SchemaViolation(_))
));
}
#[test]
fn body_offline_rejects_did_web_producer() {
assert!(matches!(
verify_body_offline(&didweb_body()),
Err(AcdpError::KeyResolution(_))
));
}
#[test]
fn body_offline_tampered_field_fails_hash() {
let mut b = ed25519_body();
b.title = "tampered title".into();
assert!(verify_body_offline(&b).is_err());
}
#[test]
fn body_offline_key_id_did_must_equal_agent_id() {
let mut b = ed25519_body();
let other = p256_body();
b.signature.key_id = other.signature.key_id.clone();
assert!(matches!(
verify_body_offline(&b),
Err(AcdpError::KeyNotAuthorized(_))
));
}
#[test]
fn body_offline_fragmentless_key_id_reaches_envelope_error() {
let mut b = ed25519_body();
b.signature.key_id = b.agent_id.as_str().to_string();
assert!(verify_body_offline(&b).is_err());
}
#[test]
fn publish_request_offline_happy() {
let producer = Producer::new_did_key(SigningKey::from_bytes(&[4u8; 32]));
let req = producer
.publish_request()
.title("pr offline")
.context_type(ContextType::DataSnapshot)
.visibility(Visibility::Public)
.build()
.expect("valid request");
assert!(verify_publish_request_signature_offline(&req).is_ok());
}
#[test]
fn publish_request_offline_did_mismatch_and_did_web() {
let producer = Producer::new_did_key(SigningKey::from_bytes(&[4u8; 32]));
let mut req = producer
.publish_request()
.title("pr offline")
.context_type(ContextType::DataSnapshot)
.visibility(Visibility::Public)
.build()
.expect("valid request");
let orig = req.signature.key_id.clone();
req.signature.key_id = p256_body().signature.key_id.clone();
assert!(matches!(
verify_publish_request_signature_offline(&req),
Err(AcdpError::KeyNotAuthorized(_))
));
req.agent_id = AgentDid::new("did:web:agents.example.com:p");
req.signature.key_id = "did:web:agents.example.com:p#key-1".into();
let _ = orig;
assert!(matches!(
verify_publish_request_signature_offline(&req),
Err(AcdpError::KeyResolution(_))
));
}
fn identity(seed: &[u8; 32]) -> (AgentDid, String) {
let key = SigningKey::from_bytes(seed);
let did = acdp_did::key::did_key_from_ed25519(&key.verifying_key_bytes());
let key_id = acdp_did::key::did_key_url(&did).expect("did:key url");
(AgentDid::new(did), key_id)
}
fn signed_event(seed: &[u8; 32], actor: AgentDid, key_id: String) -> serde_json::Value {
let event = LifecycleEvent::new(
EVENT_ID,
CtxId(CTX.into()),
LifecycleEventType::Retracted,
ts(),
actor,
Some("superseded".into()),
)
.expect("valid event")
.sign_with(SigningKey::from_bytes(seed), key_id)
.expect("signed event");
serde_json::to_value(&event).expect("event serializes")
}
#[test]
fn lifecycle_offline_happy_producer_actor() {
let (actor, key_id) = identity(&[5u8; 32]);
let raw = signed_event(&[5u8; 32], actor.clone(), key_id);
let out = verify_lifecycle_event_offline(&raw, &CtxId(CTX.into()), &actor, None);
assert!(out.is_ok());
}
#[test]
fn lifecycle_offline_registry_actor_accepted() {
let (actor, key_id) = identity(&[6u8; 32]);
let raw = signed_event(&[6u8; 32], actor.clone(), key_id);
let producer = AgentDid::new("did:key:z6MkOtherProducerDidThatIsNotTheActor");
let out = verify_lifecycle_event_offline(
&raw,
&CtxId(CTX.into()),
&producer,
Some(actor.as_str()),
);
assert!(out.is_ok());
}
#[test]
fn lifecycle_offline_unknown_member_rejected() {
let (actor, key_id) = identity(&[5u8; 32]);
let mut raw = signed_event(&[5u8; 32], actor.clone(), key_id);
raw.as_object_mut()
.unwrap()
.insert("unexpected".into(), serde_json::json!(1));
assert!(matches!(
verify_lifecycle_event_offline(&raw, &CtxId(CTX.into()), &actor, None),
Err(AcdpError::SchemaViolation(_))
));
}
#[test]
fn lifecycle_offline_ctx_id_mismatch_rejected() {
let (actor, key_id) = identity(&[5u8; 32]);
let raw = signed_event(&[5u8; 32], actor.clone(), key_id);
let other =
CtxId("acdp://registry.example.com/11111111-1111-4111-8111-111111111111".into());
assert!(matches!(
verify_lifecycle_event_offline(&raw, &other, &actor, None),
Err(AcdpError::SchemaViolation(_))
));
}
#[test]
fn lifecycle_offline_unauthorized_actor_rejected() {
let (actor, key_id) = identity(&[5u8; 32]);
let raw = signed_event(&[5u8; 32], actor, key_id);
let stranger = AgentDid::new("did:key:z6MkStrangerNeitherProducerNorRegistry");
assert!(matches!(
verify_lifecycle_event_offline(&raw, &CtxId(CTX.into()), &stranger, None),
Err(AcdpError::NotAuthorized(_))
));
assert!(matches!(
verify_lifecycle_event_offline(
&raw,
&CtxId(CTX.into()),
&stranger,
Some("did:key:z6MkSomeOtherRegistry")
),
Err(AcdpError::NotAuthorized(_))
));
}
#[test]
fn lifecycle_offline_unsigned_event_rejected() {
let (actor, _key_id) = identity(&[5u8; 32]);
let event = LifecycleEvent::new(
EVENT_ID,
CtxId(CTX.into()),
LifecycleEventType::Retracted,
ts(),
actor.clone(),
Some("superseded".into()),
)
.expect("valid event");
let raw = serde_json::to_value(&event).expect("serializes");
assert!(verify_lifecycle_event_offline(&raw, &CtxId(CTX.into()), &actor, None).is_err());
}
#[test]
fn lifecycle_offline_did_web_actor_rejected() {
let actor = AgentDid::new("did:web:agents.example.com:p");
let key_id = "did:web:agents.example.com:p#key-1".to_string();
let raw = signed_event(&[7u8; 32], actor.clone(), key_id);
assert!(matches!(
verify_lifecycle_event_offline(&raw, &CtxId(CTX.into()), &actor, None),
Err(AcdpError::KeyResolution(_))
));
}
#[test]
fn lifecycle_offline_mutated_raw_json_fails_signature() {
let (actor, key_id) = identity(&[5u8; 32]);
let mut raw = signed_event(&[5u8; 32], actor.clone(), key_id);
raw.as_object_mut()
.unwrap()
.insert("reason".into(), serde_json::json!("changed after signing"));
assert!(matches!(
verify_lifecycle_event_offline(&raw, &CtxId(CTX.into()), &actor, None),
Err(AcdpError::InvalidSignature(_))
));
}
}