@forestrie/forestrie-cli 0.10.0 → 0.11.1
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/README.md +59 -3
- package/dist/cli.js +3716 -1682
- package/package.json +3 -3
- package/src/command.ts +2 -0
- package/src/commands/fetch-ks256-attestation.ts +52 -0
- package/src/commands/verify-grant.ts +8 -2
- package/src/commands/verify.ts +8 -2
- package/src/lib/known-log-key.ts +35 -0
- package/src/lib/verify-inputs.ts +1 -1
- package/src/lib/verify-report.ts +78 -0
- package/src/main/fetch-ks256-attestation.ts +245 -0
- package/src/main/verify.ts +169 -60
- package/src/options/fetch-ks256-attestation.ts +60 -0
- package/src/options/verify.ts +10 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forestrie/forestrie-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "forestrie — participant CLI for forestrie transparency logs (SCITT / COSE receipts)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"forestrie",
|
|
@@ -66,12 +66,12 @@
|
|
|
66
66
|
"dependencies": {
|
|
67
67
|
"@forestrie/chain-rpc": "^0.3.0",
|
|
68
68
|
"@forestrie/cli-kit": "^0.1.1",
|
|
69
|
-
"@forestrie/delegation-cose": "
|
|
69
|
+
"@forestrie/delegation-cose": "0.4.0",
|
|
70
70
|
"@forestrie/deploy-core": "^0.7.1",
|
|
71
71
|
"@forestrie/encoding": "^0.9.1",
|
|
72
72
|
"@forestrie/grant-builder": "^0.5.0",
|
|
73
73
|
"@forestrie/merklelog": "^0.5.0",
|
|
74
|
-
"@forestrie/receipt-verify": "
|
|
74
|
+
"@forestrie/receipt-verify": "3.1.0",
|
|
75
75
|
"@forestrie/scrapi-client": "^0.2.2",
|
|
76
76
|
"citty": "^0.2.2",
|
|
77
77
|
"viem": "^2.31.0"
|
package/src/command.ts
CHANGED
|
@@ -45,6 +45,8 @@ export default defineForestrieCommand({
|
|
|
45
45
|
import("./commands/verify-grant.js").then((m) => m.default),
|
|
46
46
|
"fetch-accumulator": () =>
|
|
47
47
|
import("./commands/fetch-accumulator.js").then((m) => m.default),
|
|
48
|
+
"fetch-ks256-attestation": () =>
|
|
49
|
+
import("./commands/fetch-ks256-attestation.js").then((m) => m.default),
|
|
48
50
|
"create-consistency-proof": () =>
|
|
49
51
|
import("./commands/create-consistency-proof.js").then((m) => m.default),
|
|
50
52
|
},
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { defineCommandRunner, defineForestrieCommand } from "../commoncli.js";
|
|
2
|
+
import { runFetchKs256Attestation } from "../main/fetch-ks256-attestation.js";
|
|
3
|
+
import { parseFetchKs256AttestationOptions } from "../options/fetch-ks256-attestation.js";
|
|
4
|
+
|
|
5
|
+
export default defineForestrieCommand({
|
|
6
|
+
meta: {
|
|
7
|
+
name: "fetch-ks256-attestation",
|
|
8
|
+
description:
|
|
9
|
+
"Ask a KS256 contract root (a Safe) over ERC-1271 whether it signs the receipt's delegation certificate, and cache the block-bound answer as a --ks256-attestation artifact (CBOR binding chainId/address/digest/signature hash/block) — verify then accepts the contract-rooted receipt offline as valid under the contract's signers at that block. The answer is only as trustworthy as the RPC you name; for more confidence run it against several independent RPCs with the same --block and check they agree on the block hash. Auditable: anyone with RPC can re-run the call at that block. An EOA root needs no attestation (verify recovers it)",
|
|
10
|
+
},
|
|
11
|
+
args: {
|
|
12
|
+
receipt: {
|
|
13
|
+
type: "string",
|
|
14
|
+
description: "Receipt carrying the KS256 delegation certificate (label 1000)",
|
|
15
|
+
valueHint: "path",
|
|
16
|
+
required: true,
|
|
17
|
+
},
|
|
18
|
+
root: {
|
|
19
|
+
type: "string",
|
|
20
|
+
description:
|
|
21
|
+
"Contract root address (0x + 40 hex); overrides the genesis root",
|
|
22
|
+
valueHint: "address",
|
|
23
|
+
},
|
|
24
|
+
genesis: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description:
|
|
27
|
+
"Forest genesis (genesis.cbor): supplies the KS256 root unless --root is given, and the chain id the RPC must match",
|
|
28
|
+
valueHint: "path",
|
|
29
|
+
},
|
|
30
|
+
"rpc-url": {
|
|
31
|
+
type: "string",
|
|
32
|
+
description: "JSON-RPC endpoint (default: ${env} → RPC_URL)",
|
|
33
|
+
valueHint: "url",
|
|
34
|
+
},
|
|
35
|
+
block: {
|
|
36
|
+
type: "string",
|
|
37
|
+
description:
|
|
38
|
+
"Block number to pin the calls to, or 'latest' (default). The attestation speaks for the contract's signers at this block, so pick one near the receipt's time for an authorised-at-issuance reading; old blocks need an archive node",
|
|
39
|
+
valueHint: "number|latest",
|
|
40
|
+
},
|
|
41
|
+
out: {
|
|
42
|
+
type: "string",
|
|
43
|
+
description: "Output path for the attestation CBOR",
|
|
44
|
+
valueHint: "path",
|
|
45
|
+
required: true,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
run: defineCommandRunner(
|
|
49
|
+
parseFetchKs256AttestationOptions,
|
|
50
|
+
runFetchKs256Attestation,
|
|
51
|
+
),
|
|
52
|
+
});
|
|
@@ -18,8 +18,14 @@ export default defineForestrieCommand({
|
|
|
18
18
|
"known-log-key": {
|
|
19
19
|
type: "string",
|
|
20
20
|
description:
|
|
21
|
-
"Caller-known log OWNER key (the delegation issuer)
|
|
22
|
-
valueHint: "base64",
|
|
21
|
+
"Caller-known log OWNER key (the delegation issuer): base64 x||y (64-byte ES256 key) or a 20-byte KS256 address (0x + 40 hex, or base64) (env KNOWN_LOG_KEY). Offline signer-provenance anchor that replaces --genesis (give --genesis too and it only supplies the forest chain id an attestation must match); asserts (does not prove) the key-to-log binding — --genesis roots it in the forest genesis document's bootstrap key (the root log, or a delegation directly under it), while the accumulator roots (--known-accumulator / --rpc-url) supply split-view (freshness is a weaker, separate axis bearing only on coverage). See https://github.com/forestrie/protocol/blob/main/spec/receipt-trust-model.md",
|
|
22
|
+
valueHint: "base64|0xaddress",
|
|
23
|
+
},
|
|
24
|
+
"ks256-attestation": {
|
|
25
|
+
type: "string",
|
|
26
|
+
description:
|
|
27
|
+
"ERC-1271 attestation (CBOR from `forestrie fetch-ks256-attestation`) for a KS256 contract root such as a Safe: the delegation certificate then verifies offline as valid under the contract's signers at the attested block, not as a signature by one key. Needs the forest chain id from --genesis. verify never calls the chain. Trust: an attestation is unsigned and only as good as whoever fetched it — accept one only from a party you would trust to read the chain for you, or fetch it yourself",
|
|
28
|
+
valueHint: "path",
|
|
23
29
|
},
|
|
24
30
|
receipt: {
|
|
25
31
|
type: "string",
|
package/src/commands/verify.ts
CHANGED
|
@@ -18,8 +18,14 @@ export default defineForestrieCommand({
|
|
|
18
18
|
"known-log-key": {
|
|
19
19
|
type: "string",
|
|
20
20
|
description:
|
|
21
|
-
"Caller-known log OWNER key (the delegation issuer)
|
|
22
|
-
valueHint: "base64",
|
|
21
|
+
"Caller-known log OWNER key (the delegation issuer): base64 x||y (64-byte ES256 key) or a 20-byte KS256 address (0x + 40 hex, or base64) (env KNOWN_LOG_KEY). Offline signer-provenance anchor that replaces --genesis (give --genesis too and it only supplies the forest chain id an attestation must match); asserts (does not prove) the key-to-log binding — --genesis roots it in the forest genesis document's bootstrap key (the root log, or a delegation directly under it), while the accumulator roots (--known-accumulator / --rpc-url) supply split-view (freshness is a weaker, separate axis bearing only on coverage). See https://github.com/forestrie/protocol/blob/main/spec/receipt-trust-model.md",
|
|
22
|
+
valueHint: "base64|0xaddress",
|
|
23
|
+
},
|
|
24
|
+
"ks256-attestation": {
|
|
25
|
+
type: "string",
|
|
26
|
+
description:
|
|
27
|
+
"ERC-1271 attestation (CBOR from `forestrie fetch-ks256-attestation`) for a KS256 contract root such as a Safe: the delegation certificate then verifies offline as valid under the contract's signers at the attested block, not as a signature by one key. Needs the forest chain id from --genesis. verify never calls the chain. Trust: an attestation is unsigned and only as good as whoever fetched it — accept one only from a party you would trust to read the chain for you, or fetch it yourself",
|
|
28
|
+
valueHint: "path",
|
|
23
29
|
},
|
|
24
30
|
receipt: {
|
|
25
31
|
type: "string",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {
|
|
2
|
+
knownLogKeyFromBytes,
|
|
3
|
+
type RootVerifyKey,
|
|
4
|
+
} from "@forestrie/receipt-verify";
|
|
5
|
+
|
|
6
|
+
/** The accepted `--known-log-key` forms, for error messages and help. */
|
|
7
|
+
export const KNOWN_LOG_KEY_FORMS =
|
|
8
|
+
"base64 x||y (64-byte ES256 public key) or a 20-byte KS256 address (0x + 40 hex, or base64)";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse the caller-known log OWNER key (`--known-log-key`). Forms:
|
|
12
|
+
*
|
|
13
|
+
* - base64 of 64 bytes: an ES256 public key, x||y;
|
|
14
|
+
* - `0x` + 40 hex, or base64 of 20 bytes: a KS256 (Ethereum) address.
|
|
15
|
+
*
|
|
16
|
+
* Length alone decides the algorithm (via `knownLogKeyFromBytes`, the same
|
|
17
|
+
* rule univocity applies). Either way the caller ASSERTS the key owns the log.
|
|
18
|
+
*/
|
|
19
|
+
export async function parseKnownLogKey(value: string): Promise<RootVerifyKey> {
|
|
20
|
+
const v = value.trim();
|
|
21
|
+
const bytes = /^0x[0-9a-fA-F]{40}$/.test(v)
|
|
22
|
+
? new Uint8Array(Buffer.from(v.slice(2), "hex"))
|
|
23
|
+
: new Uint8Array(Buffer.from(v, "base64"));
|
|
24
|
+
if (bytes.length !== 64 && bytes.length !== 20) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`--known-log-key must be ${KNOWN_LOG_KEY_FORMS}; decoded ${bytes.length} bytes`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return knownLogKeyFromBytes(bytes);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** True for a KS256 root (a `ParsedKs256RootKey`, not a WebCrypto key). */
|
|
33
|
+
export function isKs256Root(key: RootVerifyKey): boolean {
|
|
34
|
+
return !(key instanceof CryptoKey);
|
|
35
|
+
}
|
package/src/lib/verify-inputs.ts
CHANGED
|
@@ -64,7 +64,7 @@ export type LoadedVerifyArtifacts = Omit<
|
|
|
64
64
|
"genesisCbor"
|
|
65
65
|
> & { genesisCbor: Uint8Array | undefined };
|
|
66
66
|
|
|
67
|
-
function readBytes(path: string, flag: string): Uint8Array {
|
|
67
|
+
export function readBytes(path: string, flag: string): Uint8Array {
|
|
68
68
|
try {
|
|
69
69
|
return new Uint8Array(readFileSync(path));
|
|
70
70
|
} catch (err) {
|
package/src/lib/verify-report.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
ReceiptVerifyResult,
|
|
3
3
|
ReceiptVerifyStage,
|
|
4
4
|
} from "@forestrie/receipt-verify";
|
|
5
|
+
import type { Ks256Attestation } from "@forestrie/receipt-verify";
|
|
5
6
|
import type { AnchorCheck } from "./verify-anchored.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -110,6 +111,46 @@ export function knownKeyStageRows(result: ReceiptVerifyResult): StageRow[] {
|
|
|
110
111
|
});
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* KS256 root facts for the report: whether the root is a KS256 address, how
|
|
116
|
+
* it was supplied, and any attestation given (with whether the certificate
|
|
117
|
+
* needed it, i.e. did not verify by ecrecover alone).
|
|
118
|
+
*/
|
|
119
|
+
export type Ks256ReportInfo = {
|
|
120
|
+
ks256Root: boolean;
|
|
121
|
+
knownKey: boolean;
|
|
122
|
+
attestation?: Ks256Attestation;
|
|
123
|
+
attestationUsed?: boolean;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** Reword the parse/signature rows for a KS256 root or an attestation. */
|
|
127
|
+
export function applyKs256Rows(
|
|
128
|
+
rows: StageRow[],
|
|
129
|
+
info: Ks256ReportInfo,
|
|
130
|
+
): StageRow[] {
|
|
131
|
+
const att = info.attestation;
|
|
132
|
+
return rows.map((row) => {
|
|
133
|
+
if (row.status !== "ok") return row;
|
|
134
|
+
if (row.stage === "parse" && info.ks256Root) {
|
|
135
|
+
return {
|
|
136
|
+
...row,
|
|
137
|
+
reason: `receipt COSE decodes; ${info.knownKey ? "caller-known log key" : "genesis trust root"} loads (KS256 address)`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (row.stage === "signature" && info.ks256Root) {
|
|
141
|
+
const anchorName = info.knownKey ? "caller-known log key" : "genesis root";
|
|
142
|
+
return {
|
|
143
|
+
...row,
|
|
144
|
+
reason:
|
|
145
|
+
att !== undefined && info.attestationUsed === true
|
|
146
|
+
? `delegation certificate valid under the contract's signers at block ${att.blockNumber} (ERC-1271 attestation; chain ${att.chainId}) — not a signature by one key`
|
|
147
|
+
: `delegation certificate recovers to the KS256 ${anchorName} (ecrecover)`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return row;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
113
154
|
export function stageRows(result: ReceiptVerifyResult): StageRow[] {
|
|
114
155
|
if (result.ok) {
|
|
115
156
|
return VERIFY_STAGES.map((stage) => ({ stage, status: "ok" }));
|
|
@@ -185,6 +226,19 @@ export type VerifyReport = {
|
|
|
185
226
|
reason?: string;
|
|
186
227
|
stages: StageRow[];
|
|
187
228
|
anchor?: AnchorReport;
|
|
229
|
+
/** KS256 root and ERC-1271 attestation facts (present for a KS256 root). */
|
|
230
|
+
ks256?: {
|
|
231
|
+
root: boolean;
|
|
232
|
+
/** An unused attestation reports `used: false` only: its fields were
|
|
233
|
+
* never checked against anything, so they are not echoed. */
|
|
234
|
+
attestation?: {
|
|
235
|
+
used: boolean;
|
|
236
|
+
chainId?: string;
|
|
237
|
+
address?: string;
|
|
238
|
+
blockNumber?: string;
|
|
239
|
+
blockHash?: string;
|
|
240
|
+
};
|
|
241
|
+
};
|
|
188
242
|
};
|
|
189
243
|
|
|
190
244
|
/** Extra anchor facts present only for known-accumulator checks. */
|
|
@@ -203,6 +257,7 @@ export function buildVerifyReport(opts: {
|
|
|
203
257
|
logId?: string | undefined;
|
|
204
258
|
/** Anchor-only mode: rows that reflect the skipped signature stage. */
|
|
205
259
|
stagesOverride?: StageRow[] | undefined;
|
|
260
|
+
ks256?: Ks256ReportInfo | undefined;
|
|
206
261
|
}): VerifyReport {
|
|
207
262
|
const report: VerifyReport = {
|
|
208
263
|
command: "verify",
|
|
@@ -214,6 +269,25 @@ export function buildVerifyReport(opts: {
|
|
|
214
269
|
if (opts.result.reason !== undefined) {
|
|
215
270
|
report.reason = opts.result.reason;
|
|
216
271
|
}
|
|
272
|
+
if (opts.ks256 !== undefined) {
|
|
273
|
+
const att = opts.ks256.attestation;
|
|
274
|
+
report.ks256 = {
|
|
275
|
+
root: opts.ks256.ks256Root,
|
|
276
|
+
...(att !== undefined && opts.ks256.attestationUsed !== true
|
|
277
|
+
? { attestation: { used: false } }
|
|
278
|
+
: att !== undefined
|
|
279
|
+
? {
|
|
280
|
+
attestation: {
|
|
281
|
+
used: true,
|
|
282
|
+
chainId: att.chainId.toString(),
|
|
283
|
+
address: toHex(att.address),
|
|
284
|
+
blockNumber: att.blockNumber.toString(),
|
|
285
|
+
blockHash: toHex(att.blockHash),
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
: {}),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
217
291
|
if (opts.anchor !== undefined) {
|
|
218
292
|
const anchor: AnchorReport = {
|
|
219
293
|
univocity: opts.univocity ?? "",
|
|
@@ -263,3 +337,7 @@ export function buildVerifyReport(opts: {
|
|
|
263
337
|
}
|
|
264
338
|
return report;
|
|
265
339
|
}
|
|
340
|
+
|
|
341
|
+
function toHex(bytes: Uint8Array): string {
|
|
342
|
+
return `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
343
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import type { Out } from "@forestrie/cli-kit/reporting";
|
|
3
|
+
import {
|
|
4
|
+
bytesToHex,
|
|
5
|
+
encodeIsValidSignatureCall,
|
|
6
|
+
ethRpc,
|
|
7
|
+
hexAddressToBytes,
|
|
8
|
+
normalizeHexAddress,
|
|
9
|
+
} from "@forestrie/chain-rpc";
|
|
10
|
+
import {
|
|
11
|
+
decodeChainBindingFromGenesis,
|
|
12
|
+
decodeTrustRootFromGenesis,
|
|
13
|
+
encodeKs256Attestation,
|
|
14
|
+
ERC1271_MAGIC_VALUE,
|
|
15
|
+
extractKs256DelegationDigest,
|
|
16
|
+
} from "@forestrie/receipt-verify";
|
|
17
|
+
import { keccak256 } from "viem";
|
|
18
|
+
import type { FetchKs256AttestationOptions } from "../options/fetch-ks256-attestation.js";
|
|
19
|
+
import { isKs256Root } from "../lib/known-log-key.js";
|
|
20
|
+
|
|
21
|
+
/** `--json` success shape on stdout — stable for demo scripting. */
|
|
22
|
+
export type FetchKs256AttestationReport = {
|
|
23
|
+
command: "fetch-ks256-attestation";
|
|
24
|
+
chainId: string;
|
|
25
|
+
address: string;
|
|
26
|
+
digest: string;
|
|
27
|
+
blockNumber: string;
|
|
28
|
+
blockHash: string;
|
|
29
|
+
out: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type FetchKs256AttestationErrorReport = {
|
|
33
|
+
error: "fetch_ks256_attestation_failed";
|
|
34
|
+
command: "fetch-ks256-attestation";
|
|
35
|
+
message: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function readFile(path: string, flag: string): Uint8Array {
|
|
39
|
+
try {
|
|
40
|
+
return new Uint8Array(readFileSync(path));
|
|
41
|
+
} catch (err) {
|
|
42
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
43
|
+
throw new Error(`cannot read ${flag} '${path}': ${message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hexToBigint(v: unknown, what: string): bigint {
|
|
48
|
+
if (typeof v !== "string" || !/^0x[0-9a-fA-F]+$/.test(v)) {
|
|
49
|
+
throw new Error(`${what}: expected a hex quantity, got ${String(v)}`);
|
|
50
|
+
}
|
|
51
|
+
return BigInt(v);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hexToBytes32(v: unknown, what: string): Uint8Array {
|
|
55
|
+
if (typeof v !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(v)) {
|
|
56
|
+
throw new Error(`${what}: expected a 32-byte hex value, got ${String(v)}`);
|
|
57
|
+
}
|
|
58
|
+
return new Uint8Array(Buffer.from(v.slice(2), "hex"));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Pin to one block, then: refuse an EOA root (no code), and require the
|
|
63
|
+
* contract's `isValidSignature(digest, signature)` to return the ERC-1271
|
|
64
|
+
* magic value for the certificate's own digest and signature. Nothing is
|
|
65
|
+
* written unless every check passes.
|
|
66
|
+
*/
|
|
67
|
+
export async function runFetchKs256Attestation(
|
|
68
|
+
out: Out,
|
|
69
|
+
options: FetchKs256AttestationOptions,
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
try {
|
|
72
|
+
const receiptCbor = readFile(options.receipt, "--receipt");
|
|
73
|
+
const genesisCbor =
|
|
74
|
+
options.genesis !== undefined
|
|
75
|
+
? readFile(options.genesis, "--genesis")
|
|
76
|
+
: undefined;
|
|
77
|
+
|
|
78
|
+
let root: Uint8Array;
|
|
79
|
+
if (options.root !== undefined) {
|
|
80
|
+
const normalized = normalizeHexAddress(options.root);
|
|
81
|
+
if (normalized === null) {
|
|
82
|
+
throw new Error(`--root is not a valid address: '${options.root}'`);
|
|
83
|
+
}
|
|
84
|
+
root = hexAddressToBytes(normalized);
|
|
85
|
+
} else {
|
|
86
|
+
const trust = await decodeTrustRootFromGenesis(genesisCbor!);
|
|
87
|
+
if (!isKs256Root(trust)) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
"the genesis root is not a KS256 address (an ES256 root needs no attestation)",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
root = (trust as { address: Uint8Array }).address;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let genesisChainId: bigint | undefined;
|
|
96
|
+
if (genesisCbor !== undefined) {
|
|
97
|
+
genesisChainId = BigInt(decodeChainBindingFromGenesis(genesisCbor).chainId);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let parts: ReturnType<typeof extractKs256DelegationDigest>;
|
|
101
|
+
try {
|
|
102
|
+
parts = extractKs256DelegationDigest(receiptCbor);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
105
|
+
throw new Error(`the receipt's delegation certificate is not KS256: ${message}`);
|
|
106
|
+
}
|
|
107
|
+
if (parts === null) {
|
|
108
|
+
throw new Error("receipt carries no delegation certificate");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const chainId = hexToBigint(
|
|
112
|
+
await ethRpc(options.rpcUrl, "eth_chainId", []),
|
|
113
|
+
"eth_chainId",
|
|
114
|
+
);
|
|
115
|
+
if (genesisChainId !== undefined && chainId !== genesisChainId) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`RPC chain id ${chainId} differs from the genesis chain id ${genesisChainId}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const blockTag =
|
|
122
|
+
options.block === "latest" ? "latest" : `0x${options.block.toString(16)}`;
|
|
123
|
+
const block = (await ethRpc(options.rpcUrl, "eth_getBlockByNumber", [
|
|
124
|
+
blockTag,
|
|
125
|
+
false,
|
|
126
|
+
])) as { number?: unknown; hash?: unknown } | null;
|
|
127
|
+
if (block === null || typeof block !== "object") {
|
|
128
|
+
throw new Error(`eth_getBlockByNumber returned no block for ${blockTag}`);
|
|
129
|
+
}
|
|
130
|
+
const blockNumber = hexToBigint(block.number, "block number");
|
|
131
|
+
if (options.block !== "latest" && blockNumber !== options.block) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`RPC returned block ${blockNumber} for --block ${options.block}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const blockHash = hexToBytes32(block.hash, "block hash");
|
|
137
|
+
// EIP-1898: read state at exactly the recorded block hash, so the
|
|
138
|
+
// attestation's blockHash is the state actually read (a reorg or a
|
|
139
|
+
// load-balanced RPC cannot answer from another block).
|
|
140
|
+
const pinned = {
|
|
141
|
+
blockHash: `0x${bytesToHex(blockHash)}`,
|
|
142
|
+
requireCanonical: true,
|
|
143
|
+
};
|
|
144
|
+
const rootHex = `0x${bytesToHex(root)}`;
|
|
145
|
+
|
|
146
|
+
const code = await ethRpc(options.rpcUrl, "eth_getCode", [rootHex, pinned]);
|
|
147
|
+
if (typeof code !== "string" || code === "0x" || code === "") {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`${rootHex} has no contract code at block ${blockNumber}: an EOA root needs no attestation (verify recovers it by ecrecover)`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let ret: unknown;
|
|
154
|
+
try {
|
|
155
|
+
ret = await ethRpc(options.rpcUrl, "eth_call", [
|
|
156
|
+
{
|
|
157
|
+
to: rootHex,
|
|
158
|
+
data: encodeIsValidSignatureCall(parts.digest, parts.signature),
|
|
159
|
+
},
|
|
160
|
+
pinned,
|
|
161
|
+
]);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
164
|
+
// ethRpc surfaces only the JSON-RPC error message (not its code), so a
|
|
165
|
+
// revert is recognised by message; anything else (e.g. header not
|
|
166
|
+
// found) is an RPC failure that says nothing about the contract.
|
|
167
|
+
if (/revert/i.test(message)) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`${rootHex} did not validate the certificate signature (isValidSignature reverted: ${message})`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
throw new Error(
|
|
173
|
+
`RPC error on isValidSignature eth_call at block ${blockNumber}: ${message}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
// Exactly one 32-byte word: bytes4 magic, left-aligned, zero padded.
|
|
177
|
+
// Anything else (extra data, dirty padding, a calldata echo from a
|
|
178
|
+
// fallback — the magic is also the selector) is not an attestation.
|
|
179
|
+
const expectedWord = `0x${bytesToHex(ERC1271_MAGIC_VALUE)}${"00".repeat(28)}`;
|
|
180
|
+
if (typeof ret !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(ret)) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`isValidSignature did not return exactly one 32-byte word: ${String(ret)}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (ret.toLowerCase() !== expectedWord) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`${rootHex} did not validate the certificate signature: isValidSignature returned ${ret}, not the ERC-1271 magic value word ${expectedWord}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const magic = ERC1271_MAGIC_VALUE.slice();
|
|
191
|
+
|
|
192
|
+
const signatureHash = new Uint8Array(
|
|
193
|
+
Buffer.from(keccak256(parts.signature).slice(2), "hex"),
|
|
194
|
+
);
|
|
195
|
+
writeFileSync(
|
|
196
|
+
options.out,
|
|
197
|
+
encodeKs256Attestation({
|
|
198
|
+
version: 1,
|
|
199
|
+
chainId,
|
|
200
|
+
address: root,
|
|
201
|
+
digest: parts.digest,
|
|
202
|
+
signatureHash,
|
|
203
|
+
blockNumber,
|
|
204
|
+
blockHash,
|
|
205
|
+
magic,
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
if (options.json) {
|
|
210
|
+
const report: FetchKs256AttestationReport = {
|
|
211
|
+
command: "fetch-ks256-attestation",
|
|
212
|
+
chainId: chainId.toString(),
|
|
213
|
+
address: rootHex,
|
|
214
|
+
digest: `0x${bytesToHex(parts.digest)}`,
|
|
215
|
+
blockNumber: blockNumber.toString(),
|
|
216
|
+
blockHash: `0x${bytesToHex(blockHash)}`,
|
|
217
|
+
out: options.out,
|
|
218
|
+
};
|
|
219
|
+
out.out(JSON.stringify(report, null, 2));
|
|
220
|
+
} else {
|
|
221
|
+
out.print(
|
|
222
|
+
"fetch-ks256-attestation: %s signs digest 0x%s at block %s (chain %s)",
|
|
223
|
+
rootHex,
|
|
224
|
+
bytesToHex(parts.digest),
|
|
225
|
+
blockNumber.toString(),
|
|
226
|
+
chainId.toString(),
|
|
227
|
+
);
|
|
228
|
+
out.out(`wrote ERC-1271 attestation: ${options.out}`);
|
|
229
|
+
}
|
|
230
|
+
process.exitCode = 0;
|
|
231
|
+
} catch (err) {
|
|
232
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
233
|
+
if (options.json) {
|
|
234
|
+
const report: FetchKs256AttestationErrorReport = {
|
|
235
|
+
error: "fetch_ks256_attestation_failed",
|
|
236
|
+
command: "fetch-ks256-attestation",
|
|
237
|
+
message,
|
|
238
|
+
};
|
|
239
|
+
out.out(JSON.stringify(report, null, 2));
|
|
240
|
+
} else {
|
|
241
|
+
out.warn("forestrie fetch-ks256-attestation: %s", message);
|
|
242
|
+
}
|
|
243
|
+
process.exitCode = 1;
|
|
244
|
+
}
|
|
245
|
+
}
|