@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.
@@ -1,9 +1,10 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import type { Out } from "@forestrie/cli-kit/reporting";
3
3
  import {
4
+ decodeKs256Attestation,
4
5
  decodeTrustRootFromGenesis,
5
6
  grantCommitmentHashFromGrant,
6
- importEs256PublicKeyFromGrantDataXy64,
7
+ type Ks256Attestation,
7
8
  verifyCheckpointChain,
8
9
  verifyGrantReceiptOffline,
9
10
  verifyGrantReceiptOfflineWithKeys,
@@ -38,8 +39,12 @@ import {
38
39
  import {
39
40
  loadPayloadVerifyArtifacts,
40
41
  loadVerifyArtifacts,
42
+ readBytes,
41
43
  } from "../lib/verify-inputs.js";
44
+ import { isKs256Root, parseKnownLogKey } from "../lib/known-log-key.js";
42
45
  import {
46
+ applyKs256Rows,
47
+ type Ks256ReportInfo,
43
48
  anchorOnlyStageRows,
44
49
  buildVerifyReport,
45
50
  knownKeyStageRows,
@@ -170,7 +175,9 @@ async function checkCheckpointChainAnchor(
170
175
  ): Promise<CheckpointChainAnchorCheck> {
171
176
  const rootKeys: CryptoKey[] = [];
172
177
  if (ctx.knownLogKey !== undefined) {
173
- rootKeys.push(await importKnownLogKey(ctx.knownLogKey));
178
+ const known = await parseKnownLogKey(ctx.knownLogKey);
179
+ if (isKs256Root(known)) throw new Error(KS256_CHECKPOINT_CHAIN_REFUSAL);
180
+ rootKeys.push(known as CryptoKey);
174
181
  }
175
182
  if (genesisCbor !== undefined) {
176
183
  const root = await decodeTrustRootFromGenesis(genesisCbor);
@@ -261,20 +268,48 @@ function printAnchorRow(
261
268
  }
262
269
  }
263
270
 
271
+ const KS256_CHECKPOINT_CHAIN_REFUSAL =
272
+ "--checkpoint-chain verifies checkpoint signatures, which need an ES256 trust root: a KS256 (20-byte address) known log key cannot root them";
273
+
264
274
  /**
265
- * Import the caller-known log OWNER key (`--known-log-key`, base64 x||y).
266
- * The value is the delegation-cert issuer's key, NOT the sealer key — the
267
- * label-1000 cert still resolves under it, so the anchor survives sealer
268
- * rotation and the cert-validation path is exercised (FOR-297 D1).
275
+ * Load `--ks256-attestation` (decode failure is an input error). Undefined
276
+ * when the flag is absent.
269
277
  */
270
- async function importKnownLogKey(knownLogKey: string): Promise<CryptoKey> {
271
- const bytes = new Uint8Array(Buffer.from(knownLogKey, "base64"));
272
- if (bytes.length !== 64) {
273
- throw new Error(
274
- `--known-log-key must be base64 x||y (64 bytes), decoded ${bytes.length}`,
275
- );
278
+ function loadAttestation(path: string | undefined): Ks256Attestation | undefined {
279
+ if (path === undefined) return undefined;
280
+ const bytes = readBytes(path, "--ks256-attestation");
281
+ try {
282
+ return decodeKs256Attestation(bytes);
283
+ } catch (err) {
284
+ const message = err instanceof Error ? err.message : String(err);
285
+ throw new Error(`--ks256-attestation '${path}' is not a valid attestation: ${message}`);
286
+ }
287
+ }
288
+
289
+ /** Root algorithm and attestation facts for the report. */
290
+ async function ks256Info(
291
+ options: VerifyReportContext & { ks256Attestation?: string | undefined },
292
+ genesisCbor: Uint8Array | undefined,
293
+ knownKey: Awaited<ReturnType<typeof parseKnownLogKey>> | undefined,
294
+ attestation: Ks256Attestation | undefined,
295
+ used: boolean,
296
+ ): Promise<Ks256ReportInfo | undefined> {
297
+ let ks256Root = false;
298
+ if (knownKey !== undefined) {
299
+ ks256Root = isKs256Root(knownKey);
300
+ } else if (genesisCbor !== undefined) {
301
+ try {
302
+ ks256Root = isKs256Root(await decodeTrustRootFromGenesis(genesisCbor));
303
+ } catch {
304
+ // The offline verify already reported an unreadable genesis.
305
+ }
276
306
  }
277
- return importEs256PublicKeyFromGrantDataXy64(bytes);
307
+ if (!ks256Root && attestation === undefined) return undefined;
308
+ return {
309
+ ks256Root,
310
+ knownKey: knownKey !== undefined,
311
+ ...(attestation !== undefined ? { attestation, attestationUsed: used } : {}),
312
+ };
278
313
  }
279
314
 
280
315
  /**
@@ -485,6 +520,7 @@ async function finishVerify(
485
520
  artifacts: { genesisCbor: Uint8Array | undefined; receiptCbor: Uint8Array },
486
521
  result: ReceiptVerifyResult,
487
522
  anchorLeaf?: AnchorLeaf,
523
+ ks256?: Ks256ReportInfo,
488
524
  ): Promise<void> {
489
525
  // FOR-297 approach C: in a chain-anchored mode (live read OR cached
490
526
  // known-accumulator snapshot), a delegation the offline verifier cannot
@@ -516,7 +552,11 @@ async function finishVerify(
516
552
  // checkpoint-chain checks always work from the recomputed peak.
517
553
  let recomputedPeak: Uint8Array | undefined;
518
554
  if (
519
- (ctx.knownLogKey !== undefined || ctx.anchor !== "chain") &&
555
+ // A KS256 root cannot locate a detached peak by signature trial
556
+ // (no_es256_trust_key), so it always works from the recomputed peak.
557
+ (ctx.knownLogKey !== undefined ||
558
+ ctx.anchor !== "chain" ||
559
+ ks256?.ks256Root === true) &&
520
560
  anchorLeaf !== undefined
521
561
  ) {
522
562
  const recomputed = await recomputeReceiptPeak({
@@ -595,8 +635,9 @@ async function finishVerify(
595
635
 
596
636
  const ok =
597
637
  result.ok && (ctx.anchor === "offline" || anchor?.anchored === true);
598
- const rows =
638
+ const baseRows =
599
639
  ctx.knownLogKey !== undefined ? knownKeyStageRows(result) : stageRows(result);
640
+ const rows = ks256 !== undefined ? applyKs256Rows(baseRows, ks256) : baseRows;
600
641
 
601
642
  if (ctx.json) {
602
643
  const report = buildVerifyReport({
@@ -613,7 +654,10 @@ async function finishVerify(
613
654
  anchor,
614
655
  univocity: ctx.univocity,
615
656
  logId: ctx.logId,
616
- ...(ctx.knownLogKey !== undefined ? { stagesOverride: rows } : {}),
657
+ ...(ctx.knownLogKey !== undefined || ks256 !== undefined
658
+ ? { stagesOverride: rows }
659
+ : {}),
660
+ ...(ks256 !== undefined ? { ks256 } : {}),
617
661
  });
618
662
  out.out(JSON.stringify(report, null, 2));
619
663
  } else {
@@ -674,46 +718,89 @@ export async function runVerify(
674
718
  options: VerifyOptions,
675
719
  ): Promise<void> {
676
720
  let artifacts: ReturnType<typeof loadPayloadVerifyArtifacts>;
721
+ let attestation: Ks256Attestation | undefined;
722
+ let knownKey: Awaited<ReturnType<typeof parseKnownLogKey>> | undefined;
677
723
  try {
678
724
  artifacts = loadPayloadVerifyArtifacts(options);
725
+ attestation = loadAttestation(options.ks256Attestation);
726
+ knownKey = await parseKnownKeyForRun(options);
679
727
  } catch (err) {
680
728
  reportRunError(out, options, "input", err);
681
729
  return;
682
730
  }
683
731
 
684
- let result: ReceiptVerifyResult;
685
- try {
686
- if (options.knownLogKey !== undefined) {
687
- // FOR-297 D1: caller-known log owner key replaces the genesis roots.
688
- const knownKey = await importKnownLogKey(options.knownLogKey);
689
- result = remapKnownKeyFailure(
690
- await verifyReceiptOfflineWithKeys({
732
+ const run = (att: Ks256Attestation | undefined) =>
733
+ knownKey !== undefined
734
+ ? // FOR-297 D1: caller-known log owner key replaces the genesis roots.
735
+ // Genesis, if also given, only supplies the forest chain id.
736
+ verifyReceiptOfflineWithKeys({
691
737
  receiptCbor: artifacts.receiptCbor,
692
738
  payload: artifacts.payload,
693
739
  idtimestampBe8: artifacts.idtimestampBe8,
694
740
  trustKeys: [knownKey],
695
- }),
696
- );
697
- } else {
698
- // Parsing guarantees --genesis when --known-log-key is absent.
699
- result = await verifyReceiptOffline({
700
- ...artifacts,
701
- genesisCbor: artifacts.genesisCbor!,
702
- });
703
- }
741
+ ...(att !== undefined ? { ks256Attestation: att } : {}),
742
+ ...(artifacts.genesisCbor !== undefined
743
+ ? { forestGenesisCbor: artifacts.genesisCbor }
744
+ : {}),
745
+ }).then(remapKnownKeyFailure)
746
+ : // Parsing guarantees --genesis when --known-log-key is absent.
747
+ verifyReceiptOffline({
748
+ receiptCbor: artifacts.receiptCbor,
749
+ payload: artifacts.payload,
750
+ idtimestampBe8: artifacts.idtimestampBe8,
751
+ genesisCbor: artifacts.genesisCbor!,
752
+ ...(att !== undefined ? { ks256Attestation: att } : {}),
753
+ });
754
+
755
+ let result: ReceiptVerifyResult;
756
+ let ks256: Ks256ReportInfo | undefined;
757
+ try {
758
+ result = await run(attestation);
759
+ ks256 = await ks256Info(
760
+ options,
761
+ artifacts.genesisCbor,
762
+ knownKey,
763
+ attestation,
764
+ // The attestation was needed only if the certificate does not verify
765
+ // by ecrecover alone.
766
+ attestation !== undefined && result.ok ? !(await run(undefined)).ok : false,
767
+ );
704
768
  } catch (err) {
705
769
  reportRunError(out, options, "verify", err);
706
770
  return;
707
771
  }
708
772
 
709
- await finishVerify(out, options, artifacts, result, {
710
- idtimestampBe8: artifacts.idtimestampBe8,
711
- inner: async () =>
712
- new Uint8Array(
713
- // Copy pins the generic to ArrayBuffer for the dom BufferSource type.
714
- await crypto.subtle.digest("SHA-256", new Uint8Array(artifacts.payload)),
715
- ),
716
- });
773
+ await finishVerify(
774
+ out,
775
+ options,
776
+ artifacts,
777
+ result,
778
+ {
779
+ idtimestampBe8: artifacts.idtimestampBe8,
780
+ inner: async () =>
781
+ new Uint8Array(
782
+ // Copy pins the generic to ArrayBuffer for the dom BufferSource type.
783
+ await crypto.subtle.digest("SHA-256", new Uint8Array(artifacts.payload)),
784
+ ),
785
+ },
786
+ ks256,
787
+ );
788
+ }
789
+
790
+ /**
791
+ * Parse `--known-log-key`, refusing a KS256 key where the chosen anchor mode
792
+ * needs ES256 checkpoint signatures.
793
+ */
794
+ async function parseKnownKeyForRun(options: {
795
+ knownLogKey: string | undefined;
796
+ anchor: string;
797
+ }): Promise<Awaited<ReturnType<typeof parseKnownLogKey>> | undefined> {
798
+ if (options.knownLogKey === undefined) return undefined;
799
+ const key = await parseKnownLogKey(options.knownLogKey);
800
+ if (isKs256Root(key) && options.anchor === "checkpoints") {
801
+ throw new Error(KS256_CHECKPOINT_CHAIN_REFUSAL);
802
+ }
803
+ return key;
717
804
  }
718
805
 
719
806
  /**
@@ -728,40 +815,62 @@ export async function runVerifyGrant(
728
815
  options: VerifyGrantOptions,
729
816
  ): Promise<void> {
730
817
  let artifacts: ReturnType<typeof loadVerifyArtifacts>;
818
+ let attestation: Ks256Attestation | undefined;
819
+ let knownKey: Awaited<ReturnType<typeof parseKnownLogKey>> | undefined;
731
820
  try {
732
821
  artifacts = loadVerifyArtifacts(options);
822
+ attestation = loadAttestation(options.ks256Attestation);
823
+ knownKey = await parseKnownKeyForRun(options);
733
824
  } catch (err) {
734
825
  reportRunError(out, options, "input", err);
735
826
  return;
736
827
  }
737
828
 
738
- let result: ReceiptVerifyResult;
739
- try {
740
- if (options.knownLogKey !== undefined) {
741
- // FOR-297 D1: caller-known log owner key replaces the genesis roots.
742
- const knownKey = await importKnownLogKey(options.knownLogKey);
743
- result = remapKnownKeyFailure(
744
- await verifyGrantReceiptOfflineWithKeys({
829
+ const run = (att: Ks256Attestation | undefined) =>
830
+ knownKey !== undefined
831
+ ? verifyGrantReceiptOfflineWithKeys({
745
832
  receiptCbor: artifacts.receiptCbor,
746
833
  grant: artifacts.grant,
747
834
  idtimestampBe8: artifacts.idtimestampBe8,
748
835
  trustKeys: [knownKey],
749
- }),
750
- );
751
- } else {
752
- // Parsing guarantees --genesis when --known-log-key is absent.
753
- result = await verifyGrantReceiptOffline({
754
- ...artifacts,
755
- genesisCbor: artifacts.genesisCbor!,
756
- });
757
- }
836
+ ...(att !== undefined ? { ks256Attestation: att } : {}),
837
+ ...(artifacts.genesisCbor !== undefined
838
+ ? { forestGenesisCbor: artifacts.genesisCbor }
839
+ : {}),
840
+ }).then(remapKnownKeyFailure)
841
+ : verifyGrantReceiptOffline({
842
+ receiptCbor: artifacts.receiptCbor,
843
+ grant: artifacts.grant,
844
+ idtimestampBe8: artifacts.idtimestampBe8,
845
+ genesisCbor: artifacts.genesisCbor!,
846
+ ...(att !== undefined ? { ks256Attestation: att } : {}),
847
+ });
848
+
849
+ let result: ReceiptVerifyResult;
850
+ let ks256: Ks256ReportInfo | undefined;
851
+ try {
852
+ result = await run(attestation);
853
+ ks256 = await ks256Info(
854
+ options,
855
+ artifacts.genesisCbor,
856
+ knownKey,
857
+ attestation,
858
+ attestation !== undefined && result.ok ? !(await run(undefined)).ok : false,
859
+ );
758
860
  } catch (err) {
759
861
  reportRunError(out, options, "verify", err);
760
862
  return;
761
863
  }
762
864
 
763
- await finishVerify(out, options, artifacts, result, {
764
- idtimestampBe8: artifacts.idtimestampBe8,
765
- inner: () => grantCommitmentHashFromGrant(artifacts.grant),
766
- });
865
+ await finishVerify(
866
+ out,
867
+ options,
868
+ artifacts,
869
+ result,
870
+ {
871
+ idtimestampBe8: artifacts.idtimestampBe8,
872
+ inner: () => grantCommitmentHashFromGrant(artifacts.grant),
873
+ },
874
+ ks256,
875
+ );
767
876
  }
@@ -0,0 +1,60 @@
1
+ import type { LooseParsedArgs } from "@forestrie/cli-kit";
2
+ import {
3
+ optionalStringOption,
4
+ parseForestrieCommonOptions,
5
+ requiredStringOption,
6
+ type ForestrieCommonOptions,
7
+ } from "./common.js";
8
+
9
+ /**
10
+ * `forestrie fetch-ks256-attestation` (plan-2610-03 D4): ask a KS256 contract
11
+ * root (a Safe) over ERC-1271 whether it signs the receipt's delegation
12
+ * certificate, and cache the block-bound answer as a `--ks256-attestation`
13
+ * artifact for offline `verify`. The twin of `fetch-accumulator`.
14
+ */
15
+ export type FetchKs256AttestationOptions = ForestrieCommonOptions & {
16
+ /** Receipt whose label-1000 KS256 delegation certificate is attested. */
17
+ receipt: string;
18
+ /** Contract root address override (`0x` + 40 hex). */
19
+ root: string | undefined;
20
+ /** Forest genesis: supplies the KS256 root (unless `--root`) and chain id. */
21
+ genesis: string | undefined;
22
+ /** JSON-RPC endpoint (`RPC_URL`) — the trusted chain reader. */
23
+ rpcUrl: string;
24
+ /** Block to pin the calls to; `latest` resolves to the current block. */
25
+ block: bigint | "latest";
26
+ /** Output path for the attestation CBOR. */
27
+ out: string;
28
+ };
29
+
30
+ export function parseFetchKs256AttestationOptions(
31
+ args: LooseParsedArgs,
32
+ ): FetchKs256AttestationOptions {
33
+ const root = optionalStringOption(args, "root");
34
+ const genesis = optionalStringOption(args, "genesis");
35
+ if (root === undefined && genesis === undefined) {
36
+ throw new Error(
37
+ "--root (contract address) and/or --genesis (KS256 bootstrap root) is required",
38
+ );
39
+ }
40
+ const blockRaw = optionalStringOption(args, "block") ?? "latest";
41
+ let block: bigint | "latest";
42
+ if (blockRaw === "latest") {
43
+ block = "latest";
44
+ } else if (/^[0-9]+$/.test(blockRaw)) {
45
+ block = BigInt(blockRaw);
46
+ } else {
47
+ throw new Error(
48
+ "--block must be a non-negative decimal block number or 'latest'",
49
+ );
50
+ }
51
+ return {
52
+ ...parseForestrieCommonOptions(args),
53
+ receipt: requiredStringOption(args, "receipt"),
54
+ root,
55
+ genesis,
56
+ rpcUrl: requiredStringOption(args, "rpc-url", "RPC_URL"),
57
+ block,
58
+ out: requiredStringOption(args, "out"),
59
+ };
60
+ }
@@ -79,14 +79,21 @@ type AnchorFields = {
79
79
  /** Lower bound for the CheckpointPublished history scan (FOR-368). */
80
80
  fromBlock: bigint | undefined;
81
81
  /**
82
- * Caller-known log OWNER key (the delegation-cert issuer), base64 `x||y`
83
- * (64 bytes, `KNOWN_LOG_KEY`) — FOR-297 D1. An offline trust anchor that
82
+ * Caller-known log OWNER key (the delegation-cert issuer), `KNOWN_LOG_KEY`
83
+ * — FOR-297 D1: base64 `x||y` (64-byte ES256 key), or a 20-byte KS256
84
+ * address (`0x` + 40 hex, or base64). With `--genesis` alongside, genesis
85
+ * supplies only the forest chain id an attestation must match. An offline trust anchor that
84
86
  * replaces the genesis root: the known log key root of the trust model.
85
87
  * It asserts (does not prove) the key↔log binding, and gives no
86
88
  * grant-lifecycle visibility or split-view protection — the grant-chain
87
89
  * walk (approach A) derives the binding; chain anchors add split-view.
88
90
  */
89
91
  knownLogKey: string | undefined;
92
+ /**
93
+ * ERC-1271 attestation file (`forestrie fetch-ks256-attestation`) for a
94
+ * KS256 contract root. Read and decoded by the runner; never fetches.
95
+ */
96
+ ks256Attestation: string | undefined;
90
97
  };
91
98
 
92
99
  function parseAnchorFields(args: LooseParsedArgs): AnchorFields {
@@ -157,6 +164,7 @@ function parseAnchorFields(args: LooseParsedArgs): AnchorFields {
157
164
  logId,
158
165
  rpcUrl,
159
166
  knownLogKey,
167
+ ks256Attestation: optionalStringOption(args, "ks256-attestation"),
160
168
  knownAccumulator,
161
169
  massif,
162
170
  checkpointChain,