@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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Choosing an ERC-1271 attestation from several (owner ruling D8,
|
|
3
|
+
* 2026-10-19). A contract KS256 root (a Safe) that rotated its delegate
|
|
4
|
+
* signs one certificate per delegate, and each certificate needs its own
|
|
5
|
+
* attestation; a caller holding a checkpoint chain or several receipts
|
|
6
|
+
* supplies them as a list. Each certificate takes the attestation whose
|
|
7
|
+
* `digest` AND `signatureHash` are that certificate's: keccak256 of its
|
|
8
|
+
* Sig_structure and keccak256 of its signature bytes.
|
|
9
|
+
*
|
|
10
|
+
* Selection never makes an attestation acceptable on its own:
|
|
11
|
+
* `verifyKs256DelegationCert` still checks address, digest, signature hash,
|
|
12
|
+
* genesis chain id and magic.
|
|
13
|
+
*/
|
|
14
|
+
import { keccak_256 } from "@noble/hashes/sha3";
|
|
15
|
+
import { resolveDelegatedVerifyKey, } from "./resolve-delegated-verify-key.js";
|
|
16
|
+
import { extractKs256DelegationDigest } from "./verify-ks256-delegation.js";
|
|
17
|
+
/**
|
|
18
|
+
* The attestation in `attestations` made for this certificate: the first
|
|
19
|
+
* whose `digest` equals the certificate digest and whose `signatureHash`
|
|
20
|
+
* equals keccak256 of the certificate signature.
|
|
21
|
+
*
|
|
22
|
+
* @param cert - From `extractKs256CertificateDigest` (delegation-cose) or
|
|
23
|
+
* {@link extractKs256DelegationDigest}.
|
|
24
|
+
* @returns The first matching attestation, or `undefined` when none
|
|
25
|
+
* matches. The verifiers go further: they try EVERY match in order and
|
|
26
|
+
* keep the first that passes its own checks.
|
|
27
|
+
*/
|
|
28
|
+
export function selectKs256Attestation(cert, attestations) {
|
|
29
|
+
const i = selectKs256AttestationIndex(cert, attestations);
|
|
30
|
+
return i === undefined ? undefined : attestations[i];
|
|
31
|
+
}
|
|
32
|
+
function selectKs256AttestationIndex(cert, attestations) {
|
|
33
|
+
return matchingIndexes(cert, attestations)[0];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a COSE_Sign1's label-1000 certificate (a receipt or a checkpoint)
|
|
37
|
+
* under `rootKeys`, trying the supplied attestations made for it.
|
|
38
|
+
*
|
|
39
|
+
* Every attestation whose digest and signature hash match the certificate
|
|
40
|
+
* is tried in list order, and the first that resolves wins: a stale or
|
|
41
|
+
* wrong-chain copy listed before a good one must not mask it. When none
|
|
42
|
+
* resolves, the first failure is returned. When none matches at all, a
|
|
43
|
+
* DIAGNOSTIC stand-in (one with the right digest if any, else the first) is
|
|
44
|
+
* passed so a contract root fails with the precise
|
|
45
|
+
* `ks256_attestation_*_mismatch` reason rather than a bare
|
|
46
|
+
* `ks256_root_not_recovered`; a stand-in can never verify, because
|
|
47
|
+
* `verifyKs256DelegationCert` checks digest and signature hash. With one
|
|
48
|
+
* attestation supplied this is exactly the 3.1.0 behaviour of the singular
|
|
49
|
+
* `ks256Attestation` input.
|
|
50
|
+
*
|
|
51
|
+
* @returns The resolution; `selected`, the index of the attestation it was
|
|
52
|
+
* resolved with (whether or not ecrecover made it unnecessary); and
|
|
53
|
+
* `rejected`, the matching attestations tried that failed their own
|
|
54
|
+
* checks (address, chain id, magic).
|
|
55
|
+
* @internal
|
|
56
|
+
*/
|
|
57
|
+
export async function resolveWithKs256Attestations(coseSign1Bytes, rootKeys, attestations, forestChainId) {
|
|
58
|
+
if (attestations.length === 0) {
|
|
59
|
+
return {
|
|
60
|
+
resolution: await resolveDelegatedVerifyKey(coseSign1Bytes, rootKeys),
|
|
61
|
+
rejected: [],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
let cert = null;
|
|
65
|
+
try {
|
|
66
|
+
cert = extractKs256DelegationDigest(coseSign1Bytes);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Not a KS256 certificate (or not a COSE_Sign1): no attestation can be
|
|
70
|
+
// made for it; the stand-in below is never consulted by an ES256 root.
|
|
71
|
+
cert = null;
|
|
72
|
+
}
|
|
73
|
+
const candidates = cert === null ? [] : matchingIndexes(cert, attestations);
|
|
74
|
+
const rejected = [];
|
|
75
|
+
let firstFailure;
|
|
76
|
+
for (const i of candidates) {
|
|
77
|
+
const resolution = await resolveDelegatedVerifyKey(coseSign1Bytes, rootKeys, { attestation: attestations[i], forestChainId });
|
|
78
|
+
if (resolution.kind !== "broken") {
|
|
79
|
+
return { resolution, selected: i, rejected };
|
|
80
|
+
}
|
|
81
|
+
firstFailure ??= resolution;
|
|
82
|
+
// Only an attestation's own checks make it "rejected"; any other
|
|
83
|
+
// failure is the certificate's and no other copy can change it.
|
|
84
|
+
if (!resolution.reason?.startsWith("ks256_attestation_"))
|
|
85
|
+
break;
|
|
86
|
+
rejected.push(i);
|
|
87
|
+
}
|
|
88
|
+
if (firstFailure !== undefined)
|
|
89
|
+
return { resolution: firstFailure, rejected };
|
|
90
|
+
const standIn = (cert !== null &&
|
|
91
|
+
attestations.find((a) => bytesEqual(a.digest, cert.digest))) ||
|
|
92
|
+
attestations[0];
|
|
93
|
+
return {
|
|
94
|
+
resolution: await resolveDelegatedVerifyKey(coseSign1Bytes, rootKeys, {
|
|
95
|
+
attestation: standIn,
|
|
96
|
+
forestChainId,
|
|
97
|
+
}),
|
|
98
|
+
rejected,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function matchingIndexes(cert, attestations) {
|
|
102
|
+
const signatureHash = keccak_256(cert.signature);
|
|
103
|
+
const out = [];
|
|
104
|
+
attestations.forEach((a, i) => {
|
|
105
|
+
if (bytesEqual(a.digest, cert.digest) &&
|
|
106
|
+
bytesEqual(a.signatureHash, signatureHash)) {
|
|
107
|
+
out.push(i);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
function bytesEqual(a, b) {
|
|
113
|
+
if (a.length !== b.length)
|
|
114
|
+
return false;
|
|
115
|
+
let x = 0;
|
|
116
|
+
for (let i = 0; i < a.length; i++)
|
|
117
|
+
x |= a[i] ^ b[i];
|
|
118
|
+
return x === 0;
|
|
119
|
+
}
|
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import type { Grant } from "@forestrie/encoding";
|
|
2
|
+
import type { Ks256Attestation } from "./ks256-attestation.js";
|
|
3
|
+
import { type RootVerifyKey } from "./root-verify-key.js";
|
|
2
4
|
import type { ReceiptVerifyResult } from "./receipt-verify-result.js";
|
|
3
5
|
export type VerifyGrantReceiptOfflineInput = {
|
|
4
6
|
genesisCbor: Uint8Array;
|
|
5
7
|
receiptCbor: Uint8Array;
|
|
6
8
|
grant: Grant;
|
|
7
9
|
idtimestampBe8: Uint8Array;
|
|
10
|
+
/** See {@link VerifyReceiptOfflineInput.ks256Attestation}. */
|
|
11
|
+
ks256Attestation?: Ks256Attestation;
|
|
12
|
+
/** See {@link VerifyReceiptOfflineInput.ks256Attestations}. */
|
|
13
|
+
ks256Attestations?: readonly Ks256Attestation[];
|
|
8
14
|
};
|
|
9
15
|
/**
|
|
10
16
|
* Offline grant receipt verify (layers A–C, ADR-0045). Pure over bytes; no network.
|
|
11
17
|
*/
|
|
12
18
|
export declare function verifyGrantReceiptOffline(input: VerifyGrantReceiptOfflineInput): Promise<ReceiptVerifyResult>;
|
|
13
|
-
export type VerifyGrantReceiptOfflineWithKeysInput = Omit<VerifyGrantReceiptOfflineInput, "genesisCbor"> &
|
|
14
|
-
/** Caller-known ES256 trust keys — see {@link VerifyReceiptOfflineWithKeysInput}. */
|
|
15
|
-
trustKeys: CryptoKey[];
|
|
16
|
-
};
|
|
19
|
+
export type VerifyGrantReceiptOfflineWithKeysInput = Omit<VerifyGrantReceiptOfflineInput, "genesisCbor"> & Pick<VerifyReceiptOfflineWithKeysInput, "trustKeys" | "forestGenesisCbor">;
|
|
17
20
|
/**
|
|
18
21
|
* {@link verifyGrantReceiptOffline} under caller-supplied trust keys instead of
|
|
19
22
|
* the genesis trust root — see {@link verifyReceiptOfflineWithKeys} for the
|
|
@@ -31,6 +34,21 @@ export type VerifyReceiptOfflineInput = {
|
|
|
31
34
|
*/
|
|
32
35
|
payload: Uint8Array;
|
|
33
36
|
idtimestampBe8: Uint8Array;
|
|
37
|
+
/**
|
|
38
|
+
* ERC-1271 attestation for a KS256 contract root (plan-2610-03 D4). Used
|
|
39
|
+
* only when ecrecover doesn't match the root, and accepted only when it
|
|
40
|
+
* binds the root address, the certificate digest and the genesis chain id.
|
|
41
|
+
*/
|
|
42
|
+
ks256Attestation?: Ks256Attestation;
|
|
43
|
+
/**
|
|
44
|
+
* Several attestations (owner ruling D8): the one whose digest and
|
|
45
|
+
* signature hash match the receipt's certificate is used
|
|
46
|
+
* ({@link selectKs256Attestation}). Combined with
|
|
47
|
+
* {@link ks256Attestation} when both are given. When none matches, one
|
|
48
|
+
* still reaches the KS256 check only to name the mismatch: it can never
|
|
49
|
+
* verify the certificate.
|
|
50
|
+
*/
|
|
51
|
+
ks256Attestations?: readonly Ks256Attestation[];
|
|
34
52
|
};
|
|
35
53
|
/**
|
|
36
54
|
* Offline verify of a receipt against the EXACT registered payload. The log
|
|
@@ -42,12 +60,19 @@ export type VerifyReceiptOfflineInput = {
|
|
|
42
60
|
export declare function verifyReceiptOffline(input: VerifyReceiptOfflineInput): Promise<ReceiptVerifyResult>;
|
|
43
61
|
export type VerifyReceiptOfflineWithKeysInput = Omit<VerifyReceiptOfflineInput, "genesisCbor"> & {
|
|
44
62
|
/**
|
|
45
|
-
* Caller-known
|
|
63
|
+
* Caller-known trust keys, tried in order: ES256 `CryptoKey`s or KS256
|
|
64
|
+
* 20-byte addresses ({@link knownLogKeyFromBytes}). For a child-log receipt
|
|
46
65
|
* this is the log OWNER's key (the delegation-cert issuer), NOT the sealer
|
|
47
66
|
* key — the label-1000 cert is verified under these keys and the delegated
|
|
48
67
|
* sealer key is extracted from it, so the anchor survives sealer rotation.
|
|
49
68
|
*/
|
|
50
|
-
trustKeys:
|
|
69
|
+
trustKeys: RootVerifyKey[];
|
|
70
|
+
/**
|
|
71
|
+
* The forest's genesis, read only for the chain id a `ks256Attestation`
|
|
72
|
+
* must match (D6); it supplies no trust keys. Without it an attestation is
|
|
73
|
+
* refused with `ks256_attestation_chain_unbound`.
|
|
74
|
+
*/
|
|
75
|
+
forestGenesisCbor?: Uint8Array;
|
|
51
76
|
};
|
|
52
77
|
/**
|
|
53
78
|
* {@link verifyReceiptOffline} under caller-supplied trust keys instead of the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"verify-grant-receipt-offline.d.ts","sourceRoot":"","sources":["../src/verify-grant-receipt-offline.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"verify-grant-receipt-offline.d.ts","sourceRoot":"","sources":["../src/verify-grant-receipt-offline.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAIjD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAwB,KAAK,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAOhF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAItE,MAAM,MAAM,8BAA8B,GAAG;IAC3C,WAAW,EAAE,UAAU,CAAC;IACxB,WAAW,EAAE,UAAU,CAAC;IACxB,KAAK,EAAE,KAAK,CAAC;IACb,cAAc,EAAE,UAAU,CAAC;IAC3B,8DAA8D;IAC9D,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC,+DAA+D;IAC/D,iBAAiB,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;CACjD,CAAC;AA4EF;;GAEG;AACH,wBAAsB,yBAAyB,CAC7C,KAAK,EAAE,8BAA8B,GACpC,OAAO,CAAC,mBAAmB,CAAC,CAe9B;AAED,MAAM,MAAM,sCAAsC,GAAG,IAAI,CACvD,8BAA8B,EAC9B,aAAa,CACd,GACC,IAAI,CAAC,iCAAiC,EAAE,WAAW,GAAG,mBAAmB,CAAC,CAAC;AAE7E;;;;GAIG;AACH,wBAAsB,iCAAiC,CACrD,KAAK,EAAE,sCAAsC,GAC5C,OAAO,CAAC,mBAAmB,CAAC,CAiB9B;AAED,MAAM,MAAM,yBAAyB,GAAG;IACtC,WAAW,EAAE,UAAU,CAAC;IACxB,WAAW,EAAE,UAAU,CAAC;IACxB;;;;;OAKG;IACH,OAAO,EAAE,UAAU,CAAC;IACpB,cAAc,EAAE,UAAU,CAAC;IAC3B;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;CACjD,CAAC;AAEF;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,yBAAyB,GAC/B,OAAO,CAAC,mBAAmB,CAAC,CAc9B;AAED,MAAM,MAAM,iCAAiC,GAAG,IAAI,CAClD,yBAAyB,EACzB,aAAa,CACd,GAAG;IACF;;;;;;OAMG;IACH,SAAS,EAAE,aAAa,EAAE,CAAC;IAC3B;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,UAAU,CAAC;CAChC,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,4BAA4B,CAChD,KAAK,EAAE,iCAAiC,GACvC,OAAO,CAAC,mBAAmB,CAAC,CAiB9B"}
|
|
@@ -2,11 +2,13 @@ import { verifyCoseSign1WithParsedKey } from "@forestrie/encoding";
|
|
|
2
2
|
import { calculateRoot, verifyInclusion, } from "@forestrie/merklelog";
|
|
3
3
|
import { grantCommitmentHashFromGrant } from "./grant-commitment.js";
|
|
4
4
|
import { decodeTrustRootFromGenesis } from "./decode-trust-root-from-genesis.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { decodeChainBindingFromGenesis } from "./decode-chain-binding-from-genesis.js";
|
|
6
|
+
import { isParsedKs256RootKey } from "./root-verify-key.js";
|
|
7
|
+
import { checkDelegationConstraints, } from "./resolve-delegated-verify-key.js";
|
|
7
8
|
import { univocityLeafHash } from "./leaf-commitment.js";
|
|
8
9
|
import { parseReceipt } from "./parse-receipt.js";
|
|
9
10
|
import { SubtleHasher } from "./subtle-hasher.js";
|
|
11
|
+
import { resolveWithKs256Attestations } from "./select-ks256-attestation.js";
|
|
10
12
|
function readIdtimestampBe8(bytes) {
|
|
11
13
|
if (!bytes || bytes.length < 8) {
|
|
12
14
|
throw new Error("idtimestamp required for receipt verification (8 bytes)");
|
|
@@ -72,6 +74,7 @@ export async function verifyGrantReceiptOffline(input) {
|
|
|
72
74
|
receiptCbor: input.receiptCbor,
|
|
73
75
|
idtimestampBe8: input.idtimestampBe8,
|
|
74
76
|
inner,
|
|
77
|
+
ks256Attestations: attestationCandidates(input),
|
|
75
78
|
});
|
|
76
79
|
}
|
|
77
80
|
/**
|
|
@@ -92,6 +95,10 @@ export async function verifyGrantReceiptOfflineWithKeys(input) {
|
|
|
92
95
|
idtimestampBe8: input.idtimestampBe8,
|
|
93
96
|
inner,
|
|
94
97
|
trustKeys: input.trustKeys,
|
|
98
|
+
ks256Attestations: attestationCandidates(input),
|
|
99
|
+
forestChainId: input.forestGenesisCbor
|
|
100
|
+
? genesisChainIdOrUndefined(input.forestGenesisCbor)
|
|
101
|
+
: undefined,
|
|
95
102
|
});
|
|
96
103
|
}
|
|
97
104
|
/**
|
|
@@ -108,6 +115,7 @@ export async function verifyReceiptOffline(input) {
|
|
|
108
115
|
receiptCbor: input.receiptCbor,
|
|
109
116
|
idtimestampBe8: input.idtimestampBe8,
|
|
110
117
|
inner,
|
|
118
|
+
ks256Attestations: attestationCandidates(input),
|
|
111
119
|
});
|
|
112
120
|
}
|
|
113
121
|
/**
|
|
@@ -134,8 +142,34 @@ export async function verifyReceiptOfflineWithKeys(input) {
|
|
|
134
142
|
idtimestampBe8: input.idtimestampBe8,
|
|
135
143
|
inner,
|
|
136
144
|
trustKeys: input.trustKeys,
|
|
145
|
+
ks256Attestations: attestationCandidates(input),
|
|
146
|
+
forestChainId: input.forestGenesisCbor
|
|
147
|
+
? genesisChainIdOrUndefined(input.forestGenesisCbor)
|
|
148
|
+
: undefined,
|
|
137
149
|
});
|
|
138
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Every attestation the caller supplied: the list, then the singular field
|
|
153
|
+
* (kept for 3.1.0 callers).
|
|
154
|
+
*/
|
|
155
|
+
function attestationCandidates(input) {
|
|
156
|
+
return [
|
|
157
|
+
...(input.ks256Attestations ?? []),
|
|
158
|
+
...(input.ks256Attestation ? [input.ks256Attestation] : []),
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The forest's chain id from its genesis chain binding, or `undefined` when
|
|
163
|
+
* the genesis carries none (an attestation then can't be bound, D6).
|
|
164
|
+
*/
|
|
165
|
+
function genesisChainIdOrUndefined(genesisCbor) {
|
|
166
|
+
try {
|
|
167
|
+
return BigInt(decodeChainBindingFromGenesis(genesisCbor).chainId);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
139
173
|
/**
|
|
140
174
|
* Shared offline-verify core: reconstruct the leaf as
|
|
141
175
|
* `univocityLeafHash(idtimestamp, inner)`, resolve the (possibly delegated,
|
|
@@ -151,21 +185,21 @@ async function verifyReceiptOfflineWithLeafInner(input) {
|
|
|
151
185
|
catch {
|
|
152
186
|
return { ok: false, stage: "parse", reason: "genesis_invalid" };
|
|
153
187
|
}
|
|
154
|
-
const verifyKeys = es256ReceiptVerifyKeys([trustRoot]);
|
|
155
|
-
if (!verifyKeys.length) {
|
|
156
|
-
return { ok: false, stage: "signature", reason: "no_es256_trust_key" };
|
|
157
|
-
}
|
|
158
188
|
return verifyReceiptOfflineWithLeafInnerKeys({
|
|
159
189
|
receiptCbor: input.receiptCbor,
|
|
160
190
|
idtimestampBe8: input.idtimestampBe8,
|
|
161
191
|
inner: input.inner,
|
|
162
|
-
trustKeys:
|
|
192
|
+
trustKeys: [trustRoot],
|
|
193
|
+
ks256Attestations: input.ks256Attestations,
|
|
194
|
+
forestChainId: genesisChainIdOrUndefined(input.genesisCbor),
|
|
163
195
|
});
|
|
164
196
|
}
|
|
165
197
|
/**
|
|
166
198
|
* Trust-key-parameterised offline-verify core. Identical to the genesis path
|
|
167
199
|
* once the trust keys are in hand: resolve the (possibly delegated, FOR-297)
|
|
168
|
-
* verify key against `trustKeys`, then check signature + inclusion.
|
|
200
|
+
* verify key against `trustKeys`, then check signature + inclusion. Receipts
|
|
201
|
+
* are always ES256, so a KS256 trust key only ever verifies the delegation
|
|
202
|
+
* certificate; only ES256 trust keys are tried on the receipt itself.
|
|
169
203
|
*/
|
|
170
204
|
async function verifyReceiptOfflineWithLeafInnerKeys(input) {
|
|
171
205
|
let parsed;
|
|
@@ -183,13 +217,30 @@ async function verifyReceiptOfflineWithLeafInnerKeys(input) {
|
|
|
183
217
|
// delegated key first. A cert present but not chaining to a trust key is a
|
|
184
218
|
// hard failure — do not silently fall back to the trust key (which cannot
|
|
185
219
|
// verify a delegated-signed receipt anyway).
|
|
186
|
-
|
|
220
|
+
// D8: the attestations made for THIS receipt's certificate (digest and
|
|
221
|
+
// signature hash), tried in order; none matching, a stand-in only names
|
|
222
|
+
// the mismatch.
|
|
223
|
+
const { resolution: delegation } = await resolveWithKs256Attestations(input.receiptCbor, input.trustKeys, input.ks256Attestations, input.forestChainId);
|
|
187
224
|
if (delegation.kind === "broken") {
|
|
188
|
-
return {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
stage: "signature",
|
|
228
|
+
reason: delegation.reason ?? "delegation_invalid",
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const es256TrustKeys = input.trustKeys.filter((k) => !isParsedKs256RootKey(k));
|
|
232
|
+
if (delegation.kind === "root-only" && !es256TrustKeys.length) {
|
|
233
|
+
// A KS256 root never signs a receipt; without a certificate there is no
|
|
234
|
+
// ES256 key to check it with.
|
|
235
|
+
return {
|
|
236
|
+
ok: false,
|
|
237
|
+
stage: "signature",
|
|
238
|
+
reason: "ks256_root_requires_delegation",
|
|
239
|
+
};
|
|
189
240
|
}
|
|
190
241
|
const allVerifyKeys = delegation.kind === "resolved"
|
|
191
|
-
? [delegation.delegatedKey, ...
|
|
192
|
-
:
|
|
242
|
+
? [delegation.delegatedKey, ...es256TrustKeys]
|
|
243
|
+
: es256TrustKeys;
|
|
193
244
|
let idtimestamp;
|
|
194
245
|
try {
|
|
195
246
|
idtimestamp = readIdtimestampBe8(input.idtimestampBe8);
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
import { type Ks256CertificateDigest } from "@forestrie/delegation-cose";
|
|
20
|
+
import { type Ks256Attestation } from "./ks256-attestation.js";
|
|
21
|
+
/**
|
|
22
|
+
* Chain evidence a caller may supply for a KS256 contract root. `forestChainId`
|
|
23
|
+
* is read from the forest's genesis, never from the attestation or a
|
|
24
|
+
* coordinator; an attestation without it is refused.
|
|
25
|
+
*/
|
|
26
|
+
export type Ks256DelegationEvidence = {
|
|
27
|
+
attestation?: Ks256Attestation | undefined;
|
|
28
|
+
forestChainId?: bigint | undefined;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Check a delegation certificate against a KS256 root address.
|
|
32
|
+
*
|
|
33
|
+
* @param certBytes - The receipt's label-1000 COSE_Sign1 certificate.
|
|
34
|
+
* @param rootAddress - 20-byte root from genesis or a caller-known log key.
|
|
35
|
+
* @param evidence - Optional ERC-1271 attestation and the genesis chain id.
|
|
36
|
+
* @returns `ok`, or a stable reason: `delegation_not_ks256` (wrong alg,
|
|
37
|
+
* WebAuthn envelope or malformed), `ks256_root_not_recovered`, or a
|
|
38
|
+
* `ks256_attestation_*` mismatch.
|
|
39
|
+
*/
|
|
40
|
+
export declare function verifyKs256DelegationCert(certBytes: Uint8Array, rootAddress: Uint8Array, evidence: Ks256DelegationEvidence): Promise<{
|
|
41
|
+
ok: true;
|
|
42
|
+
} | {
|
|
43
|
+
ok: false;
|
|
44
|
+
reason: string;
|
|
45
|
+
}>;
|
|
46
|
+
/**
|
|
47
|
+
* Extract the KS256 delegation certificate's root address, digest and
|
|
48
|
+
* signature from a receipt: what an ERC-1271 attestation helper calls
|
|
49
|
+
* `isValidSignature` with. Does not verify anything.
|
|
50
|
+
*
|
|
51
|
+
* @param receiptCbor - COSE_Sign1 receipt bytes.
|
|
52
|
+
* @returns `null` when the receipt carries no label-1000 certificate.
|
|
53
|
+
* @throws When the certificate is not a KS256 certificate.
|
|
54
|
+
*/
|
|
55
|
+
export declare function extractKs256DelegationDigest(receiptCbor: Uint8Array): Ks256CertificateDigest | null;
|
|
56
|
+
//# sourceMappingURL=verify-ks256-delegation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verify-ks256-delegation.d.ts","sourceRoot":"","sources":["../src/verify-ks256-delegation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAGH,OAAO,EAGL,KAAK,sBAAsB,EAC5B,MAAM,4BAA4B,CAAC;AAEpC,OAAO,EAEL,KAAK,gBAAgB,EACtB,MAAM,wBAAwB,CAAC;AAKhC;;;;GAIG;AACH,MAAM,MAAM,uBAAuB,GAAG;IACpC,WAAW,CAAC,EAAE,gBAAgB,GAAG,SAAS,CAAC;IAC3C,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACpC,CAAC;AAEF;;;;;;;;;GASG;AACH,wBAAsB,yBAAyB,CAC7C,SAAS,EAAE,UAAU,EACrB,WAAW,EAAE,UAAU,EACvB,QAAQ,EAAE,uBAAuB,GAChC,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAiCvD;AAED;;;;;;;;GAQG;AACH,wBAAgB,4BAA4B,CAC1C,WAAW,EAAE,UAAU,GACtB,sBAAsB,GAAG,IAAI,CAQ/B"}
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
import { coseUnprotectedToMap, decodeCoseSign1 } from "@forestrie/encoding";
|
|
20
|
+
import { extractKs256CertificateDigest, verifyDelegationCertificateKs256, } from "@forestrie/delegation-cose";
|
|
21
|
+
import { keccak_256 } from "@noble/hashes/sha3";
|
|
22
|
+
import { ERC1271_MAGIC_VALUE, } from "./ks256-attestation.js";
|
|
23
|
+
/** Unprotected header label carrying the delegation certificate. */
|
|
24
|
+
const DELEGATION_CERT_LABEL = 1000;
|
|
25
|
+
/**
|
|
26
|
+
* Check a delegation certificate against a KS256 root address.
|
|
27
|
+
*
|
|
28
|
+
* @param certBytes - The receipt's label-1000 COSE_Sign1 certificate.
|
|
29
|
+
* @param rootAddress - 20-byte root from genesis or a caller-known log key.
|
|
30
|
+
* @param evidence - Optional ERC-1271 attestation and the genesis chain id.
|
|
31
|
+
* @returns `ok`, or a stable reason: `delegation_not_ks256` (wrong alg,
|
|
32
|
+
* WebAuthn envelope or malformed), `ks256_root_not_recovered`, or a
|
|
33
|
+
* `ks256_attestation_*` mismatch.
|
|
34
|
+
*/
|
|
35
|
+
export async function verifyKs256DelegationCert(certBytes, rootAddress, evidence) {
|
|
36
|
+
let parts;
|
|
37
|
+
try {
|
|
38
|
+
parts = extractKs256CertificateDigest(certBytes);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return { ok: false, reason: "delegation_not_ks256" };
|
|
42
|
+
}
|
|
43
|
+
if (await verifyDelegationCertificateKs256(certBytes, rootAddress)) {
|
|
44
|
+
return { ok: true };
|
|
45
|
+
}
|
|
46
|
+
const { attestation, forestChainId } = evidence;
|
|
47
|
+
if (!attestation) {
|
|
48
|
+
return { ok: false, reason: "ks256_root_not_recovered" };
|
|
49
|
+
}
|
|
50
|
+
if (!bytesEqual(attestation.address, rootAddress)) {
|
|
51
|
+
return { ok: false, reason: "ks256_attestation_address_mismatch" };
|
|
52
|
+
}
|
|
53
|
+
if (!bytesEqual(attestation.digest, parts.digest)) {
|
|
54
|
+
return { ok: false, reason: "ks256_attestation_digest_mismatch" };
|
|
55
|
+
}
|
|
56
|
+
if (!bytesEqual(attestation.signatureHash, keccak_256(parts.signature))) {
|
|
57
|
+
return { ok: false, reason: "ks256_attestation_signature_mismatch" };
|
|
58
|
+
}
|
|
59
|
+
if (forestChainId === undefined) {
|
|
60
|
+
return { ok: false, reason: "ks256_attestation_chain_unbound" };
|
|
61
|
+
}
|
|
62
|
+
if (attestation.chainId !== forestChainId) {
|
|
63
|
+
return { ok: false, reason: "ks256_attestation_chain_mismatch" };
|
|
64
|
+
}
|
|
65
|
+
if (!bytesEqual(attestation.magic, ERC1271_MAGIC_VALUE)) {
|
|
66
|
+
return { ok: false, reason: "ks256_attestation_magic_invalid" };
|
|
67
|
+
}
|
|
68
|
+
return { ok: true };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Extract the KS256 delegation certificate's root address, digest and
|
|
72
|
+
* signature from a receipt: what an ERC-1271 attestation helper calls
|
|
73
|
+
* `isValidSignature` with. Does not verify anything.
|
|
74
|
+
*
|
|
75
|
+
* @param receiptCbor - COSE_Sign1 receipt bytes.
|
|
76
|
+
* @returns `null` when the receipt carries no label-1000 certificate.
|
|
77
|
+
* @throws When the certificate is not a KS256 certificate.
|
|
78
|
+
*/
|
|
79
|
+
export function extractKs256DelegationDigest(receiptCbor) {
|
|
80
|
+
const decoded = decodeCoseSign1(receiptCbor);
|
|
81
|
+
if (!decoded)
|
|
82
|
+
throw new Error("receipt is not a COSE_Sign1");
|
|
83
|
+
const certRaw = coseUnprotectedToMap(decoded.unprotected).get(DELEGATION_CERT_LABEL);
|
|
84
|
+
if (!(certRaw instanceof Uint8Array) || certRaw.length === 0)
|
|
85
|
+
return null;
|
|
86
|
+
return extractKs256CertificateDigest(certRaw);
|
|
87
|
+
}
|
|
88
|
+
function bytesEqual(a, b) {
|
|
89
|
+
if (a.length !== b.length)
|
|
90
|
+
return false;
|
|
91
|
+
let x = 0;
|
|
92
|
+
for (let i = 0; i < a.length; i++)
|
|
93
|
+
x |= a[i] ^ b[i];
|
|
94
|
+
return x === 0;
|
|
95
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { CheckpointChainReceiptOutcome } from "./checkpoint-chain-receipt-outcome.js";
|
|
2
|
+
import type { Ks256Attestation } from "./ks256-attestation.js";
|
|
3
|
+
import type { RootVerifyKey } from "./root-verify-key.js";
|
|
4
|
+
/** Input to {@link verifyReceiptAgainstCheckpointChain}. */
|
|
5
|
+
export type VerifyReceiptAgainstCheckpointChainInput = {
|
|
6
|
+
receiptCbor: Uint8Array;
|
|
7
|
+
/** The leaf's idtimestamp; the last 8 bytes are used (big-endian). */
|
|
8
|
+
idtimestampBe8: Uint8Array;
|
|
9
|
+
/** Leaf ContentHash: SHA-256(payload), or the grant commitment hash. */
|
|
10
|
+
inner: Uint8Array;
|
|
11
|
+
/** Retained checkpoints in fold (ascending size) order. */
|
|
12
|
+
checkpoints: readonly Uint8Array[];
|
|
13
|
+
/** ES256 keys and KS256 addresses, from genesis or caller-known keys. */
|
|
14
|
+
rootKeys: readonly RootVerifyKey[];
|
|
15
|
+
/** ERC-1271 attestations, one per contract-root certificate. */
|
|
16
|
+
attestations?: readonly Ks256Attestation[] | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* The forest genesis, read only for its chain id (attestations must
|
|
19
|
+
* match it). Give this or {@link forestChainId}; both must agree.
|
|
20
|
+
*/
|
|
21
|
+
forestGenesisCbor?: Uint8Array | undefined;
|
|
22
|
+
forestChainId?: bigint | undefined;
|
|
23
|
+
/** Trusted base for a suffix chain; see `verifyCheckpointChain`. */
|
|
24
|
+
trustedBase?: {
|
|
25
|
+
size: bigint;
|
|
26
|
+
accumulator: Uint8Array[];
|
|
27
|
+
} | undefined;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Verify a receipt against a retained checkpoint chain.
|
|
31
|
+
*
|
|
32
|
+
* The receipt's own signature is not checked: the authenticated chain is
|
|
33
|
+
* the authority, and the peak is ALWAYS recomputed from the leaf
|
|
34
|
+
* (`idtimestamp ‖ inner`) and the inclusion path, never taken from an
|
|
35
|
+
* attached payload. A match binds the leaf's idtimestamp (through the leaf
|
|
36
|
+
* hash) and its mmrIndex (through the matched peak's POSITION, which must be
|
|
37
|
+
* the node the inclusion walk reaches: the leaf hash alone does not commit
|
|
38
|
+
* its position, and an empty path commits none).
|
|
39
|
+
* Links are searched newest first; the newest cover is reported.
|
|
40
|
+
*/
|
|
41
|
+
export declare function verifyReceiptAgainstCheckpointChain(input: VerifyReceiptAgainstCheckpointChainInput): Promise<CheckpointChainReceiptOutcome>;
|
|
42
|
+
//# sourceMappingURL=verify-receipt-against-checkpoint-chain.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verify-receipt-against-checkpoint-chain.d.ts","sourceRoot":"","sources":["../src/verify-receipt-against-checkpoint-chain.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,uCAAuC,CAAC;AAM3F,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAK/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAG1D,4DAA4D;AAC5D,MAAM,MAAM,wCAAwC,GAAG;IACrD,WAAW,EAAE,UAAU,CAAC;IACxB,sEAAsE;IACtE,cAAc,EAAE,UAAU,CAAC;IAC3B,wEAAwE;IACxE,KAAK,EAAE,UAAU,CAAC;IAClB,2DAA2D;IAC3D,WAAW,EAAE,SAAS,UAAU,EAAE,CAAC;IACnC,yEAAyE;IACzE,QAAQ,EAAE,SAAS,aAAa,EAAE,CAAC;IACnC,gEAAgE;IAChE,YAAY,CAAC,EAAE,SAAS,gBAAgB,EAAE,GAAG,SAAS,CAAC;IACvD;;;OAGG;IACH,iBAAiB,CAAC,EAAE,UAAU,GAAG,SAAS,CAAC;IAC3C,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,oEAAoE;IACpE,WAAW,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,UAAU,EAAE,CAAA;KAAE,GAAG,SAAS,CAAC;CACvE,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,wBAAsB,mCAAmC,CACvD,KAAK,EAAE,wCAAwC,GAC9C,OAAO,CAAC,6BAA6B,CAAC,CA0KxC"}
|