Skip to main content

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::primitives::ContentHash;
14use acdp_types::publish::PublishRequest;
15
16#[cfg(feature = "client")]
17use {acdp_did::web::WebResolver, acdp_types::primitives::AgentDid};
18
19/// Stateless verifier.  Requires a DID resolver to fetch producer keys.
20#[cfg(feature = "client")]
21pub struct Verifier<'a> {
22    resolver: &'a WebResolver,
23}
24
25#[cfg(feature = "client")]
26impl<'a> Verifier<'a> {
27    pub fn new(resolver: &'a WebResolver) -> Self {
28        Self { resolver }
29    }
30
31    /// Full end-to-end verification per RFC-ACDP-0001 §5.11.
32    ///
33    /// Steps:
34    ///  1. (Implicit) Check `key_id` has a `#fragment`.
35    ///  2. Verify `key_id` DID portion equals `body.agent_id`.
36    ///  3. Resolve the DID document.
37    ///  4. Find the verification method by fragment.
38    ///  5. Check `assertionMethod` authorization.
39    ///  6. Extract the Ed25519 public key.
40    ///  7. Verify the signature over the content_hash ASCII bytes.
41    ///
42    ///  (Hash recomputation is step 0, performed first.)
43    #[cfg_attr(
44        feature = "tracing",
45        tracing::instrument(
46            name = "acdp.verify_body",
47            skip_all,
48            fields(ctx_id = %body.ctx_id.0, agent_id = body.agent_id.as_str()),
49            err(Display)
50        )
51    )]
52    pub async fn verify_body(&self, body: &Body) -> Result<(), AcdpError> {
53        // Step -1 (BUG-04): structural / runtime validation. A body may be
54        // cryptographically correct but protocol-invalid (non-did:web
55        // producer, inverted data_period, oversize metadata). Catch those
56        // before paying the SHA-256 + DID resolution cost.
57        acdp_validation::validate_body(body)?;
58
59        self.verify_body_signed(body).await
60    }
61
62    /// Verify only the hash recomputation + DID resolution + signature
63    /// envelope, assuming structural validation has already been done by
64    /// the caller. Use when you want to separate structural failures
65    /// from cryptographic ones — e.g.
66    /// `acdp::client::VerifiedContext::fetch_report` runs the
67    /// structural part itself and records per-`DataRef` outcomes
68    /// individually.
69    #[cfg_attr(
70        feature = "tracing",
71        tracing::instrument(
72            name = "acdp.verify_body_signed",
73            skip_all,
74            fields(ctx_id = %body.ctx_id.0),
75            err(Display)
76        )
77    )]
78    pub async fn verify_body_signed(&self, body: &Body) -> Result<(), AcdpError> {
79        self.verify_body_hash(body)?;
80        #[cfg(feature = "tracing")]
81        tracing::debug!(
82            stage = "content_hash",
83            "content hash recomputed and matched"
84        );
85        self.verify_body_signature(body).await?;
86        #[cfg(feature = "tracing")]
87        tracing::debug!(stage = "signature", "producer signature verified");
88        Ok(())
89    }
90
91    /// Step 0 only — recompute the `content_hash` over ProducerContent
92    /// and compare against `body.content_hash`. Lets diagnostic
93    /// callers record hash-pass/fail independently of the signature
94    /// stage (FEAT-05).
95    pub fn verify_body_hash(&self, body: &Body) -> Result<(), AcdpError> {
96        let body_val = serde_json::to_value(body)?;
97        verify_content_hash(&body_val, &body.content_hash)
98    }
99
100    /// Steps 1–7 only — resolve the producer's DID, find the signing
101    /// key, verify the signature over the (already-stored)
102    /// `body.content_hash`. Assumes [`Self::verify_body_hash`] (or an
103    /// equivalent check) has already run.
104    pub async fn verify_body_signature(&self, body: &Body) -> Result<(), AcdpError> {
105        verify_signature_envelope(
106            &body.agent_id,
107            &body.signature,
108            &body.content_hash,
109            self.resolver,
110        )
111        .await
112    }
113}
114
115/// Verify the producer signature on a [`PublishRequest`] per RFC-ACDP-0003
116/// §2.1 steps 7–8.
117///
118/// Assumes structural validation and `content_hash` recomputation have
119/// already been performed (e.g. by `acdp::registry::PublishValidator::validate_post_schema`).
120/// Executes only the DID resolution + signature verification steps shared
121/// with [`Verifier::verify_body`].
122///
123/// Used by `acdp::registry::RegistryServer::publish_verified` to fulfill
124/// the §2.1 publish algorithm before persistence; consumers wanting end-to-end
125/// verification on retrieval should prefer
126/// `acdp::client::VerifiedContext::fetch` which calls [`Verifier::verify_body`].
127#[cfg(feature = "client")]
128#[cfg_attr(
129    feature = "tracing",
130    tracing::instrument(
131        name = "acdp.verify_publish_request_signature",
132        skip_all,
133        fields(agent_id = req.agent_id.as_str(), key_id = %req.signature.key_id),
134        err(Display)
135    )
136)]
137pub async fn verify_publish_request_signature(
138    req: &PublishRequest,
139    resolver: &WebResolver,
140) -> Result<(), AcdpError> {
141    verify_signature_envelope(&req.agent_id, &req.signature, &req.content_hash, resolver).await
142}
143
144/// Steps 1–7 of RFC-ACDP-0001 §5.11 — the part of body verification that
145/// operates only on the signature envelope and is identical for stored
146/// `Body` values and incoming `PublishRequest` values. Caller is responsible
147/// for hash recomputation (step 0).
148#[cfg(feature = "client")]
149async fn verify_signature_envelope(
150    agent_id: &AgentDid,
151    signature: &Signature,
152    content_hash: &ContentHash,
153    resolver: &WebResolver,
154) -> Result<(), AcdpError> {
155    // Step 1: parse key_id — must contain a non-empty '#' fragment
156    // (RFC-ACDP-0001 §5.11 step 1). An empty fragment (`did:web:x#`) is
157    // rejected rather than used as a lookup key (#22).
158    let key_id = &signature.key_id;
159    let (did_part, fragment) = key_id.split_once('#').ok_or_else(|| {
160        AcdpError::KeyResolution(format!("signature.key_id '{key_id}' has no '#fragment'"))
161    })?;
162    if fragment.is_empty() {
163        return Err(AcdpError::KeyResolution(format!(
164            "signature.key_id '{key_id}' has an empty '#fragment'"
165        )));
166    }
167
168    // Step 2: DID portion MUST equal agent_id
169    if did_part != agent_id.as_str() {
170        return Err(AcdpError::KeyNotAuthorized(format!(
171            "key_id DID '{did_part}' ≠ agent_id '{agent_id}'"
172        )));
173    }
174
175    // Step 1.5: method dispatch. `did:key` resolves purely (the DID is
176    // the key — no document fetch, no assertionMethod check); `did:web`
177    // takes the HTTPS resolver path below. Any other method has no
178    // resolver in this version.
179    if did_part.starts_with("did:key:") {
180        return verify_did_key_envelope(signature, content_hash);
181    }
182    if !did_part.starts_with("did:web:") {
183        return Err(AcdpError::KeyNotAuthorized(format!(
184            "signatures require a did:web or did:key key_id; got '{did_part}'"
185        )));
186    }
187
188    // Step 3: resolve DID document
189    let doc = resolver.resolve(did_part).await?;
190
191    // Step 4: find verification method by fragment
192    let method = doc.find_by_fragment(fragment).ok_or_else(|| {
193        AcdpError::KeyResolution(format!(
194            "no verification method with fragment '#{fragment}'"
195        ))
196    })?;
197
198    // Step 5: assertionMethod authorization
199    if !doc.is_assertion_method(&method.id) {
200        return Err(AcdpError::KeyNotAuthorized(format!(
201            "'{}' is not in assertionMethod",
202            method.id
203        )));
204    }
205
206    // Step 5.5: algorithm-downgrade rejection (RFC-ACDP-0008 §3.9 +
207    // RFC-ACDP-0001 §5.11 step 6). When the verification method declares
208    // an algorithm via its `type` (or `publicKeyJwk` params), it MUST equal
209    // `signature.algorithm`. Otherwise an attacker could route an Ed25519
210    // key through a verifier that thinks it's checking some other algorithm.
211    if let Some(declared) = method.declared_algorithm() {
212        if declared != signature.algorithm {
213            return Err(AcdpError::InvalidSignature(format!(
214                "signature.algorithm '{}' does not match verification method type \
215                 (resolved key declares '{declared}')",
216                signature.algorithm
217            )));
218        }
219    }
220
221    // Steps 6 + 7: dispatch by algorithm.
222    match signature.algorithm.as_str() {
223        "ed25519" => {
224            let pub_bytes = method.ed25519_public_key_bytes()?;
225            verify_ed25519(&pub_bytes, &signature.value, content_hash.as_str())
226        }
227        "ecdsa-p256" => {
228            let pub_sec1 = method.ecdsa_p256_public_key_sec1()?;
229            verify_ecdsa_p256(&pub_sec1, &signature.value, content_hash.as_str())
230        }
231        other => Err(AcdpError::UnsupportedAlgorithm(format!(
232            "verifier does not support signature algorithm '{other}'"
233        ))),
234    }
235}
236
237/// Verify a signature envelope whose key is a `did:key` — a pure
238/// function available without the `client` feature (no resolver, no
239/// network, no async).
240///
241/// Performs:
242/// 1. `key_id` form check (`did:key:z<mb>#z<mb>`, fragment = key).
243/// 2. Pure key resolution from the DID itself.
244/// 3. Algorithm-downgrade rejection: `signature.algorithm` MUST equal
245///    the algorithm implied by the key's multicodec prefix
246///    (RFC-ACDP-0008 §3.9).
247/// 4. Signature verification over the ASCII bytes of `content_hash`.
248///
249/// The caller is responsible for the `key_id`-DID-equals-`agent_id`
250/// binding check and for `content_hash` recomputation (use
251/// [`verify_body_offline`] for the full pipeline).
252pub fn verify_did_key_envelope(
253    signature: &Signature,
254    content_hash: &ContentHash,
255) -> Result<(), AcdpError> {
256    let material = acdp_did::key::resolve_did_key_url(&signature.key_id)?;
257
258    if material.algorithm() != signature.algorithm {
259        return Err(AcdpError::InvalidSignature(format!(
260            "signature.algorithm '{}' does not match the did:key multicodec \
261             (key implies '{}')",
262            signature.algorithm,
263            material.algorithm()
264        )));
265    }
266
267    match material {
268        acdp_did::key::DidKeyMaterial::Ed25519(pub_bytes) => {
269            verify_ed25519(&pub_bytes, &signature.value, content_hash.as_str())
270        }
271        acdp_did::key::DidKeyMaterial::EcdsaP256(sec1_compressed) => {
272            verify_ecdsa_p256(&sec1_compressed, &signature.value, content_hash.as_str())
273        }
274    }
275}
276
277/// Full offline body verification for `did:key` producers — works with
278/// `--no-default-features` (no HTTP stack, no resolver, no async).
279///
280/// Pipeline (mirrors [`Verifier::verify_body`] minus DID-document
281/// resolution, which did:key does not have):
282/// 1. Structural validation ([`acdp_validation::validate_body`]).
283/// 2. `content_hash` recomputation over ProducerContent (§5.7).
284/// 3. `key_id` DID portion equals `agent_id`.
285/// 4. Pure did:key envelope verification (algorithm + signature).
286///
287/// Returns [`AcdpError::KeyResolution`] for a `did:web` (or other
288/// method) body — those require the resolver-backed
289/// [`Verifier::verify_body`] under the `client` feature.
290pub fn verify_body_offline(body: &Body) -> Result<(), AcdpError> {
291    acdp_validation::validate_body(body)?;
292
293    if !body.agent_id.as_str().starts_with("did:key:") {
294        return Err(AcdpError::KeyResolution(format!(
295            "verify_body_offline supports did:key producers only; '{}' requires \
296             the resolver-backed Verifier (client feature)",
297            body.agent_id
298        )));
299    }
300
301    let body_val = serde_json::to_value(body)?;
302    verify_content_hash(&body_val, &body.content_hash)?;
303
304    let did_part = body
305        .signature
306        .key_id
307        .split_once('#')
308        .map(|(d, _)| d)
309        .unwrap_or(body.signature.key_id.as_str());
310    if did_part != body.agent_id.as_str() {
311        return Err(AcdpError::KeyNotAuthorized(format!(
312            "key_id DID '{did_part}' ≠ agent_id '{}'",
313            body.agent_id
314        )));
315    }
316
317    verify_did_key_envelope(&body.signature, &body.content_hash)
318}
319
320/// Offline counterpart of [`verify_publish_request_signature`] for
321/// `did:key` producers — used by registries (and the bindings) to verify
322/// a publish request without the `client` feature. Assumes structural
323/// validation and `content_hash` recomputation have already run
324/// (e.g. via `PublishValidator::validate_post_schema`).
325pub fn verify_publish_request_signature_offline(req: &PublishRequest) -> Result<(), AcdpError> {
326    let key_id = req.signature.key_id.as_str();
327    let did_part = key_id.split_once('#').map(|(d, _)| d).unwrap_or(key_id);
328    if did_part != req.agent_id.as_str() {
329        return Err(AcdpError::KeyNotAuthorized(format!(
330            "key_id DID '{did_part}' ≠ agent_id '{}'",
331            req.agent_id
332        )));
333    }
334    if !did_part.starts_with("did:key:") {
335        return Err(AcdpError::KeyResolution(format!(
336            "offline verification supports did:key only; got '{did_part}'"
337        )));
338    }
339    verify_did_key_envelope(&req.signature, &req.content_hash)
340}
341
342/// Historical-key body-signature verification (ACDP 0.2, WS-B).
343///
344/// Identical to the standard envelope verification EXCEPT that the
345/// `assertionMethod` membership check is skipped: a rotated-out key
346/// that the producer retained in `verificationMethod` (per the
347/// RFC-ACDP-0010 key-retention rule) still verifies. Callers MUST
348/// gate this on a **verified registry receipt** whose
349/// `key_fingerprint` matches this key — without that attestation,
350/// accepting a non-assertion key is exactly the bypass the
351/// `assertionMethod` check exists to prevent. did:key bodies never
352/// take this path (the key cannot rotate).
353#[cfg(feature = "client")]
354pub async fn verify_body_signature_historical(
355    body: &Body,
356    resolver: &WebResolver,
357) -> Result<(), AcdpError> {
358    let key_id = &body.signature.key_id;
359    let (did_part, fragment) = key_id.split_once('#').ok_or_else(|| {
360        AcdpError::KeyResolution(format!("signature.key_id '{key_id}' has no '#fragment'"))
361    })?;
362    if did_part != body.agent_id.as_str() {
363        return Err(AcdpError::KeyNotAuthorized(format!(
364            "key_id DID '{did_part}' ≠ agent_id '{}'",
365            body.agent_id
366        )));
367    }
368    if !did_part.starts_with("did:web:") {
369        return Err(AcdpError::KeyResolution(format!(
370            "historical-key verification applies to did:web only; got '{did_part}'"
371        )));
372    }
373    let doc = resolver.resolve(did_part).await?;
374    // Key fully removed from the DID document → fail closed. The
375    // producer's obligation is to RETAIN rotated keys in
376    // verificationMethod (RFC-ACDP-0010); a deleted key is
377    // unverifiable by design.
378    let method = doc.find_by_fragment(fragment).ok_or_else(|| {
379        AcdpError::KeyResolution(format!(
380            "no verification method with fragment '#{fragment}' — the key was \
381             removed from the DID document, not just rotated out of assertionMethod"
382        ))
383    })?;
384    if let Some(declared) = method.declared_algorithm() {
385        if declared != body.signature.algorithm {
386            return Err(AcdpError::InvalidSignature(format!(
387                "signature.algorithm '{}' does not match verification method type \
388                 (resolved key declares '{declared}')",
389                body.signature.algorithm
390            )));
391        }
392    }
393    match body.signature.algorithm.as_str() {
394        "ed25519" => verify_ed25519(
395            &method.ed25519_public_key_bytes()?,
396            &body.signature.value,
397            body.content_hash.as_str(),
398        ),
399        "ecdsa-p256" => verify_ecdsa_p256(
400            &method.ecdsa_p256_public_key_sec1()?,
401            &body.signature.value,
402            body.content_hash.as_str(),
403        ),
404        other => Err(AcdpError::UnsupportedAlgorithm(format!(
405            "verifier does not support signature algorithm '{other}'"
406        ))),
407    }
408}