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::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. [`Self::verify_body`] itself runs
67    /// `acdp_validation::validate_body` first and then delegates the
68    /// hash + signature phases to this method, so the two concerns stay
69    /// independently testable.
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// ── Context-identity binding (RFC-ACDP-0006 §4.1 step 7) ─────────────────────
412
413/// Verify that a served body's `ctx_id` is the one the caller actually
414/// requested — RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind the resolved
415/// identity").
416///
417/// `ctx_id` is registry-assigned and sits in the RFC-ACDP-0001 §5.7
418/// exclusion set, so it is stripped from `ProducerContent` before hashing:
419/// neither `content_hash` recomputation nor the producer signature covers
420/// it. Without this explicit comparison a registry can serve any other
421/// validly-signed body from the same producer under the requested
422/// context's URL, and every other check still passes (RFC-ACDP-0008 §9.1).
423/// This is the only binding available on the receipt-less path, since
424/// `ctx_id` is assigned by the registry rather than the producer.
425///
426/// Fails closed: both `served_ctx_id` and `expected_ctx_id` are parsed with
427/// [`CtxId::parse`] before comparison, so a malformed id on *either* side
428/// is rejected with [`AcdpError::SchemaViolation`] rather than silently
429/// passing or silently failing to match. `CtxId::parse` mandates a unique
430/// canonical text form (`acdp://` prefix, lowercase DNS authority,
431/// lowercase v4 UUID), so byte equality and parsed equality coincide for
432/// every value that clears parsing.
433///
434/// This is deliberately **stricter** than conformance fixture
435/// `fed-011-ctx-id-binding.json`'s `uri_encoding_and_path_style_equivalence`
436/// case, which asks that percent-encoded and path-style forms compare
437/// *equal* to the canonical form. Requiring canonical form on both sides
438/// instead produces false refusals for such alternate encodings, never
439/// false acceptances — a safe deviation from that one fixture case.
440///
441/// On mismatch, returns the existing [`AcdpError::ContextIdMismatch`]
442/// (shipped in 0.9.0 for the Rust client's equivalent check) rather than a
443/// new variant, so the two enforcement points share one typed error.
444pub fn verify_ctx_id_binding(served_ctx_id: &str, expected_ctx_id: &str) -> Result<(), AcdpError> {
445    let served = CtxId::parse(served_ctx_id)?;
446    let expected = CtxId::parse(expected_ctx_id)?;
447    if served != expected {
448        return Err(AcdpError::ContextIdMismatch {
449            requested: expected.as_str().to_string(),
450            served: served.as_str().to_string(),
451        });
452    }
453    Ok(())
454}
455
456// ── Lifecycle events (ACDP 0.3, RFC-ACDP-0013 §5) ────────────────────────────
457
458/// The pure (offline) prefix of RFC-ACDP-0013 §5 lifecycle-event
459/// verification, shared by the resolver-backed and did:key paths:
460///
461/// 1. Recompute the preimage hash over the RAW wire JSON (minus
462///    `signature` — verifiers MUST NOT re-serialize a parsed struct).
463/// 2. Parse the closed event schema (§4 — an unknown member is
464///    malformed registry state).
465/// 3. **`ctx_id` binding**: the event's `ctx_id` MUST equal the
466///    retrieved context's — a signed event cannot be replayed against
467///    another context.
468/// 4. **Actor rule**: producer-initiated events carry
469///    `actor == body.agent_id`; registry-initiated events carry
470///    `actor == capabilities.registry_did`. Any other actor is refused
471///    — there is no third-party retraction (§12).
472/// 5. **Actor binding**: `signature.key_id`'s DID portion MUST equal
473///    `actor`, and the signature MUST be present (producer events MUST
474///    be signed; an unsigned registry event is attributable only as far
475///    as transport and cannot be *verified* — callers that tolerate it
476///    check `event.is_signed()` before calling here).
477fn lifecycle_event_prechecks(
478    raw_event: &serde_json::Value,
479    expected_ctx_id: &CtxId,
480    producer_did: &AgentDid,
481    registry_did: Option<&str>,
482) -> Result<(LifecycleEvent, ContentHash), AcdpError> {
483    let hash = LifecycleEvent::preimage_hash_of_value(raw_event)?;
484    let event = LifecycleEvent::from_value(raw_event)?;
485    if &event.ctx_id != expected_ctx_id {
486        return Err(AcdpError::SchemaViolation(format!(
487            "lifecycle event ctx_id '{}' ≠ the context's ctx_id '{expected_ctx_id}' \
488             (RFC-ACDP-0013 §4: an event binds to exactly one context)",
489            event.ctx_id
490        )));
491    }
492    let is_producer = event.actor.as_str() == producer_did.as_str();
493    let is_registry = registry_did.is_some_and(|did| event.actor.as_str() == did);
494    if !is_producer && !is_registry {
495        return Err(AcdpError::NotAuthorized(format!(
496            "lifecycle event actor '{}' is neither the producer '{producer_did}' nor the \
497             registry DID — only the producer and the serving registry can record \
498             lifecycle events (RFC-ACDP-0013 §4, §12)",
499            event.actor
500        )));
501    }
502    // Presence + §5 actor binding (key_id DID portion == actor).
503    event.actor_bound_signature()?;
504    Ok((event, hash))
505}
506
507/// Verify a lifecycle event per RFC-ACDP-0013 §5 — the helper both
508/// registries (at `/retract`/`/republish` submission time, §6 step 3)
509/// and consumers (before treating an event as attributable evidence)
510/// use.
511///
512/// **Producer-actor events** (`actor == producer_did`, i.e.
513/// `body.agent_id`) verify against the producer's DID through the full
514/// RFC-ACDP-0001 §5.11 pipeline — the same resolution, `assertionMethod`
515/// authorization, algorithm-downgrade rejection, and SSRF protections
516/// as a publish. **Registry-actor events** (`actor == registry_did`,
517/// i.e. `capabilities.registry_did`) verify against the registry's DID
518/// document through the same envelope path (RFC-ACDP-0010 §8 step 1
519/// resolution). In both cases the signature is over the ASCII bytes of
520/// the event hash recomputed from `raw_event` exactly as received.
521///
522/// Pass `registry_did: None` when validating a producer-initiated
523/// endpoint submission (only the producer may use the §6 endpoints).
524/// Returns the parsed event on success.
525#[cfg(feature = "client")]
526pub async fn verify_lifecycle_event(
527    raw_event: &serde_json::Value,
528    expected_ctx_id: &CtxId,
529    producer_did: &AgentDid,
530    registry_did: Option<&str>,
531    resolver: &WebResolver,
532) -> Result<LifecycleEvent, AcdpError> {
533    let (event, hash) =
534        lifecycle_event_prechecks(raw_event, expected_ctx_id, producer_did, registry_did)?;
535    // The envelope path re-checks key_id-DID == actor, resolves the
536    // actor's DID (did:web via the resolver; did:key purely), enforces
537    // assertionMethod + algorithm binding, and verifies over the ASCII
538    // bytes of the event hash — identical framing to a body signature.
539    let signature = event.actor_bound_signature()?.clone();
540    verify_signature_envelope(&event.actor, &signature, &hash, resolver).await?;
541    Ok(event)
542}
543
544/// Offline counterpart of [`verify_lifecycle_event`] for `did:key`
545/// actors — no resolver, no network, works with
546/// `--no-default-features`. A `did:web` actor is refused with
547/// [`AcdpError::KeyResolution`]; use the resolver-backed helper.
548pub fn verify_lifecycle_event_offline(
549    raw_event: &serde_json::Value,
550    expected_ctx_id: &CtxId,
551    producer_did: &AgentDid,
552    registry_did: Option<&str>,
553) -> Result<LifecycleEvent, AcdpError> {
554    let (event, hash) =
555        lifecycle_event_prechecks(raw_event, expected_ctx_id, producer_did, registry_did)?;
556    if !event.actor.as_str().starts_with("did:key:") {
557        return Err(AcdpError::KeyResolution(format!(
558            "offline lifecycle-event verification supports did:key actors only; '{}' \
559             requires the resolver-backed verify_lifecycle_event (client feature)",
560            event.actor
561        )));
562    }
563    let signature = event.actor_bound_signature()?;
564    verify_did_key_envelope(signature, &hash)?;
565    Ok(event)
566}
567
568// ── Offline verification tests ───────────────────────────────────────────────
569//
570// These exercise the resolver-free surface (did:key envelope, offline
571// body / publish-request / lifecycle-event verification) and compile
572// without the `client` feature. The resolver-backed 7-step algorithm is
573// covered by `tests/verify_algorithm.rs` at the workspace root, which
574// reuses the TLS DID-document harness.
575#[cfg(test)]
576mod offline_tests {
577    use super::*;
578    use acdp_crypto::{P256SigningKey, SigningKey};
579    use acdp_producer::Producer;
580    use acdp_types::body::{Body, DataPeriod};
581    use acdp_types::lifecycle::{LifecycleEvent, LifecycleEventType};
582    use acdp_types::{AgentDid, ContextType, CtxId, LineageId, Visibility};
583
584    const CTX: &str = "acdp://registry.example.com/00000000-0000-4000-8000-000000000000";
585    const LIN: &str = "lin:sha256:0000000000000000000000000000000000000000000000000000000000000000";
586    const EVENT_ID: &str = "00000000-0000-4000-8000-0000000000aa";
587
588    fn ts() -> chrono::DateTime<chrono::Utc> {
589        chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap()
590    }
591
592    fn body_of(producer: &Producer) -> Body {
593        let req = producer
594            .publish_request()
595            .title("offline verify test")
596            .context_type(ContextType::DataSnapshot)
597            .visibility(Visibility::Public)
598            .build()
599            .expect("valid request");
600        Body::from_publish_request(
601            &req,
602            CtxId(CTX.into()),
603            LineageId(LIN.into()),
604            "registry.example.com",
605            ts(),
606        )
607    }
608
609    fn ed25519_body() -> Body {
610        body_of(&Producer::new_did_key(SigningKey::from_bytes(&[1u8; 32])))
611    }
612
613    fn p256_body() -> Body {
614        let key = P256SigningKey::from_bytes(&[2u8; 32]).expect("valid p256 scalar");
615        body_of(&Producer::new_did_key_p256(key).expect("did:key p256 producer"))
616    }
617
618    fn didweb_body() -> Body {
619        body_of(&Producer::new(
620            SigningKey::from_bytes(&[3u8; 32]),
621            AgentDid::new("did:web:agents.example.com:p"),
622            "did:web:agents.example.com:p#key-1",
623        ))
624    }
625
626    // ── verify_did_key_envelope ──────────────────────────────────────────
627
628    #[test]
629    fn did_key_envelope_ed25519_and_p256_happy() {
630        let b = ed25519_body();
631        assert!(verify_did_key_envelope(&b.signature, &b.content_hash).is_ok());
632        let p = p256_body();
633        assert!(verify_did_key_envelope(&p.signature, &p.content_hash).is_ok());
634    }
635
636    #[test]
637    fn did_key_envelope_algorithm_downgrade_rejected() {
638        // An Ed25519 key whose signature claims ecdsa-p256 must not verify
639        // (RFC-ACDP-0008 §3.9 downgrade rejection).
640        let b = ed25519_body();
641        let mut sig = b.signature.clone();
642        sig.algorithm = "ecdsa-p256".into();
643        assert!(matches!(
644            verify_did_key_envelope(&sig, &b.content_hash),
645            Err(AcdpError::InvalidSignature(_))
646        ));
647    }
648
649    #[test]
650    fn did_key_envelope_malformed_key_id_errors() {
651        let b = ed25519_body();
652        let mut sig = b.signature.clone();
653        // Strip the "#<multibase>" fragment: no key to resolve.
654        sig.key_id = sig.key_id.split('#').next().unwrap().to_string();
655        assert!(verify_did_key_envelope(&sig, &b.content_hash).is_err());
656    }
657
658    #[test]
659    fn did_key_envelope_tampered_signature_rejected() {
660        // Substitute a *different* key's valid Ed25519 signature: correct
661        // length and base64, but not a signature by this key over this
662        // hash → InvalidSignature (not a decode/length error).
663        let b = ed25519_body();
664        let other = body_of(&Producer::new_did_key(SigningKey::from_bytes(&[9u8; 32])));
665        let mut sig = b.signature.clone();
666        sig.value = other.signature.value.clone();
667        assert!(matches!(
668            verify_did_key_envelope(&sig, &b.content_hash),
669            Err(AcdpError::InvalidSignature(_))
670        ));
671    }
672
673    // ── verify_body_offline ──────────────────────────────────────────────
674
675    #[test]
676    fn body_offline_happy_ed25519_and_p256() {
677        assert!(verify_body_offline(&ed25519_body()).is_ok());
678        assert!(verify_body_offline(&p256_body()).is_ok());
679    }
680
681    #[test]
682    fn body_offline_structural_failure_precedes_hash() {
683        // An inverted data_period is a structural error. It must be caught
684        // by validate_body BEFORE the content_hash is recomputed — proven
685        // by leaving content_hash intact yet still failing on structure.
686        let mut b = ed25519_body();
687        b.data_period = Some(DataPeriod {
688            start: ts(),
689            end: ts() - chrono::Duration::days(1),
690        });
691        assert!(matches!(
692            verify_body_offline(&b),
693            Err(AcdpError::SchemaViolation(_))
694        ));
695    }
696
697    #[test]
698    fn body_offline_rejects_did_web_producer() {
699        assert!(matches!(
700            verify_body_offline(&didweb_body()),
701            Err(AcdpError::KeyResolution(_))
702        ));
703    }
704
705    #[test]
706    fn body_offline_tampered_field_fails_hash() {
707        // Mutating a ProducerContent field (not in the §5.7 exclusion set)
708        // changes the recomputed hash but not the stored content_hash.
709        let mut b = ed25519_body();
710        b.title = "tampered title".into();
711        assert!(verify_body_offline(&b).is_err());
712    }
713
714    #[test]
715    fn body_offline_key_id_did_must_equal_agent_id() {
716        // signature/key_id are in the exclusion set, so swapping key_id to
717        // a different did:key leaves content_hash valid but fails the
718        // agent-binding check.
719        let mut b = ed25519_body();
720        let other = p256_body();
721        b.signature.key_id = other.signature.key_id.clone();
722        assert!(matches!(
723            verify_body_offline(&b),
724            Err(AcdpError::KeyNotAuthorized(_))
725        ));
726    }
727
728    #[test]
729    fn body_offline_fragmentless_key_id_reaches_envelope_error() {
730        // A key_id with no '#fragment' passes the DID-equality check via
731        // the `unwrap_or` fallback, then fails in resolve_did_key_url
732        // (which needs the fragment to recover the key).
733        let mut b = ed25519_body();
734        b.signature.key_id = b.agent_id.as_str().to_string();
735        assert!(verify_body_offline(&b).is_err());
736    }
737
738    // ── verify_publish_request_signature_offline ─────────────────────────
739
740    #[test]
741    fn publish_request_offline_happy() {
742        let producer = Producer::new_did_key(SigningKey::from_bytes(&[4u8; 32]));
743        let req = producer
744            .publish_request()
745            .title("pr offline")
746            .context_type(ContextType::DataSnapshot)
747            .visibility(Visibility::Public)
748            .build()
749            .expect("valid request");
750        assert!(verify_publish_request_signature_offline(&req).is_ok());
751    }
752
753    #[test]
754    fn publish_request_offline_did_mismatch_and_did_web() {
755        let producer = Producer::new_did_key(SigningKey::from_bytes(&[4u8; 32]));
756        let mut req = producer
757            .publish_request()
758            .title("pr offline")
759            .context_type(ContextType::DataSnapshot)
760            .visibility(Visibility::Public)
761            .build()
762            .expect("valid request");
763        let orig = req.signature.key_id.clone();
764        // DID portion no longer matches agent_id → KeyNotAuthorized.
765        req.signature.key_id = p256_body().signature.key_id.clone();
766        assert!(matches!(
767            verify_publish_request_signature_offline(&req),
768            Err(AcdpError::KeyNotAuthorized(_))
769        ));
770        // A did:web key_id (matching a did:web agent) → KeyResolution.
771        req.agent_id = AgentDid::new("did:web:agents.example.com:p");
772        req.signature.key_id = "did:web:agents.example.com:p#key-1".into();
773        let _ = orig;
774        assert!(matches!(
775            verify_publish_request_signature_offline(&req),
776            Err(AcdpError::KeyResolution(_))
777        ));
778    }
779
780    // ── verify_lifecycle_event_offline ───────────────────────────────────
781
782    fn identity(seed: &[u8; 32]) -> (AgentDid, String) {
783        let key = SigningKey::from_bytes(seed);
784        let did = acdp_did::key::did_key_from_ed25519(&key.verifying_key_bytes());
785        let key_id = acdp_did::key::did_key_url(&did).expect("did:key url");
786        (AgentDid::new(did), key_id)
787    }
788
789    fn signed_event(seed: &[u8; 32], actor: AgentDid, key_id: String) -> serde_json::Value {
790        let event = LifecycleEvent::new(
791            EVENT_ID,
792            CtxId(CTX.into()),
793            LifecycleEventType::Retracted,
794            ts(),
795            actor,
796            Some("superseded".into()),
797        )
798        .expect("valid event")
799        .sign_with(SigningKey::from_bytes(seed), key_id)
800        .expect("signed event");
801        serde_json::to_value(&event).expect("event serializes")
802    }
803
804    #[test]
805    fn lifecycle_offline_happy_producer_actor() {
806        let (actor, key_id) = identity(&[5u8; 32]);
807        let raw = signed_event(&[5u8; 32], actor.clone(), key_id);
808        let out = verify_lifecycle_event_offline(&raw, &CtxId(CTX.into()), &actor, None);
809        assert!(out.is_ok());
810    }
811
812    #[test]
813    fn lifecycle_offline_registry_actor_accepted() {
814        let (actor, key_id) = identity(&[6u8; 32]);
815        let raw = signed_event(&[6u8; 32], actor.clone(), key_id);
816        // Actor is the registry DID, not the producer.
817        let producer = AgentDid::new("did:key:z6MkOtherProducerDidThatIsNotTheActor");
818        let out = verify_lifecycle_event_offline(
819            &raw,
820            &CtxId(CTX.into()),
821            &producer,
822            Some(actor.as_str()),
823        );
824        assert!(out.is_ok());
825    }
826
827    #[test]
828    fn lifecycle_offline_unknown_member_rejected() {
829        let (actor, key_id) = identity(&[5u8; 32]);
830        let mut raw = signed_event(&[5u8; 32], actor.clone(), key_id);
831        raw.as_object_mut()
832            .unwrap()
833            .insert("unexpected".into(), serde_json::json!(1));
834        assert!(matches!(
835            verify_lifecycle_event_offline(&raw, &CtxId(CTX.into()), &actor, None),
836            Err(AcdpError::SchemaViolation(_))
837        ));
838    }
839
840    #[test]
841    fn lifecycle_offline_ctx_id_mismatch_rejected() {
842        let (actor, key_id) = identity(&[5u8; 32]);
843        let raw = signed_event(&[5u8; 32], actor.clone(), key_id);
844        let other =
845            CtxId("acdp://registry.example.com/11111111-1111-4111-8111-111111111111".into());
846        assert!(matches!(
847            verify_lifecycle_event_offline(&raw, &other, &actor, None),
848            Err(AcdpError::SchemaViolation(_))
849        ));
850    }
851
852    #[test]
853    fn lifecycle_offline_unauthorized_actor_rejected() {
854        let (actor, key_id) = identity(&[5u8; 32]);
855        let raw = signed_event(&[5u8; 32], actor, key_id);
856        let stranger = AgentDid::new("did:key:z6MkStrangerNeitherProducerNorRegistry");
857        // Neither producer nor (absent) registry.
858        assert!(matches!(
859            verify_lifecycle_event_offline(&raw, &CtxId(CTX.into()), &stranger, None),
860            Err(AcdpError::NotAuthorized(_))
861        ));
862        // Still unauthorized when a *different* registry DID is supplied.
863        assert!(matches!(
864            verify_lifecycle_event_offline(
865                &raw,
866                &CtxId(CTX.into()),
867                &stranger,
868                Some("did:key:z6MkSomeOtherRegistry")
869            ),
870            Err(AcdpError::NotAuthorized(_))
871        ));
872    }
873
874    #[test]
875    fn lifecycle_offline_unsigned_event_rejected() {
876        let (actor, _key_id) = identity(&[5u8; 32]);
877        let event = LifecycleEvent::new(
878            EVENT_ID,
879            CtxId(CTX.into()),
880            LifecycleEventType::Retracted,
881            ts(),
882            actor.clone(),
883            Some("superseded".into()),
884        )
885        .expect("valid event");
886        let raw = serde_json::to_value(&event).expect("serializes");
887        assert!(verify_lifecycle_event_offline(&raw, &CtxId(CTX.into()), &actor, None).is_err());
888    }
889
890    #[test]
891    fn lifecycle_offline_did_web_actor_rejected() {
892        let actor = AgentDid::new("did:web:agents.example.com:p");
893        let key_id = "did:web:agents.example.com:p#key-1".to_string();
894        let raw = signed_event(&[7u8; 32], actor.clone(), key_id);
895        assert!(matches!(
896            verify_lifecycle_event_offline(&raw, &CtxId(CTX.into()), &actor, None),
897            Err(AcdpError::KeyResolution(_))
898        ));
899    }
900
901    #[test]
902    fn lifecycle_offline_mutated_raw_json_fails_signature() {
903        // The preimage is hashed from the RAW wire JSON, so mutating a
904        // non-signature field after signing invalidates the signature.
905        let (actor, key_id) = identity(&[5u8; 32]);
906        let mut raw = signed_event(&[5u8; 32], actor.clone(), key_id);
907        raw.as_object_mut()
908            .unwrap()
909            .insert("reason".into(), serde_json::json!("changed after signing"));
910        assert!(matches!(
911            verify_lifecycle_event_offline(&raw, &CtxId(CTX.into()), &actor, None),
912            Err(AcdpError::InvalidSignature(_))
913        ));
914    }
915
916    // ── verify_ctx_id_binding ─────────────────────────────────────────────
917
918    const OTHER_CTX: &str = "acdp://registry.example.com/11111111-1111-4111-8111-111111111111";
919
920    #[test]
921    fn ctx_id_binding_matching_ids_ok() {
922        // Positive control for every failure case below.
923        assert!(verify_ctx_id_binding(CTX, CTX).is_ok());
924    }
925
926    #[test]
927    fn ctx_id_binding_mismatch_rejected_with_both_fields() {
928        match verify_ctx_id_binding(OTHER_CTX, CTX) {
929            Err(AcdpError::ContextIdMismatch { requested, served }) => {
930                assert_eq!(requested, CTX);
931                assert_eq!(served, OTHER_CTX);
932            }
933            other => panic!("expected ContextIdMismatch, got {other:?}"),
934        }
935    }
936
937    #[test]
938    fn ctx_id_binding_non_canonical_expected_is_schema_violation_not_silent_pass() {
939        // Positive control: CTX vs CTX matches (see
940        // `ctx_id_binding_matching_ids_ok`); an uppercase-authority variant
941        // of the *expected* side must fail parsing, not silently pass and
942        // not be reported as a `ContextIdMismatch`.
943        let uppercase_authority =
944            "acdp://Registry.example.com/00000000-0000-4000-8000-000000000000";
945        assert!(matches!(
946            verify_ctx_id_binding(CTX, uppercase_authority),
947            Err(AcdpError::SchemaViolation(_))
948        ));
949
950        let uppercase_uuid = "acdp://registry.example.com/00000000-0000-4000-8000-000000000AAA";
951        assert!(matches!(
952            verify_ctx_id_binding(CTX, uppercase_uuid),
953            Err(AcdpError::SchemaViolation(_))
954        ));
955
956        let missing_prefix = "registry.example.com/00000000-0000-4000-8000-000000000000";
957        assert!(matches!(
958            verify_ctx_id_binding(CTX, missing_prefix),
959            Err(AcdpError::SchemaViolation(_))
960        ));
961
962        let malformed_uuid = "acdp://registry.example.com/not-a-uuid";
963        assert!(matches!(
964            verify_ctx_id_binding(CTX, malformed_uuid),
965            Err(AcdpError::SchemaViolation(_))
966        ));
967    }
968
969    #[test]
970    fn ctx_id_binding_non_canonical_served_is_schema_violation() {
971        let uppercase_authority =
972            "acdp://Registry.example.com/00000000-0000-4000-8000-000000000000";
973        assert!(matches!(
974            verify_ctx_id_binding(uppercase_authority, CTX),
975            Err(AcdpError::SchemaViolation(_))
976        ));
977    }
978
979    #[test]
980    fn ctx_id_binding_both_non_canonical_errors_without_panic() {
981        let bad_served = "acdp://Registry.example.com/00000000-0000-4000-8000-000000000000";
982        let bad_expected = "not-acdp-at-all";
983        assert!(matches!(
984            verify_ctx_id_binding(bad_served, bad_expected),
985            Err(AcdpError::SchemaViolation(_))
986        ));
987    }
988
989    #[test]
990    fn ctx_id_binding_empty_string_either_side_errors_without_panic() {
991        assert!(matches!(
992            verify_ctx_id_binding("", CTX),
993            Err(AcdpError::SchemaViolation(_))
994        ));
995        assert!(matches!(
996            verify_ctx_id_binding(CTX, ""),
997            Err(AcdpError::SchemaViolation(_))
998        ));
999        assert!(matches!(
1000            verify_ctx_id_binding("", ""),
1001            Err(AcdpError::SchemaViolation(_))
1002        ));
1003    }
1004}