acdp-client 0.17.1

Consumer client (RegistryClient, VerifiedContext, CrossRegistryResolver) for the Agent Context Distribution Protocol (ACDP)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! Client-side registry-receipt verification (ACDP 0.2, RFC-ACDP-0010).
//!
//! Runs the full cross-check set. The cross-checks — not the signature
//! alone — are the security value: a receipt that verifies
//! cryptographically but binds a different `ctx_id`, a different body
//! hash, a different producer key, or a foreign registry is an attack,
//! not a receipt.

use acdp_did::web::WebResolver;
use acdp_primitives::error::AcdpError;
use acdp_types::primitives::{ContentHash, CtxId, LineageId};
use acdp_types::receipt::{LineageHeadReceipt, ReceiptKeyStatus, RegistryReceipt};

/// Resolve the registry DID document holding receipt key `#fragment`,
/// re-fetching a stale cached copy once (RFC-ACDP-0001 §5.11 "Caching").
async fn resolve_receipt_key_doc(
    did: &str,
    fragment: &str,
    resolver: &WebResolver,
    since: std::time::Instant,
) -> Result<acdp_did::DidDocument, AcdpError> {
    resolver
        .resolve_with_fragment(did, fragment, since)
        .await
        .map_err(|e| match e {
            AcdpError::KeyResolution(_) => AcdpError::InvalidReceipt(format!(
                "registry DID document has no verification method '#{fragment}' — \
                 receipt keys (including retired ones) must remain in verificationMethod"
            )),
            other => other,
        })
}

/// Resolve the registry DID document for receipt key `#fragment` and
/// report its RFC-ACDP-0010 §8 step 1 status. A cached copy on which the
/// key is absent or merely *historical* is re-fetched once (the status
/// is an adverse input to RFC-ACDP-0014 §7 placement, so it is subject
/// to the same RFC-ACDP-0001 §5.11 refresh rule as a step-5 verdict).
/// Under the forced-refresh rate limit the stale copy is used: a key it
/// lacks is [`AcdpError::KeyResolutionUnreachable`] (transient), a key
/// it holds outside `assertionMethod` is reported `Historical`.
async fn resolve_receipt_key_with_status(
    did: &str,
    fragment: &str,
    resolver: &WebResolver,
    since: std::time::Instant,
) -> Result<(acdp_did::DidDocument, ReceiptKeyStatus), AcdpError> {
    let res = resolver
        .resolve_until(did, since, |doc| {
            doc.find_by_fragment(fragment)
                .is_some_and(|m| doc.is_assertion_method(&m.id))
        })
        .await
        .map_err(|e| match e {
            AcdpError::KeyResolution(why) => {
                AcdpError::InvalidReceipt(format!("registry DID document: {why}"))
            }
            other => other,
        })?;
    let doc = res.document;
    let status = match doc.find_by_fragment(fragment) {
        Some(m) if doc.is_assertion_method(&m.id) => ReceiptKeyStatus::Current,
        Some(_) => ReceiptKeyStatus::Historical,
        None if res.fresh => {
            return Err(AcdpError::InvalidReceipt(format!(
                "registry DID document has no verification method '#{fragment}' — \
                 receipt keys (including retired ones) must remain in verificationMethod"
            )))
        }
        None => {
            return Err(AcdpError::KeyResolutionUnreachable(format!(
                "receipt key '#{fragment}' is absent from a stale cached copy of {did} \
                 and the re-fetch is rate-limited (RFC-ACDP-0001 §5.11)"
            )))
        }
    };
    Ok((doc, status))
}

/// Verify a `registry_receipt` value end-to-end:
///
/// 1. Parse. The receipt schema is CLOSED (RFC-ACDP-0010 §4): an
///    unknown member fails parsing with `invalid_receipt`.
/// 2. `registry_did` equals `did:web:<serving_authority>` — the
///    authority the context was actually fetched from, not whatever
///    the receipt claims.
/// 3. Pure cross-checks: `ctx_id`, `content_hash` (against the
///    *recomputed* body hash), `key_fingerprint` (against the resolved
///    producer key), millisecond `created_at`, internal
///    `registry_did`/`origin_registry` consistency.
/// 4. Resolve the registry's DID document and verify the receipt
///    signature. The receipt key is looked up in `verificationMethod`
///    WITHOUT requiring `assertionMethod` membership: retired receipt
///    keys remain in `verificationMethod` indefinitely (RFC-ACDP-0010
///    key lifecycle), keeping old receipts verifiable after rotation.
///
/// All failures map to [`AcdpError::InvalidReceipt`] except transport-
/// level DID-resolution failures, which keep their transient/permanent
/// classification so retry logic still works.
pub async fn verify_receipt_value(
    value: &serde_json::Value,
    expected_ctx_id: &CtxId,
    body: &acdp_types::body::Body,
    recomputed_body_hash: &ContentHash,
    producer_key_fingerprint: &str,
    serving_authority: &str,
    resolver: &WebResolver,
) -> Result<RegistryReceipt, AcdpError> {
    verify_receipt_value_since(
        value,
        expected_ctx_id,
        body,
        recomputed_body_hash,
        producer_key_fingerprint,
        serving_authority,
        resolver,
        std::time::Instant::now(),
    )
    .await
}

/// [`verify_receipt_value`] with an explicit DID-document freshness
/// scope (RFC-ACDP-0001 §5.11 "Caching"): a registry DID document fetched
/// at or after `since` is fresh for this check; an older cached copy that
/// lacks the receipt key is re-fetched once before the receipt is
/// rejected. A key still absent after that re-fetch is
/// [`AcdpError::InvalidReceipt`]; a key absent from a stale copy whose
/// re-fetch was rate-limited is [`AcdpError::KeyResolutionUnreachable`]
/// (transient).
#[allow(clippy::too_many_arguments)]
pub async fn verify_receipt_value_since(
    value: &serde_json::Value,
    expected_ctx_id: &CtxId,
    body: &acdp_types::body::Body,
    recomputed_body_hash: &ContentHash,
    producer_key_fingerprint: &str,
    serving_authority: &str,
    resolver: &WebResolver,
    since: std::time::Instant,
) -> Result<RegistryReceipt, AcdpError> {
    verify_receipt_value_with_status_since(
        value,
        expected_ctx_id,
        body,
        recomputed_body_hash,
        producer_key_fingerprint,
        serving_authority,
        resolver,
        since,
    )
    .await
    .map(|(receipt, _)| receipt)
}

/// [`verify_receipt_value_since`] that also reports the receipt-key
/// status (RFC-ACDP-0010 §8 step 1, 0.5.0): [`ReceiptKeyStatus::Current`]
/// when the receipt key is in the registry DID document's
/// `assertionMethod`, [`ReceiptKeyStatus::Historical`] when it is in
/// `verificationMethod` only. Both verify; a historical status MUST be
/// reported as such and, under the strict profile, cannot place a
/// revoked-key context before its compromise boundary
/// (RFC-ACDP-0014 §7 step 1).
#[allow(clippy::too_many_arguments)]
pub async fn verify_receipt_value_with_status_since(
    value: &serde_json::Value,
    expected_ctx_id: &CtxId,
    body: &acdp_types::body::Body,
    recomputed_body_hash: &ContentHash,
    producer_key_fingerprint: &str,
    serving_authority: &str,
    resolver: &WebResolver,
    since: std::time::Instant,
) -> Result<(RegistryReceipt, ReceiptKeyStatus), AcdpError> {
    let receipt = RegistryReceipt::from_value(value)?;

    // §8 step 6: canonical millisecond byte form, checked on the RAW
    // wire string before any parsing normalization.
    RegistryReceipt::validate_created_at_form(value)?;

    // Serving-authority binding (the client-side half of cross-check 2;
    // the receipt-internal half lives in `RegistryReceipt::cross_check`).
    let expected_did = acdp_did::web::authority_to_did_web(serving_authority);
    if receipt.registry_did != expected_did {
        return Err(AcdpError::InvalidReceipt(format!(
            "receipt registry_did '{}' ≠ serving authority's DID '{expected_did}'",
            receipt.registry_did
        )));
    }

    receipt.cross_check(
        expected_ctx_id,
        recomputed_body_hash,
        producer_key_fingerprint,
    )?;
    // §8 step 3 body bindings: lineage_id / origin_registry /
    // created_at must equal the accompanying body's fields.
    receipt.cross_check_body(body)?;

    // Resolve the registry's receipt key and verify the signature.
    let key_id = &receipt.signature.key_id;
    let (did_part, fragment) = key_id.split_once('#').ok_or_else(|| {
        AcdpError::InvalidReceipt(format!(
            "receipt signature.key_id '{key_id}' has no fragment"
        ))
    })?;
    if did_part != receipt.registry_did {
        return Err(AcdpError::InvalidReceipt(format!(
            "receipt signature.key_id DID '{did_part}' ≠ registry_did '{}'",
            receipt.registry_did
        )));
    }
    let (doc, key_status) =
        resolve_receipt_key_with_status(did_part, fragment, resolver, since).await?;
    let method = doc
        .find_by_fragment(fragment)
        .expect("resolve_receipt_key_with_status guarantees the fragment");

    // §5 / RFC-ACDP-0001 §6 raw-JSON rule: hash the receipt exactly as
    // received — re-serializing the parsed struct could normalize byte
    // details and falsely reject an honest receipt.
    let raw_hash = RegistryReceipt::preimage_hash_of_value(value)?;
    match receipt.signature.algorithm.as_str() {
        "ed25519" => {
            let key = method
                .ed25519_public_key_bytes()
                .map_err(|e| AcdpError::InvalidReceipt(format!("receipt key extraction: {e}")))?;
            receipt.verify_signature_against_hash(&raw_hash, Some(&key), None)?;
        }
        "ecdsa-p256" => {
            let key = method
                .ecdsa_p256_public_key_sec1()
                .map_err(|e| AcdpError::InvalidReceipt(format!("receipt key extraction: {e}")))?;
            receipt.verify_signature_against_hash(&raw_hash, None, Some(&key))?;
        }
        other => {
            return Err(AcdpError::InvalidReceipt(format!(
                "receipt signature algorithm '{other}' is not supported"
            )));
        }
    }

    Ok((receipt, key_status))
}

/// Verify a `lineage_head_receipt` value end-to-end per the
/// RFC-ACDP-0011 §7 procedure (ACDP 0.3):
///
/// 1. **Closed parse** — the schema is CLOSED and `receipt_version`
///    MUST be exactly `"acdp-lhr/1"`; unknown or missing members fail
///    with `invalid_receipt`. The raw `as_of` byte form is checked
///    before any parsing normalization.
/// 2. **Registry binding** — `registry_did` equals
///    `did:web:<serving_authority>` (the authority the response was
///    actually fetched from) AND equals `capabilities.registry_did`;
///    `signature.key_id` is a DID URL under `registry_did`; the
///    `head_ctx_id` authority equals the registry's (fixture `lhr-003`).
/// 3. **Lineage binding** — `lineage_id` equals the lineage the
///    consumer requested (byte-for-byte).
/// 4. **Head binding** — the receipt's head fields byte-match the
///    accompanying response (`on_current_endpoint = true` for
///    `/current`, where the receipt MUST describe the very head being
///    served; fixture `lhr-002`). On full retrieval of a non-head
///    version the §7 step 5b consistency rule applies.
/// 5. **Signature** — JCS-recompute the preimage from the RAW wire
///    JSON, resolve `signature.key_id` from the registry's DID document
///    and verify. Like RFC-ACDP-0010 receipts, the key is looked up in
///    `verificationMethod` WITHOUT requiring `assertionMethod`
///    membership (retired receipt keys keep persisted head receipts
///    verifiable, RFC-ACDP-0011 §8).
/// 6. **`as_of` skew** — not in the future beyond `max_clock_skew`
///    (RFC recommends 120 s; fixture `lhr-004`).
///
/// Staleness (an old but honest `as_of`) is deliberately NOT checked
/// here — it is consumer freshness policy (§6), evaluated separately
/// via [`LineageHeadReceipt::age_at`] and reported distinctly from
/// these verification failures.
///
/// All failures map to [`AcdpError::InvalidReceipt`] except transport-
/// level DID-resolution failures, which keep their transient/permanent
/// classification so retry logic still works.
#[allow(clippy::too_many_arguments)]
pub async fn verify_lineage_head_receipt_value(
    value: &serde_json::Value,
    requested_lineage: &LineageId,
    served_ctx_id: &CtxId,
    served_version: u32,
    served_status: &acdp_types::primitives::Status,
    on_current_endpoint: bool,
    serving_authority: &str,
    capabilities_registry_did: &str,
    max_clock_skew: chrono::Duration,
    resolver: &WebResolver,
) -> Result<LineageHeadReceipt, AcdpError> {
    // §7 step 1: closed parse + semantic invariants + raw as_of form.
    let receipt = LineageHeadReceipt::from_value(value)?;

    // §7 steps 3–5 cross-checks (pure) — the security value; run before
    // the network round-trip for the signature, exactly like
    // `verify_receipt_value`.
    receipt.cross_check_registry_binding(serving_authority, capabilities_registry_did)?;
    receipt.cross_check_lineage(requested_lineage)?;
    receipt.cross_check_head(
        served_ctx_id,
        served_version,
        served_status,
        on_current_endpoint,
    )?;

    // §7 step 2: resolve the registry's receipt key and verify the
    // signature over the RAW wire preimage (re-serializing the parsed
    // struct could normalize byte details and falsely reject an honest
    // receipt).
    let key_id = &receipt.signature.key_id;
    let (did_part, fragment) = key_id.split_once('#').ok_or_else(|| {
        AcdpError::InvalidReceipt(format!(
            "lineage_head_receipt signature.key_id '{key_id}' has no fragment"
        ))
    })?;
    let doc =
        resolve_receipt_key_doc(did_part, fragment, resolver, std::time::Instant::now()).await?;
    let method = doc
        .find_by_fragment(fragment)
        .expect("resolve_receipt_key_doc guarantees the fragment");
    let raw_hash = LineageHeadReceipt::preimage_hash_of_value(value)?;
    match receipt.signature.algorithm.as_str() {
        "ed25519" => {
            let key = method
                .ed25519_public_key_bytes()
                .map_err(|e| AcdpError::InvalidReceipt(format!("receipt key extraction: {e}")))?;
            receipt.verify_signature_against_hash(&raw_hash, Some(&key), None)?;
        }
        "ecdsa-p256" => {
            let key = method
                .ecdsa_p256_public_key_sec1()
                .map_err(|e| AcdpError::InvalidReceipt(format!("receipt key extraction: {e}")))?;
            receipt.verify_signature_against_hash(&raw_hash, None, Some(&key))?;
        }
        other => {
            return Err(AcdpError::InvalidReceipt(format!(
                "receipt signature algorithm '{other}' is not supported"
            )));
        }
    }

    // §7 step 6: forged-freshness rejection against the consumer clock.
    receipt.check_as_of_skew(chrono::Utc::now(), max_clock_skew)?;

    Ok(receipt)
}

/// Verify an `acdp-event-receipt/1` value end-to-end (RFC-ACDP-0017
/// §10.3) for the lifecycle event `raw_event` at position `index` of
/// context `ctx_id`, whose origin registry is `origin_registry` and whose
/// body was created at `created_at`:
///
/// 1. Closed parse (an unknown member is `invalid_receipt`).
/// 2. Steps 2–6 and 9: [`EventReceipt::check_binding`] — registry DID,
///    `ctx_id`, `event_id`, `index`, the recomputed `event_hash`, and
///    `accepted_at` not before `created_at`.
/// 3. Step 7: resolve the registry's receipt key (in `verificationMethod`;
///    retired keys stay verifiable) and verify the signature over the
///    receipt exactly as received.
///
/// Step 8 (cross-receipt `accepted_at` ordering) needs the neighbouring
/// receipts and is the caller's. Failures are [`AcdpError::InvalidReceipt`]
/// except DID-resolution transport failures, which keep their
/// transient/permanent classification.
///
/// [`EventReceipt::check_binding`]: acdp_types::EventReceipt::check_binding
#[allow(clippy::too_many_arguments)]
pub async fn verify_event_receipt_value(
    value: &serde_json::Value,
    origin_registry: &str,
    ctx_id: &CtxId,
    raw_event: &serde_json::Value,
    index: u64,
    created_at: chrono::DateTime<chrono::Utc>,
    resolver: &WebResolver,
    since: std::time::Instant,
) -> Result<(acdp_types::EventReceipt, ReceiptKeyStatus), AcdpError> {
    use acdp_types::EventReceipt;
    let receipt = EventReceipt::from_value(value)?;
    receipt.check_binding(origin_registry, ctx_id, raw_event, index, created_at)?;
    let key_id = &receipt.signature.key_id;
    let (did_part, fragment) = key_id
        .split_once('#')
        .filter(|(did, _)| *did == receipt.registry_did)
        .ok_or_else(|| {
            AcdpError::InvalidReceipt(format!(
                "event receipt signature.key_id '{key_id}' is not a DID URL under registry_did \
                 '{}'",
                receipt.registry_did
            ))
        })?;
    let (doc, key_status) =
        resolve_receipt_key_with_status(did_part, fragment, resolver, since).await?;
    let method = doc
        .find_by_fragment(fragment)
        .expect("resolve_receipt_key_with_status guarantees the fragment");
    let raw_hash = EventReceipt::preimage_hash_of_value(value)?;
    match receipt.signature.algorithm.as_str() {
        "ed25519" => {
            let key = method
                .ed25519_public_key_bytes()
                .map_err(|e| AcdpError::InvalidReceipt(format!("receipt key extraction: {e}")))?;
            receipt.verify_signature_against_hash(&raw_hash, Some(&key), None)?;
        }
        "ecdsa-p256" => {
            let key = method
                .ecdsa_p256_public_key_sec1()
                .map_err(|e| AcdpError::InvalidReceipt(format!("receipt key extraction: {e}")))?;
            receipt.verify_signature_against_hash(&raw_hash, None, Some(&key))?;
        }
        other => {
            return Err(AcdpError::InvalidReceipt(format!(
                "event receipt signature algorithm '{other}' is not supported"
            )));
        }
    }
    Ok((receipt, key_status))
}