@forestrie/receipt-verify 3.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/checkpoint-chain-receipt-outcome.d.ts +56 -0
  2. package/dist/checkpoint-chain-receipt-outcome.d.ts.map +1 -0
  3. package/dist/checkpoint-chain-receipt-outcome.js +1 -0
  4. package/dist/checkpoint-link-record.d.ts +69 -0
  5. package/dist/checkpoint-link-record.d.ts.map +1 -0
  6. package/dist/checkpoint-link-record.js +1 -0
  7. package/dist/checkpoint-link-verifier.d.ts +80 -0
  8. package/dist/checkpoint-link-verifier.d.ts.map +1 -0
  9. package/dist/checkpoint-link-verifier.js +181 -0
  10. package/dist/decode-trust-root-cbor.d.ts +8 -1
  11. package/dist/decode-trust-root-cbor.d.ts.map +1 -1
  12. package/dist/decode-trust-root-cbor.js +10 -1
  13. package/dist/inclusion-walk-end.d.ts +6 -0
  14. package/dist/inclusion-walk-end.d.ts.map +1 -0
  15. package/dist/inclusion-walk-end.js +35 -0
  16. package/dist/index.d.ts +29 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +23 -0
  19. package/dist/known-accumulator.d.ts.map +1 -1
  20. package/dist/known-accumulator.js +25 -9
  21. package/dist/known-log-key.d.ts +15 -0
  22. package/dist/known-log-key.d.ts.map +1 -0
  23. package/dist/known-log-key.js +20 -0
  24. package/dist/ks256-attestation-usage.d.ts +50 -0
  25. package/dist/ks256-attestation-usage.d.ts.map +1 -0
  26. package/dist/ks256-attestation-usage.js +51 -0
  27. package/dist/ks256-attestation.d.ts +50 -0
  28. package/dist/ks256-attestation.d.ts.map +1 -0
  29. package/dist/ks256-attestation.js +104 -0
  30. package/dist/resolve-delegated-verify-key.d.ts +18 -9
  31. package/dist/resolve-delegated-verify-key.d.ts.map +1 -1
  32. package/dist/resolve-delegated-verify-key.js +26 -9
  33. package/dist/select-ks256-attestation.d.ts +57 -0
  34. package/dist/select-ks256-attestation.d.ts.map +1 -0
  35. package/dist/select-ks256-attestation.js +119 -0
  36. package/dist/verify-grant-receipt-offline.d.ts +31 -6
  37. package/dist/verify-grant-receipt-offline.d.ts.map +1 -1
  38. package/dist/verify-grant-receipt-offline.js +63 -12
  39. package/dist/verify-ks256-delegation.d.ts +56 -0
  40. package/dist/verify-ks256-delegation.d.ts.map +1 -0
  41. package/dist/verify-ks256-delegation.js +95 -0
  42. package/dist/verify-receipt-against-checkpoint-chain.d.ts +42 -0
  43. package/dist/verify-receipt-against-checkpoint-chain.d.ts.map +1 -0
  44. package/dist/verify-receipt-against-checkpoint-chain.js +205 -0
  45. package/package.json +4 -2
  46. package/src/checkpoint-chain-receipt-outcome.ts +67 -0
  47. package/src/checkpoint-link-record.ts +74 -0
  48. package/src/checkpoint-link-verifier.ts +241 -0
  49. package/src/decode-trust-root-cbor.ts +10 -1
  50. package/src/inclusion-walk-end.ts +38 -0
  51. package/src/index.ts +53 -0
  52. package/src/known-accumulator.ts +30 -8
  53. package/src/known-log-key.ts +28 -0
  54. package/src/ks256-attestation-usage.ts +103 -0
  55. package/src/ks256-attestation.ts +144 -0
  56. package/src/resolve-delegated-verify-key.ts +37 -11
  57. package/src/select-ks256-attestation.ts +151 -0
  58. package/src/verify-grant-receipt-offline.ts +109 -20
  59. package/src/verify-ks256-delegation.ts +120 -0
  60. package/src/verify-receipt-against-checkpoint-chain.ts +256 -0
@@ -7,7 +7,9 @@ import {
7
7
  import type { Grant } from "@forestrie/encoding";
8
8
  import { grantCommitmentHashFromGrant } from "./grant-commitment.js";
9
9
  import { decodeTrustRootFromGenesis } from "./decode-trust-root-from-genesis.js";
10
- import { es256ReceiptVerifyKeys } from "./decode-trust-root-cbor.js";
10
+ import { decodeChainBindingFromGenesis } from "./decode-chain-binding-from-genesis.js";
11
+ import type { Ks256Attestation } from "./ks256-attestation.js";
12
+ import { isParsedKs256RootKey, type RootVerifyKey } from "./root-verify-key.js";
11
13
  import {
12
14
  checkDelegationConstraints,
13
15
  resolveDelegatedVerifyKey,
@@ -16,12 +18,17 @@ import { univocityLeafHash } from "./leaf-commitment.js";
16
18
  import { parseReceipt } from "./parse-receipt.js";
17
19
  import type { ReceiptVerifyResult } from "./receipt-verify-result.js";
18
20
  import { SubtleHasher } from "./subtle-hasher.js";
21
+ import { resolveWithKs256Attestations } from "./select-ks256-attestation.js";
19
22
 
20
23
  export type VerifyGrantReceiptOfflineInput = {
21
24
  genesisCbor: Uint8Array;
22
25
  receiptCbor: Uint8Array;
23
26
  grant: Grant;
24
27
  idtimestampBe8: Uint8Array;
28
+ /** See {@link VerifyReceiptOfflineInput.ks256Attestation}. */
29
+ ks256Attestation?: Ks256Attestation;
30
+ /** See {@link VerifyReceiptOfflineInput.ks256Attestations}. */
31
+ ks256Attestations?: readonly Ks256Attestation[];
25
32
  };
26
33
 
27
34
  function readIdtimestampBe8(bytes: Uint8Array): bigint {
@@ -116,16 +123,15 @@ export async function verifyGrantReceiptOffline(
116
123
  receiptCbor: input.receiptCbor,
117
124
  idtimestampBe8: input.idtimestampBe8,
118
125
  inner,
126
+ ks256Attestations: attestationCandidates(input),
119
127
  });
120
128
  }
121
129
 
122
130
  export type VerifyGrantReceiptOfflineWithKeysInput = Omit<
123
131
  VerifyGrantReceiptOfflineInput,
124
132
  "genesisCbor"
125
- > & {
126
- /** Caller-known ES256 trust keys — see {@link VerifyReceiptOfflineWithKeysInput}. */
127
- trustKeys: CryptoKey[];
128
- };
133
+ > &
134
+ Pick<VerifyReceiptOfflineWithKeysInput, "trustKeys" | "forestGenesisCbor">;
129
135
 
130
136
  /**
131
137
  * {@link verifyGrantReceiptOffline} under caller-supplied trust keys instead of
@@ -146,6 +152,10 @@ export async function verifyGrantReceiptOfflineWithKeys(
146
152
  idtimestampBe8: input.idtimestampBe8,
147
153
  inner,
148
154
  trustKeys: input.trustKeys,
155
+ ks256Attestations: attestationCandidates(input),
156
+ forestChainId: input.forestGenesisCbor
157
+ ? genesisChainIdOrUndefined(input.forestGenesisCbor)
158
+ : undefined,
149
159
  });
150
160
  }
151
161
 
@@ -160,6 +170,21 @@ export type VerifyReceiptOfflineInput = {
160
170
  */
161
171
  payload: Uint8Array;
162
172
  idtimestampBe8: Uint8Array;
173
+ /**
174
+ * ERC-1271 attestation for a KS256 contract root (plan-2610-03 D4). Used
175
+ * only when ecrecover doesn't match the root, and accepted only when it
176
+ * binds the root address, the certificate digest and the genesis chain id.
177
+ */
178
+ ks256Attestation?: Ks256Attestation;
179
+ /**
180
+ * Several attestations (owner ruling D8): the one whose digest and
181
+ * signature hash match the receipt's certificate is used
182
+ * ({@link selectKs256Attestation}). Combined with
183
+ * {@link ks256Attestation} when both are given. When none matches, one
184
+ * still reaches the KS256 check only to name the mismatch: it can never
185
+ * verify the certificate.
186
+ */
187
+ ks256Attestations?: readonly Ks256Attestation[];
163
188
  };
164
189
 
165
190
  /**
@@ -183,6 +208,7 @@ export async function verifyReceiptOffline(
183
208
  receiptCbor: input.receiptCbor,
184
209
  idtimestampBe8: input.idtimestampBe8,
185
210
  inner,
211
+ ks256Attestations: attestationCandidates(input),
186
212
  });
187
213
  }
188
214
 
@@ -191,12 +217,19 @@ export type VerifyReceiptOfflineWithKeysInput = Omit<
191
217
  "genesisCbor"
192
218
  > & {
193
219
  /**
194
- * Caller-known ES256 trust keys, tried in order. For a child-log receipt
220
+ * Caller-known trust keys, tried in order: ES256 `CryptoKey`s or KS256
221
+ * 20-byte addresses ({@link knownLogKeyFromBytes}). For a child-log receipt
195
222
  * this is the log OWNER's key (the delegation-cert issuer), NOT the sealer
196
223
  * key — the label-1000 cert is verified under these keys and the delegated
197
224
  * sealer key is extracted from it, so the anchor survives sealer rotation.
198
225
  */
199
- trustKeys: CryptoKey[];
226
+ trustKeys: RootVerifyKey[];
227
+ /**
228
+ * The forest's genesis, read only for the chain id a `ks256Attestation`
229
+ * must match (D6); it supplies no trust keys. Without it an attestation is
230
+ * refused with `ks256_attestation_chain_unbound`.
231
+ */
232
+ forestGenesisCbor?: Uint8Array;
200
233
  };
201
234
 
202
235
  /**
@@ -230,9 +263,41 @@ export async function verifyReceiptOfflineWithKeys(
230
263
  idtimestampBe8: input.idtimestampBe8,
231
264
  inner,
232
265
  trustKeys: input.trustKeys,
266
+ ks256Attestations: attestationCandidates(input),
267
+ forestChainId: input.forestGenesisCbor
268
+ ? genesisChainIdOrUndefined(input.forestGenesisCbor)
269
+ : undefined,
233
270
  });
234
271
  }
235
272
 
273
+ /**
274
+ * Every attestation the caller supplied: the list, then the singular field
275
+ * (kept for 3.1.0 callers).
276
+ */
277
+ function attestationCandidates(input: {
278
+ ks256Attestation?: Ks256Attestation | undefined;
279
+ ks256Attestations?: readonly Ks256Attestation[] | undefined;
280
+ }): Ks256Attestation[] {
281
+ return [
282
+ ...(input.ks256Attestations ?? []),
283
+ ...(input.ks256Attestation ? [input.ks256Attestation] : []),
284
+ ];
285
+ }
286
+
287
+ /**
288
+ * The forest's chain id from its genesis chain binding, or `undefined` when
289
+ * the genesis carries none (an attestation then can't be bound, D6).
290
+ */
291
+ function genesisChainIdOrUndefined(
292
+ genesisCbor: Uint8Array,
293
+ ): bigint | undefined {
294
+ try {
295
+ return BigInt(decodeChainBindingFromGenesis(genesisCbor).chainId);
296
+ } catch {
297
+ return undefined;
298
+ }
299
+ }
300
+
236
301
  /**
237
302
  * Shared offline-verify core: reconstruct the leaf as
238
303
  * `univocityLeafHash(idtimestamp, inner)`, resolve the (possibly delegated,
@@ -245,37 +310,40 @@ async function verifyReceiptOfflineWithLeafInner(input: {
245
310
  receiptCbor: Uint8Array;
246
311
  idtimestampBe8: Uint8Array;
247
312
  inner: Uint8Array;
313
+ ks256Attestations: Ks256Attestation[];
248
314
  }): Promise<ReceiptVerifyResult> {
249
- let trustRoot;
315
+ let trustRoot: RootVerifyKey;
250
316
  try {
251
317
  trustRoot = await decodeTrustRootFromGenesis(input.genesisCbor);
252
318
  } catch {
253
319
  return { ok: false, stage: "parse", reason: "genesis_invalid" };
254
320
  }
255
321
 
256
- const verifyKeys = es256ReceiptVerifyKeys([trustRoot]) as CryptoKey[];
257
- if (!verifyKeys.length) {
258
- return { ok: false, stage: "signature", reason: "no_es256_trust_key" };
259
- }
260
-
261
322
  return verifyReceiptOfflineWithLeafInnerKeys({
262
323
  receiptCbor: input.receiptCbor,
263
324
  idtimestampBe8: input.idtimestampBe8,
264
325
  inner: input.inner,
265
- trustKeys: verifyKeys,
326
+ trustKeys: [trustRoot],
327
+ ks256Attestations: input.ks256Attestations,
328
+ forestChainId: genesisChainIdOrUndefined(input.genesisCbor),
266
329
  });
267
330
  }
268
331
 
269
332
  /**
270
333
  * Trust-key-parameterised offline-verify core. Identical to the genesis path
271
334
  * once the trust keys are in hand: resolve the (possibly delegated, FOR-297)
272
- * verify key against `trustKeys`, then check signature + inclusion.
335
+ * verify key against `trustKeys`, then check signature + inclusion. Receipts
336
+ * are always ES256, so a KS256 trust key only ever verifies the delegation
337
+ * certificate; only ES256 trust keys are tried on the receipt itself.
273
338
  */
274
339
  async function verifyReceiptOfflineWithLeafInnerKeys(input: {
275
340
  receiptCbor: Uint8Array;
276
341
  idtimestampBe8: Uint8Array;
277
342
  inner: Uint8Array;
278
- trustKeys: CryptoKey[];
343
+ trustKeys: RootVerifyKey[];
344
+ ks256Attestations: Ks256Attestation[];
345
+ /** The forest genesis chain id an attestation must carry, if known. */
346
+ forestChainId: bigint | undefined;
279
347
  }): Promise<ReceiptVerifyResult> {
280
348
  let parsed: ReturnType<typeof parseReceipt>;
281
349
  try {
@@ -293,17 +361,38 @@ async function verifyReceiptOfflineWithLeafInnerKeys(input: {
293
361
  // delegated key first. A cert present but not chaining to a trust key is a
294
362
  // hard failure — do not silently fall back to the trust key (which cannot
295
363
  // verify a delegated-signed receipt anyway).
296
- const delegation = await resolveDelegatedVerifyKey(
364
+ // D8: the attestations made for THIS receipt's certificate (digest and
365
+ // signature hash), tried in order; none matching, a stand-in only names
366
+ // the mismatch.
367
+ const { resolution: delegation } = await resolveWithKs256Attestations(
297
368
  input.receiptCbor,
298
369
  input.trustKeys,
370
+ input.ks256Attestations,
371
+ input.forestChainId,
299
372
  );
300
373
  if (delegation.kind === "broken") {
301
- return { ok: false, stage: "signature", reason: "delegation_invalid" };
374
+ return {
375
+ ok: false,
376
+ stage: "signature",
377
+ reason: delegation.reason ?? "delegation_invalid",
378
+ };
379
+ }
380
+ const es256TrustKeys = input.trustKeys.filter(
381
+ (k): k is CryptoKey => !isParsedKs256RootKey(k),
382
+ );
383
+ if (delegation.kind === "root-only" && !es256TrustKeys.length) {
384
+ // A KS256 root never signs a receipt; without a certificate there is no
385
+ // ES256 key to check it with.
386
+ return {
387
+ ok: false,
388
+ stage: "signature",
389
+ reason: "ks256_root_requires_delegation",
390
+ };
302
391
  }
303
392
  const allVerifyKeys =
304
393
  delegation.kind === "resolved"
305
- ? [delegation.delegatedKey, ...input.trustKeys]
306
- : input.trustKeys;
394
+ ? [delegation.delegatedKey, ...es256TrustKeys]
395
+ : es256TrustKeys;
307
396
 
308
397
  let idtimestamp: bigint;
309
398
  try {
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Offline KS256 root → delegation certificate check (plan-2610-03 D4, D5;
3
+ * protocol ADR-0045 amendment 2026-10-18). Receipts are always ES256, signed
4
+ * by a sealer's P-256 delegated key; what a KS256 root changes is how the
5
+ * label-1000 delegation certificate is checked against it.
6
+ *
7
+ * - An EOA root verifies by ecrecover through `@forestrie/delegation-cose`'s
8
+ * `verifyDelegationCertificateKs256` with no hooks: pure, the same guarantee
9
+ * as an ES256 root. delegation-cose also enforces the KS256 alg binding.
10
+ * - Anything ecrecover doesn't match (a contract root such as a Safe, or a
11
+ * wrong signer: offline you can't tell which) fails with
12
+ * `ks256_root_not_recovered`, unless the caller supplies an ERC-1271
13
+ * attestation (ks256-attestation.ts) bound to this root, this digest, this
14
+ * signature and the forest's genesis chain.
15
+ *
16
+ * No network and no injected callback: the chain read is a separate helper's
17
+ * job (forestrie-cli, mcp-resolve), as for the known accumulator.
18
+ */
19
+
20
+ import { coseUnprotectedToMap, decodeCoseSign1 } from "@forestrie/encoding";
21
+ import {
22
+ extractKs256CertificateDigest,
23
+ verifyDelegationCertificateKs256,
24
+ type Ks256CertificateDigest,
25
+ } from "@forestrie/delegation-cose";
26
+ import { keccak_256 } from "@noble/hashes/sha3";
27
+ import {
28
+ ERC1271_MAGIC_VALUE,
29
+ type Ks256Attestation,
30
+ } from "./ks256-attestation.js";
31
+
32
+ /** Unprotected header label carrying the delegation certificate. */
33
+ const DELEGATION_CERT_LABEL = 1000;
34
+
35
+ /**
36
+ * Chain evidence a caller may supply for a KS256 contract root. `forestChainId`
37
+ * is read from the forest's genesis, never from the attestation or a
38
+ * coordinator; an attestation without it is refused.
39
+ */
40
+ export type Ks256DelegationEvidence = {
41
+ attestation?: Ks256Attestation | undefined;
42
+ forestChainId?: bigint | undefined;
43
+ };
44
+
45
+ /**
46
+ * Check a delegation certificate against a KS256 root address.
47
+ *
48
+ * @param certBytes - The receipt's label-1000 COSE_Sign1 certificate.
49
+ * @param rootAddress - 20-byte root from genesis or a caller-known log key.
50
+ * @param evidence - Optional ERC-1271 attestation and the genesis chain id.
51
+ * @returns `ok`, or a stable reason: `delegation_not_ks256` (wrong alg,
52
+ * WebAuthn envelope or malformed), `ks256_root_not_recovered`, or a
53
+ * `ks256_attestation_*` mismatch.
54
+ */
55
+ export async function verifyKs256DelegationCert(
56
+ certBytes: Uint8Array,
57
+ rootAddress: Uint8Array,
58
+ evidence: Ks256DelegationEvidence,
59
+ ): Promise<{ ok: true } | { ok: false; reason: string }> {
60
+ let parts: Ks256CertificateDigest;
61
+ try {
62
+ parts = extractKs256CertificateDigest(certBytes);
63
+ } catch {
64
+ return { ok: false, reason: "delegation_not_ks256" };
65
+ }
66
+ if (await verifyDelegationCertificateKs256(certBytes, rootAddress)) {
67
+ return { ok: true };
68
+ }
69
+ const { attestation, forestChainId } = evidence;
70
+ if (!attestation) {
71
+ return { ok: false, reason: "ks256_root_not_recovered" };
72
+ }
73
+ if (!bytesEqual(attestation.address, rootAddress)) {
74
+ return { ok: false, reason: "ks256_attestation_address_mismatch" };
75
+ }
76
+ if (!bytesEqual(attestation.digest, parts.digest)) {
77
+ return { ok: false, reason: "ks256_attestation_digest_mismatch" };
78
+ }
79
+ if (!bytesEqual(attestation.signatureHash, keccak_256(parts.signature))) {
80
+ return { ok: false, reason: "ks256_attestation_signature_mismatch" };
81
+ }
82
+ if (forestChainId === undefined) {
83
+ return { ok: false, reason: "ks256_attestation_chain_unbound" };
84
+ }
85
+ if (attestation.chainId !== forestChainId) {
86
+ return { ok: false, reason: "ks256_attestation_chain_mismatch" };
87
+ }
88
+ if (!bytesEqual(attestation.magic, ERC1271_MAGIC_VALUE)) {
89
+ return { ok: false, reason: "ks256_attestation_magic_invalid" };
90
+ }
91
+ return { ok: true };
92
+ }
93
+
94
+ /**
95
+ * Extract the KS256 delegation certificate's root address, digest and
96
+ * signature from a receipt: what an ERC-1271 attestation helper calls
97
+ * `isValidSignature` with. Does not verify anything.
98
+ *
99
+ * @param receiptCbor - COSE_Sign1 receipt bytes.
100
+ * @returns `null` when the receipt carries no label-1000 certificate.
101
+ * @throws When the certificate is not a KS256 certificate.
102
+ */
103
+ export function extractKs256DelegationDigest(
104
+ receiptCbor: Uint8Array,
105
+ ): Ks256CertificateDigest | null {
106
+ const decoded = decodeCoseSign1(receiptCbor);
107
+ if (!decoded) throw new Error("receipt is not a COSE_Sign1");
108
+ const certRaw = coseUnprotectedToMap(decoded.unprotected).get(
109
+ DELEGATION_CERT_LABEL,
110
+ );
111
+ if (!(certRaw instanceof Uint8Array) || certRaw.length === 0) return null;
112
+ return extractKs256CertificateDigest(certRaw);
113
+ }
114
+
115
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
116
+ if (a.length !== b.length) return false;
117
+ let x = 0;
118
+ for (let i = 0; i < a.length; i++) x |= a[i]! ^ b[i]!;
119
+ return x === 0;
120
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * The checkpoint-chain trust root (FOR-368 Phase 3; owner ruling D8): fold a
3
+ * retained `.sth` chain under the caller's roots with per-link delegation
4
+ * limits ({@link makeCheckpointLinkVerifier}), match the receipt's peak
5
+ * against ANY authenticated link, then judge certificate expiry on the
6
+ * links covering the now-bound leaf ({@link checkCheckpointLinkExpiry}).
7
+ * Lifted from mcp-verify 0.6.1's `verifyAtCheckpointChain`; forestrie-cli,
8
+ * which matches peaks its own way, can use the pieces instead.
9
+ *
10
+ * Later links' signed consistency proofs commit an earlier accumulator
11
+ * forward, so burial never turns an honest receipt tamper-shaped. Retention
12
+ * limits coverage, never validity: a receipt newer than the chain fails
13
+ * closed with a refresh remedy.
14
+ *
15
+ * Pure over bytes; no network (canopy rule C1).
16
+ */
17
+ import { calculateRoot, peakMMRIndexes } from "@forestrie/merklelog";
18
+ import {
19
+ verifyCheckpointChain,
20
+ type CheckpointChainLink,
21
+ } from "./checkpoint-chain.js";
22
+ import type { CheckpointChainReceiptOutcome } from "./checkpoint-chain-receipt-outcome.js";
23
+ import {
24
+ checkCheckpointLinkExpiry,
25
+ makeCheckpointLinkVerifier,
26
+ } from "./checkpoint-link-verifier.js";
27
+ import { decodeChainBindingFromGenesis } from "./decode-chain-binding-from-genesis.js";
28
+ import type { Ks256Attestation } from "./ks256-attestation.js";
29
+ import { summarizeKs256AttestationUsage } from "./ks256-attestation-usage.js";
30
+ import { inclusionWalkEndIndex } from "./inclusion-walk-end.js";
31
+ import { univocityLeafHash } from "./leaf-commitment.js";
32
+ import { parseReceipt } from "./parse-receipt.js";
33
+ import type { RootVerifyKey } from "./root-verify-key.js";
34
+ import { SubtleHasher } from "./subtle-hasher.js";
35
+
36
+ /** Input to {@link verifyReceiptAgainstCheckpointChain}. */
37
+ export type VerifyReceiptAgainstCheckpointChainInput = {
38
+ receiptCbor: Uint8Array;
39
+ /** The leaf's idtimestamp; the last 8 bytes are used (big-endian). */
40
+ idtimestampBe8: Uint8Array;
41
+ /** Leaf ContentHash: SHA-256(payload), or the grant commitment hash. */
42
+ inner: Uint8Array;
43
+ /** Retained checkpoints in fold (ascending size) order. */
44
+ checkpoints: readonly Uint8Array[];
45
+ /** ES256 keys and KS256 addresses, from genesis or caller-known keys. */
46
+ rootKeys: readonly RootVerifyKey[];
47
+ /** ERC-1271 attestations, one per contract-root certificate. */
48
+ attestations?: readonly Ks256Attestation[] | undefined;
49
+ /**
50
+ * The forest genesis, read only for its chain id (attestations must
51
+ * match it). Give this or {@link forestChainId}; both must agree.
52
+ */
53
+ forestGenesisCbor?: Uint8Array | undefined;
54
+ forestChainId?: bigint | undefined;
55
+ /** Trusted base for a suffix chain; see `verifyCheckpointChain`. */
56
+ trustedBase?: { size: bigint; accumulator: Uint8Array[] } | undefined;
57
+ };
58
+
59
+ /**
60
+ * Verify a receipt against a retained checkpoint chain.
61
+ *
62
+ * The receipt's own signature is not checked: the authenticated chain is
63
+ * the authority, and the peak is ALWAYS recomputed from the leaf
64
+ * (`idtimestamp ‖ inner`) and the inclusion path, never taken from an
65
+ * attached payload. A match binds the leaf's idtimestamp (through the leaf
66
+ * hash) and its mmrIndex (through the matched peak's POSITION, which must be
67
+ * the node the inclusion walk reaches: the leaf hash alone does not commit
68
+ * its position, and an empty path commits none).
69
+ * Links are searched newest first; the newest cover is reported.
70
+ */
71
+ export async function verifyReceiptAgainstCheckpointChain(
72
+ input: VerifyReceiptAgainstCheckpointChainInput,
73
+ ): Promise<CheckpointChainReceiptOutcome> {
74
+ const attestations = input.attestations ?? [];
75
+ const empty = {
76
+ links: [] as CheckpointChainLink[],
77
+ records: [],
78
+ attestations: summarizeKs256AttestationUsage(attestations, []),
79
+ };
80
+ if (input.rootKeys.length === 0) {
81
+ return { ...empty, ok: false, stage: "input", reason: "no_trust_key" };
82
+ }
83
+ let forestChainId = input.forestChainId;
84
+ if (input.forestGenesisCbor !== undefined) {
85
+ let fromGenesis: bigint;
86
+ try {
87
+ fromGenesis = BigInt(
88
+ decodeChainBindingFromGenesis(input.forestGenesisCbor).chainId,
89
+ );
90
+ } catch {
91
+ return {
92
+ ...empty,
93
+ ok: false,
94
+ stage: "input",
95
+ reason: "forest_genesis_invalid",
96
+ };
97
+ }
98
+ if (forestChainId !== undefined && forestChainId !== fromGenesis) {
99
+ return {
100
+ ...empty,
101
+ ok: false,
102
+ stage: "input",
103
+ reason: "forest_chain_id_mismatch",
104
+ };
105
+ }
106
+ forestChainId = fromGenesis;
107
+ }
108
+
109
+ const verifier = makeCheckpointLinkVerifier({
110
+ rootKeys: input.rootKeys,
111
+ attestations,
112
+ forestChainId,
113
+ });
114
+ const chain = await verifyCheckpointChain({
115
+ checkpoints: [...input.checkpoints],
116
+ verifySignature: verifier.verifySignature,
117
+ ...(input.trustedBase !== undefined
118
+ ? { trustedBase: input.trustedBase }
119
+ : {}),
120
+ });
121
+ const linkFailure = verifier.failure();
122
+ const common = {
123
+ links: chain.links,
124
+ records: verifier.records,
125
+ attestations: summarizeKs256AttestationUsage(
126
+ attestations,
127
+ verifier.records,
128
+ linkFailure,
129
+ ),
130
+ };
131
+ if (!chain.ok) {
132
+ return {
133
+ ...common,
134
+ ok: false,
135
+ stage: "chain",
136
+ reason: linkFailure?.reason ?? `checkpoint_chain_${chain.reason}`,
137
+ at: chain.at,
138
+ detail: chain.detail,
139
+ ...(linkFailure !== undefined ? { linkFailure } : {}),
140
+ };
141
+ }
142
+
143
+ let parsed: ReturnType<typeof parseReceipt>;
144
+ try {
145
+ parsed = parseReceipt(input.receiptCbor);
146
+ } catch {
147
+ return {
148
+ ...common,
149
+ ok: false,
150
+ stage: "parse",
151
+ reason: "receipt_malformed",
152
+ };
153
+ }
154
+ if (input.idtimestampBe8.length < 8) {
155
+ return {
156
+ ...common,
157
+ ok: false,
158
+ stage: "parse",
159
+ reason: "idtimestamp_invalid",
160
+ };
161
+ }
162
+ const idtimestamp = readU64Be(input.idtimestampBe8.slice(-8));
163
+ // parseReceipt always sets mmrIndex; leafIndex wins when present, as in
164
+ // every other peak recompute in this package.
165
+ const leafMmrIndex = parsed.proof.leafIndex ?? parsed.proof.mmrIndex;
166
+ if (leafMmrIndex === undefined) {
167
+ return {
168
+ ...common,
169
+ ok: false,
170
+ stage: "parse",
171
+ reason: "receipt_malformed",
172
+ };
173
+ }
174
+
175
+ const links = chain.links;
176
+ const final = links[links.length - 1]!;
177
+ if (leafMmrIndex >= final.treeSize2) {
178
+ return {
179
+ ...common,
180
+ ok: false,
181
+ stage: "anchor",
182
+ reason: "receipt_newer_than_checkpoint_chain",
183
+ };
184
+ }
185
+
186
+ let peak: Uint8Array;
187
+ try {
188
+ peak = await calculateRoot(
189
+ new SubtleHasher(),
190
+ await univocityLeafHash(idtimestamp, input.inner),
191
+ parsed.proof,
192
+ leafMmrIndex,
193
+ );
194
+ } catch {
195
+ return {
196
+ ...common,
197
+ ok: false,
198
+ stage: "parse",
199
+ reason: "receipt_malformed",
200
+ };
201
+ }
202
+
203
+ // The peak the path claims to reach. A leaf hash does not commit its
204
+ // position (and an empty path commits none), so a value match alone would
205
+ // leave mmrIndex a caller claim: the matched peak must sit at this index.
206
+ const peakIndex = inclusionWalkEndIndex(
207
+ leafMmrIndex,
208
+ parsed.proof.path.length,
209
+ );
210
+ for (let i = links.length - 1; i >= 0; i--) {
211
+ const link = links[i]!;
212
+ const p =
213
+ link.treeSize2 > 0n
214
+ ? peakMMRIndexes(link.treeSize2 - 1n).indexOf(peakIndex)
215
+ : -1;
216
+ if (p < 0 || !bytesEqual(link.accumulator[p] ?? new Uint8Array(), peak)) {
217
+ continue;
218
+ }
219
+ // The leaf hash, inclusion path and peak position now bind idtimestamp
220
+ // and mmrIndex to an authenticated accumulator, so expiry can be judged
221
+ // on them.
222
+ const expiry = checkCheckpointLinkExpiry(verifier.records, {
223
+ mmrIndex: leafMmrIndex,
224
+ idtimestamp,
225
+ });
226
+ if (!expiry.ok) {
227
+ return {
228
+ ...common,
229
+ ok: false,
230
+ stage: "delegation",
231
+ reason: expiry.reason,
232
+ at: expiry.at,
233
+ };
234
+ }
235
+ return { ...common, ok: true, matchedLink: i, matchedPeak: p };
236
+ }
237
+ return {
238
+ ...common,
239
+ ok: false,
240
+ stage: "anchor",
241
+ reason: "peak_not_in_checkpoint_chain",
242
+ };
243
+ }
244
+
245
+ function readU64Be(be8: Uint8Array): bigint {
246
+ let v = 0n;
247
+ for (const b of be8) v = (v << 8n) | BigInt(b);
248
+ return v;
249
+ }
250
+
251
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
252
+ if (a.length !== b.length) return false;
253
+ let x = 0;
254
+ for (let i = 0; i < a.length; i++) x |= a[i]! ^ b[i]!;
255
+ return x === 0;
256
+ }