@forestrie/receipt-verify 3.1.0 → 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 (40) 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/inclusion-walk-end.d.ts +6 -0
  11. package/dist/inclusion-walk-end.d.ts.map +1 -0
  12. package/dist/inclusion-walk-end.js +35 -0
  13. package/dist/index.d.ts +14 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +11 -0
  16. package/dist/known-accumulator.d.ts.map +1 -1
  17. package/dist/known-accumulator.js +25 -9
  18. package/dist/ks256-attestation-usage.d.ts +50 -0
  19. package/dist/ks256-attestation-usage.d.ts.map +1 -0
  20. package/dist/ks256-attestation-usage.js +51 -0
  21. package/dist/select-ks256-attestation.d.ts +57 -0
  22. package/dist/select-ks256-attestation.d.ts.map +1 -0
  23. package/dist/select-ks256-attestation.js +119 -0
  24. package/dist/verify-grant-receipt-offline.d.ts +11 -0
  25. package/dist/verify-grant-receipt-offline.d.ts.map +1 -1
  26. package/dist/verify-grant-receipt-offline.js +25 -23
  27. package/dist/verify-receipt-against-checkpoint-chain.d.ts +42 -0
  28. package/dist/verify-receipt-against-checkpoint-chain.d.ts.map +1 -0
  29. package/dist/verify-receipt-against-checkpoint-chain.js +205 -0
  30. package/package.json +3 -3
  31. package/src/checkpoint-chain-receipt-outcome.ts +67 -0
  32. package/src/checkpoint-link-record.ts +74 -0
  33. package/src/checkpoint-link-verifier.ts +241 -0
  34. package/src/inclusion-walk-end.ts +38 -0
  35. package/src/index.ts +30 -0
  36. package/src/known-accumulator.ts +30 -8
  37. package/src/ks256-attestation-usage.ts +103 -0
  38. package/src/select-ks256-attestation.ts +151 -0
  39. package/src/verify-grant-receipt-offline.ts +43 -27
  40. package/src/verify-receipt-against-checkpoint-chain.ts +256 -0
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Choosing an ERC-1271 attestation from several (owner ruling D8,
3
+ * 2026-10-19). A contract KS256 root (a Safe) that rotated its delegate
4
+ * signs one certificate per delegate, and each certificate needs its own
5
+ * attestation; a caller holding a checkpoint chain or several receipts
6
+ * supplies them as a list. Each certificate takes the attestation whose
7
+ * `digest` AND `signatureHash` are that certificate's: keccak256 of its
8
+ * Sig_structure and keccak256 of its signature bytes.
9
+ *
10
+ * Selection never makes an attestation acceptable on its own:
11
+ * `verifyKs256DelegationCert` still checks address, digest, signature hash,
12
+ * genesis chain id and magic.
13
+ */
14
+
15
+ import { keccak_256 } from "@noble/hashes/sha3";
16
+ import type { Ks256CertificateDigest } from "@forestrie/delegation-cose";
17
+ import type { Ks256Attestation } from "./ks256-attestation.js";
18
+ import {
19
+ resolveDelegatedVerifyKey,
20
+ type DelegatedResolution,
21
+ } from "./resolve-delegated-verify-key.js";
22
+ import type { RootVerifyKey } from "./root-verify-key.js";
23
+ import { extractKs256DelegationDigest } from "./verify-ks256-delegation.js";
24
+
25
+ /**
26
+ * The attestation in `attestations` made for this certificate: the first
27
+ * whose `digest` equals the certificate digest and whose `signatureHash`
28
+ * equals keccak256 of the certificate signature.
29
+ *
30
+ * @param cert - From `extractKs256CertificateDigest` (delegation-cose) or
31
+ * {@link extractKs256DelegationDigest}.
32
+ * @returns The first matching attestation, or `undefined` when none
33
+ * matches. The verifiers go further: they try EVERY match in order and
34
+ * keep the first that passes its own checks.
35
+ */
36
+ export function selectKs256Attestation(
37
+ cert: Ks256CertificateDigest,
38
+ attestations: readonly Ks256Attestation[],
39
+ ): Ks256Attestation | undefined {
40
+ const i = selectKs256AttestationIndex(cert, attestations);
41
+ return i === undefined ? undefined : attestations[i];
42
+ }
43
+
44
+ function selectKs256AttestationIndex(
45
+ cert: Ks256CertificateDigest,
46
+ attestations: readonly Ks256Attestation[],
47
+ ): number | undefined {
48
+ return matchingIndexes(cert, attestations)[0];
49
+ }
50
+
51
+ /**
52
+ * Resolve a COSE_Sign1's label-1000 certificate (a receipt or a checkpoint)
53
+ * under `rootKeys`, trying the supplied attestations made for it.
54
+ *
55
+ * Every attestation whose digest and signature hash match the certificate
56
+ * is tried in list order, and the first that resolves wins: a stale or
57
+ * wrong-chain copy listed before a good one must not mask it. When none
58
+ * resolves, the first failure is returned. When none matches at all, a
59
+ * DIAGNOSTIC stand-in (one with the right digest if any, else the first) is
60
+ * passed so a contract root fails with the precise
61
+ * `ks256_attestation_*_mismatch` reason rather than a bare
62
+ * `ks256_root_not_recovered`; a stand-in can never verify, because
63
+ * `verifyKs256DelegationCert` checks digest and signature hash. With one
64
+ * attestation supplied this is exactly the 3.1.0 behaviour of the singular
65
+ * `ks256Attestation` input.
66
+ *
67
+ * @returns The resolution; `selected`, the index of the attestation it was
68
+ * resolved with (whether or not ecrecover made it unnecessary); and
69
+ * `rejected`, the matching attestations tried that failed their own
70
+ * checks (address, chain id, magic).
71
+ * @internal
72
+ */
73
+ export async function resolveWithKs256Attestations(
74
+ coseSign1Bytes: Uint8Array,
75
+ rootKeys: RootVerifyKey[],
76
+ attestations: readonly Ks256Attestation[],
77
+ forestChainId: bigint | undefined,
78
+ ): Promise<{
79
+ resolution: DelegatedResolution;
80
+ selected?: number;
81
+ rejected: number[];
82
+ }> {
83
+ if (attestations.length === 0) {
84
+ return {
85
+ resolution: await resolveDelegatedVerifyKey(coseSign1Bytes, rootKeys),
86
+ rejected: [],
87
+ };
88
+ }
89
+ let cert: Ks256CertificateDigest | null = null;
90
+ try {
91
+ cert = extractKs256DelegationDigest(coseSign1Bytes);
92
+ } catch {
93
+ // Not a KS256 certificate (or not a COSE_Sign1): no attestation can be
94
+ // made for it; the stand-in below is never consulted by an ES256 root.
95
+ cert = null;
96
+ }
97
+ const candidates = cert === null ? [] : matchingIndexes(cert, attestations);
98
+ const rejected: number[] = [];
99
+ let firstFailure: DelegatedResolution | undefined;
100
+ for (const i of candidates) {
101
+ const resolution = await resolveDelegatedVerifyKey(
102
+ coseSign1Bytes,
103
+ rootKeys,
104
+ { attestation: attestations[i], forestChainId },
105
+ );
106
+ if (resolution.kind !== "broken") {
107
+ return { resolution, selected: i, rejected };
108
+ }
109
+ firstFailure ??= resolution;
110
+ // Only an attestation's own checks make it "rejected"; any other
111
+ // failure is the certificate's and no other copy can change it.
112
+ if (!resolution.reason?.startsWith("ks256_attestation_")) break;
113
+ rejected.push(i);
114
+ }
115
+ if (firstFailure !== undefined) return { resolution: firstFailure, rejected };
116
+ const standIn =
117
+ (cert !== null &&
118
+ attestations.find((a) => bytesEqual(a.digest, cert.digest))) ||
119
+ attestations[0];
120
+ return {
121
+ resolution: await resolveDelegatedVerifyKey(coseSign1Bytes, rootKeys, {
122
+ attestation: standIn,
123
+ forestChainId,
124
+ }),
125
+ rejected,
126
+ };
127
+ }
128
+
129
+ function matchingIndexes(
130
+ cert: Ks256CertificateDigest,
131
+ attestations: readonly Ks256Attestation[],
132
+ ): number[] {
133
+ const signatureHash = keccak_256(cert.signature);
134
+ const out: number[] = [];
135
+ attestations.forEach((a, i) => {
136
+ if (
137
+ bytesEqual(a.digest, cert.digest) &&
138
+ bytesEqual(a.signatureHash, signatureHash)
139
+ ) {
140
+ out.push(i);
141
+ }
142
+ });
143
+ return out;
144
+ }
145
+
146
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
147
+ if (a.length !== b.length) return false;
148
+ let x = 0;
149
+ for (let i = 0; i < a.length; i++) x |= a[i]! ^ b[i]!;
150
+ return x === 0;
151
+ }
@@ -9,7 +9,6 @@ import { grantCommitmentHashFromGrant } from "./grant-commitment.js";
9
9
  import { decodeTrustRootFromGenesis } from "./decode-trust-root-from-genesis.js";
10
10
  import { decodeChainBindingFromGenesis } from "./decode-chain-binding-from-genesis.js";
11
11
  import type { Ks256Attestation } from "./ks256-attestation.js";
12
- import type { Ks256DelegationEvidence } from "./verify-ks256-delegation.js";
13
12
  import { isParsedKs256RootKey, type RootVerifyKey } from "./root-verify-key.js";
14
13
  import {
15
14
  checkDelegationConstraints,
@@ -19,6 +18,7 @@ import { univocityLeafHash } from "./leaf-commitment.js";
19
18
  import { parseReceipt } from "./parse-receipt.js";
20
19
  import type { ReceiptVerifyResult } from "./receipt-verify-result.js";
21
20
  import { SubtleHasher } from "./subtle-hasher.js";
21
+ import { resolveWithKs256Attestations } from "./select-ks256-attestation.js";
22
22
 
23
23
  export type VerifyGrantReceiptOfflineInput = {
24
24
  genesisCbor: Uint8Array;
@@ -27,6 +27,8 @@ export type VerifyGrantReceiptOfflineInput = {
27
27
  idtimestampBe8: Uint8Array;
28
28
  /** See {@link VerifyReceiptOfflineInput.ks256Attestation}. */
29
29
  ks256Attestation?: Ks256Attestation;
30
+ /** See {@link VerifyReceiptOfflineInput.ks256Attestations}. */
31
+ ks256Attestations?: readonly Ks256Attestation[];
30
32
  };
31
33
 
32
34
  function readIdtimestampBe8(bytes: Uint8Array): bigint {
@@ -121,7 +123,7 @@ export async function verifyGrantReceiptOffline(
121
123
  receiptCbor: input.receiptCbor,
122
124
  idtimestampBe8: input.idtimestampBe8,
123
125
  inner,
124
- ks256Attestation: input.ks256Attestation,
126
+ ks256Attestations: attestationCandidates(input),
125
127
  });
126
128
  }
127
129
 
@@ -150,7 +152,10 @@ export async function verifyGrantReceiptOfflineWithKeys(
150
152
  idtimestampBe8: input.idtimestampBe8,
151
153
  inner,
152
154
  trustKeys: input.trustKeys,
153
- ks256Evidence: withKeysKs256Evidence(input),
155
+ ks256Attestations: attestationCandidates(input),
156
+ forestChainId: input.forestGenesisCbor
157
+ ? genesisChainIdOrUndefined(input.forestGenesisCbor)
158
+ : undefined,
154
159
  });
155
160
  }
156
161
 
@@ -171,6 +176,15 @@ export type VerifyReceiptOfflineInput = {
171
176
  * binds the root address, the certificate digest and the genesis chain id.
172
177
  */
173
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[];
174
188
  };
175
189
 
176
190
  /**
@@ -194,7 +208,7 @@ export async function verifyReceiptOffline(
194
208
  receiptCbor: input.receiptCbor,
195
209
  idtimestampBe8: input.idtimestampBe8,
196
210
  inner,
197
- ks256Attestation: input.ks256Attestation,
211
+ ks256Attestations: attestationCandidates(input),
198
212
  });
199
213
  }
200
214
 
@@ -249,25 +263,25 @@ export async function verifyReceiptOfflineWithKeys(
249
263
  idtimestampBe8: input.idtimestampBe8,
250
264
  inner,
251
265
  trustKeys: input.trustKeys,
252
- ks256Evidence: withKeysKs256Evidence(input),
266
+ ks256Attestations: attestationCandidates(input),
267
+ forestChainId: input.forestGenesisCbor
268
+ ? genesisChainIdOrUndefined(input.forestGenesisCbor)
269
+ : undefined,
253
270
  });
254
271
  }
255
272
 
256
273
  /**
257
- * KS256 chain evidence for the caller-known-key entries: the attestation, and
258
- * the chain id from the forest genesis the caller supplied, if any.
274
+ * Every attestation the caller supplied: the list, then the singular field
275
+ * (kept for 3.1.0 callers).
259
276
  */
260
- function withKeysKs256Evidence(input: {
277
+ function attestationCandidates(input: {
261
278
  ks256Attestation?: Ks256Attestation | undefined;
262
- forestGenesisCbor?: Uint8Array | undefined;
263
- }): Ks256DelegationEvidence {
264
- if (!input.ks256Attestation) return {};
265
- return {
266
- attestation: input.ks256Attestation,
267
- forestChainId: input.forestGenesisCbor
268
- ? genesisChainIdOrUndefined(input.forestGenesisCbor)
269
- : undefined,
270
- };
279
+ ks256Attestations?: readonly Ks256Attestation[] | undefined;
280
+ }): Ks256Attestation[] {
281
+ return [
282
+ ...(input.ks256Attestations ?? []),
283
+ ...(input.ks256Attestation ? [input.ks256Attestation] : []),
284
+ ];
271
285
  }
272
286
 
273
287
  /**
@@ -296,7 +310,7 @@ async function verifyReceiptOfflineWithLeafInner(input: {
296
310
  receiptCbor: Uint8Array;
297
311
  idtimestampBe8: Uint8Array;
298
312
  inner: Uint8Array;
299
- ks256Attestation?: Ks256Attestation | undefined;
313
+ ks256Attestations: Ks256Attestation[];
300
314
  }): Promise<ReceiptVerifyResult> {
301
315
  let trustRoot: RootVerifyKey;
302
316
  try {
@@ -310,12 +324,8 @@ async function verifyReceiptOfflineWithLeafInner(input: {
310
324
  idtimestampBe8: input.idtimestampBe8,
311
325
  inner: input.inner,
312
326
  trustKeys: [trustRoot],
313
- ks256Evidence: input.ks256Attestation
314
- ? {
315
- attestation: input.ks256Attestation,
316
- forestChainId: genesisChainIdOrUndefined(input.genesisCbor),
317
- }
318
- : {},
327
+ ks256Attestations: input.ks256Attestations,
328
+ forestChainId: genesisChainIdOrUndefined(input.genesisCbor),
319
329
  });
320
330
  }
321
331
 
@@ -331,7 +341,9 @@ async function verifyReceiptOfflineWithLeafInnerKeys(input: {
331
341
  idtimestampBe8: Uint8Array;
332
342
  inner: Uint8Array;
333
343
  trustKeys: RootVerifyKey[];
334
- ks256Evidence: Ks256DelegationEvidence;
344
+ ks256Attestations: Ks256Attestation[];
345
+ /** The forest genesis chain id an attestation must carry, if known. */
346
+ forestChainId: bigint | undefined;
335
347
  }): Promise<ReceiptVerifyResult> {
336
348
  let parsed: ReturnType<typeof parseReceipt>;
337
349
  try {
@@ -349,10 +361,14 @@ async function verifyReceiptOfflineWithLeafInnerKeys(input: {
349
361
  // delegated key first. A cert present but not chaining to a trust key is a
350
362
  // hard failure — do not silently fall back to the trust key (which cannot
351
363
  // verify a delegated-signed receipt anyway).
352
- 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(
353
368
  input.receiptCbor,
354
369
  input.trustKeys,
355
- input.ks256Evidence,
370
+ input.ks256Attestations,
371
+ input.forestChainId,
356
372
  );
357
373
  if (delegation.kind === "broken") {
358
374
  return {
@@ -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
+ }