@forestrie/receipt-verify 3.0.0 → 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/build-receipt-offline.d.ts +8 -4
- package/dist/build-receipt-offline.d.ts.map +1 -1
- package/dist/build-receipt-offline.js +13 -18
- 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 +3 -1
- package/src/build-receipt-offline.ts +21 -20
- 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
|
@@ -2,7 +2,8 @@ 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 {
|
|
5
|
+
import { decodeChainBindingFromGenesis } from "./decode-chain-binding-from-genesis.js";
|
|
6
|
+
import { isParsedKs256RootKey } from "./root-verify-key.js";
|
|
6
7
|
import { checkDelegationConstraints, resolveDelegatedVerifyKey, } from "./resolve-delegated-verify-key.js";
|
|
7
8
|
import { univocityLeafHash } from "./leaf-commitment.js";
|
|
8
9
|
import { parseReceipt } from "./parse-receipt.js";
|
|
@@ -72,6 +73,7 @@ export async function verifyGrantReceiptOffline(input) {
|
|
|
72
73
|
receiptCbor: input.receiptCbor,
|
|
73
74
|
idtimestampBe8: input.idtimestampBe8,
|
|
74
75
|
inner,
|
|
76
|
+
ks256Attestation: input.ks256Attestation,
|
|
75
77
|
});
|
|
76
78
|
}
|
|
77
79
|
/**
|
|
@@ -92,6 +94,7 @@ export async function verifyGrantReceiptOfflineWithKeys(input) {
|
|
|
92
94
|
idtimestampBe8: input.idtimestampBe8,
|
|
93
95
|
inner,
|
|
94
96
|
trustKeys: input.trustKeys,
|
|
97
|
+
ks256Evidence: withKeysKs256Evidence(input),
|
|
95
98
|
});
|
|
96
99
|
}
|
|
97
100
|
/**
|
|
@@ -108,6 +111,7 @@ export async function verifyReceiptOffline(input) {
|
|
|
108
111
|
receiptCbor: input.receiptCbor,
|
|
109
112
|
idtimestampBe8: input.idtimestampBe8,
|
|
110
113
|
inner,
|
|
114
|
+
ks256Attestation: input.ks256Attestation,
|
|
111
115
|
});
|
|
112
116
|
}
|
|
113
117
|
/**
|
|
@@ -134,8 +138,35 @@ export async function verifyReceiptOfflineWithKeys(input) {
|
|
|
134
138
|
idtimestampBe8: input.idtimestampBe8,
|
|
135
139
|
inner,
|
|
136
140
|
trustKeys: input.trustKeys,
|
|
141
|
+
ks256Evidence: withKeysKs256Evidence(input),
|
|
137
142
|
});
|
|
138
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* KS256 chain evidence for the caller-known-key entries: the attestation, and
|
|
146
|
+
* the chain id from the forest genesis the caller supplied, if any.
|
|
147
|
+
*/
|
|
148
|
+
function withKeysKs256Evidence(input) {
|
|
149
|
+
if (!input.ks256Attestation)
|
|
150
|
+
return {};
|
|
151
|
+
return {
|
|
152
|
+
attestation: input.ks256Attestation,
|
|
153
|
+
forestChainId: input.forestGenesisCbor
|
|
154
|
+
? genesisChainIdOrUndefined(input.forestGenesisCbor)
|
|
155
|
+
: undefined,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The forest's chain id from its genesis chain binding, or `undefined` when
|
|
160
|
+
* the genesis carries none (an attestation then can't be bound, D6).
|
|
161
|
+
*/
|
|
162
|
+
function genesisChainIdOrUndefined(genesisCbor) {
|
|
163
|
+
try {
|
|
164
|
+
return BigInt(decodeChainBindingFromGenesis(genesisCbor).chainId);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
139
170
|
/**
|
|
140
171
|
* Shared offline-verify core: reconstruct the leaf as
|
|
141
172
|
* `univocityLeafHash(idtimestamp, inner)`, resolve the (possibly delegated,
|
|
@@ -151,21 +182,25 @@ async function verifyReceiptOfflineWithLeafInner(input) {
|
|
|
151
182
|
catch {
|
|
152
183
|
return { ok: false, stage: "parse", reason: "genesis_invalid" };
|
|
153
184
|
}
|
|
154
|
-
const verifyKeys = es256ReceiptVerifyKeys([trustRoot]);
|
|
155
|
-
if (!verifyKeys.length) {
|
|
156
|
-
return { ok: false, stage: "signature", reason: "no_es256_trust_key" };
|
|
157
|
-
}
|
|
158
185
|
return verifyReceiptOfflineWithLeafInnerKeys({
|
|
159
186
|
receiptCbor: input.receiptCbor,
|
|
160
187
|
idtimestampBe8: input.idtimestampBe8,
|
|
161
188
|
inner: input.inner,
|
|
162
|
-
trustKeys:
|
|
189
|
+
trustKeys: [trustRoot],
|
|
190
|
+
ks256Evidence: input.ks256Attestation
|
|
191
|
+
? {
|
|
192
|
+
attestation: input.ks256Attestation,
|
|
193
|
+
forestChainId: genesisChainIdOrUndefined(input.genesisCbor),
|
|
194
|
+
}
|
|
195
|
+
: {},
|
|
163
196
|
});
|
|
164
197
|
}
|
|
165
198
|
/**
|
|
166
199
|
* Trust-key-parameterised offline-verify core. Identical to the genesis path
|
|
167
200
|
* once the trust keys are in hand: resolve the (possibly delegated, FOR-297)
|
|
168
|
-
* verify key against `trustKeys`, then check signature + inclusion.
|
|
201
|
+
* verify key against `trustKeys`, then check signature + inclusion. Receipts
|
|
202
|
+
* are always ES256, so a KS256 trust key only ever verifies the delegation
|
|
203
|
+
* certificate; only ES256 trust keys are tried on the receipt itself.
|
|
169
204
|
*/
|
|
170
205
|
async function verifyReceiptOfflineWithLeafInnerKeys(input) {
|
|
171
206
|
let parsed;
|
|
@@ -183,13 +218,27 @@ async function verifyReceiptOfflineWithLeafInnerKeys(input) {
|
|
|
183
218
|
// delegated key first. A cert present but not chaining to a trust key is a
|
|
184
219
|
// hard failure — do not silently fall back to the trust key (which cannot
|
|
185
220
|
// verify a delegated-signed receipt anyway).
|
|
186
|
-
const delegation = await resolveDelegatedVerifyKey(input.receiptCbor, input.trustKeys);
|
|
221
|
+
const delegation = await resolveDelegatedVerifyKey(input.receiptCbor, input.trustKeys, input.ks256Evidence);
|
|
187
222
|
if (delegation.kind === "broken") {
|
|
188
|
-
return {
|
|
223
|
+
return {
|
|
224
|
+
ok: false,
|
|
225
|
+
stage: "signature",
|
|
226
|
+
reason: delegation.reason ?? "delegation_invalid",
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const es256TrustKeys = input.trustKeys.filter((k) => !isParsedKs256RootKey(k));
|
|
230
|
+
if (delegation.kind === "root-only" && !es256TrustKeys.length) {
|
|
231
|
+
// A KS256 root never signs a receipt; without a certificate there is no
|
|
232
|
+
// ES256 key to check it with.
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
stage: "signature",
|
|
236
|
+
reason: "ks256_root_requires_delegation",
|
|
237
|
+
};
|
|
189
238
|
}
|
|
190
239
|
const allVerifyKeys = delegation.kind === "resolved"
|
|
191
|
-
? [delegation.delegatedKey, ...
|
|
192
|
-
:
|
|
240
|
+
? [delegation.delegatedKey, ...es256TrustKeys]
|
|
241
|
+
: es256TrustKeys;
|
|
193
242
|
let idtimestamp;
|
|
194
243
|
try {
|
|
195
244
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forestrie/receipt-verify",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Offline SCITT grant receipt verification (ADR-0045)",
|
|
@@ -28,11 +28,13 @@
|
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@noble/hashes": "^1.7.1",
|
|
31
|
+
"@forestrie/delegation-cose": "0.4.0",
|
|
31
32
|
"@forestrie/encoding": "0.9.1",
|
|
32
33
|
"@forestrie/merklelog": "0.5.0",
|
|
33
34
|
"@forestrie/chain-rpc": "0.3.0"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
37
|
+
"@noble/curves": "^1.9.7",
|
|
36
38
|
"esbuild": "^0.24.0",
|
|
37
39
|
"typescript": "^5.9.2",
|
|
38
40
|
"vitest": "^3.2.4"
|
|
@@ -59,10 +59,14 @@ export type ParsedCheckpoint = {
|
|
|
59
59
|
/**
|
|
60
60
|
* Sealed tree size: the SIGNED `tree-size-2` from the checkpoint's
|
|
61
61
|
* PROTECTED header (ADR-0066 D1 as amended, label -65933), not the
|
|
62
|
-
* unprotected consistency proof's declared value. `null` when the
|
|
63
|
-
* checkpoint
|
|
64
|
-
*
|
|
65
|
-
* treat
|
|
62
|
+
* unprotected consistency proof's declared value. `null` ONLY when the
|
|
63
|
+
* checkpoint is genuinely unsealed — no embedded consistency proof, or a
|
|
64
|
+
* well-formed protected header with no signed tree-size-2 label — and
|
|
65
|
+
* callers must treat that the same as a checkpoint with no proof at all.
|
|
66
|
+
* A structurally malformed consistency proof, or a protected header that
|
|
67
|
+
* is not deterministically encoded (ADR-0066 D9), is a DIFFERENT
|
|
68
|
+
* condition — {@link parseCheckpoint} throws for those rather than
|
|
69
|
+
* folding them into this `null` (review finding O3).
|
|
66
70
|
*/
|
|
67
71
|
mmrSize: bigint | null;
|
|
68
72
|
delegationCert: Uint8Array | null;
|
|
@@ -273,26 +277,23 @@ function cborBytes(value: unknown): Uint8Array {
|
|
|
273
277
|
* validating decode that requires the signed size and the last relayed
|
|
274
278
|
* proof's to agree.
|
|
275
279
|
*
|
|
276
|
-
* Lenient
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
280
|
+
* Lenient only for an ABSENT proof or an absent signed tree-size-2: both
|
|
281
|
+
* yield `null`, so a caller of {@link parseCheckpoint} sees the same "not
|
|
282
|
+
* verifiable" outcome for either. A proof or protected header that IS
|
|
283
|
+
* present but malformed is a different condition and is not swallowed here
|
|
284
|
+
* — {@link decodeConsistencyProofsFromUnprotected} throws for a structurally
|
|
285
|
+
* malformed proof (including an empty consistency-proofs array), and
|
|
286
|
+
* {@link readProtectedTreeSize2} throws for a protected header that is not
|
|
287
|
+
* deterministically encoded (ADR-0066 D9); both propagate rather than
|
|
288
|
+
* folding into `null` (review finding O3: a D9 conformance failure is a
|
|
289
|
+
* sealer-side defect worth attributing, not the ordinary "no proof yet"
|
|
290
|
+
* case).
|
|
281
291
|
*/
|
|
282
292
|
function sealedSizeFromCheckpoint(
|
|
283
293
|
coseSign1: CoseSign1,
|
|
284
294
|
unprotected: Map<number, unknown>,
|
|
285
295
|
): bigint | null {
|
|
286
|
-
|
|
287
|
-
try {
|
|
288
|
-
declared = decodeConsistencyProofsFromUnprotected(unprotected);
|
|
289
|
-
} catch {
|
|
290
|
-
return null;
|
|
291
|
-
}
|
|
296
|
+
const declared = decodeConsistencyProofsFromUnprotected(unprotected);
|
|
292
297
|
if (declared === null) return null;
|
|
293
|
-
|
|
294
|
-
return readProtectedTreeSize2(coseSign1[0]);
|
|
295
|
-
} catch {
|
|
296
|
-
return null;
|
|
297
|
-
}
|
|
298
|
+
return readProtectedTreeSize2(coseSign1[0]);
|
|
298
299
|
}
|
|
@@ -44,7 +44,16 @@ function parseAlgInt(raw: unknown): number | null {
|
|
|
44
44
|
return null;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Wrap a 20-byte KS256 root address as a {@link ParsedKs256RootKey}.
|
|
49
|
+
*
|
|
50
|
+
* @throws When `address` is not 20 bytes: a key of any other length would
|
|
51
|
+
* fail {@link isParsedKs256RootKey} and be mistaken for an ES256 key.
|
|
52
|
+
*/
|
|
53
|
+
export function ks256RootFromAddress(address: Uint8Array): ParsedKs256RootKey {
|
|
54
|
+
if (address.length !== 20) {
|
|
55
|
+
throw new Error("KS256 root address must be 20 bytes");
|
|
56
|
+
}
|
|
48
57
|
return { kind: "KS256", alg: COSE_ALG_KS256, address };
|
|
49
58
|
}
|
|
50
59
|
|
package/src/index.ts
CHANGED
|
@@ -89,6 +89,29 @@ export type {
|
|
|
89
89
|
} from "./resolve-delegated-verify-key.js";
|
|
90
90
|
/** Import a raw 64-byte x||y P-256 public key as an ES256 verify key. */
|
|
91
91
|
export { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
|
|
92
|
+
/**
|
|
93
|
+
* KS256 roots in offline verification (plan-2610-03 D4–D6, protocol ADR-0045
|
|
94
|
+
* amendment 2026-10-18). A KS256 root comes only from genesis, a caller-known
|
|
95
|
+
* log key ({@link knownLogKeyFromBytes}: asserts, does not prove) or, for a
|
|
96
|
+
* contract root, a caller-supplied ERC-1271 attestation; never from the
|
|
97
|
+
* delegation coordinator. EOA roots verify by ecrecover; anything else needs
|
|
98
|
+
* an attestation or fails with `ks256_root_not_recovered`.
|
|
99
|
+
*/
|
|
100
|
+
export { knownLogKeyFromBytes } from "./known-log-key.js";
|
|
101
|
+
export { ks256RootFromAddress } from "./decode-trust-root-cbor.js";
|
|
102
|
+
export type { ParsedKs256RootKey, RootVerifyKey } from "./root-verify-key.js";
|
|
103
|
+
export {
|
|
104
|
+
ERC1271_MAGIC_VALUE,
|
|
105
|
+
decodeKs256Attestation,
|
|
106
|
+
encodeKs256Attestation,
|
|
107
|
+
type Ks256Attestation,
|
|
108
|
+
} from "./ks256-attestation.js";
|
|
109
|
+
export {
|
|
110
|
+
extractKs256DelegationDigest,
|
|
111
|
+
verifyKs256DelegationCert,
|
|
112
|
+
} from "./verify-ks256-delegation.js";
|
|
113
|
+
export type { Ks256DelegationEvidence } from "./verify-ks256-delegation.js";
|
|
114
|
+
export type { Ks256CertificateDigest } from "@forestrie/delegation-cose";
|
|
92
115
|
/**
|
|
93
116
|
* Passkey session-key endorsement v2 (ADR-0064 as amended by ADR-0065):
|
|
94
117
|
* build, assemble, and verify the artifact chaining a passkey log root to the
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller-known log key bytes → trust key (FOR-297 "known log key"; plan-2610-03
|
|
3
|
+
* D6). The bytes alone decide the type, as univocity's
|
|
4
|
+
* `GrantDataInvalidKeyLength` and arbor's `grantDataIdentity` do: 64 bytes is
|
|
5
|
+
* an ES256 x||y public key, 20 bytes a KS256 address, anything else is
|
|
6
|
+
* refused. Either way the caller ASSERTS, and does not prove, that the key
|
|
7
|
+
* owns the log — see `verifyReceiptOfflineWithKeys`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
importEs256PublicKeyFromGrantDataXy64,
|
|
12
|
+
ks256RootFromAddress,
|
|
13
|
+
} from "./decode-trust-root-cbor.js";
|
|
14
|
+
import type { RootVerifyKey } from "./root-verify-key.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param bytes - 64-byte ES256 x||y or 20-byte KS256 address.
|
|
18
|
+
* @throws When the length is neither.
|
|
19
|
+
*/
|
|
20
|
+
export async function knownLogKeyFromBytes(
|
|
21
|
+
bytes: Uint8Array,
|
|
22
|
+
): Promise<RootVerifyKey> {
|
|
23
|
+
if (bytes.length === 64) return importEs256PublicKeyFromGrantDataXy64(bytes);
|
|
24
|
+
if (bytes.length === 20) return ks256RootFromAddress(bytes);
|
|
25
|
+
throw new Error(
|
|
26
|
+
`known log key must be 64 bytes (ES256 x||y) or 20 bytes (KS256 address), got ${bytes.length}`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ERC-1271 attestation (plan-2610-03 D4, protocol ADR-0045 amendment
|
|
3
|
+
* 2026-10-18): a caller-supplied record that a contract KS256 root (a Safe)
|
|
4
|
+
* answered `isValidSignature(digest, signature)` with the ERC-1271 magic value
|
|
5
|
+
* at a given block. It lets a contract-rooted delegation certificate verify
|
|
6
|
+
* offline with the narrower guarantee "valid under the contract's signers at
|
|
7
|
+
* the attested block".
|
|
8
|
+
*
|
|
9
|
+
* Same trust model as the known-accumulator snapshot (known-accumulator.ts):
|
|
10
|
+
* the record is the fetching party's chain read, made explicit and auditable.
|
|
11
|
+
* It binds `(chainId, address, digest, signature hash, block)`, so anyone with
|
|
12
|
+
* RPC can re-run exactly that call at that block (the signature itself is in
|
|
13
|
+
* the receipt's certificate) and confirm or disprove it. This package never makes
|
|
14
|
+
* that call; forestrie-cli and mcp-resolve fetch it (slice 04).
|
|
15
|
+
*
|
|
16
|
+
* The verifier accepts an attestation only for the exact root address and
|
|
17
|
+
* certificate digest, and only when `chainId` equals the forest's genesis
|
|
18
|
+
* chain: the same Safe address on another chain can have other owners (D6).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
decodeCborDeterministic,
|
|
23
|
+
encodeCborDeterministic,
|
|
24
|
+
} from "@forestrie/encoding";
|
|
25
|
+
|
|
26
|
+
/** ERC-1271 `isValidSignature` success value, `bytes4(0x1626ba7e)`. */
|
|
27
|
+
export const ERC1271_MAGIC_VALUE = new Uint8Array([0x16, 0x26, 0xba, 0x7e]);
|
|
28
|
+
|
|
29
|
+
/** CBOR map labels for the attestation artifact (strict RFC 8949 §4.2). */
|
|
30
|
+
const LABEL_VERSION = 1;
|
|
31
|
+
const LABEL_CHAIN_ID = 2;
|
|
32
|
+
const LABEL_ADDRESS = 3;
|
|
33
|
+
const LABEL_DIGEST = 4;
|
|
34
|
+
const LABEL_BLOCK_NUMBER = 5;
|
|
35
|
+
const LABEL_BLOCK_HASH = 6;
|
|
36
|
+
const LABEL_MAGIC = 7;
|
|
37
|
+
const LABEL_SIGNATURE_HASH = 8;
|
|
38
|
+
|
|
39
|
+
const KNOWN_LABELS = new Set<number>([
|
|
40
|
+
LABEL_VERSION,
|
|
41
|
+
LABEL_CHAIN_ID,
|
|
42
|
+
LABEL_ADDRESS,
|
|
43
|
+
LABEL_DIGEST,
|
|
44
|
+
LABEL_BLOCK_NUMBER,
|
|
45
|
+
LABEL_BLOCK_HASH,
|
|
46
|
+
LABEL_MAGIC,
|
|
47
|
+
LABEL_SIGNATURE_HASH,
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const ATTESTATION_VERSION = 1;
|
|
51
|
+
|
|
52
|
+
/** A block-bound ERC-1271 `isValidSignature` result for one digest. */
|
|
53
|
+
export type Ks256Attestation = {
|
|
54
|
+
version: number;
|
|
55
|
+
/** Chain the call was made on; must equal the forest's genesis chain. */
|
|
56
|
+
chainId: bigint;
|
|
57
|
+
/** Contract root address (20 bytes) the call was made to. */
|
|
58
|
+
address: Uint8Array;
|
|
59
|
+
/** keccak256(Sig_structure) of the delegation certificate (32 bytes). */
|
|
60
|
+
digest: Uint8Array;
|
|
61
|
+
/**
|
|
62
|
+
* keccak256 of the signature bytes `isValidSignature` was called with
|
|
63
|
+
* (32 bytes); must match the certificate's signature.
|
|
64
|
+
*/
|
|
65
|
+
signatureHash: Uint8Array;
|
|
66
|
+
/** Block the call was made at. */
|
|
67
|
+
blockNumber: bigint;
|
|
68
|
+
/** Hash of that block (32 bytes): the falsifiability handle. */
|
|
69
|
+
blockHash: Uint8Array;
|
|
70
|
+
/** The call's 4-byte return; only {@link ERC1271_MAGIC_VALUE} attests. */
|
|
71
|
+
magic: Uint8Array;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Encode an attestation as canonical CBOR (RFC 8949 §4.2, hard policy). */
|
|
75
|
+
export function encodeKs256Attestation(
|
|
76
|
+
attestation: Ks256Attestation,
|
|
77
|
+
): Uint8Array {
|
|
78
|
+
return encodeCborDeterministic(
|
|
79
|
+
new Map<number, unknown>([
|
|
80
|
+
[LABEL_VERSION, attestation.version],
|
|
81
|
+
[LABEL_CHAIN_ID, attestation.chainId],
|
|
82
|
+
[LABEL_ADDRESS, attestation.address],
|
|
83
|
+
[LABEL_DIGEST, attestation.digest],
|
|
84
|
+
[LABEL_BLOCK_NUMBER, attestation.blockNumber],
|
|
85
|
+
[LABEL_BLOCK_HASH, attestation.blockHash],
|
|
86
|
+
[LABEL_MAGIC, attestation.magic],
|
|
87
|
+
[LABEL_SIGNATURE_HASH, attestation.signatureHash],
|
|
88
|
+
]),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function asBigint(v: unknown, what: string): bigint {
|
|
93
|
+
if (typeof v === "bigint") return v;
|
|
94
|
+
if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0)
|
|
95
|
+
return BigInt(v);
|
|
96
|
+
throw new Error(`ks256 attestation: ${what} must be an unsigned integer`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function asBytes(v: unknown, length: number, what: string): Uint8Array {
|
|
100
|
+
if (!(v instanceof Uint8Array) || v.length !== length) {
|
|
101
|
+
throw new Error(`ks256 attestation: ${what} must be ${length} bytes`);
|
|
102
|
+
}
|
|
103
|
+
return v;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Strict decode and shape validation of an attestation artifact: canonical
|
|
108
|
+
* CBOR, known labels only, version 1, exact types and lengths.
|
|
109
|
+
*/
|
|
110
|
+
export function decodeKs256Attestation(bytes: Uint8Array): Ks256Attestation {
|
|
111
|
+
let decoded: unknown;
|
|
112
|
+
try {
|
|
113
|
+
decoded = decodeCborDeterministic(bytes);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
116
|
+
throw new Error(`ks256 attestation is not canonical CBOR: ${message}`);
|
|
117
|
+
}
|
|
118
|
+
if (!(decoded instanceof Map)) {
|
|
119
|
+
throw new Error("ks256 attestation must be a CBOR map");
|
|
120
|
+
}
|
|
121
|
+
for (const label of decoded.keys()) {
|
|
122
|
+
if (!KNOWN_LABELS.has(label as number)) {
|
|
123
|
+
throw new Error(`ks256 attestation: unknown label ${String(label)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const version = Number(asBigint(decoded.get(LABEL_VERSION), "version"));
|
|
127
|
+
if (version !== ATTESTATION_VERSION) {
|
|
128
|
+
throw new Error(`ks256 attestation version ${version} not supported`);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
version,
|
|
132
|
+
chainId: asBigint(decoded.get(LABEL_CHAIN_ID), "chainId"),
|
|
133
|
+
address: asBytes(decoded.get(LABEL_ADDRESS), 20, "address"),
|
|
134
|
+
digest: asBytes(decoded.get(LABEL_DIGEST), 32, "digest"),
|
|
135
|
+
blockNumber: asBigint(decoded.get(LABEL_BLOCK_NUMBER), "blockNumber"),
|
|
136
|
+
blockHash: asBytes(decoded.get(LABEL_BLOCK_HASH), 32, "blockHash"),
|
|
137
|
+
magic: asBytes(decoded.get(LABEL_MAGIC), 4, "magic"),
|
|
138
|
+
signatureHash: asBytes(
|
|
139
|
+
decoded.get(LABEL_SIGNATURE_HASH),
|
|
140
|
+
32,
|
|
141
|
+
"signatureHash",
|
|
142
|
+
),
|
|
143
|
+
};
|
|
144
|
+
}
|