@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,51 @@
1
+ /**
2
+ * Classify each supplied attestation against the per-link records.
3
+ *
4
+ * @param attestations - The list the verifier was given, in order.
5
+ * @param records - Records of the links that verified.
6
+ * @param failure - The link the verifier refused, if any.
7
+ */
8
+ export function summarizeKs256AttestationUsage(attestations, records, failure) {
9
+ const usage = { used: [], rejected: [], unused: [] };
10
+ const seen = [...records, ...(failure !== undefined ? [failure] : [])];
11
+ const tried = (i) => seen.some((r) => r.attestationIndex === i || (r.attestationsRejected ?? []).includes(i));
12
+ attestations.forEach((a, index) => {
13
+ const digestHex = bytesToHex(a.digest);
14
+ const used = records
15
+ .filter((r) => r.attestationIndex === index && r.attestationNeeded)
16
+ .map((r) => r.index);
17
+ const rejected = seen
18
+ .filter((r) => (r.attestationsRejected ?? []).includes(index))
19
+ .map((r) => r.index);
20
+ if (used.length > 0)
21
+ usage.used.push({ index, digestHex, links: used });
22
+ if (rejected.length > 0) {
23
+ usage.rejected.push({ index, digestHex, links: rejected });
24
+ }
25
+ if (used.length > 0 || rejected.length > 0)
26
+ return;
27
+ let reason = "no_matching_certificate";
28
+ if (seen.some((r) => r.attestationIndex === index)) {
29
+ reason = "not_needed";
30
+ }
31
+ else if (attestations.some((b, j) => j !== index &&
32
+ tried(j) &&
33
+ bytesEqual(a.digest, b.digest) &&
34
+ bytesEqual(a.signatureHash, b.signatureHash))) {
35
+ reason = "duplicate";
36
+ }
37
+ usage.unused.push({ index, digestHex, reason });
38
+ });
39
+ return usage;
40
+ }
41
+ function bytesEqual(a, b) {
42
+ if (a.length !== b.length)
43
+ return false;
44
+ let x = 0;
45
+ for (let i = 0; i < a.length; i++)
46
+ x |= a[i] ^ b[i];
47
+ return x === 0;
48
+ }
49
+ function bytesToHex(bytes) {
50
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
51
+ }
@@ -0,0 +1,57 @@
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
+ import type { Ks256CertificateDigest } from "@forestrie/delegation-cose";
15
+ import type { Ks256Attestation } from "./ks256-attestation.js";
16
+ import { type DelegatedResolution } from "./resolve-delegated-verify-key.js";
17
+ import type { RootVerifyKey } from "./root-verify-key.js";
18
+ /**
19
+ * The attestation in `attestations` made for this certificate: the first
20
+ * whose `digest` equals the certificate digest and whose `signatureHash`
21
+ * equals keccak256 of the certificate signature.
22
+ *
23
+ * @param cert - From `extractKs256CertificateDigest` (delegation-cose) or
24
+ * {@link extractKs256DelegationDigest}.
25
+ * @returns The first matching attestation, or `undefined` when none
26
+ * matches. The verifiers go further: they try EVERY match in order and
27
+ * keep the first that passes its own checks.
28
+ */
29
+ export declare function selectKs256Attestation(cert: Ks256CertificateDigest, attestations: readonly Ks256Attestation[]): Ks256Attestation | undefined;
30
+ /**
31
+ * Resolve a COSE_Sign1's label-1000 certificate (a receipt or a checkpoint)
32
+ * under `rootKeys`, trying the supplied attestations made for it.
33
+ *
34
+ * Every attestation whose digest and signature hash match the certificate
35
+ * is tried in list order, and the first that resolves wins: a stale or
36
+ * wrong-chain copy listed before a good one must not mask it. When none
37
+ * resolves, the first failure is returned. When none matches at all, a
38
+ * DIAGNOSTIC stand-in (one with the right digest if any, else the first) is
39
+ * passed so a contract root fails with the precise
40
+ * `ks256_attestation_*_mismatch` reason rather than a bare
41
+ * `ks256_root_not_recovered`; a stand-in can never verify, because
42
+ * `verifyKs256DelegationCert` checks digest and signature hash. With one
43
+ * attestation supplied this is exactly the 3.1.0 behaviour of the singular
44
+ * `ks256Attestation` input.
45
+ *
46
+ * @returns The resolution; `selected`, the index of the attestation it was
47
+ * resolved with (whether or not ecrecover made it unnecessary); and
48
+ * `rejected`, the matching attestations tried that failed their own
49
+ * checks (address, chain id, magic).
50
+ * @internal
51
+ */
52
+ export declare function resolveWithKs256Attestations(coseSign1Bytes: Uint8Array, rootKeys: RootVerifyKey[], attestations: readonly Ks256Attestation[], forestChainId: bigint | undefined): Promise<{
53
+ resolution: DelegatedResolution;
54
+ selected?: number;
55
+ rejected: number[];
56
+ }>;
57
+ //# sourceMappingURL=select-ks256-attestation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"select-ks256-attestation.d.ts","sourceRoot":"","sources":["../src/select-ks256-attestation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AACzE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAEL,KAAK,mBAAmB,EACzB,MAAM,mCAAmC,CAAC;AAC3C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAG1D;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,sBAAsB,EAC5B,YAAY,EAAE,SAAS,gBAAgB,EAAE,GACxC,gBAAgB,GAAG,SAAS,CAG9B;AASD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAsB,4BAA4B,CAChD,cAAc,EAAE,UAAU,EAC1B,QAAQ,EAAE,aAAa,EAAE,EACzB,YAAY,EAAE,SAAS,gBAAgB,EAAE,EACzC,aAAa,EAAE,MAAM,GAAG,SAAS,GAChC,OAAO,CAAC;IACT,UAAU,EAAE,mBAAmB,CAAC;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB,CAAC,CA6CD"}
@@ -0,0 +1,119 @@
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
+ import { keccak_256 } from "@noble/hashes/sha3";
15
+ import { resolveDelegatedVerifyKey, } from "./resolve-delegated-verify-key.js";
16
+ import { extractKs256DelegationDigest } from "./verify-ks256-delegation.js";
17
+ /**
18
+ * The attestation in `attestations` made for this certificate: the first
19
+ * whose `digest` equals the certificate digest and whose `signatureHash`
20
+ * equals keccak256 of the certificate signature.
21
+ *
22
+ * @param cert - From `extractKs256CertificateDigest` (delegation-cose) or
23
+ * {@link extractKs256DelegationDigest}.
24
+ * @returns The first matching attestation, or `undefined` when none
25
+ * matches. The verifiers go further: they try EVERY match in order and
26
+ * keep the first that passes its own checks.
27
+ */
28
+ export function selectKs256Attestation(cert, attestations) {
29
+ const i = selectKs256AttestationIndex(cert, attestations);
30
+ return i === undefined ? undefined : attestations[i];
31
+ }
32
+ function selectKs256AttestationIndex(cert, attestations) {
33
+ return matchingIndexes(cert, attestations)[0];
34
+ }
35
+ /**
36
+ * Resolve a COSE_Sign1's label-1000 certificate (a receipt or a checkpoint)
37
+ * under `rootKeys`, trying the supplied attestations made for it.
38
+ *
39
+ * Every attestation whose digest and signature hash match the certificate
40
+ * is tried in list order, and the first that resolves wins: a stale or
41
+ * wrong-chain copy listed before a good one must not mask it. When none
42
+ * resolves, the first failure is returned. When none matches at all, a
43
+ * DIAGNOSTIC stand-in (one with the right digest if any, else the first) is
44
+ * passed so a contract root fails with the precise
45
+ * `ks256_attestation_*_mismatch` reason rather than a bare
46
+ * `ks256_root_not_recovered`; a stand-in can never verify, because
47
+ * `verifyKs256DelegationCert` checks digest and signature hash. With one
48
+ * attestation supplied this is exactly the 3.1.0 behaviour of the singular
49
+ * `ks256Attestation` input.
50
+ *
51
+ * @returns The resolution; `selected`, the index of the attestation it was
52
+ * resolved with (whether or not ecrecover made it unnecessary); and
53
+ * `rejected`, the matching attestations tried that failed their own
54
+ * checks (address, chain id, magic).
55
+ * @internal
56
+ */
57
+ export async function resolveWithKs256Attestations(coseSign1Bytes, rootKeys, attestations, forestChainId) {
58
+ if (attestations.length === 0) {
59
+ return {
60
+ resolution: await resolveDelegatedVerifyKey(coseSign1Bytes, rootKeys),
61
+ rejected: [],
62
+ };
63
+ }
64
+ let cert = null;
65
+ try {
66
+ cert = extractKs256DelegationDigest(coseSign1Bytes);
67
+ }
68
+ catch {
69
+ // Not a KS256 certificate (or not a COSE_Sign1): no attestation can be
70
+ // made for it; the stand-in below is never consulted by an ES256 root.
71
+ cert = null;
72
+ }
73
+ const candidates = cert === null ? [] : matchingIndexes(cert, attestations);
74
+ const rejected = [];
75
+ let firstFailure;
76
+ for (const i of candidates) {
77
+ const resolution = await resolveDelegatedVerifyKey(coseSign1Bytes, rootKeys, { attestation: attestations[i], forestChainId });
78
+ if (resolution.kind !== "broken") {
79
+ return { resolution, selected: i, rejected };
80
+ }
81
+ firstFailure ??= resolution;
82
+ // Only an attestation's own checks make it "rejected"; any other
83
+ // failure is the certificate's and no other copy can change it.
84
+ if (!resolution.reason?.startsWith("ks256_attestation_"))
85
+ break;
86
+ rejected.push(i);
87
+ }
88
+ if (firstFailure !== undefined)
89
+ return { resolution: firstFailure, rejected };
90
+ const standIn = (cert !== null &&
91
+ attestations.find((a) => bytesEqual(a.digest, cert.digest))) ||
92
+ attestations[0];
93
+ return {
94
+ resolution: await resolveDelegatedVerifyKey(coseSign1Bytes, rootKeys, {
95
+ attestation: standIn,
96
+ forestChainId,
97
+ }),
98
+ rejected,
99
+ };
100
+ }
101
+ function matchingIndexes(cert, attestations) {
102
+ const signatureHash = keccak_256(cert.signature);
103
+ const out = [];
104
+ attestations.forEach((a, i) => {
105
+ if (bytesEqual(a.digest, cert.digest) &&
106
+ bytesEqual(a.signatureHash, signatureHash)) {
107
+ out.push(i);
108
+ }
109
+ });
110
+ return out;
111
+ }
112
+ function bytesEqual(a, b) {
113
+ if (a.length !== b.length)
114
+ return false;
115
+ let x = 0;
116
+ for (let i = 0; i < a.length; i++)
117
+ x |= a[i] ^ b[i];
118
+ return x === 0;
119
+ }
@@ -9,6 +9,8 @@ export type VerifyGrantReceiptOfflineInput = {
9
9
  idtimestampBe8: Uint8Array;
10
10
  /** See {@link VerifyReceiptOfflineInput.ks256Attestation}. */
11
11
  ks256Attestation?: Ks256Attestation;
12
+ /** See {@link VerifyReceiptOfflineInput.ks256Attestations}. */
13
+ ks256Attestations?: readonly Ks256Attestation[];
12
14
  };
13
15
  /**
14
16
  * Offline grant receipt verify (layers A–C, ADR-0045). Pure over bytes; no network.
@@ -38,6 +40,15 @@ export type VerifyReceiptOfflineInput = {
38
40
  * binds the root address, the certificate digest and the genesis chain id.
39
41
  */
40
42
  ks256Attestation?: Ks256Attestation;
43
+ /**
44
+ * Several attestations (owner ruling D8): the one whose digest and
45
+ * signature hash match the receipt's certificate is used
46
+ * ({@link selectKs256Attestation}). Combined with
47
+ * {@link ks256Attestation} when both are given. When none matches, one
48
+ * still reaches the KS256 check only to name the mismatch: it can never
49
+ * verify the certificate.
50
+ */
51
+ ks256Attestations?: readonly Ks256Attestation[];
41
52
  };
42
53
  /**
43
54
  * Offline verify of a receipt against the EXACT registered payload. The log
@@ -1 +1 @@
1
- {"version":3,"file":"verify-grant-receipt-offline.d.ts","sourceRoot":"","sources":["../src/verify-grant-receipt-offline.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAIjD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE/D,OAAO,EAAwB,KAAK,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAOhF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAGtE,MAAM,MAAM,8BAA8B,GAAG;IAC3C,WAAW,EAAE,UAAU,CAAC;IACxB,WAAW,EAAE,UAAU,CAAC;IACxB,KAAK,EAAE,KAAK,CAAC;IACb,cAAc,EAAE,UAAU,CAAC;IAC3B,8DAA8D;IAC9D,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;CACrC,CAAC;AA4EF;;GAEG;AACH,wBAAsB,yBAAyB,CAC7C,KAAK,EAAE,8BAA8B,GACpC,OAAO,CAAC,mBAAmB,CAAC,CAe9B;AAED,MAAM,MAAM,sCAAsC,GAAG,IAAI,CACvD,8BAA8B,EAC9B,aAAa,CACd,GACC,IAAI,CAAC,iCAAiC,EAAE,WAAW,GAAG,mBAAmB,CAAC,CAAC;AAE7E;;;;GAIG;AACH,wBAAsB,iCAAiC,CACrD,KAAK,EAAE,sCAAsC,GAC5C,OAAO,CAAC,mBAAmB,CAAC,CAc9B;AAED,MAAM,MAAM,yBAAyB,GAAG;IACtC,WAAW,EAAE,UAAU,CAAC;IACxB,WAAW,EAAE,UAAU,CAAC;IACxB;;;;;OAKG;IACH,OAAO,EAAE,UAAU,CAAC;IACpB,cAAc,EAAE,UAAU,CAAC;IAC3B;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;CACrC,CAAC;AAEF;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,yBAAyB,GAC/B,OAAO,CAAC,mBAAmB,CAAC,CAc9B;AAED,MAAM,MAAM,iCAAiC,GAAG,IAAI,CAClD,yBAAyB,EACzB,aAAa,CACd,GAAG;IACF;;;;;;OAMG;IACH,SAAS,EAAE,aAAa,EAAE,CAAC;IAC3B;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,UAAU,CAAC;CAChC,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,4BAA4B,CAChD,KAAK,EAAE,iCAAiC,GACvC,OAAO,CAAC,mBAAmB,CAAC,CAc9B"}
1
+ {"version":3,"file":"verify-grant-receipt-offline.d.ts","sourceRoot":"","sources":["../src/verify-grant-receipt-offline.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAIjD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAwB,KAAK,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAOhF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAItE,MAAM,MAAM,8BAA8B,GAAG;IAC3C,WAAW,EAAE,UAAU,CAAC;IACxB,WAAW,EAAE,UAAU,CAAC;IACxB,KAAK,EAAE,KAAK,CAAC;IACb,cAAc,EAAE,UAAU,CAAC;IAC3B,8DAA8D;IAC9D,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC,+DAA+D;IAC/D,iBAAiB,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;CACjD,CAAC;AA4EF;;GAEG;AACH,wBAAsB,yBAAyB,CAC7C,KAAK,EAAE,8BAA8B,GACpC,OAAO,CAAC,mBAAmB,CAAC,CAe9B;AAED,MAAM,MAAM,sCAAsC,GAAG,IAAI,CACvD,8BAA8B,EAC9B,aAAa,CACd,GACC,IAAI,CAAC,iCAAiC,EAAE,WAAW,GAAG,mBAAmB,CAAC,CAAC;AAE7E;;;;GAIG;AACH,wBAAsB,iCAAiC,CACrD,KAAK,EAAE,sCAAsC,GAC5C,OAAO,CAAC,mBAAmB,CAAC,CAiB9B;AAED,MAAM,MAAM,yBAAyB,GAAG;IACtC,WAAW,EAAE,UAAU,CAAC;IACxB,WAAW,EAAE,UAAU,CAAC;IACxB;;;;;OAKG;IACH,OAAO,EAAE,UAAU,CAAC;IACpB,cAAc,EAAE,UAAU,CAAC;IAC3B;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;CACjD,CAAC;AAEF;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,yBAAyB,GAC/B,OAAO,CAAC,mBAAmB,CAAC,CAc9B;AAED,MAAM,MAAM,iCAAiC,GAAG,IAAI,CAClD,yBAAyB,EACzB,aAAa,CACd,GAAG;IACF;;;;;;OAMG;IACH,SAAS,EAAE,aAAa,EAAE,CAAC;IAC3B;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,UAAU,CAAC;CAChC,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,4BAA4B,CAChD,KAAK,EAAE,iCAAiC,GACvC,OAAO,CAAC,mBAAmB,CAAC,CAiB9B"}
@@ -4,10 +4,11 @@ import { grantCommitmentHashFromGrant } from "./grant-commitment.js";
4
4
  import { decodeTrustRootFromGenesis } from "./decode-trust-root-from-genesis.js";
5
5
  import { decodeChainBindingFromGenesis } from "./decode-chain-binding-from-genesis.js";
6
6
  import { isParsedKs256RootKey } from "./root-verify-key.js";
7
- import { checkDelegationConstraints, resolveDelegatedVerifyKey, } from "./resolve-delegated-verify-key.js";
7
+ import { checkDelegationConstraints, } from "./resolve-delegated-verify-key.js";
8
8
  import { univocityLeafHash } from "./leaf-commitment.js";
9
9
  import { parseReceipt } from "./parse-receipt.js";
10
10
  import { SubtleHasher } from "./subtle-hasher.js";
11
+ import { resolveWithKs256Attestations } from "./select-ks256-attestation.js";
11
12
  function readIdtimestampBe8(bytes) {
12
13
  if (!bytes || bytes.length < 8) {
13
14
  throw new Error("idtimestamp required for receipt verification (8 bytes)");
@@ -73,7 +74,7 @@ export async function verifyGrantReceiptOffline(input) {
73
74
  receiptCbor: input.receiptCbor,
74
75
  idtimestampBe8: input.idtimestampBe8,
75
76
  inner,
76
- ks256Attestation: input.ks256Attestation,
77
+ ks256Attestations: attestationCandidates(input),
77
78
  });
78
79
  }
79
80
  /**
@@ -94,7 +95,10 @@ export async function verifyGrantReceiptOfflineWithKeys(input) {
94
95
  idtimestampBe8: input.idtimestampBe8,
95
96
  inner,
96
97
  trustKeys: input.trustKeys,
97
- ks256Evidence: withKeysKs256Evidence(input),
98
+ ks256Attestations: attestationCandidates(input),
99
+ forestChainId: input.forestGenesisCbor
100
+ ? genesisChainIdOrUndefined(input.forestGenesisCbor)
101
+ : undefined,
98
102
  });
99
103
  }
100
104
  /**
@@ -111,7 +115,7 @@ export async function verifyReceiptOffline(input) {
111
115
  receiptCbor: input.receiptCbor,
112
116
  idtimestampBe8: input.idtimestampBe8,
113
117
  inner,
114
- ks256Attestation: input.ks256Attestation,
118
+ ks256Attestations: attestationCandidates(input),
115
119
  });
116
120
  }
117
121
  /**
@@ -138,22 +142,21 @@ export async function verifyReceiptOfflineWithKeys(input) {
138
142
  idtimestampBe8: input.idtimestampBe8,
139
143
  inner,
140
144
  trustKeys: input.trustKeys,
141
- ks256Evidence: withKeysKs256Evidence(input),
145
+ ks256Attestations: attestationCandidates(input),
146
+ forestChainId: input.forestGenesisCbor
147
+ ? genesisChainIdOrUndefined(input.forestGenesisCbor)
148
+ : undefined,
142
149
  });
143
150
  }
144
151
  /**
145
- * KS256 chain evidence for the caller-known-key entries: the attestation, and
146
- * the chain id from the forest genesis the caller supplied, if any.
152
+ * Every attestation the caller supplied: the list, then the singular field
153
+ * (kept for 3.1.0 callers).
147
154
  */
148
- function withKeysKs256Evidence(input) {
149
- if (!input.ks256Attestation)
150
- return {};
151
- return {
152
- attestation: input.ks256Attestation,
153
- forestChainId: input.forestGenesisCbor
154
- ? genesisChainIdOrUndefined(input.forestGenesisCbor)
155
- : undefined,
156
- };
155
+ function attestationCandidates(input) {
156
+ return [
157
+ ...(input.ks256Attestations ?? []),
158
+ ...(input.ks256Attestation ? [input.ks256Attestation] : []),
159
+ ];
157
160
  }
158
161
  /**
159
162
  * The forest's chain id from its genesis chain binding, or `undefined` when
@@ -187,12 +190,8 @@ async function verifyReceiptOfflineWithLeafInner(input) {
187
190
  idtimestampBe8: input.idtimestampBe8,
188
191
  inner: input.inner,
189
192
  trustKeys: [trustRoot],
190
- ks256Evidence: input.ks256Attestation
191
- ? {
192
- attestation: input.ks256Attestation,
193
- forestChainId: genesisChainIdOrUndefined(input.genesisCbor),
194
- }
195
- : {},
193
+ ks256Attestations: input.ks256Attestations,
194
+ forestChainId: genesisChainIdOrUndefined(input.genesisCbor),
196
195
  });
197
196
  }
198
197
  /**
@@ -218,7 +217,10 @@ async function verifyReceiptOfflineWithLeafInnerKeys(input) {
218
217
  // delegated key first. A cert present but not chaining to a trust key is a
219
218
  // hard failure — do not silently fall back to the trust key (which cannot
220
219
  // verify a delegated-signed receipt anyway).
221
- const delegation = await resolveDelegatedVerifyKey(input.receiptCbor, input.trustKeys, input.ks256Evidence);
220
+ // D8: the attestations made for THIS receipt's certificate (digest and
221
+ // signature hash), tried in order; none matching, a stand-in only names
222
+ // the mismatch.
223
+ const { resolution: delegation } = await resolveWithKs256Attestations(input.receiptCbor, input.trustKeys, input.ks256Attestations, input.forestChainId);
222
224
  if (delegation.kind === "broken") {
223
225
  return {
224
226
  ok: false,
@@ -0,0 +1,42 @@
1
+ import type { CheckpointChainReceiptOutcome } from "./checkpoint-chain-receipt-outcome.js";
2
+ import type { Ks256Attestation } from "./ks256-attestation.js";
3
+ import type { RootVerifyKey } from "./root-verify-key.js";
4
+ /** Input to {@link verifyReceiptAgainstCheckpointChain}. */
5
+ export type VerifyReceiptAgainstCheckpointChainInput = {
6
+ receiptCbor: Uint8Array;
7
+ /** The leaf's idtimestamp; the last 8 bytes are used (big-endian). */
8
+ idtimestampBe8: Uint8Array;
9
+ /** Leaf ContentHash: SHA-256(payload), or the grant commitment hash. */
10
+ inner: Uint8Array;
11
+ /** Retained checkpoints in fold (ascending size) order. */
12
+ checkpoints: readonly Uint8Array[];
13
+ /** ES256 keys and KS256 addresses, from genesis or caller-known keys. */
14
+ rootKeys: readonly RootVerifyKey[];
15
+ /** ERC-1271 attestations, one per contract-root certificate. */
16
+ attestations?: readonly Ks256Attestation[] | undefined;
17
+ /**
18
+ * The forest genesis, read only for its chain id (attestations must
19
+ * match it). Give this or {@link forestChainId}; both must agree.
20
+ */
21
+ forestGenesisCbor?: Uint8Array | undefined;
22
+ forestChainId?: bigint | undefined;
23
+ /** Trusted base for a suffix chain; see `verifyCheckpointChain`. */
24
+ trustedBase?: {
25
+ size: bigint;
26
+ accumulator: Uint8Array[];
27
+ } | undefined;
28
+ };
29
+ /**
30
+ * Verify a receipt against a retained checkpoint chain.
31
+ *
32
+ * The receipt's own signature is not checked: the authenticated chain is
33
+ * the authority, and the peak is ALWAYS recomputed from the leaf
34
+ * (`idtimestamp ‖ inner`) and the inclusion path, never taken from an
35
+ * attached payload. A match binds the leaf's idtimestamp (through the leaf
36
+ * hash) and its mmrIndex (through the matched peak's POSITION, which must be
37
+ * the node the inclusion walk reaches: the leaf hash alone does not commit
38
+ * its position, and an empty path commits none).
39
+ * Links are searched newest first; the newest cover is reported.
40
+ */
41
+ export declare function verifyReceiptAgainstCheckpointChain(input: VerifyReceiptAgainstCheckpointChainInput): Promise<CheckpointChainReceiptOutcome>;
42
+ //# sourceMappingURL=verify-receipt-against-checkpoint-chain.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify-receipt-against-checkpoint-chain.d.ts","sourceRoot":"","sources":["../src/verify-receipt-against-checkpoint-chain.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,uCAAuC,CAAC;AAM3F,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAK/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAG1D,4DAA4D;AAC5D,MAAM,MAAM,wCAAwC,GAAG;IACrD,WAAW,EAAE,UAAU,CAAC;IACxB,sEAAsE;IACtE,cAAc,EAAE,UAAU,CAAC;IAC3B,wEAAwE;IACxE,KAAK,EAAE,UAAU,CAAC;IAClB,2DAA2D;IAC3D,WAAW,EAAE,SAAS,UAAU,EAAE,CAAC;IACnC,yEAAyE;IACzE,QAAQ,EAAE,SAAS,aAAa,EAAE,CAAC;IACnC,gEAAgE;IAChE,YAAY,CAAC,EAAE,SAAS,gBAAgB,EAAE,GAAG,SAAS,CAAC;IACvD;;;OAGG;IACH,iBAAiB,CAAC,EAAE,UAAU,GAAG,SAAS,CAAC;IAC3C,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,oEAAoE;IACpE,WAAW,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,UAAU,EAAE,CAAA;KAAE,GAAG,SAAS,CAAC;CACvE,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,wBAAsB,mCAAmC,CACvD,KAAK,EAAE,wCAAwC,GAC9C,OAAO,CAAC,6BAA6B,CAAC,CA0KxC"}
@@ -0,0 +1,205 @@
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 { verifyCheckpointChain, } from "./checkpoint-chain.js";
19
+ import { checkCheckpointLinkExpiry, makeCheckpointLinkVerifier, } from "./checkpoint-link-verifier.js";
20
+ import { decodeChainBindingFromGenesis } from "./decode-chain-binding-from-genesis.js";
21
+ import { summarizeKs256AttestationUsage } from "./ks256-attestation-usage.js";
22
+ import { inclusionWalkEndIndex } from "./inclusion-walk-end.js";
23
+ import { univocityLeafHash } from "./leaf-commitment.js";
24
+ import { parseReceipt } from "./parse-receipt.js";
25
+ import { SubtleHasher } from "./subtle-hasher.js";
26
+ /**
27
+ * Verify a receipt against a retained checkpoint chain.
28
+ *
29
+ * The receipt's own signature is not checked: the authenticated chain is
30
+ * the authority, and the peak is ALWAYS recomputed from the leaf
31
+ * (`idtimestamp ‖ inner`) and the inclusion path, never taken from an
32
+ * attached payload. A match binds the leaf's idtimestamp (through the leaf
33
+ * hash) and its mmrIndex (through the matched peak's POSITION, which must be
34
+ * the node the inclusion walk reaches: the leaf hash alone does not commit
35
+ * its position, and an empty path commits none).
36
+ * Links are searched newest first; the newest cover is reported.
37
+ */
38
+ export async function verifyReceiptAgainstCheckpointChain(input) {
39
+ const attestations = input.attestations ?? [];
40
+ const empty = {
41
+ links: [],
42
+ records: [],
43
+ attestations: summarizeKs256AttestationUsage(attestations, []),
44
+ };
45
+ if (input.rootKeys.length === 0) {
46
+ return { ...empty, ok: false, stage: "input", reason: "no_trust_key" };
47
+ }
48
+ let forestChainId = input.forestChainId;
49
+ if (input.forestGenesisCbor !== undefined) {
50
+ let fromGenesis;
51
+ try {
52
+ fromGenesis = BigInt(decodeChainBindingFromGenesis(input.forestGenesisCbor).chainId);
53
+ }
54
+ catch {
55
+ return {
56
+ ...empty,
57
+ ok: false,
58
+ stage: "input",
59
+ reason: "forest_genesis_invalid",
60
+ };
61
+ }
62
+ if (forestChainId !== undefined && forestChainId !== fromGenesis) {
63
+ return {
64
+ ...empty,
65
+ ok: false,
66
+ stage: "input",
67
+ reason: "forest_chain_id_mismatch",
68
+ };
69
+ }
70
+ forestChainId = fromGenesis;
71
+ }
72
+ const verifier = makeCheckpointLinkVerifier({
73
+ rootKeys: input.rootKeys,
74
+ attestations,
75
+ forestChainId,
76
+ });
77
+ const chain = await verifyCheckpointChain({
78
+ checkpoints: [...input.checkpoints],
79
+ verifySignature: verifier.verifySignature,
80
+ ...(input.trustedBase !== undefined
81
+ ? { trustedBase: input.trustedBase }
82
+ : {}),
83
+ });
84
+ const linkFailure = verifier.failure();
85
+ const common = {
86
+ links: chain.links,
87
+ records: verifier.records,
88
+ attestations: summarizeKs256AttestationUsage(attestations, verifier.records, linkFailure),
89
+ };
90
+ if (!chain.ok) {
91
+ return {
92
+ ...common,
93
+ ok: false,
94
+ stage: "chain",
95
+ reason: linkFailure?.reason ?? `checkpoint_chain_${chain.reason}`,
96
+ at: chain.at,
97
+ detail: chain.detail,
98
+ ...(linkFailure !== undefined ? { linkFailure } : {}),
99
+ };
100
+ }
101
+ let parsed;
102
+ try {
103
+ parsed = parseReceipt(input.receiptCbor);
104
+ }
105
+ catch {
106
+ return {
107
+ ...common,
108
+ ok: false,
109
+ stage: "parse",
110
+ reason: "receipt_malformed",
111
+ };
112
+ }
113
+ if (input.idtimestampBe8.length < 8) {
114
+ return {
115
+ ...common,
116
+ ok: false,
117
+ stage: "parse",
118
+ reason: "idtimestamp_invalid",
119
+ };
120
+ }
121
+ const idtimestamp = readU64Be(input.idtimestampBe8.slice(-8));
122
+ // parseReceipt always sets mmrIndex; leafIndex wins when present, as in
123
+ // every other peak recompute in this package.
124
+ const leafMmrIndex = parsed.proof.leafIndex ?? parsed.proof.mmrIndex;
125
+ if (leafMmrIndex === undefined) {
126
+ return {
127
+ ...common,
128
+ ok: false,
129
+ stage: "parse",
130
+ reason: "receipt_malformed",
131
+ };
132
+ }
133
+ const links = chain.links;
134
+ const final = links[links.length - 1];
135
+ if (leafMmrIndex >= final.treeSize2) {
136
+ return {
137
+ ...common,
138
+ ok: false,
139
+ stage: "anchor",
140
+ reason: "receipt_newer_than_checkpoint_chain",
141
+ };
142
+ }
143
+ let peak;
144
+ try {
145
+ peak = await calculateRoot(new SubtleHasher(), await univocityLeafHash(idtimestamp, input.inner), parsed.proof, leafMmrIndex);
146
+ }
147
+ catch {
148
+ return {
149
+ ...common,
150
+ ok: false,
151
+ stage: "parse",
152
+ reason: "receipt_malformed",
153
+ };
154
+ }
155
+ // The peak the path claims to reach. A leaf hash does not commit its
156
+ // position (and an empty path commits none), so a value match alone would
157
+ // leave mmrIndex a caller claim: the matched peak must sit at this index.
158
+ const peakIndex = inclusionWalkEndIndex(leafMmrIndex, parsed.proof.path.length);
159
+ for (let i = links.length - 1; i >= 0; i--) {
160
+ const link = links[i];
161
+ const p = link.treeSize2 > 0n
162
+ ? peakMMRIndexes(link.treeSize2 - 1n).indexOf(peakIndex)
163
+ : -1;
164
+ if (p < 0 || !bytesEqual(link.accumulator[p] ?? new Uint8Array(), peak)) {
165
+ continue;
166
+ }
167
+ // The leaf hash, inclusion path and peak position now bind idtimestamp
168
+ // and mmrIndex to an authenticated accumulator, so expiry can be judged
169
+ // on them.
170
+ const expiry = checkCheckpointLinkExpiry(verifier.records, {
171
+ mmrIndex: leafMmrIndex,
172
+ idtimestamp,
173
+ });
174
+ if (!expiry.ok) {
175
+ return {
176
+ ...common,
177
+ ok: false,
178
+ stage: "delegation",
179
+ reason: expiry.reason,
180
+ at: expiry.at,
181
+ };
182
+ }
183
+ return { ...common, ok: true, matchedLink: i, matchedPeak: p };
184
+ }
185
+ return {
186
+ ...common,
187
+ ok: false,
188
+ stage: "anchor",
189
+ reason: "peak_not_in_checkpoint_chain",
190
+ };
191
+ }
192
+ function readU64Be(be8) {
193
+ let v = 0n;
194
+ for (const b of be8)
195
+ v = (v << 8n) | BigInt(b);
196
+ return v;
197
+ }
198
+ function bytesEqual(a, b) {
199
+ if (a.length !== b.length)
200
+ return false;
201
+ let x = 0;
202
+ for (let i = 0; i < a.length; i++)
203
+ x |= a[i] ^ b[i];
204
+ return x === 0;
205
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forestrie/receipt-verify",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Offline SCITT grant receipt verification (ADR-0045)",
@@ -28,10 +28,10 @@
28
28
  ],
29
29
  "dependencies": {
30
30
  "@noble/hashes": "^1.7.1",
31
- "@forestrie/delegation-cose": "0.4.0",
31
+ "@forestrie/chain-rpc": "0.3.0",
32
32
  "@forestrie/encoding": "0.9.1",
33
33
  "@forestrie/merklelog": "0.5.0",
34
- "@forestrie/chain-rpc": "0.3.0"
34
+ "@forestrie/delegation-cose": "0.4.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@noble/curves": "^1.9.7",