@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
package/src/index.ts CHANGED
@@ -89,6 +89,29 @@ export type {
89
89
  } from "./resolve-delegated-verify-key.js";
90
90
  /** Import a raw 64-byte x||y P-256 public key as an ES256 verify key. */
91
91
  export { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
92
+ /**
93
+ * KS256 roots in offline verification (plan-2610-03 D4–D6, protocol ADR-0045
94
+ * amendment 2026-10-18). A KS256 root comes only from genesis, a caller-known
95
+ * log key ({@link knownLogKeyFromBytes}: asserts, does not prove) or, for a
96
+ * contract root, a caller-supplied ERC-1271 attestation; never from the
97
+ * delegation coordinator. EOA roots verify by ecrecover; anything else needs
98
+ * an attestation or fails with `ks256_root_not_recovered`.
99
+ */
100
+ export { knownLogKeyFromBytes } from "./known-log-key.js";
101
+ export { ks256RootFromAddress } from "./decode-trust-root-cbor.js";
102
+ export type { ParsedKs256RootKey, RootVerifyKey } from "./root-verify-key.js";
103
+ export {
104
+ ERC1271_MAGIC_VALUE,
105
+ decodeKs256Attestation,
106
+ encodeKs256Attestation,
107
+ type Ks256Attestation,
108
+ } from "./ks256-attestation.js";
109
+ export {
110
+ extractKs256DelegationDigest,
111
+ verifyKs256DelegationCert,
112
+ } from "./verify-ks256-delegation.js";
113
+ export type { Ks256DelegationEvidence } from "./verify-ks256-delegation.js";
114
+ export type { Ks256CertificateDigest } from "@forestrie/delegation-cose";
92
115
  /**
93
116
  * Passkey session-key endorsement v2 (ADR-0064 as amended by ADR-0065):
94
117
  * build, assemble, and verify the artifact chaining a passkey log root to the
@@ -188,3 +211,33 @@ export {
188
211
  verifyReceiptOfflineAgainstKnownAccumulator,
189
212
  type KnownAccumulator,
190
213
  } from "./known-accumulator.js";
214
+ /**
215
+ * Checkpoint-chain trust root with per-link delegation limits and several
216
+ * ERC-1271 attestations (owner ruling D8, receipt-verify 3.2.0): the one
217
+ * shared copy mcp-verify and forestrie-cli use. The high-level verifier
218
+ * folds, matches and judges expiry; the pieces serve callers that match
219
+ * peaks their own way.
220
+ */
221
+ export { verifyReceiptAgainstCheckpointChain } from "./verify-receipt-against-checkpoint-chain.js";
222
+ export type { VerifyReceiptAgainstCheckpointChainInput } from "./verify-receipt-against-checkpoint-chain.js";
223
+ export type {
224
+ CheckpointChainReceiptOutcome,
225
+ CheckpointChainReceiptStage,
226
+ } from "./checkpoint-chain-receipt-outcome.js";
227
+ export {
228
+ checkCheckpointLinkExpiry,
229
+ checkCheckpointLinkSizeBound,
230
+ makeCheckpointLinkVerifier,
231
+ type CheckpointLinkVerifier,
232
+ } from "./checkpoint-link-verifier.js";
233
+ export type {
234
+ CheckpointLinkFailure,
235
+ CheckpointLinkRecord,
236
+ CheckpointLinkVerifiedBy,
237
+ } from "./checkpoint-link-record.js";
238
+ export { selectKs256Attestation } from "./select-ks256-attestation.js";
239
+ export {
240
+ summarizeKs256AttestationUsage,
241
+ type Ks256AttestationUnusedReason,
242
+ type Ks256AttestationUsage,
243
+ } from "./ks256-attestation-usage.js";
@@ -33,8 +33,13 @@ import {
33
33
  decodeCborDeterministic,
34
34
  encodeCborDeterministic,
35
35
  } from "@forestrie/encoding";
36
- import { calculateRoot, type Proof } from "@forestrie/merklelog";
36
+ import {
37
+ calculateRoot,
38
+ peakMMRIndexes,
39
+ type Proof,
40
+ } from "@forestrie/merklelog";
37
41
  import { normalizeHexAddress } from "@forestrie/chain-rpc";
42
+ import { inclusionWalkEndIndex } from "./inclusion-walk-end.js";
38
43
  import { parseReceipt } from "./parse-receipt.js";
39
44
  import { univocityLeafHash } from "./leaf-commitment.js";
40
45
  import { SubtleHasher } from "./subtle-hasher.js";
@@ -243,23 +248,40 @@ export async function verifyReceiptOfflineAgainstKnownAccumulator(input: {
243
248
  };
244
249
  }
245
250
 
251
+ // The leaf (idtimestamp ‖ inner) and inclusion path ALWAYS decide the
252
+ // peak. An attached payload is only a claim of what that peak is: taken on
253
+ // its own, any receipt attaching a real anchored peak would "verify" for
254
+ // any payload and idtimestamp. Same stage and reason as the main offline
255
+ // path's inclusion check (verifySignatureAndInclusion).
246
256
  let recomputedPeak: Uint8Array;
247
- if (parsed.explicitPeak !== null) {
248
- recomputedPeak = parsed.explicitPeak;
249
- } else {
250
- const hasher = new SubtleHasher();
257
+ try {
251
258
  const leafHash = await univocityLeafHash(input.idtimestampBe8, input.inner);
252
259
  recomputedPeak = await calculateRoot(
253
- hasher,
260
+ new SubtleHasher(),
254
261
  leafHash,
255
262
  parsed.proof,
256
263
  leafIdx,
257
264
  );
265
+ } catch {
266
+ return { ok: false, stage: "inclusion", reason: "inclusion_failed" };
267
+ }
268
+ if (
269
+ parsed.explicitPeak !== null &&
270
+ !bytesEqual(parsed.explicitPeak, recomputedPeak)
271
+ ) {
272
+ return { ok: false, stage: "inclusion", reason: "inclusion_failed" };
258
273
  }
259
274
 
260
275
  // 2. Exact peak match — receipt state is a snapshot-covered accumulator.
261
- for (const peak of input.accumulator) {
262
- if (bytesEqual(recomputedPeak, peak)) {
276
+ // The peak must also sit at the node the inclusion walk reaches (contract
277
+ // order is ascending MMR index, as peakMMRIndexes lists them): a leaf hash
278
+ // does not commit its position, so a value match alone would leave the
279
+ // mmrIndex the freshness check above relies on a caller claim.
280
+ const peakIndex = inclusionWalkEndIndex(leafIdx, parsed.proof.path.length);
281
+ const p = peakMMRIndexes(input.size - 1n).indexOf(peakIndex);
282
+ if (p >= 0) {
283
+ const anchored = input.accumulator[p];
284
+ if (anchored !== undefined && bytesEqual(recomputedPeak, anchored)) {
263
285
  return { ok: true, stage: "binding" };
264
286
  }
265
287
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Caller-known log key bytes → trust key (FOR-297 "known log key"; plan-2610-03
3
+ * D6). The bytes alone decide the type, as univocity's
4
+ * `GrantDataInvalidKeyLength` and arbor's `grantDataIdentity` do: 64 bytes is
5
+ * an ES256 x||y public key, 20 bytes a KS256 address, anything else is
6
+ * refused. Either way the caller ASSERTS, and does not prove, that the key
7
+ * owns the log — see `verifyReceiptOfflineWithKeys`.
8
+ */
9
+
10
+ import {
11
+ importEs256PublicKeyFromGrantDataXy64,
12
+ ks256RootFromAddress,
13
+ } from "./decode-trust-root-cbor.js";
14
+ import type { RootVerifyKey } from "./root-verify-key.js";
15
+
16
+ /**
17
+ * @param bytes - 64-byte ES256 x||y or 20-byte KS256 address.
18
+ * @throws When the length is neither.
19
+ */
20
+ export async function knownLogKeyFromBytes(
21
+ bytes: Uint8Array,
22
+ ): Promise<RootVerifyKey> {
23
+ if (bytes.length === 64) return importEs256PublicKeyFromGrantDataXy64(bytes);
24
+ if (bytes.length === 20) return ks256RootFromAddress(bytes);
25
+ throw new Error(
26
+ `known log key must be 64 bytes (ES256 x||y) or 20 bytes (KS256 address), got ${bytes.length}`,
27
+ );
28
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Accounting for a LIST of ERC-1271 attestations over a checkpoint chain
3
+ * (owner ruling D8): every supplied attestation is reported as used or as
4
+ * unused with a reason, never silently ignored.
5
+ */
6
+ import type { Ks256Attestation } from "./ks256-attestation.js";
7
+ import type {
8
+ CheckpointLinkFailure,
9
+ CheckpointLinkRecord,
10
+ } from "./checkpoint-link-record.js";
11
+
12
+ /**
13
+ * Why a supplied attestation contributed nothing:
14
+ * - `no_matching_certificate`: no link's certificate has its digest and
15
+ * signature hash.
16
+ * - `not_needed`: it matched a certificate that verified without it
17
+ * (ecrecover, or an ES256 root also vouched).
18
+ * - `duplicate`: another supplied attestation for the same certificate
19
+ * (same digest and signature hash) was used, selected or rejected first,
20
+ * so this one was never tried.
21
+ */
22
+ export type Ks256AttestationUnusedReason =
23
+ | "no_matching_certificate"
24
+ | "not_needed"
25
+ | "duplicate";
26
+
27
+ /**
28
+ * Which supplied attestations carried which links. An attestation may be
29
+ * in both `used` and `rejected` (used on earlier links, refused on a later
30
+ * one); it is in `unused` only when it is in neither.
31
+ */
32
+ export type Ks256AttestationUsage = {
33
+ used: { index: number; digestHex: string; links: number[] }[];
34
+ /** Tried for a link's certificate and failed its own checks there. */
35
+ rejected: { index: number; digestHex: string; links: number[] }[];
36
+ unused: {
37
+ index: number;
38
+ digestHex: string;
39
+ reason: Ks256AttestationUnusedReason;
40
+ }[];
41
+ };
42
+
43
+ /**
44
+ * Classify each supplied attestation against the per-link records.
45
+ *
46
+ * @param attestations - The list the verifier was given, in order.
47
+ * @param records - Records of the links that verified.
48
+ * @param failure - The link the verifier refused, if any.
49
+ */
50
+ export function summarizeKs256AttestationUsage(
51
+ attestations: readonly Ks256Attestation[],
52
+ records: readonly CheckpointLinkRecord[],
53
+ failure?: CheckpointLinkFailure,
54
+ ): Ks256AttestationUsage {
55
+ const usage: Ks256AttestationUsage = { used: [], rejected: [], unused: [] };
56
+ const seen = [...records, ...(failure !== undefined ? [failure] : [])];
57
+ const tried = (i: number) =>
58
+ seen.some(
59
+ (r) =>
60
+ r.attestationIndex === i || (r.attestationsRejected ?? []).includes(i),
61
+ );
62
+ attestations.forEach((a, index) => {
63
+ const digestHex = bytesToHex(a.digest);
64
+ const used = records
65
+ .filter((r) => r.attestationIndex === index && r.attestationNeeded)
66
+ .map((r) => r.index);
67
+ const rejected = seen
68
+ .filter((r) => (r.attestationsRejected ?? []).includes(index))
69
+ .map((r) => r.index);
70
+ if (used.length > 0) usage.used.push({ index, digestHex, links: used });
71
+ if (rejected.length > 0) {
72
+ usage.rejected.push({ index, digestHex, links: rejected });
73
+ }
74
+ if (used.length > 0 || rejected.length > 0) return;
75
+ let reason: Ks256AttestationUnusedReason = "no_matching_certificate";
76
+ if (seen.some((r) => r.attestationIndex === index)) {
77
+ reason = "not_needed";
78
+ } else if (
79
+ attestations.some(
80
+ (b, j) =>
81
+ j !== index &&
82
+ tried(j) &&
83
+ bytesEqual(a.digest, b.digest) &&
84
+ bytesEqual(a.signatureHash, b.signatureHash),
85
+ )
86
+ ) {
87
+ reason = "duplicate";
88
+ }
89
+ usage.unused.push({ index, digestHex, reason });
90
+ });
91
+ return usage;
92
+ }
93
+
94
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
95
+ if (a.length !== b.length) return false;
96
+ let x = 0;
97
+ for (let i = 0; i < a.length; i++) x |= a[i]! ^ b[i]!;
98
+ return x === 0;
99
+ }
100
+
101
+ function bytesToHex(bytes: Uint8Array): string {
102
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
103
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * ERC-1271 attestation (plan-2610-03 D4, protocol ADR-0045 amendment
3
+ * 2026-10-18): a caller-supplied record that a contract KS256 root (a Safe)
4
+ * answered `isValidSignature(digest, signature)` with the ERC-1271 magic value
5
+ * at a given block. It lets a contract-rooted delegation certificate verify
6
+ * offline with the narrower guarantee "valid under the contract's signers at
7
+ * the attested block".
8
+ *
9
+ * Same trust model as the known-accumulator snapshot (known-accumulator.ts):
10
+ * the record is the fetching party's chain read, made explicit and auditable.
11
+ * It binds `(chainId, address, digest, signature hash, block)`, so anyone with
12
+ * RPC can re-run exactly that call at that block (the signature itself is in
13
+ * the receipt's certificate) and confirm or disprove it. This package never makes
14
+ * that call; forestrie-cli and mcp-resolve fetch it (slice 04).
15
+ *
16
+ * The verifier accepts an attestation only for the exact root address and
17
+ * certificate digest, and only when `chainId` equals the forest's genesis
18
+ * chain: the same Safe address on another chain can have other owners (D6).
19
+ */
20
+
21
+ import {
22
+ decodeCborDeterministic,
23
+ encodeCborDeterministic,
24
+ } from "@forestrie/encoding";
25
+
26
+ /** ERC-1271 `isValidSignature` success value, `bytes4(0x1626ba7e)`. */
27
+ export const ERC1271_MAGIC_VALUE = new Uint8Array([0x16, 0x26, 0xba, 0x7e]);
28
+
29
+ /** CBOR map labels for the attestation artifact (strict RFC 8949 §4.2). */
30
+ const LABEL_VERSION = 1;
31
+ const LABEL_CHAIN_ID = 2;
32
+ const LABEL_ADDRESS = 3;
33
+ const LABEL_DIGEST = 4;
34
+ const LABEL_BLOCK_NUMBER = 5;
35
+ const LABEL_BLOCK_HASH = 6;
36
+ const LABEL_MAGIC = 7;
37
+ const LABEL_SIGNATURE_HASH = 8;
38
+
39
+ const KNOWN_LABELS = new Set<number>([
40
+ LABEL_VERSION,
41
+ LABEL_CHAIN_ID,
42
+ LABEL_ADDRESS,
43
+ LABEL_DIGEST,
44
+ LABEL_BLOCK_NUMBER,
45
+ LABEL_BLOCK_HASH,
46
+ LABEL_MAGIC,
47
+ LABEL_SIGNATURE_HASH,
48
+ ]);
49
+
50
+ const ATTESTATION_VERSION = 1;
51
+
52
+ /** A block-bound ERC-1271 `isValidSignature` result for one digest. */
53
+ export type Ks256Attestation = {
54
+ version: number;
55
+ /** Chain the call was made on; must equal the forest's genesis chain. */
56
+ chainId: bigint;
57
+ /** Contract root address (20 bytes) the call was made to. */
58
+ address: Uint8Array;
59
+ /** keccak256(Sig_structure) of the delegation certificate (32 bytes). */
60
+ digest: Uint8Array;
61
+ /**
62
+ * keccak256 of the signature bytes `isValidSignature` was called with
63
+ * (32 bytes); must match the certificate's signature.
64
+ */
65
+ signatureHash: Uint8Array;
66
+ /** Block the call was made at. */
67
+ blockNumber: bigint;
68
+ /** Hash of that block (32 bytes): the falsifiability handle. */
69
+ blockHash: Uint8Array;
70
+ /** The call's 4-byte return; only {@link ERC1271_MAGIC_VALUE} attests. */
71
+ magic: Uint8Array;
72
+ };
73
+
74
+ /** Encode an attestation as canonical CBOR (RFC 8949 §4.2, hard policy). */
75
+ export function encodeKs256Attestation(
76
+ attestation: Ks256Attestation,
77
+ ): Uint8Array {
78
+ return encodeCborDeterministic(
79
+ new Map<number, unknown>([
80
+ [LABEL_VERSION, attestation.version],
81
+ [LABEL_CHAIN_ID, attestation.chainId],
82
+ [LABEL_ADDRESS, attestation.address],
83
+ [LABEL_DIGEST, attestation.digest],
84
+ [LABEL_BLOCK_NUMBER, attestation.blockNumber],
85
+ [LABEL_BLOCK_HASH, attestation.blockHash],
86
+ [LABEL_MAGIC, attestation.magic],
87
+ [LABEL_SIGNATURE_HASH, attestation.signatureHash],
88
+ ]),
89
+ );
90
+ }
91
+
92
+ function asBigint(v: unknown, what: string): bigint {
93
+ if (typeof v === "bigint") return v;
94
+ if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0)
95
+ return BigInt(v);
96
+ throw new Error(`ks256 attestation: ${what} must be an unsigned integer`);
97
+ }
98
+
99
+ function asBytes(v: unknown, length: number, what: string): Uint8Array {
100
+ if (!(v instanceof Uint8Array) || v.length !== length) {
101
+ throw new Error(`ks256 attestation: ${what} must be ${length} bytes`);
102
+ }
103
+ return v;
104
+ }
105
+
106
+ /**
107
+ * Strict decode and shape validation of an attestation artifact: canonical
108
+ * CBOR, known labels only, version 1, exact types and lengths.
109
+ */
110
+ export function decodeKs256Attestation(bytes: Uint8Array): Ks256Attestation {
111
+ let decoded: unknown;
112
+ try {
113
+ decoded = decodeCborDeterministic(bytes);
114
+ } catch (err) {
115
+ const message = err instanceof Error ? err.message : String(err);
116
+ throw new Error(`ks256 attestation is not canonical CBOR: ${message}`);
117
+ }
118
+ if (!(decoded instanceof Map)) {
119
+ throw new Error("ks256 attestation must be a CBOR map");
120
+ }
121
+ for (const label of decoded.keys()) {
122
+ if (!KNOWN_LABELS.has(label as number)) {
123
+ throw new Error(`ks256 attestation: unknown label ${String(label)}`);
124
+ }
125
+ }
126
+ const version = Number(asBigint(decoded.get(LABEL_VERSION), "version"));
127
+ if (version !== ATTESTATION_VERSION) {
128
+ throw new Error(`ks256 attestation version ${version} not supported`);
129
+ }
130
+ return {
131
+ version,
132
+ chainId: asBigint(decoded.get(LABEL_CHAIN_ID), "chainId"),
133
+ address: asBytes(decoded.get(LABEL_ADDRESS), 20, "address"),
134
+ digest: asBytes(decoded.get(LABEL_DIGEST), 32, "digest"),
135
+ blockNumber: asBigint(decoded.get(LABEL_BLOCK_NUMBER), "blockNumber"),
136
+ blockHash: asBytes(decoded.get(LABEL_BLOCK_HASH), 32, "blockHash"),
137
+ magic: asBytes(decoded.get(LABEL_MAGIC), 4, "magic"),
138
+ signatureHash: asBytes(
139
+ decoded.get(LABEL_SIGNATURE_HASH),
140
+ 32,
141
+ "signatureHash",
142
+ ),
143
+ };
144
+ }
@@ -6,12 +6,13 @@
6
6
  * trust root and returns the delegated public key as an additional verify
7
7
  * candidate — the offline port of canopy-api's `resolveReceiptVerifyKey`.
8
8
  *
9
- * P-256 roots only: the genesis alg is ES256 and sealer delegate keys are
10
- * always secp256r1; KS256-rooted delegation is a server-only concern. A
11
- * passkey (WebAuthn) root signs the certificate with alg -65800 and the
12
- * ADR-0063 unprotected-header envelope — `verifyCoseSign1WithParsedKey`
13
- * verifies that envelope against the same P-256 root keys, so the chain walk
14
- * here is alg-agnostic (plan-2608-13 1.4).
9
+ * Sealer delegate keys are always secp256r1. A P-256 root's certificate is
10
+ * checked here; a passkey (WebAuthn) root signs the certificate with alg
11
+ * -65800 and the ADR-0063 unprotected-header envelope, which
12
+ * `verifyCoseSign1WithParsedKey` verifies against the same P-256 root keys
13
+ * (plan-2608-13 1.4). A KS256 root's certificate is checked by
14
+ * {@link verifyKs256DelegationCert}: ecrecover for an EOA, or a caller-supplied
15
+ * ERC-1271 attestation for a contract (plan-2610-03 D4).
15
16
  *
16
17
  * Trust anchors (FOR-297 trust ladder): `rootKeys` need not be genesis-derived
17
18
  * — a caller-known log OWNER key works identically (see
@@ -41,6 +42,11 @@ import {
41
42
  decodeCoseSign1,
42
43
  verifyCoseSign1WithParsedKey,
43
44
  } from "@forestrie/encoding";
45
+ import { isParsedKs256RootKey, type RootVerifyKey } from "./root-verify-key.js";
46
+ import {
47
+ verifyKs256DelegationCert,
48
+ type Ks256DelegationEvidence,
49
+ } from "./verify-ks256-delegation.js";
44
50
 
45
51
  /** Unprotected header label carrying the delegation certificate. */
46
52
  const DELEGATION_CERT_LABEL = 1000;
@@ -104,7 +110,8 @@ export type DelegationConstraints = {
104
110
  * `constraints` carries the cert's coverage/validity window when the payload
105
111
  * declares it (null for legacy certs that predate labels 3/4/8/9).
106
112
  * broken: a cert is present but did not verify under the root, or its delegated
107
- * key could not be parsed — the delegation chain is invalid.
113
+ * key could not be parsed — the delegation chain is invalid. `reason` is set
114
+ * when a KS256 root gives a more specific one than `delegation_invalid`.
108
115
  */
109
116
  export type DelegatedResolution =
110
117
  | { kind: "root-only" }
@@ -113,7 +120,7 @@ export type DelegatedResolution =
113
120
  delegatedKey: CryptoKey;
114
121
  constraints: DelegationConstraints | null;
115
122
  }
116
- | { kind: "broken" };
123
+ | { kind: "broken"; reason?: string };
117
124
 
118
125
  function extractDelegationCertBytes(unprotected: unknown): Uint8Array | null {
119
126
  const umap = coseUnprotectedToMap(unprotected);
@@ -262,11 +269,16 @@ export function checkDelegationConstraints(
262
269
  * Resolve the delegated verify key from a receipt's delegation certificate.
263
270
  *
264
271
  * @param receiptCbor - the COSE_Sign1 receipt bytes.
265
- * @param rootKeys - genesis-derived ES256 root verify keys.
272
+ * @param rootKeys - trust roots: ES256 keys and KS256 addresses, from genesis
273
+ * or caller-known log keys. A KS256 root never comes from the coordinator
274
+ * (its public roots are last-write-wins, FOR-601/FOR-603).
275
+ * @param ks256Evidence - optional ERC-1271 attestation for a KS256 contract
276
+ * root, with the forest's genesis chain id it must match.
266
277
  */
267
278
  export async function resolveDelegatedVerifyKey(
268
279
  receiptCbor: Uint8Array,
269
- rootKeys: CryptoKey[],
280
+ rootKeys: RootVerifyKey[],
281
+ ks256Evidence: Ks256DelegationEvidence = {},
270
282
  ): Promise<DelegatedResolution> {
271
283
  const decoded = decodeCoseSign1(receiptCbor);
272
284
  if (!decoded) return { kind: "root-only" };
@@ -275,7 +287,21 @@ export async function resolveDelegatedVerifyKey(
275
287
  if (!certBytes) return { kind: "root-only" };
276
288
 
277
289
  let verified = false;
290
+ let ks256Reason: string | undefined;
278
291
  for (const rootKey of rootKeys) {
292
+ if (isParsedKs256RootKey(rootKey)) {
293
+ const check = await verifyKs256DelegationCert(
294
+ certBytes,
295
+ rootKey.address,
296
+ ks256Evidence,
297
+ );
298
+ if (check.ok) {
299
+ verified = true;
300
+ break;
301
+ }
302
+ ks256Reason ??= check.reason;
303
+ continue;
304
+ }
279
305
  if (
280
306
  await verifyCoseSign1WithParsedKey(certBytes, rootKey, {
281
307
  logPrefix: "delegation-cert-offline",
@@ -285,7 +311,7 @@ export async function resolveDelegatedVerifyKey(
285
311
  break;
286
312
  }
287
313
  }
288
- if (!verified) return { kind: "broken" };
314
+ if (!verified) return { kind: "broken", reason: ks256Reason };
289
315
 
290
316
  const certDecoded = decodeCoseSign1(certBytes);
291
317
  if (!certDecoded) return { kind: "broken" };
@@ -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
+ }