acdp_verify/lib.rs
1//! High-level body / publish-request verification — RFC-ACDP-0001 §5.11
2//! (7-step algorithm).
3//!
4//! This layer sits above `validation`, `types`, `crypto`, and `did`: it
5//! recomputes the `content_hash`, runs structural validation, resolves
6//! the producer DID, and verifies the signature envelope. The byte-level
7//! primitives ([`acdp_crypto::verify_ed25519`] /
8//! [`acdp_crypto::verify_ecdsa_p256`]) live in `crypto`.
9
10use acdp_crypto::{verify_content_hash, verify_ecdsa_p256, verify_ed25519};
11use acdp_primitives::error::AcdpError;
12use acdp_types::body::{Body, Signature};
13use acdp_types::lifecycle::LifecycleEvent;
14use acdp_types::primitives::{AgentDid, ContentHash, CtxId};
15use acdp_types::publish::PublishRequest;
16
17#[cfg(feature = "client")]
18use acdp_did::web::WebResolver;
19
20/// Stateless verifier. Requires a DID resolver to fetch producer keys.
21#[cfg(feature = "client")]
22pub struct Verifier<'a> {
23 resolver: &'a WebResolver,
24}
25
26#[cfg(feature = "client")]
27impl<'a> Verifier<'a> {
28 pub fn new(resolver: &'a WebResolver) -> Self {
29 Self { resolver }
30 }
31
32 /// Full end-to-end verification per RFC-ACDP-0001 §5.11.
33 ///
34 /// Steps:
35 /// 1. (Implicit) Check `key_id` has a `#fragment`.
36 /// 2. Verify `key_id` DID portion equals `body.agent_id`.
37 /// 3. Resolve the DID document.
38 /// 4. Find the verification method by fragment.
39 /// 5. Check `assertionMethod` authorization.
40 /// 6. Extract the Ed25519 public key.
41 /// 7. Verify the signature over the content_hash ASCII bytes.
42 ///
43 /// (Hash recomputation is step 0, performed first.)
44 #[cfg_attr(
45 feature = "tracing",
46 tracing::instrument(
47 name = "acdp.verify_body",
48 skip_all,
49 fields(ctx_id = %body.ctx_id.0, agent_id = body.agent_id.as_str()),
50 err(Display)
51 )
52 )]
53 pub async fn verify_body(&self, body: &Body) -> Result<(), AcdpError> {
54 // Step -1 (BUG-04): structural / runtime validation. A body may be
55 // cryptographically correct but protocol-invalid (non-did:web
56 // producer, inverted data_period, oversize metadata). Catch those
57 // before paying the SHA-256 + DID resolution cost.
58 acdp_validation::validate_body(body)?;
59
60 self.verify_body_signed(body).await
61 }
62
63 /// Verify only the hash recomputation + DID resolution + signature
64 /// envelope, assuming structural validation has already been done by
65 /// the caller. Use when you want to separate structural failures
66 /// from cryptographic ones — e.g.
67 /// `acdp::client::VerifiedContext::fetch_report` runs the
68 /// structural part itself and records per-`DataRef` outcomes
69 /// individually.
70 #[cfg_attr(
71 feature = "tracing",
72 tracing::instrument(
73 name = "acdp.verify_body_signed",
74 skip_all,
75 fields(ctx_id = %body.ctx_id.0),
76 err(Display)
77 )
78 )]
79 pub async fn verify_body_signed(&self, body: &Body) -> Result<(), AcdpError> {
80 self.verify_body_hash(body)?;
81 #[cfg(feature = "tracing")]
82 tracing::debug!(
83 stage = "content_hash",
84 "content hash recomputed and matched"
85 );
86 self.verify_body_signature(body).await?;
87 #[cfg(feature = "tracing")]
88 tracing::debug!(stage = "signature", "producer signature verified");
89 Ok(())
90 }
91
92 /// Step 0 only — recompute the `content_hash` over ProducerContent
93 /// and compare against `body.content_hash`. Lets diagnostic
94 /// callers record hash-pass/fail independently of the signature
95 /// stage (FEAT-05).
96 pub fn verify_body_hash(&self, body: &Body) -> Result<(), AcdpError> {
97 let body_val = serde_json::to_value(body)?;
98 verify_content_hash(&body_val, &body.content_hash)
99 }
100
101 /// Steps 1–7 only — resolve the producer's DID, find the signing
102 /// key, verify the signature over the (already-stored)
103 /// `body.content_hash`. Assumes [`Self::verify_body_hash`] (or an
104 /// equivalent check) has already run.
105 pub async fn verify_body_signature(&self, body: &Body) -> Result<(), AcdpError> {
106 verify_signature_envelope(
107 &body.agent_id,
108 &body.signature,
109 &body.content_hash,
110 self.resolver,
111 )
112 .await
113 }
114}
115
116/// Verify the producer signature on a [`PublishRequest`] per RFC-ACDP-0003
117/// §2.1 steps 7–8.
118///
119/// Assumes structural validation and `content_hash` recomputation have
120/// already been performed (e.g. by `acdp::registry::PublishValidator::validate_post_schema`).
121/// Executes only the DID resolution + signature verification steps shared
122/// with [`Verifier::verify_body`].
123///
124/// Used by `acdp::registry::RegistryServer::publish_verified` to fulfill
125/// the §2.1 publish algorithm before persistence; consumers wanting end-to-end
126/// verification on retrieval should prefer
127/// `acdp::client::VerifiedContext::fetch` which calls [`Verifier::verify_body`].
128#[cfg(feature = "client")]
129#[cfg_attr(
130 feature = "tracing",
131 tracing::instrument(
132 name = "acdp.verify_publish_request_signature",
133 skip_all,
134 fields(agent_id = req.agent_id.as_str(), key_id = %req.signature.key_id),
135 err(Display)
136 )
137)]
138pub async fn verify_publish_request_signature(
139 req: &PublishRequest,
140 resolver: &WebResolver,
141) -> Result<(), AcdpError> {
142 verify_signature_envelope(&req.agent_id, &req.signature, &req.content_hash, resolver).await
143}
144
145/// Steps 1–7 of RFC-ACDP-0001 §5.11 — the part of body verification that
146/// operates only on the signature envelope and is identical for stored
147/// `Body` values and incoming `PublishRequest` values. Caller is responsible
148/// for hash recomputation (step 0).
149#[cfg(feature = "client")]
150async fn verify_signature_envelope(
151 agent_id: &AgentDid,
152 signature: &Signature,
153 content_hash: &ContentHash,
154 resolver: &WebResolver,
155) -> Result<(), AcdpError> {
156 // Step 1: parse key_id — must contain a non-empty '#' fragment
157 // (RFC-ACDP-0001 §5.11 step 1). An empty fragment (`did:web:x#`) is
158 // rejected rather than used as a lookup key (#22).
159 let key_id = &signature.key_id;
160 let (did_part, fragment) = key_id.split_once('#').ok_or_else(|| {
161 AcdpError::KeyResolution(format!("signature.key_id '{key_id}' has no '#fragment'"))
162 })?;
163 if fragment.is_empty() {
164 return Err(AcdpError::KeyResolution(format!(
165 "signature.key_id '{key_id}' has an empty '#fragment'"
166 )));
167 }
168
169 // Step 2: DID portion MUST equal agent_id
170 if did_part != agent_id.as_str() {
171 return Err(AcdpError::KeyNotAuthorized(format!(
172 "key_id DID '{did_part}' ≠ agent_id '{agent_id}'"
173 )));
174 }
175
176 // Step 1.5: method dispatch. `did:key` resolves purely (the DID is
177 // the key — no document fetch, no assertionMethod check); `did:web`
178 // takes the HTTPS resolver path below. Any other method has no
179 // resolver in this version.
180 if did_part.starts_with("did:key:") {
181 return verify_did_key_envelope(signature, content_hash);
182 }
183 if !did_part.starts_with("did:web:") {
184 return Err(AcdpError::KeyNotAuthorized(format!(
185 "signatures require a did:web or did:key key_id; got '{did_part}'"
186 )));
187 }
188
189 // Step 3: resolve DID document
190 let doc = resolver.resolve(did_part).await?;
191
192 // Step 4: find verification method by fragment
193 let method = doc.find_by_fragment(fragment).ok_or_else(|| {
194 AcdpError::KeyResolution(format!(
195 "no verification method with fragment '#{fragment}'"
196 ))
197 })?;
198
199 // Step 5: assertionMethod authorization
200 if !doc.is_assertion_method(&method.id) {
201 return Err(AcdpError::KeyNotAuthorized(format!(
202 "'{}' is not in assertionMethod",
203 method.id
204 )));
205 }
206
207 // Step 5.5: algorithm-downgrade rejection (RFC-ACDP-0008 §3.9 +
208 // RFC-ACDP-0001 §5.11 step 6). When the verification method declares
209 // an algorithm via its `type` (or `publicKeyJwk` params), it MUST equal
210 // `signature.algorithm`. Otherwise an attacker could route an Ed25519
211 // key through a verifier that thinks it's checking some other algorithm.
212 if let Some(declared) = method.declared_algorithm() {
213 if declared != signature.algorithm {
214 return Err(AcdpError::InvalidSignature(format!(
215 "signature.algorithm '{}' does not match verification method type \
216 (resolved key declares '{declared}')",
217 signature.algorithm
218 )));
219 }
220 }
221
222 // Steps 6 + 7: dispatch by algorithm.
223 match signature.algorithm.as_str() {
224 "ed25519" => {
225 let pub_bytes = method.ed25519_public_key_bytes()?;
226 verify_ed25519(&pub_bytes, &signature.value, content_hash.as_str())
227 }
228 "ecdsa-p256" => {
229 let pub_sec1 = method.ecdsa_p256_public_key_sec1()?;
230 verify_ecdsa_p256(&pub_sec1, &signature.value, content_hash.as_str())
231 }
232 other => Err(AcdpError::UnsupportedAlgorithm(format!(
233 "verifier does not support signature algorithm '{other}'"
234 ))),
235 }
236}
237
238/// Verify a signature envelope whose key is a `did:key` — a pure
239/// function available without the `client` feature (no resolver, no
240/// network, no async).
241///
242/// Performs:
243/// 1. `key_id` form check (`did:key:z<mb>#z<mb>`, fragment = key).
244/// 2. Pure key resolution from the DID itself.
245/// 3. Algorithm-downgrade rejection: `signature.algorithm` MUST equal
246/// the algorithm implied by the key's multicodec prefix
247/// (RFC-ACDP-0008 §3.9).
248/// 4. Signature verification over the ASCII bytes of `content_hash`.
249///
250/// The caller is responsible for the `key_id`-DID-equals-`agent_id`
251/// binding check and for `content_hash` recomputation (use
252/// [`verify_body_offline`] for the full pipeline).
253pub fn verify_did_key_envelope(
254 signature: &Signature,
255 content_hash: &ContentHash,
256) -> Result<(), AcdpError> {
257 let material = acdp_did::key::resolve_did_key_url(&signature.key_id)?;
258
259 if material.algorithm() != signature.algorithm {
260 return Err(AcdpError::InvalidSignature(format!(
261 "signature.algorithm '{}' does not match the did:key multicodec \
262 (key implies '{}')",
263 signature.algorithm,
264 material.algorithm()
265 )));
266 }
267
268 match material {
269 acdp_did::key::DidKeyMaterial::Ed25519(pub_bytes) => {
270 verify_ed25519(&pub_bytes, &signature.value, content_hash.as_str())
271 }
272 acdp_did::key::DidKeyMaterial::EcdsaP256(sec1_compressed) => {
273 verify_ecdsa_p256(&sec1_compressed, &signature.value, content_hash.as_str())
274 }
275 }
276}
277
278/// Full offline body verification for `did:key` producers — works with
279/// `--no-default-features` (no HTTP stack, no resolver, no async).
280///
281/// Pipeline (mirrors [`Verifier::verify_body`] minus DID-document
282/// resolution, which did:key does not have):
283/// 1. Structural validation ([`acdp_validation::validate_body`]).
284/// 2. `content_hash` recomputation over ProducerContent (§5.7).
285/// 3. `key_id` DID portion equals `agent_id`.
286/// 4. Pure did:key envelope verification (algorithm + signature).
287///
288/// Returns [`AcdpError::KeyResolution`] for a `did:web` (or other
289/// method) body — those require the resolver-backed
290/// [`Verifier::verify_body`] under the `client` feature.
291pub fn verify_body_offline(body: &Body) -> Result<(), AcdpError> {
292 acdp_validation::validate_body(body)?;
293
294 if !body.agent_id.as_str().starts_with("did:key:") {
295 return Err(AcdpError::KeyResolution(format!(
296 "verify_body_offline supports did:key producers only; '{}' requires \
297 the resolver-backed Verifier (client feature)",
298 body.agent_id
299 )));
300 }
301
302 let body_val = serde_json::to_value(body)?;
303 verify_content_hash(&body_val, &body.content_hash)?;
304
305 let did_part = body
306 .signature
307 .key_id
308 .split_once('#')
309 .map(|(d, _)| d)
310 .unwrap_or(body.signature.key_id.as_str());
311 if did_part != body.agent_id.as_str() {
312 return Err(AcdpError::KeyNotAuthorized(format!(
313 "key_id DID '{did_part}' ≠ agent_id '{}'",
314 body.agent_id
315 )));
316 }
317
318 verify_did_key_envelope(&body.signature, &body.content_hash)
319}
320
321/// Offline counterpart of [`verify_publish_request_signature`] for
322/// `did:key` producers — used by registries (and the bindings) to verify
323/// a publish request without the `client` feature. Assumes structural
324/// validation and `content_hash` recomputation have already run
325/// (e.g. via `PublishValidator::validate_post_schema`).
326pub fn verify_publish_request_signature_offline(req: &PublishRequest) -> Result<(), AcdpError> {
327 let key_id = req.signature.key_id.as_str();
328 let did_part = key_id.split_once('#').map(|(d, _)| d).unwrap_or(key_id);
329 if did_part != req.agent_id.as_str() {
330 return Err(AcdpError::KeyNotAuthorized(format!(
331 "key_id DID '{did_part}' ≠ agent_id '{}'",
332 req.agent_id
333 )));
334 }
335 if !did_part.starts_with("did:key:") {
336 return Err(AcdpError::KeyResolution(format!(
337 "offline verification supports did:key only; got '{did_part}'"
338 )));
339 }
340 verify_did_key_envelope(&req.signature, &req.content_hash)
341}
342
343/// Historical-key body-signature verification (ACDP 0.2, WS-B).
344///
345/// Identical to the standard envelope verification EXCEPT that the
346/// `assertionMethod` membership check is skipped: a rotated-out key
347/// that the producer retained in `verificationMethod` (per the
348/// RFC-ACDP-0010 key-retention rule) still verifies. Callers MUST
349/// gate this on a **verified registry receipt** whose
350/// `key_fingerprint` matches this key — without that attestation,
351/// accepting a non-assertion key is exactly the bypass the
352/// `assertionMethod` check exists to prevent. did:key bodies never
353/// take this path (the key cannot rotate).
354#[cfg(feature = "client")]
355pub async fn verify_body_signature_historical(
356 body: &Body,
357 resolver: &WebResolver,
358) -> Result<(), AcdpError> {
359 let key_id = &body.signature.key_id;
360 let (did_part, fragment) = key_id.split_once('#').ok_or_else(|| {
361 AcdpError::KeyResolution(format!("signature.key_id '{key_id}' has no '#fragment'"))
362 })?;
363 if did_part != body.agent_id.as_str() {
364 return Err(AcdpError::KeyNotAuthorized(format!(
365 "key_id DID '{did_part}' ≠ agent_id '{}'",
366 body.agent_id
367 )));
368 }
369 if !did_part.starts_with("did:web:") {
370 return Err(AcdpError::KeyResolution(format!(
371 "historical-key verification applies to did:web only; got '{did_part}'"
372 )));
373 }
374 let doc = resolver.resolve(did_part).await?;
375 // Key fully removed from the DID document → fail closed. The
376 // producer's obligation is to RETAIN rotated keys in
377 // verificationMethod (RFC-ACDP-0010); a deleted key is
378 // unverifiable by design.
379 let method = doc.find_by_fragment(fragment).ok_or_else(|| {
380 AcdpError::KeyResolution(format!(
381 "no verification method with fragment '#{fragment}' — the key was \
382 removed from the DID document, not just rotated out of assertionMethod"
383 ))
384 })?;
385 if let Some(declared) = method.declared_algorithm() {
386 if declared != body.signature.algorithm {
387 return Err(AcdpError::InvalidSignature(format!(
388 "signature.algorithm '{}' does not match verification method type \
389 (resolved key declares '{declared}')",
390 body.signature.algorithm
391 )));
392 }
393 }
394 match body.signature.algorithm.as_str() {
395 "ed25519" => verify_ed25519(
396 &method.ed25519_public_key_bytes()?,
397 &body.signature.value,
398 body.content_hash.as_str(),
399 ),
400 "ecdsa-p256" => verify_ecdsa_p256(
401 &method.ecdsa_p256_public_key_sec1()?,
402 &body.signature.value,
403 body.content_hash.as_str(),
404 ),
405 other => Err(AcdpError::UnsupportedAlgorithm(format!(
406 "verifier does not support signature algorithm '{other}'"
407 ))),
408 }
409}
410
411// ── Lifecycle events (ACDP 0.3, RFC-ACDP-0013 §5) ────────────────────────────
412
413/// The pure (offline) prefix of RFC-ACDP-0013 §5 lifecycle-event
414/// verification, shared by the resolver-backed and did:key paths:
415///
416/// 1. Recompute the preimage hash over the RAW wire JSON (minus
417/// `signature` — verifiers MUST NOT re-serialize a parsed struct).
418/// 2. Parse the closed event schema (§4 — an unknown member is
419/// malformed registry state).
420/// 3. **`ctx_id` binding**: the event's `ctx_id` MUST equal the
421/// retrieved context's — a signed event cannot be replayed against
422/// another context.
423/// 4. **Actor rule**: producer-initiated events carry
424/// `actor == body.agent_id`; registry-initiated events carry
425/// `actor == capabilities.registry_did`. Any other actor is refused
426/// — there is no third-party retraction (§12).
427/// 5. **Actor binding**: `signature.key_id`'s DID portion MUST equal
428/// `actor`, and the signature MUST be present (producer events MUST
429/// be signed; an unsigned registry event is attributable only as far
430/// as transport and cannot be *verified* — callers that tolerate it
431/// check `event.is_signed()` before calling here).
432fn lifecycle_event_prechecks(
433 raw_event: &serde_json::Value,
434 expected_ctx_id: &CtxId,
435 producer_did: &AgentDid,
436 registry_did: Option<&str>,
437) -> Result<(LifecycleEvent, ContentHash), AcdpError> {
438 let hash = LifecycleEvent::preimage_hash_of_value(raw_event)?;
439 let event = LifecycleEvent::from_value(raw_event)?;
440 if &event.ctx_id != expected_ctx_id {
441 return Err(AcdpError::SchemaViolation(format!(
442 "lifecycle event ctx_id '{}' ≠ the context's ctx_id '{expected_ctx_id}' \
443 (RFC-ACDP-0013 §4: an event binds to exactly one context)",
444 event.ctx_id
445 )));
446 }
447 let is_producer = event.actor.as_str() == producer_did.as_str();
448 let is_registry = registry_did.is_some_and(|did| event.actor.as_str() == did);
449 if !is_producer && !is_registry {
450 return Err(AcdpError::NotAuthorized(format!(
451 "lifecycle event actor '{}' is neither the producer '{producer_did}' nor the \
452 registry DID — only the producer and the serving registry can record \
453 lifecycle events (RFC-ACDP-0013 §4, §12)",
454 event.actor
455 )));
456 }
457 // Presence + §5 actor binding (key_id DID portion == actor).
458 event.actor_bound_signature()?;
459 Ok((event, hash))
460}
461
462/// Verify a lifecycle event per RFC-ACDP-0013 §5 — the helper both
463/// registries (at `/retract`/`/republish` submission time, §6 step 3)
464/// and consumers (before treating an event as attributable evidence)
465/// use.
466///
467/// **Producer-actor events** (`actor == producer_did`, i.e.
468/// `body.agent_id`) verify against the producer's DID through the full
469/// RFC-ACDP-0001 §5.11 pipeline — the same resolution, `assertionMethod`
470/// authorization, algorithm-downgrade rejection, and SSRF protections
471/// as a publish. **Registry-actor events** (`actor == registry_did`,
472/// i.e. `capabilities.registry_did`) verify against the registry's DID
473/// document through the same envelope path (RFC-ACDP-0010 §8 step 1
474/// resolution). In both cases the signature is over the ASCII bytes of
475/// the event hash recomputed from `raw_event` exactly as received.
476///
477/// Pass `registry_did: None` when validating a producer-initiated
478/// endpoint submission (only the producer may use the §6 endpoints).
479/// Returns the parsed event on success.
480#[cfg(feature = "client")]
481pub async fn verify_lifecycle_event(
482 raw_event: &serde_json::Value,
483 expected_ctx_id: &CtxId,
484 producer_did: &AgentDid,
485 registry_did: Option<&str>,
486 resolver: &WebResolver,
487) -> Result<LifecycleEvent, AcdpError> {
488 let (event, hash) =
489 lifecycle_event_prechecks(raw_event, expected_ctx_id, producer_did, registry_did)?;
490 // The envelope path re-checks key_id-DID == actor, resolves the
491 // actor's DID (did:web via the resolver; did:key purely), enforces
492 // assertionMethod + algorithm binding, and verifies over the ASCII
493 // bytes of the event hash — identical framing to a body signature.
494 let signature = event.actor_bound_signature()?.clone();
495 verify_signature_envelope(&event.actor, &signature, &hash, resolver).await?;
496 Ok(event)
497}
498
499/// Offline counterpart of [`verify_lifecycle_event`] for `did:key`
500/// actors — no resolver, no network, works with
501/// `--no-default-features`. A `did:web` actor is refused with
502/// [`AcdpError::KeyResolution`]; use the resolver-backed helper.
503pub fn verify_lifecycle_event_offline(
504 raw_event: &serde_json::Value,
505 expected_ctx_id: &CtxId,
506 producer_did: &AgentDid,
507 registry_did: Option<&str>,
508) -> Result<LifecycleEvent, AcdpError> {
509 let (event, hash) =
510 lifecycle_event_prechecks(raw_event, expected_ctx_id, producer_did, registry_did)?;
511 if !event.actor.as_str().starts_with("did:key:") {
512 return Err(AcdpError::KeyResolution(format!(
513 "offline lifecycle-event verification supports did:key actors only; '{}' \
514 requires the resolver-backed verify_lifecycle_event (client feature)",
515 event.actor
516 )));
517 }
518 let signature = event.actor_bound_signature()?;
519 verify_did_key_envelope(signature, &hash)?;
520 Ok(event)
521}