@forestrie/forestrie-cli 0.9.1 → 0.11.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.
@@ -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
+ }
@@ -23,22 +23,28 @@ import {
23
23
  * Both add `--univocity --log-id --rpc-url` for the chain-anchored check (the
24
24
  * only networked path).
25
25
  *
26
- * Trust anchors (FOR-297 ladder; each rung needs strictly less trust):
27
- * 1. `--known-log-key` — caller-known log OWNER key: offline, no genesis;
28
- * the key↔log binding is asserted by the key's provenance, not proven;
29
- * no lifecycle visibility, no split-view protection.
30
- * 2. `--genesis` — genesis-derived roots (the grant-chain walk, approach A,
31
- * will derive per-log bindings from genesis + public tiles).
32
- * 3. `--known-accumulator` — cached, auditable chain read: contract-enforced
33
- * state (signature + grant chain + split-view for covered entries),
34
- * fully offline; `--massif` (tiles) or `--consistency-proof` (portable
35
- * top-up artifact) extends older receipts to a newer snapshot.
36
- * 4. `--checkpoint-chain` — retained `.sth` fold (FOR-368): authenticated
37
- * accumulator at every retained seal from the public log store alone;
38
- * signatures root in rung 1/2 trust.
39
- * 5. `--rpc-url` — live chain read: rung 3 plus freshness (the RPC provider
40
- * is itself a trusted chain reader); buried peaks resolve via the
41
- * CheckpointPublished history scan (public chain data only).
26
+ * Trust roots (protocol: spec/receipt-trust-model.md). They are alternatives,
27
+ * not an ordering — each answers a different subset of the four trust
28
+ * questions, and the report says which root was used:
29
+ * - `--known-log-key` — the known log key root: an owner key the caller holds
30
+ * out of band; offline, no genesis; the key↔log binding is asserted by the
31
+ * key's provenance, not proven; no split-view protection.
32
+ * - `--genesis` — the genesis root: the forest genesis document's bootstrap
33
+ * key, which roots the root log or a delegation directly under it. A deeper
34
+ * child log's authority needs the off-chain grant-chain walk, which no
35
+ * verifier implements (spec/implementation-status.md).
36
+ * - `--known-accumulator` — the known accumulator root, from a cached,
37
+ * auditable chain read: the contract-anchored state, fully offline;
38
+ * `--massif` (tiles) or `--consistency-proof` (portable top-up artifact)
39
+ * extends older receipts to a newer snapshot.
40
+ * - `--checkpoint-chain` — the checkpoint chain root: a retained fold of
41
+ * checkpoint objects, an authenticated accumulator at every retained seal
42
+ * from the public log store alone; signatures root in the genesis or known
43
+ * log key root.
44
+ * - `--rpc-url` — the known accumulator root from a live chain read, plus
45
+ * freshness (the RPC provider is itself a trusted chain reader); buried
46
+ * peaks resolve via the CheckpointPublished history scan (public chain
47
+ * data only).
42
48
  */
43
49
 
44
50
  type AnchorFields = {
@@ -73,14 +79,21 @@ type AnchorFields = {
73
79
  /** Lower bound for the CheckpointPublished history scan (FOR-368). */
74
80
  fromBlock: bigint | undefined;
75
81
  /**
76
- * Caller-known log OWNER key (the delegation-cert issuer), base64 `x||y`
77
- * (64 bytes, `KNOWN_LOG_KEY`) — FOR-297 D1. An offline trust anchor that
78
- * replaces the genesis-derived roots: the "known hosts" rung of the trust
79
- * ladder. It asserts (does not prove) the key↔log binding, and gives no
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
86
+ * replaces the genesis root: the known log key root of the trust model.
87
+ * It asserts (does not prove) the key↔log binding, and gives no
80
88
  * grant-lifecycle visibility or split-view protection — the grant-chain
81
89
  * walk (approach A) derives the binding; chain anchors add split-view.
82
90
  */
83
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;
84
97
  };
85
98
 
86
99
  function parseAnchorFields(args: LooseParsedArgs): AnchorFields {
@@ -151,6 +164,7 @@ function parseAnchorFields(args: LooseParsedArgs): AnchorFields {
151
164
  logId,
152
165
  rpcUrl,
153
166
  knownLogKey,
167
+ ks256Attestation: optionalStringOption(args, "ks256-attestation"),
154
168
  knownAccumulator,
155
169
  massif,
156
170
  checkpointChain,