@forestrie/receipt-verify 3.0.1 → 3.1.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.
@@ -7,7 +7,10 @@ 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 type { Ks256DelegationEvidence } from "./verify-ks256-delegation.js";
13
+ import { isParsedKs256RootKey, type RootVerifyKey } from "./root-verify-key.js";
11
14
  import {
12
15
  checkDelegationConstraints,
13
16
  resolveDelegatedVerifyKey,
@@ -22,6 +25,8 @@ export type VerifyGrantReceiptOfflineInput = {
22
25
  receiptCbor: Uint8Array;
23
26
  grant: Grant;
24
27
  idtimestampBe8: Uint8Array;
28
+ /** See {@link VerifyReceiptOfflineInput.ks256Attestation}. */
29
+ ks256Attestation?: Ks256Attestation;
25
30
  };
26
31
 
27
32
  function readIdtimestampBe8(bytes: Uint8Array): bigint {
@@ -116,16 +121,15 @@ export async function verifyGrantReceiptOffline(
116
121
  receiptCbor: input.receiptCbor,
117
122
  idtimestampBe8: input.idtimestampBe8,
118
123
  inner,
124
+ ks256Attestation: input.ks256Attestation,
119
125
  });
120
126
  }
121
127
 
122
128
  export type VerifyGrantReceiptOfflineWithKeysInput = Omit<
123
129
  VerifyGrantReceiptOfflineInput,
124
130
  "genesisCbor"
125
- > & {
126
- /** Caller-known ES256 trust keys — see {@link VerifyReceiptOfflineWithKeysInput}. */
127
- trustKeys: CryptoKey[];
128
- };
131
+ > &
132
+ Pick<VerifyReceiptOfflineWithKeysInput, "trustKeys" | "forestGenesisCbor">;
129
133
 
130
134
  /**
131
135
  * {@link verifyGrantReceiptOffline} under caller-supplied trust keys instead of
@@ -146,6 +150,7 @@ export async function verifyGrantReceiptOfflineWithKeys(
146
150
  idtimestampBe8: input.idtimestampBe8,
147
151
  inner,
148
152
  trustKeys: input.trustKeys,
153
+ ks256Evidence: withKeysKs256Evidence(input),
149
154
  });
150
155
  }
151
156
 
@@ -160,6 +165,12 @@ export type VerifyReceiptOfflineInput = {
160
165
  */
161
166
  payload: Uint8Array;
162
167
  idtimestampBe8: Uint8Array;
168
+ /**
169
+ * ERC-1271 attestation for a KS256 contract root (plan-2610-03 D4). Used
170
+ * only when ecrecover doesn't match the root, and accepted only when it
171
+ * binds the root address, the certificate digest and the genesis chain id.
172
+ */
173
+ ks256Attestation?: Ks256Attestation;
163
174
  };
164
175
 
165
176
  /**
@@ -183,6 +194,7 @@ export async function verifyReceiptOffline(
183
194
  receiptCbor: input.receiptCbor,
184
195
  idtimestampBe8: input.idtimestampBe8,
185
196
  inner,
197
+ ks256Attestation: input.ks256Attestation,
186
198
  });
187
199
  }
188
200
 
@@ -191,12 +203,19 @@ export type VerifyReceiptOfflineWithKeysInput = Omit<
191
203
  "genesisCbor"
192
204
  > & {
193
205
  /**
194
- * Caller-known ES256 trust keys, tried in order. For a child-log receipt
206
+ * Caller-known trust keys, tried in order: ES256 `CryptoKey`s or KS256
207
+ * 20-byte addresses ({@link knownLogKeyFromBytes}). For a child-log receipt
195
208
  * this is the log OWNER's key (the delegation-cert issuer), NOT the sealer
196
209
  * key — the label-1000 cert is verified under these keys and the delegated
197
210
  * sealer key is extracted from it, so the anchor survives sealer rotation.
198
211
  */
199
- trustKeys: CryptoKey[];
212
+ trustKeys: RootVerifyKey[];
213
+ /**
214
+ * The forest's genesis, read only for the chain id a `ks256Attestation`
215
+ * must match (D6); it supplies no trust keys. Without it an attestation is
216
+ * refused with `ks256_attestation_chain_unbound`.
217
+ */
218
+ forestGenesisCbor?: Uint8Array;
200
219
  };
201
220
 
202
221
  /**
@@ -230,9 +249,41 @@ export async function verifyReceiptOfflineWithKeys(
230
249
  idtimestampBe8: input.idtimestampBe8,
231
250
  inner,
232
251
  trustKeys: input.trustKeys,
252
+ ks256Evidence: withKeysKs256Evidence(input),
233
253
  });
234
254
  }
235
255
 
256
+ /**
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.
259
+ */
260
+ function withKeysKs256Evidence(input: {
261
+ 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
+ };
271
+ }
272
+
273
+ /**
274
+ * The forest's chain id from its genesis chain binding, or `undefined` when
275
+ * the genesis carries none (an attestation then can't be bound, D6).
276
+ */
277
+ function genesisChainIdOrUndefined(
278
+ genesisCbor: Uint8Array,
279
+ ): bigint | undefined {
280
+ try {
281
+ return BigInt(decodeChainBindingFromGenesis(genesisCbor).chainId);
282
+ } catch {
283
+ return undefined;
284
+ }
285
+ }
286
+
236
287
  /**
237
288
  * Shared offline-verify core: reconstruct the leaf as
238
289
  * `univocityLeafHash(idtimestamp, inner)`, resolve the (possibly delegated,
@@ -245,37 +296,42 @@ async function verifyReceiptOfflineWithLeafInner(input: {
245
296
  receiptCbor: Uint8Array;
246
297
  idtimestampBe8: Uint8Array;
247
298
  inner: Uint8Array;
299
+ ks256Attestation?: Ks256Attestation | undefined;
248
300
  }): Promise<ReceiptVerifyResult> {
249
- let trustRoot;
301
+ let trustRoot: RootVerifyKey;
250
302
  try {
251
303
  trustRoot = await decodeTrustRootFromGenesis(input.genesisCbor);
252
304
  } catch {
253
305
  return { ok: false, stage: "parse", reason: "genesis_invalid" };
254
306
  }
255
307
 
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
308
  return verifyReceiptOfflineWithLeafInnerKeys({
262
309
  receiptCbor: input.receiptCbor,
263
310
  idtimestampBe8: input.idtimestampBe8,
264
311
  inner: input.inner,
265
- trustKeys: verifyKeys,
312
+ trustKeys: [trustRoot],
313
+ ks256Evidence: input.ks256Attestation
314
+ ? {
315
+ attestation: input.ks256Attestation,
316
+ forestChainId: genesisChainIdOrUndefined(input.genesisCbor),
317
+ }
318
+ : {},
266
319
  });
267
320
  }
268
321
 
269
322
  /**
270
323
  * Trust-key-parameterised offline-verify core. Identical to the genesis path
271
324
  * once the trust keys are in hand: resolve the (possibly delegated, FOR-297)
272
- * verify key against `trustKeys`, then check signature + inclusion.
325
+ * verify key against `trustKeys`, then check signature + inclusion. Receipts
326
+ * are always ES256, so a KS256 trust key only ever verifies the delegation
327
+ * certificate; only ES256 trust keys are tried on the receipt itself.
273
328
  */
274
329
  async function verifyReceiptOfflineWithLeafInnerKeys(input: {
275
330
  receiptCbor: Uint8Array;
276
331
  idtimestampBe8: Uint8Array;
277
332
  inner: Uint8Array;
278
- trustKeys: CryptoKey[];
333
+ trustKeys: RootVerifyKey[];
334
+ ks256Evidence: Ks256DelegationEvidence;
279
335
  }): Promise<ReceiptVerifyResult> {
280
336
  let parsed: ReturnType<typeof parseReceipt>;
281
337
  try {
@@ -296,14 +352,31 @@ async function verifyReceiptOfflineWithLeafInnerKeys(input: {
296
352
  const delegation = await resolveDelegatedVerifyKey(
297
353
  input.receiptCbor,
298
354
  input.trustKeys,
355
+ input.ks256Evidence,
299
356
  );
300
357
  if (delegation.kind === "broken") {
301
- return { ok: false, stage: "signature", reason: "delegation_invalid" };
358
+ return {
359
+ ok: false,
360
+ stage: "signature",
361
+ reason: delegation.reason ?? "delegation_invalid",
362
+ };
363
+ }
364
+ const es256TrustKeys = input.trustKeys.filter(
365
+ (k): k is CryptoKey => !isParsedKs256RootKey(k),
366
+ );
367
+ if (delegation.kind === "root-only" && !es256TrustKeys.length) {
368
+ // A KS256 root never signs a receipt; without a certificate there is no
369
+ // ES256 key to check it with.
370
+ return {
371
+ ok: false,
372
+ stage: "signature",
373
+ reason: "ks256_root_requires_delegation",
374
+ };
302
375
  }
303
376
  const allVerifyKeys =
304
377
  delegation.kind === "resolved"
305
- ? [delegation.delegatedKey, ...input.trustKeys]
306
- : input.trustKeys;
378
+ ? [delegation.delegatedKey, ...es256TrustKeys]
379
+ : es256TrustKeys;
307
380
 
308
381
  let idtimestamp: bigint;
309
382
  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
+ }