@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.
- package/dist/decode-trust-root-cbor.d.ts +8 -1
- package/dist/decode-trust-root-cbor.d.ts.map +1 -1
- package/dist/decode-trust-root-cbor.js +10 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/known-log-key.d.ts +15 -0
- package/dist/known-log-key.d.ts.map +1 -0
- package/dist/known-log-key.js +20 -0
- package/dist/ks256-attestation.d.ts +50 -0
- package/dist/ks256-attestation.d.ts.map +1 -0
- package/dist/ks256-attestation.js +104 -0
- package/dist/resolve-delegated-verify-key.d.ts +18 -9
- package/dist/resolve-delegated-verify-key.d.ts.map +1 -1
- package/dist/resolve-delegated-verify-key.js +26 -9
- package/dist/verify-grant-receipt-offline.d.ts +20 -6
- package/dist/verify-grant-receipt-offline.d.ts.map +1 -1
- package/dist/verify-grant-receipt-offline.js +60 -11
- package/dist/verify-ks256-delegation.d.ts +56 -0
- package/dist/verify-ks256-delegation.d.ts.map +1 -0
- package/dist/verify-ks256-delegation.js +95 -0
- package/package.json +5 -3
- package/src/decode-trust-root-cbor.ts +10 -1
- package/src/index.ts +23 -0
- package/src/known-log-key.ts +28 -0
- package/src/ks256-attestation.ts +144 -0
- package/src/resolve-delegated-verify-key.ts +37 -11
- package/src/verify-grant-receipt-offline.ts +92 -19
- package/src/verify-ks256-delegation.ts +120 -0
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|
|
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, ...
|
|
306
|
-
:
|
|
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
|
+
}
|