@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.
- package/dist/checkpoint-chain-receipt-outcome.d.ts +56 -0
- package/dist/checkpoint-chain-receipt-outcome.d.ts.map +1 -0
- package/dist/checkpoint-chain-receipt-outcome.js +1 -0
- package/dist/checkpoint-link-record.d.ts +69 -0
- package/dist/checkpoint-link-record.d.ts.map +1 -0
- package/dist/checkpoint-link-record.js +1 -0
- package/dist/checkpoint-link-verifier.d.ts +80 -0
- package/dist/checkpoint-link-verifier.d.ts.map +1 -0
- package/dist/checkpoint-link-verifier.js +181 -0
- 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/inclusion-walk-end.d.ts +6 -0
- package/dist/inclusion-walk-end.d.ts.map +1 -0
- package/dist/inclusion-walk-end.js +35 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -0
- package/dist/known-accumulator.d.ts.map +1 -1
- package/dist/known-accumulator.js +25 -9
- 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-usage.d.ts +50 -0
- package/dist/ks256-attestation-usage.d.ts.map +1 -0
- package/dist/ks256-attestation-usage.js +51 -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/select-ks256-attestation.d.ts +57 -0
- package/dist/select-ks256-attestation.d.ts.map +1 -0
- package/dist/select-ks256-attestation.js +119 -0
- package/dist/verify-grant-receipt-offline.d.ts +31 -6
- package/dist/verify-grant-receipt-offline.d.ts.map +1 -1
- package/dist/verify-grant-receipt-offline.js +63 -12
- 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/dist/verify-receipt-against-checkpoint-chain.d.ts +42 -0
- package/dist/verify-receipt-against-checkpoint-chain.d.ts.map +1 -0
- package/dist/verify-receipt-against-checkpoint-chain.js +205 -0
- package/package.json +4 -2
- package/src/checkpoint-chain-receipt-outcome.ts +67 -0
- package/src/checkpoint-link-record.ts +74 -0
- package/src/checkpoint-link-verifier.ts +241 -0
- package/src/decode-trust-root-cbor.ts +10 -1
- package/src/inclusion-walk-end.ts +38 -0
- package/src/index.ts +53 -0
- package/src/known-accumulator.ts +30 -8
- package/src/known-log-key.ts +28 -0
- package/src/ks256-attestation-usage.ts +103 -0
- package/src/ks256-attestation.ts +144 -0
- package/src/resolve-delegated-verify-key.ts +37 -11
- package/src/select-ks256-attestation.ts +151 -0
- package/src/verify-grant-receipt-offline.ts +109 -20
- package/src/verify-ks256-delegation.ts +120 -0
- package/src/verify-receipt-against-checkpoint-chain.ts +256 -0
|
@@ -7,7 +7,9 @@ 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 { isParsedKs256RootKey, type RootVerifyKey } from "./root-verify-key.js";
|
|
11
13
|
import {
|
|
12
14
|
checkDelegationConstraints,
|
|
13
15
|
resolveDelegatedVerifyKey,
|
|
@@ -16,12 +18,17 @@ import { univocityLeafHash } from "./leaf-commitment.js";
|
|
|
16
18
|
import { parseReceipt } from "./parse-receipt.js";
|
|
17
19
|
import type { ReceiptVerifyResult } from "./receipt-verify-result.js";
|
|
18
20
|
import { SubtleHasher } from "./subtle-hasher.js";
|
|
21
|
+
import { resolveWithKs256Attestations } from "./select-ks256-attestation.js";
|
|
19
22
|
|
|
20
23
|
export type VerifyGrantReceiptOfflineInput = {
|
|
21
24
|
genesisCbor: Uint8Array;
|
|
22
25
|
receiptCbor: Uint8Array;
|
|
23
26
|
grant: Grant;
|
|
24
27
|
idtimestampBe8: Uint8Array;
|
|
28
|
+
/** See {@link VerifyReceiptOfflineInput.ks256Attestation}. */
|
|
29
|
+
ks256Attestation?: Ks256Attestation;
|
|
30
|
+
/** See {@link VerifyReceiptOfflineInput.ks256Attestations}. */
|
|
31
|
+
ks256Attestations?: readonly Ks256Attestation[];
|
|
25
32
|
};
|
|
26
33
|
|
|
27
34
|
function readIdtimestampBe8(bytes: Uint8Array): bigint {
|
|
@@ -116,16 +123,15 @@ export async function verifyGrantReceiptOffline(
|
|
|
116
123
|
receiptCbor: input.receiptCbor,
|
|
117
124
|
idtimestampBe8: input.idtimestampBe8,
|
|
118
125
|
inner,
|
|
126
|
+
ks256Attestations: attestationCandidates(input),
|
|
119
127
|
});
|
|
120
128
|
}
|
|
121
129
|
|
|
122
130
|
export type VerifyGrantReceiptOfflineWithKeysInput = Omit<
|
|
123
131
|
VerifyGrantReceiptOfflineInput,
|
|
124
132
|
"genesisCbor"
|
|
125
|
-
> &
|
|
126
|
-
|
|
127
|
-
trustKeys: CryptoKey[];
|
|
128
|
-
};
|
|
133
|
+
> &
|
|
134
|
+
Pick<VerifyReceiptOfflineWithKeysInput, "trustKeys" | "forestGenesisCbor">;
|
|
129
135
|
|
|
130
136
|
/**
|
|
131
137
|
* {@link verifyGrantReceiptOffline} under caller-supplied trust keys instead of
|
|
@@ -146,6 +152,10 @@ export async function verifyGrantReceiptOfflineWithKeys(
|
|
|
146
152
|
idtimestampBe8: input.idtimestampBe8,
|
|
147
153
|
inner,
|
|
148
154
|
trustKeys: input.trustKeys,
|
|
155
|
+
ks256Attestations: attestationCandidates(input),
|
|
156
|
+
forestChainId: input.forestGenesisCbor
|
|
157
|
+
? genesisChainIdOrUndefined(input.forestGenesisCbor)
|
|
158
|
+
: undefined,
|
|
149
159
|
});
|
|
150
160
|
}
|
|
151
161
|
|
|
@@ -160,6 +170,21 @@ export type VerifyReceiptOfflineInput = {
|
|
|
160
170
|
*/
|
|
161
171
|
payload: Uint8Array;
|
|
162
172
|
idtimestampBe8: Uint8Array;
|
|
173
|
+
/**
|
|
174
|
+
* ERC-1271 attestation for a KS256 contract root (plan-2610-03 D4). Used
|
|
175
|
+
* only when ecrecover doesn't match the root, and accepted only when it
|
|
176
|
+
* binds the root address, the certificate digest and the genesis chain id.
|
|
177
|
+
*/
|
|
178
|
+
ks256Attestation?: Ks256Attestation;
|
|
179
|
+
/**
|
|
180
|
+
* Several attestations (owner ruling D8): the one whose digest and
|
|
181
|
+
* signature hash match the receipt's certificate is used
|
|
182
|
+
* ({@link selectKs256Attestation}). Combined with
|
|
183
|
+
* {@link ks256Attestation} when both are given. When none matches, one
|
|
184
|
+
* still reaches the KS256 check only to name the mismatch: it can never
|
|
185
|
+
* verify the certificate.
|
|
186
|
+
*/
|
|
187
|
+
ks256Attestations?: readonly Ks256Attestation[];
|
|
163
188
|
};
|
|
164
189
|
|
|
165
190
|
/**
|
|
@@ -183,6 +208,7 @@ export async function verifyReceiptOffline(
|
|
|
183
208
|
receiptCbor: input.receiptCbor,
|
|
184
209
|
idtimestampBe8: input.idtimestampBe8,
|
|
185
210
|
inner,
|
|
211
|
+
ks256Attestations: attestationCandidates(input),
|
|
186
212
|
});
|
|
187
213
|
}
|
|
188
214
|
|
|
@@ -191,12 +217,19 @@ export type VerifyReceiptOfflineWithKeysInput = Omit<
|
|
|
191
217
|
"genesisCbor"
|
|
192
218
|
> & {
|
|
193
219
|
/**
|
|
194
|
-
* Caller-known
|
|
220
|
+
* Caller-known trust keys, tried in order: ES256 `CryptoKey`s or KS256
|
|
221
|
+
* 20-byte addresses ({@link knownLogKeyFromBytes}). For a child-log receipt
|
|
195
222
|
* this is the log OWNER's key (the delegation-cert issuer), NOT the sealer
|
|
196
223
|
* key — the label-1000 cert is verified under these keys and the delegated
|
|
197
224
|
* sealer key is extracted from it, so the anchor survives sealer rotation.
|
|
198
225
|
*/
|
|
199
|
-
trustKeys:
|
|
226
|
+
trustKeys: RootVerifyKey[];
|
|
227
|
+
/**
|
|
228
|
+
* The forest's genesis, read only for the chain id a `ks256Attestation`
|
|
229
|
+
* must match (D6); it supplies no trust keys. Without it an attestation is
|
|
230
|
+
* refused with `ks256_attestation_chain_unbound`.
|
|
231
|
+
*/
|
|
232
|
+
forestGenesisCbor?: Uint8Array;
|
|
200
233
|
};
|
|
201
234
|
|
|
202
235
|
/**
|
|
@@ -230,9 +263,41 @@ export async function verifyReceiptOfflineWithKeys(
|
|
|
230
263
|
idtimestampBe8: input.idtimestampBe8,
|
|
231
264
|
inner,
|
|
232
265
|
trustKeys: input.trustKeys,
|
|
266
|
+
ks256Attestations: attestationCandidates(input),
|
|
267
|
+
forestChainId: input.forestGenesisCbor
|
|
268
|
+
? genesisChainIdOrUndefined(input.forestGenesisCbor)
|
|
269
|
+
: undefined,
|
|
233
270
|
});
|
|
234
271
|
}
|
|
235
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Every attestation the caller supplied: the list, then the singular field
|
|
275
|
+
* (kept for 3.1.0 callers).
|
|
276
|
+
*/
|
|
277
|
+
function attestationCandidates(input: {
|
|
278
|
+
ks256Attestation?: Ks256Attestation | undefined;
|
|
279
|
+
ks256Attestations?: readonly Ks256Attestation[] | undefined;
|
|
280
|
+
}): Ks256Attestation[] {
|
|
281
|
+
return [
|
|
282
|
+
...(input.ks256Attestations ?? []),
|
|
283
|
+
...(input.ks256Attestation ? [input.ks256Attestation] : []),
|
|
284
|
+
];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The forest's chain id from its genesis chain binding, or `undefined` when
|
|
289
|
+
* the genesis carries none (an attestation then can't be bound, D6).
|
|
290
|
+
*/
|
|
291
|
+
function genesisChainIdOrUndefined(
|
|
292
|
+
genesisCbor: Uint8Array,
|
|
293
|
+
): bigint | undefined {
|
|
294
|
+
try {
|
|
295
|
+
return BigInt(decodeChainBindingFromGenesis(genesisCbor).chainId);
|
|
296
|
+
} catch {
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
236
301
|
/**
|
|
237
302
|
* Shared offline-verify core: reconstruct the leaf as
|
|
238
303
|
* `univocityLeafHash(idtimestamp, inner)`, resolve the (possibly delegated,
|
|
@@ -245,37 +310,40 @@ async function verifyReceiptOfflineWithLeafInner(input: {
|
|
|
245
310
|
receiptCbor: Uint8Array;
|
|
246
311
|
idtimestampBe8: Uint8Array;
|
|
247
312
|
inner: Uint8Array;
|
|
313
|
+
ks256Attestations: Ks256Attestation[];
|
|
248
314
|
}): Promise<ReceiptVerifyResult> {
|
|
249
|
-
let trustRoot;
|
|
315
|
+
let trustRoot: RootVerifyKey;
|
|
250
316
|
try {
|
|
251
317
|
trustRoot = await decodeTrustRootFromGenesis(input.genesisCbor);
|
|
252
318
|
} catch {
|
|
253
319
|
return { ok: false, stage: "parse", reason: "genesis_invalid" };
|
|
254
320
|
}
|
|
255
321
|
|
|
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
322
|
return verifyReceiptOfflineWithLeafInnerKeys({
|
|
262
323
|
receiptCbor: input.receiptCbor,
|
|
263
324
|
idtimestampBe8: input.idtimestampBe8,
|
|
264
325
|
inner: input.inner,
|
|
265
|
-
trustKeys:
|
|
326
|
+
trustKeys: [trustRoot],
|
|
327
|
+
ks256Attestations: input.ks256Attestations,
|
|
328
|
+
forestChainId: genesisChainIdOrUndefined(input.genesisCbor),
|
|
266
329
|
});
|
|
267
330
|
}
|
|
268
331
|
|
|
269
332
|
/**
|
|
270
333
|
* Trust-key-parameterised offline-verify core. Identical to the genesis path
|
|
271
334
|
* once the trust keys are in hand: resolve the (possibly delegated, FOR-297)
|
|
272
|
-
* verify key against `trustKeys`, then check signature + inclusion.
|
|
335
|
+
* verify key against `trustKeys`, then check signature + inclusion. Receipts
|
|
336
|
+
* are always ES256, so a KS256 trust key only ever verifies the delegation
|
|
337
|
+
* certificate; only ES256 trust keys are tried on the receipt itself.
|
|
273
338
|
*/
|
|
274
339
|
async function verifyReceiptOfflineWithLeafInnerKeys(input: {
|
|
275
340
|
receiptCbor: Uint8Array;
|
|
276
341
|
idtimestampBe8: Uint8Array;
|
|
277
342
|
inner: Uint8Array;
|
|
278
|
-
trustKeys:
|
|
343
|
+
trustKeys: RootVerifyKey[];
|
|
344
|
+
ks256Attestations: Ks256Attestation[];
|
|
345
|
+
/** The forest genesis chain id an attestation must carry, if known. */
|
|
346
|
+
forestChainId: bigint | undefined;
|
|
279
347
|
}): Promise<ReceiptVerifyResult> {
|
|
280
348
|
let parsed: ReturnType<typeof parseReceipt>;
|
|
281
349
|
try {
|
|
@@ -293,17 +361,38 @@ async function verifyReceiptOfflineWithLeafInnerKeys(input: {
|
|
|
293
361
|
// delegated key first. A cert present but not chaining to a trust key is a
|
|
294
362
|
// hard failure — do not silently fall back to the trust key (which cannot
|
|
295
363
|
// verify a delegated-signed receipt anyway).
|
|
296
|
-
|
|
364
|
+
// D8: the attestations made for THIS receipt's certificate (digest and
|
|
365
|
+
// signature hash), tried in order; none matching, a stand-in only names
|
|
366
|
+
// the mismatch.
|
|
367
|
+
const { resolution: delegation } = await resolveWithKs256Attestations(
|
|
297
368
|
input.receiptCbor,
|
|
298
369
|
input.trustKeys,
|
|
370
|
+
input.ks256Attestations,
|
|
371
|
+
input.forestChainId,
|
|
299
372
|
);
|
|
300
373
|
if (delegation.kind === "broken") {
|
|
301
|
-
return {
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
stage: "signature",
|
|
377
|
+
reason: delegation.reason ?? "delegation_invalid",
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
const es256TrustKeys = input.trustKeys.filter(
|
|
381
|
+
(k): k is CryptoKey => !isParsedKs256RootKey(k),
|
|
382
|
+
);
|
|
383
|
+
if (delegation.kind === "root-only" && !es256TrustKeys.length) {
|
|
384
|
+
// A KS256 root never signs a receipt; without a certificate there is no
|
|
385
|
+
// ES256 key to check it with.
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
stage: "signature",
|
|
389
|
+
reason: "ks256_root_requires_delegation",
|
|
390
|
+
};
|
|
302
391
|
}
|
|
303
392
|
const allVerifyKeys =
|
|
304
393
|
delegation.kind === "resolved"
|
|
305
|
-
? [delegation.delegatedKey, ...
|
|
306
|
-
:
|
|
394
|
+
? [delegation.delegatedKey, ...es256TrustKeys]
|
|
395
|
+
: es256TrustKeys;
|
|
307
396
|
|
|
308
397
|
let idtimestamp: bigint;
|
|
309
398
|
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
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checkpoint-chain trust root (FOR-368 Phase 3; owner ruling D8): fold a
|
|
3
|
+
* retained `.sth` chain under the caller's roots with per-link delegation
|
|
4
|
+
* limits ({@link makeCheckpointLinkVerifier}), match the receipt's peak
|
|
5
|
+
* against ANY authenticated link, then judge certificate expiry on the
|
|
6
|
+
* links covering the now-bound leaf ({@link checkCheckpointLinkExpiry}).
|
|
7
|
+
* Lifted from mcp-verify 0.6.1's `verifyAtCheckpointChain`; forestrie-cli,
|
|
8
|
+
* which matches peaks its own way, can use the pieces instead.
|
|
9
|
+
*
|
|
10
|
+
* Later links' signed consistency proofs commit an earlier accumulator
|
|
11
|
+
* forward, so burial never turns an honest receipt tamper-shaped. Retention
|
|
12
|
+
* limits coverage, never validity: a receipt newer than the chain fails
|
|
13
|
+
* closed with a refresh remedy.
|
|
14
|
+
*
|
|
15
|
+
* Pure over bytes; no network (canopy rule C1).
|
|
16
|
+
*/
|
|
17
|
+
import { calculateRoot, peakMMRIndexes } from "@forestrie/merklelog";
|
|
18
|
+
import {
|
|
19
|
+
verifyCheckpointChain,
|
|
20
|
+
type CheckpointChainLink,
|
|
21
|
+
} from "./checkpoint-chain.js";
|
|
22
|
+
import type { CheckpointChainReceiptOutcome } from "./checkpoint-chain-receipt-outcome.js";
|
|
23
|
+
import {
|
|
24
|
+
checkCheckpointLinkExpiry,
|
|
25
|
+
makeCheckpointLinkVerifier,
|
|
26
|
+
} from "./checkpoint-link-verifier.js";
|
|
27
|
+
import { decodeChainBindingFromGenesis } from "./decode-chain-binding-from-genesis.js";
|
|
28
|
+
import type { Ks256Attestation } from "./ks256-attestation.js";
|
|
29
|
+
import { summarizeKs256AttestationUsage } from "./ks256-attestation-usage.js";
|
|
30
|
+
import { inclusionWalkEndIndex } from "./inclusion-walk-end.js";
|
|
31
|
+
import { univocityLeafHash } from "./leaf-commitment.js";
|
|
32
|
+
import { parseReceipt } from "./parse-receipt.js";
|
|
33
|
+
import type { RootVerifyKey } from "./root-verify-key.js";
|
|
34
|
+
import { SubtleHasher } from "./subtle-hasher.js";
|
|
35
|
+
|
|
36
|
+
/** Input to {@link verifyReceiptAgainstCheckpointChain}. */
|
|
37
|
+
export type VerifyReceiptAgainstCheckpointChainInput = {
|
|
38
|
+
receiptCbor: Uint8Array;
|
|
39
|
+
/** The leaf's idtimestamp; the last 8 bytes are used (big-endian). */
|
|
40
|
+
idtimestampBe8: Uint8Array;
|
|
41
|
+
/** Leaf ContentHash: SHA-256(payload), or the grant commitment hash. */
|
|
42
|
+
inner: Uint8Array;
|
|
43
|
+
/** Retained checkpoints in fold (ascending size) order. */
|
|
44
|
+
checkpoints: readonly Uint8Array[];
|
|
45
|
+
/** ES256 keys and KS256 addresses, from genesis or caller-known keys. */
|
|
46
|
+
rootKeys: readonly RootVerifyKey[];
|
|
47
|
+
/** ERC-1271 attestations, one per contract-root certificate. */
|
|
48
|
+
attestations?: readonly Ks256Attestation[] | undefined;
|
|
49
|
+
/**
|
|
50
|
+
* The forest genesis, read only for its chain id (attestations must
|
|
51
|
+
* match it). Give this or {@link forestChainId}; both must agree.
|
|
52
|
+
*/
|
|
53
|
+
forestGenesisCbor?: Uint8Array | undefined;
|
|
54
|
+
forestChainId?: bigint | undefined;
|
|
55
|
+
/** Trusted base for a suffix chain; see `verifyCheckpointChain`. */
|
|
56
|
+
trustedBase?: { size: bigint; accumulator: Uint8Array[] } | undefined;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Verify a receipt against a retained checkpoint chain.
|
|
61
|
+
*
|
|
62
|
+
* The receipt's own signature is not checked: the authenticated chain is
|
|
63
|
+
* the authority, and the peak is ALWAYS recomputed from the leaf
|
|
64
|
+
* (`idtimestamp ‖ inner`) and the inclusion path, never taken from an
|
|
65
|
+
* attached payload. A match binds the leaf's idtimestamp (through the leaf
|
|
66
|
+
* hash) and its mmrIndex (through the matched peak's POSITION, which must be
|
|
67
|
+
* the node the inclusion walk reaches: the leaf hash alone does not commit
|
|
68
|
+
* its position, and an empty path commits none).
|
|
69
|
+
* Links are searched newest first; the newest cover is reported.
|
|
70
|
+
*/
|
|
71
|
+
export async function verifyReceiptAgainstCheckpointChain(
|
|
72
|
+
input: VerifyReceiptAgainstCheckpointChainInput,
|
|
73
|
+
): Promise<CheckpointChainReceiptOutcome> {
|
|
74
|
+
const attestations = input.attestations ?? [];
|
|
75
|
+
const empty = {
|
|
76
|
+
links: [] as CheckpointChainLink[],
|
|
77
|
+
records: [],
|
|
78
|
+
attestations: summarizeKs256AttestationUsage(attestations, []),
|
|
79
|
+
};
|
|
80
|
+
if (input.rootKeys.length === 0) {
|
|
81
|
+
return { ...empty, ok: false, stage: "input", reason: "no_trust_key" };
|
|
82
|
+
}
|
|
83
|
+
let forestChainId = input.forestChainId;
|
|
84
|
+
if (input.forestGenesisCbor !== undefined) {
|
|
85
|
+
let fromGenesis: bigint;
|
|
86
|
+
try {
|
|
87
|
+
fromGenesis = BigInt(
|
|
88
|
+
decodeChainBindingFromGenesis(input.forestGenesisCbor).chainId,
|
|
89
|
+
);
|
|
90
|
+
} catch {
|
|
91
|
+
return {
|
|
92
|
+
...empty,
|
|
93
|
+
ok: false,
|
|
94
|
+
stage: "input",
|
|
95
|
+
reason: "forest_genesis_invalid",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (forestChainId !== undefined && forestChainId !== fromGenesis) {
|
|
99
|
+
return {
|
|
100
|
+
...empty,
|
|
101
|
+
ok: false,
|
|
102
|
+
stage: "input",
|
|
103
|
+
reason: "forest_chain_id_mismatch",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
forestChainId = fromGenesis;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const verifier = makeCheckpointLinkVerifier({
|
|
110
|
+
rootKeys: input.rootKeys,
|
|
111
|
+
attestations,
|
|
112
|
+
forestChainId,
|
|
113
|
+
});
|
|
114
|
+
const chain = await verifyCheckpointChain({
|
|
115
|
+
checkpoints: [...input.checkpoints],
|
|
116
|
+
verifySignature: verifier.verifySignature,
|
|
117
|
+
...(input.trustedBase !== undefined
|
|
118
|
+
? { trustedBase: input.trustedBase }
|
|
119
|
+
: {}),
|
|
120
|
+
});
|
|
121
|
+
const linkFailure = verifier.failure();
|
|
122
|
+
const common = {
|
|
123
|
+
links: chain.links,
|
|
124
|
+
records: verifier.records,
|
|
125
|
+
attestations: summarizeKs256AttestationUsage(
|
|
126
|
+
attestations,
|
|
127
|
+
verifier.records,
|
|
128
|
+
linkFailure,
|
|
129
|
+
),
|
|
130
|
+
};
|
|
131
|
+
if (!chain.ok) {
|
|
132
|
+
return {
|
|
133
|
+
...common,
|
|
134
|
+
ok: false,
|
|
135
|
+
stage: "chain",
|
|
136
|
+
reason: linkFailure?.reason ?? `checkpoint_chain_${chain.reason}`,
|
|
137
|
+
at: chain.at,
|
|
138
|
+
detail: chain.detail,
|
|
139
|
+
...(linkFailure !== undefined ? { linkFailure } : {}),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let parsed: ReturnType<typeof parseReceipt>;
|
|
144
|
+
try {
|
|
145
|
+
parsed = parseReceipt(input.receiptCbor);
|
|
146
|
+
} catch {
|
|
147
|
+
return {
|
|
148
|
+
...common,
|
|
149
|
+
ok: false,
|
|
150
|
+
stage: "parse",
|
|
151
|
+
reason: "receipt_malformed",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (input.idtimestampBe8.length < 8) {
|
|
155
|
+
return {
|
|
156
|
+
...common,
|
|
157
|
+
ok: false,
|
|
158
|
+
stage: "parse",
|
|
159
|
+
reason: "idtimestamp_invalid",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const idtimestamp = readU64Be(input.idtimestampBe8.slice(-8));
|
|
163
|
+
// parseReceipt always sets mmrIndex; leafIndex wins when present, as in
|
|
164
|
+
// every other peak recompute in this package.
|
|
165
|
+
const leafMmrIndex = parsed.proof.leafIndex ?? parsed.proof.mmrIndex;
|
|
166
|
+
if (leafMmrIndex === undefined) {
|
|
167
|
+
return {
|
|
168
|
+
...common,
|
|
169
|
+
ok: false,
|
|
170
|
+
stage: "parse",
|
|
171
|
+
reason: "receipt_malformed",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const links = chain.links;
|
|
176
|
+
const final = links[links.length - 1]!;
|
|
177
|
+
if (leafMmrIndex >= final.treeSize2) {
|
|
178
|
+
return {
|
|
179
|
+
...common,
|
|
180
|
+
ok: false,
|
|
181
|
+
stage: "anchor",
|
|
182
|
+
reason: "receipt_newer_than_checkpoint_chain",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let peak: Uint8Array;
|
|
187
|
+
try {
|
|
188
|
+
peak = await calculateRoot(
|
|
189
|
+
new SubtleHasher(),
|
|
190
|
+
await univocityLeafHash(idtimestamp, input.inner),
|
|
191
|
+
parsed.proof,
|
|
192
|
+
leafMmrIndex,
|
|
193
|
+
);
|
|
194
|
+
} catch {
|
|
195
|
+
return {
|
|
196
|
+
...common,
|
|
197
|
+
ok: false,
|
|
198
|
+
stage: "parse",
|
|
199
|
+
reason: "receipt_malformed",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The peak the path claims to reach. A leaf hash does not commit its
|
|
204
|
+
// position (and an empty path commits none), so a value match alone would
|
|
205
|
+
// leave mmrIndex a caller claim: the matched peak must sit at this index.
|
|
206
|
+
const peakIndex = inclusionWalkEndIndex(
|
|
207
|
+
leafMmrIndex,
|
|
208
|
+
parsed.proof.path.length,
|
|
209
|
+
);
|
|
210
|
+
for (let i = links.length - 1; i >= 0; i--) {
|
|
211
|
+
const link = links[i]!;
|
|
212
|
+
const p =
|
|
213
|
+
link.treeSize2 > 0n
|
|
214
|
+
? peakMMRIndexes(link.treeSize2 - 1n).indexOf(peakIndex)
|
|
215
|
+
: -1;
|
|
216
|
+
if (p < 0 || !bytesEqual(link.accumulator[p] ?? new Uint8Array(), peak)) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
// The leaf hash, inclusion path and peak position now bind idtimestamp
|
|
220
|
+
// and mmrIndex to an authenticated accumulator, so expiry can be judged
|
|
221
|
+
// on them.
|
|
222
|
+
const expiry = checkCheckpointLinkExpiry(verifier.records, {
|
|
223
|
+
mmrIndex: leafMmrIndex,
|
|
224
|
+
idtimestamp,
|
|
225
|
+
});
|
|
226
|
+
if (!expiry.ok) {
|
|
227
|
+
return {
|
|
228
|
+
...common,
|
|
229
|
+
ok: false,
|
|
230
|
+
stage: "delegation",
|
|
231
|
+
reason: expiry.reason,
|
|
232
|
+
at: expiry.at,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
return { ...common, ok: true, matchedLink: i, matchedPeak: p };
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
...common,
|
|
239
|
+
ok: false,
|
|
240
|
+
stage: "anchor",
|
|
241
|
+
reason: "peak_not_in_checkpoint_chain",
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function readU64Be(be8: Uint8Array): bigint {
|
|
246
|
+
let v = 0n;
|
|
247
|
+
for (const b of be8) v = (v << 8n) | BigInt(b);
|
|
248
|
+
return v;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
252
|
+
if (a.length !== b.length) return false;
|
|
253
|
+
let x = 0;
|
|
254
|
+
for (let i = 0; i < a.length; i++) x |= a[i]! ^ b[i]!;
|
|
255
|
+
return x === 0;
|
|
256
|
+
}
|