@forestrie/forestrie-cli 0.8.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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +425 -0
  3. package/TRUST-MODEL.md +8 -0
  4. package/dist/cli.js +33811 -0
  5. package/dist/decode-receipt.d.ts +19 -0
  6. package/dist/decode-receipt.d.ts.map +1 -0
  7. package/dist/decode-receipt.js +19 -0
  8. package/dist/decode-receipt.js.map +1 -0
  9. package/dist/index.d.ts +11 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +11 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/lib/decode-receipt-cbor.d.ts +26 -0
  14. package/dist/lib/decode-receipt-cbor.d.ts.map +1 -0
  15. package/dist/lib/decode-receipt-cbor.js +182 -0
  16. package/dist/lib/decode-receipt-cbor.js.map +1 -0
  17. package/dist/lib/decode-receipt-decode.d.ts +103 -0
  18. package/dist/lib/decode-receipt-decode.d.ts.map +1 -0
  19. package/dist/lib/decode-receipt-decode.js +237 -0
  20. package/dist/lib/decode-receipt-decode.js.map +1 -0
  21. package/dist/lib/decode-receipt-labels.d.ts +54 -0
  22. package/dist/lib/decode-receipt-labels.d.ts.map +1 -0
  23. package/dist/lib/decode-receipt-labels.js +117 -0
  24. package/dist/lib/decode-receipt-labels.js.map +1 -0
  25. package/dist/lib/decode-receipt-render.d.ts +11 -0
  26. package/dist/lib/decode-receipt-render.d.ts.map +1 -0
  27. package/dist/lib/decode-receipt-render.js +242 -0
  28. package/dist/lib/decode-receipt-render.js.map +1 -0
  29. package/package.json +83 -0
  30. package/src/cli.ts +5 -0
  31. package/src/command.ts +51 -0
  32. package/src/commands/admin-onboard-token.ts +51 -0
  33. package/src/commands/admin.ts +20 -0
  34. package/src/commands/complete-grant.ts +41 -0
  35. package/src/commands/create-consistency-proof.ts +44 -0
  36. package/src/commands/create-log.ts +95 -0
  37. package/src/commands/decode-receipt.ts +20 -0
  38. package/src/commands/delegate.ts +55 -0
  39. package/src/commands/deploy.ts +72 -0
  40. package/src/commands/fetch-accumulator.ts +49 -0
  41. package/src/commands/onboard-genesis.ts +70 -0
  42. package/src/commands/onboard-request.ts +59 -0
  43. package/src/commands/register-grant.ts +74 -0
  44. package/src/commands/register.ts +54 -0
  45. package/src/commands/resolve-receipt.ts +101 -0
  46. package/src/commands/sign-statement.ts +53 -0
  47. package/src/commands/verify-grant.ts +89 -0
  48. package/src/commands/verify.ts +92 -0
  49. package/src/commoncli.ts +34 -0
  50. package/src/decode-receipt.ts +56 -0
  51. package/src/index.ts +10 -0
  52. package/src/lib/bytes.ts +7 -0
  53. package/src/lib/checkpoint-provider.ts +297 -0
  54. package/src/lib/complete-grant-derive.ts +237 -0
  55. package/src/lib/consistency-proof.ts +271 -0
  56. package/src/lib/create-log-build.ts +168 -0
  57. package/src/lib/create-log-prepare.ts +75 -0
  58. package/src/lib/create-receipt-chain-verify.ts +267 -0
  59. package/src/lib/create-receipt-derive.ts +208 -0
  60. package/src/lib/create-receipt-inputs.ts +79 -0
  61. package/src/lib/decode-checkpoint-calldata.ts +188 -0
  62. package/src/lib/decode-receipt-cbor.ts +202 -0
  63. package/src/lib/decode-receipt-decode.ts +333 -0
  64. package/src/lib/decode-receipt-labels.ts +132 -0
  65. package/src/lib/decode-receipt-render.ts +284 -0
  66. package/src/lib/delegate-flow.ts +289 -0
  67. package/src/lib/delegate-voucher.ts +122 -0
  68. package/src/lib/deploy-artifact.ts +79 -0
  69. package/src/lib/deploy-bootstrap.ts +100 -0
  70. package/src/lib/deploy-flow.ts +184 -0
  71. package/src/lib/deploy-genesis-log-id.ts +29 -0
  72. package/src/lib/deploy-rpc.ts +40 -0
  73. package/src/lib/deployment-record.ts +65 -0
  74. package/src/lib/fsio.ts +91 -0
  75. package/src/lib/grant-flags-local.ts +19 -0
  76. package/src/lib/onboard-attestation.ts +120 -0
  77. package/src/lib/onboard-genesis-body.ts +94 -0
  78. package/src/lib/openssl-error-queue.ts +56 -0
  79. package/src/lib/register-flow.ts +292 -0
  80. package/src/lib/register-grant-build.ts +173 -0
  81. package/src/lib/register-grant-complete.ts +47 -0
  82. package/src/lib/register-grant-flow.ts +258 -0
  83. package/src/lib/resolve-receipt-freshen.ts +221 -0
  84. package/src/lib/sign-statement-build.ts +117 -0
  85. package/src/lib/sign-statement-key.ts +145 -0
  86. package/src/lib/verify-anchored.ts +326 -0
  87. package/src/lib/verify-checkpoint-chain.ts +160 -0
  88. package/src/lib/verify-eventscan.ts +214 -0
  89. package/src/lib/verify-inputs.ts +143 -0
  90. package/src/lib/verify-known-accumulator.ts +161 -0
  91. package/src/lib/verify-report.ts +265 -0
  92. package/src/lib/x402-payment.ts +115 -0
  93. package/src/main/admin-onboard-token.ts +216 -0
  94. package/src/main/complete-grant.ts +265 -0
  95. package/src/main/create-consistency-proof.ts +83 -0
  96. package/src/main/create-log.ts +347 -0
  97. package/src/main/create-receipt.ts +763 -0
  98. package/src/main/decode-receipt.ts +72 -0
  99. package/src/main/delegate.ts +141 -0
  100. package/src/main/deploy.ts +273 -0
  101. package/src/main/fetch-accumulator.ts +181 -0
  102. package/src/main/not-implemented.ts +43 -0
  103. package/src/main/onboard-genesis.ts +192 -0
  104. package/src/main/onboard-request.ts +333 -0
  105. package/src/main/register-grant.ts +270 -0
  106. package/src/main/register.ts +243 -0
  107. package/src/main/sign-statement.ts +168 -0
  108. package/src/main/verify.ts +767 -0
  109. package/src/options/admin-onboard-token.ts +48 -0
  110. package/src/options/common.ts +69 -0
  111. package/src/options/complete-grant.ts +40 -0
  112. package/src/options/create-consistency-proof.ts +63 -0
  113. package/src/options/create-log.ts +99 -0
  114. package/src/options/create-receipt.ts +243 -0
  115. package/src/options/decode-receipt.ts +25 -0
  116. package/src/options/delegate.ts +93 -0
  117. package/src/options/deploy.ts +105 -0
  118. package/src/options/fetch-accumulator.ts +62 -0
  119. package/src/options/onboard-genesis.ts +74 -0
  120. package/src/options/onboard-request.ts +57 -0
  121. package/src/options/register-grant.ts +72 -0
  122. package/src/options/register.ts +71 -0
  123. package/src/options/sign-statement.ts +64 -0
  124. package/src/options/verify.ts +250 -0
@@ -0,0 +1,767 @@
1
+ import { readFileSync } from "node:fs";
2
+ import type { Out } from "@forestrie/cli-kit/reporting";
3
+ import {
4
+ decodeTrustRootFromGenesis,
5
+ grantCommitmentHashFromGrant,
6
+ importEs256PublicKeyFromGrantDataXy64,
7
+ verifyCheckpointChain,
8
+ verifyGrantReceiptOffline,
9
+ verifyGrantReceiptOfflineWithKeys,
10
+ verifyReceiptOffline,
11
+ verifyReceiptOfflineWithKeys,
12
+ type ReceiptVerifyResult,
13
+ } from "@forestrie/receipt-verify";
14
+ import type { VerifyGrantOptions, VerifyOptions } from "../options/verify.js";
15
+ import { findPeakInPublishedHistory } from "../lib/verify-eventscan.js";
16
+ import {
17
+ checkReceiptAnchored,
18
+ receiptLeafIndex,
19
+ recomputeReceiptPeak,
20
+ type AnchorCheck,
21
+ } from "../lib/verify-anchored.js";
22
+ import {
23
+ checkReceiptAnchoredToCheckpointChain,
24
+ loadCheckpointChainFiles,
25
+ makeCheckpointSignatureVerifier,
26
+ type CheckpointChainAnchorCheck,
27
+ } from "../lib/verify-checkpoint-chain.js";
28
+ import {
29
+ checkReceiptAnchoredViaConsistencyProof,
30
+ decodeConsistencyProofArtifact,
31
+ } from "../lib/consistency-proof.js";
32
+ import {
33
+ assertSnapshotBinding,
34
+ checkReceiptAnchoredToSnapshot,
35
+ decodeKnownAccumulator,
36
+ type SnapshotAnchorCheck,
37
+ } from "../lib/verify-known-accumulator.js";
38
+ import {
39
+ loadPayloadVerifyArtifacts,
40
+ loadVerifyArtifacts,
41
+ } from "../lib/verify-inputs.js";
42
+ import {
43
+ anchorOnlyStageRows,
44
+ buildVerifyReport,
45
+ knownKeyStageRows,
46
+ STAGE_NARRATION,
47
+ stageRows,
48
+ } from "../lib/verify-report.js";
49
+
50
+ /** Where an operational error broke the run (distinct from a verification
51
+ * FAIL, which is the structured `VerifyReport` with `ok: false`). */
52
+ export type VerifyErrorStage = "input" | "verify" | "anchor";
53
+
54
+ export type VerifyErrorReport = {
55
+ error: "verify_input_failed" | "verify_failed" | "anchor_check_failed";
56
+ command: "verify";
57
+ stage: VerifyErrorStage;
58
+ message: string;
59
+ };
60
+
61
+ const VERIFY_ERROR_CODES: Record<
62
+ VerifyErrorStage,
63
+ VerifyErrorReport["error"]
64
+ > = {
65
+ input: "verify_input_failed",
66
+ verify: "verify_failed",
67
+ anchor: "anchor_check_failed",
68
+ };
69
+
70
+ /** Fields both verify commands share for reporting + the chain-anchor check. */
71
+ type VerifyReportContext = {
72
+ json?: boolean;
73
+ anchor: "offline" | "chain" | "accumulator" | "checkpoints";
74
+ univocity: string | undefined;
75
+ logId: string | undefined;
76
+ rpcUrl: string | undefined;
77
+ /** Set when `--known-log-key` supplied the trust anchor (FOR-297 D1). */
78
+ knownLogKey?: string | undefined;
79
+ /** Cached chain-read snapshot path (`--known-accumulator`, FOR-297 D5). */
80
+ knownAccumulator?: string | undefined;
81
+ /** Local massif blob for stale-snapshot proof-path extension. */
82
+ massif?: string | undefined;
83
+ /** Retained `.sth` chain — dir or comma list (FOR-368 Phase 3). */
84
+ checkpointChain?: string | undefined;
85
+ /** Portable top-up artifact for tile-free extension (FOR-368 Phase 3). */
86
+ consistencyProof?: string | undefined;
87
+ /** Genesis path, needed to root checkpoint-chain signatures. */
88
+ genesis?: string | undefined;
89
+ /** Lower bound for the CheckpointPublished history scan (FOR-368). */
90
+ fromBlock?: bigint | undefined;
91
+ };
92
+
93
+ /**
94
+ * Known-accumulator anchor check (FOR-297 D5): decode the snapshot, reject a
95
+ * wrong-log/wrong-contract binding BEFORE any peak math, then match the
96
+ * recomputed receipt peak (directly, or via massif proof-path extension).
97
+ */
98
+ async function checkSnapshotAnchor(
99
+ ctx: VerifyReportContext,
100
+ receiptCbor: Uint8Array,
101
+ anchorLeaf: AnchorLeaf,
102
+ recomputedPeak: Uint8Array,
103
+ ): Promise<SnapshotAnchorCheck> {
104
+ const snapshot = decodeKnownAccumulator(
105
+ new Uint8Array(readFileSync(ctx.knownAccumulator!)),
106
+ );
107
+ assertSnapshotBinding(snapshot, { logId: ctx.logId });
108
+ const massifBytes =
109
+ ctx.massif !== undefined
110
+ ? new Uint8Array(readFileSync(ctx.massif))
111
+ : undefined;
112
+ const direct = await checkReceiptAnchoredToSnapshot({
113
+ snapshot,
114
+ receiptCbor,
115
+ idtimestampBe8: anchorLeaf.idtimestampBe8,
116
+ inner: await anchorLeaf.inner(),
117
+ recomputedPeak,
118
+ massifBytes,
119
+ });
120
+ if (
121
+ direct.anchored ||
122
+ ctx.consistencyProof === undefined ||
123
+ direct.reason !== "peak_not_in_known_accumulator"
124
+ ) {
125
+ return direct;
126
+ }
127
+ // FOR-368 Phase 3: tile-free extension. The untrusted top-up artifact can
128
+ // only fail — a pass means the receipt's peak sits in the artifact's base
129
+ // accumulator AND that base folds into the TRUSTED snapshot state.
130
+ const artifact = decodeConsistencyProofArtifact(
131
+ new Uint8Array(readFileSync(ctx.consistencyProof)),
132
+ );
133
+ const viaProof = await checkReceiptAnchoredViaConsistencyProof({
134
+ artifact,
135
+ recomputedPeak,
136
+ leafMmrIndex: receiptLeafIndex(receiptCbor),
137
+ trustedSize: snapshot.size,
138
+ trustedAccumulator: snapshot.accumulator,
139
+ });
140
+ if (!viaProof.anchored) {
141
+ return { ...direct, reason: viaProof.reason ?? direct.reason };
142
+ }
143
+ const anchored: SnapshotAnchorCheck & { topUpBaseSize: bigint } = {
144
+ accumulator: snapshot.accumulator,
145
+ size: snapshot.size,
146
+ blockNumber: snapshot.blockNumber,
147
+ blockHashHex: direct.blockHashHex,
148
+ anchored: true,
149
+ matchedPeak: viaProof.matchedPeak,
150
+ extended: true,
151
+ topUpBaseSize: artifact.fromSize,
152
+ };
153
+ return anchored;
154
+ }
155
+
156
+ /**
157
+ * Retained checkpoint-chain anchor (FOR-368 Phase 3): fold the store's
158
+ * `.sth` chain — each link's accumulator computed from the previous one and
159
+ * its signature verified over exactly that computed payload — then match
160
+ * the recomputed receipt peak against ANY authenticated link. Signature
161
+ * trust roots in the same anchors as the offline verify (genesis-derived
162
+ * roots or the caller-known log key); the label-1000 delegation cert on
163
+ * each checkpoint resolves the sealer key under them.
164
+ */
165
+ async function checkCheckpointChainAnchor(
166
+ ctx: VerifyReportContext,
167
+ genesisCbor: Uint8Array | undefined,
168
+ receiptCbor: Uint8Array,
169
+ recomputedPeak: Uint8Array,
170
+ ): Promise<CheckpointChainAnchorCheck> {
171
+ const rootKeys: CryptoKey[] = [];
172
+ if (ctx.knownLogKey !== undefined) {
173
+ rootKeys.push(await importKnownLogKey(ctx.knownLogKey));
174
+ }
175
+ if (genesisCbor !== undefined) {
176
+ const root = await decodeTrustRootFromGenesis(genesisCbor);
177
+ if (root instanceof CryptoKey) rootKeys.push(root);
178
+ }
179
+ if (rootKeys.length === 0) {
180
+ throw new Error(
181
+ "checkpoint-chain verification needs an ES256 trust root (--genesis or --known-log-key)",
182
+ );
183
+ }
184
+ const { checkpoints, files } = loadCheckpointChainFiles(ctx.checkpointChain!);
185
+ const chain = await verifyCheckpointChain({
186
+ checkpoints,
187
+ verifySignature: makeCheckpointSignatureVerifier(rootKeys),
188
+ });
189
+ if (!chain.ok) {
190
+ throw new Error(
191
+ `checkpoint chain did not verify (${chain.reason} at ${files[chain.at] ?? `link ${chain.at}`}): ${chain.detail}`,
192
+ );
193
+ }
194
+ return checkReceiptAnchoredToCheckpointChain({
195
+ links: chain.links,
196
+ recomputedPeak,
197
+ leafMmrIndex: receiptLeafIndex(receiptCbor),
198
+ });
199
+ }
200
+
201
+ /** Human narration for the anchor row, naming the anchor distinctly (D5). */
202
+ function printAnchorRow(
203
+ out: Out,
204
+ ctx: VerifyReportContext,
205
+ anchor: AnchorCheck & Partial<SnapshotAnchorCheck>,
206
+ ): void {
207
+ const isSnapshot = ctx.anchor === "accumulator";
208
+ if (anchor.anchored) {
209
+ const historical = (anchor as Partial<HistoricalAnchorFields>).historical === true;
210
+ const chained = anchor as Partial<CheckpointChainAnchorCheck>;
211
+ const topUpBaseSize = (anchor as { topUpBaseSize?: bigint }).topUpBaseSize;
212
+ const where = isSnapshot
213
+ ? `known accumulator peak %d/%d (read from chain at block ${anchor.blockNumber}, anchored size %s)` +
214
+ (topUpBaseSize !== undefined
215
+ ? ` via consistency-proof top-up from size ${topUpBaseSize}`
216
+ : anchor.extended === true
217
+ ? " via proof-path extension"
218
+ : "")
219
+ : ctx.anchor === "checkpoints"
220
+ ? `retained checkpoint-chain peak %d/%d at link sealed size %s (${chained.linkCount} links folded) — later links' signed proofs commit it forward (FOR-368)`
221
+ : historical
222
+ ? `HISTORICAL anchored accumulator peak %d/%d (size %s, block ${(anchor as unknown as HistoricalAnchorFields).anchoredAtBlock}) — consistency-gated forward by the contract (FOR-368)`
223
+ : "on-chain accumulator peak %d/%d at anchored size %s";
224
+ out.print(
225
+ `verify: anchor ok — receipt peak matches ${where}`,
226
+ anchor.matchedPeak! + 1,
227
+ anchor.accumulator.length,
228
+ anchor.size.toString(),
229
+ );
230
+ } else {
231
+ let hint = "";
232
+ if (anchor.reason === "receipt_newer_than_known_accumulator") {
233
+ hint = " — refresh the accumulator (forestrie fetch-accumulator)";
234
+ } else if (anchor.reason === "peak_not_current") {
235
+ // FOR-368: an honest receipt whose peak the log has since buried —
236
+ // distinguishable from tamper; growth evidence proves it.
237
+ hint =
238
+ " — receipt predates log growth (peak buried); NOT tamper-evidence." +
239
+ " Re-resolve the receipt from the log, or verify with growth" +
240
+ " evidence (grow-proof support: FOR-368)";
241
+ } else if (anchor.reason === "receipt_newer_than_anchored_state") {
242
+ hint =
243
+ " — entry not anchored on-chain yet; retry after the next" +
244
+ " checkpoint publishes";
245
+ } else if (anchor.reason === "receipt_newer_than_checkpoint_chain") {
246
+ hint =
247
+ " — the supplied chain ends before this entry; fetch the newer" +
248
+ " .sth objects and retry";
249
+ } else if (anchor.reason === "peak_not_in_checkpoint_chain") {
250
+ hint =
251
+ " — no retained seal covers this peak (seals are replaced within a" +
252
+ " massif); use --known-accumulator with a top-up, or a chain anchor";
253
+ }
254
+ out.print(
255
+ "verify: anchor failed — %s (anchored size %s, %d peaks)%s",
256
+ anchor.reason ?? "unknown",
257
+ anchor.size.toString(),
258
+ anchor.accumulator.length,
259
+ hint,
260
+ );
261
+ }
262
+ }
263
+
264
+ /**
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).
269
+ */
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
+ );
276
+ }
277
+ return importEs256PublicKeyFromGrantDataXy64(bytes);
278
+ }
279
+
280
+ /**
281
+ * Under a caller-known key, a broken delegation chain means the cert did not
282
+ * verify under the CALLER's key — a different trust failure than a
283
+ * genesis-rooted `delegation_invalid` (wrong known key, or a forged cert).
284
+ * Rename it so the operator reaches for "check the key you were given",
285
+ * not "check the log's delegation".
286
+ */
287
+ function remapKnownKeyFailure(result: ReceiptVerifyResult): ReceiptVerifyResult {
288
+ if (!result.ok && result.reason === "delegation_invalid") {
289
+ return { ...result, reason: "known_key_mismatch" };
290
+ }
291
+ return result;
292
+ }
293
+
294
+ /** Structured envelope under `--json`; one clean line on stderr otherwise. */
295
+ function reportRunError(
296
+ out: Out,
297
+ ctx: { json?: boolean },
298
+ stage: VerifyErrorStage,
299
+ err: unknown,
300
+ ): void {
301
+ const message = err instanceof Error ? err.message : String(err);
302
+ if (ctx.json) {
303
+ const report: VerifyErrorReport = {
304
+ error: VERIFY_ERROR_CODES[stage],
305
+ command: "verify",
306
+ stage,
307
+ message,
308
+ };
309
+ out.out(JSON.stringify(report, null, 2));
310
+ } else {
311
+ out.warn("forestrie verify: %s: %s", stage, message);
312
+ }
313
+ process.exitCode = 1;
314
+ }
315
+
316
+ /**
317
+ * Anchor-only leaf material (FOR-297 approach C): what `finishVerify` needs
318
+ * to recompute the receipt's peak without any signature — the sequenced
319
+ * idtimestamp plus a supplier for the leaf ContentHash (`SHA-256(payload)`
320
+ * for `verify`, the grant commitment hash for `verify-grant`).
321
+ */
322
+ type AnchorLeaf = {
323
+ idtimestampBe8: Uint8Array;
324
+ inner: () => Promise<Uint8Array>;
325
+ };
326
+
327
+ /**
328
+ * Chain-anchored fallback for delegated seals the offline verifier cannot
329
+ * resolve (a child log's per-log delegation does not chain to the root
330
+ * genesis — FOR-297). Recompute the peak from leaf + proof (no signature)
331
+ * and match it against the on-chain accumulator: an anchored match proves
332
+ * binding + inclusion under the contract's consistency-gated state, which is
333
+ * exactly the split-view trust model. Returns true when it handled the
334
+ * verdict (either way); false to fall through to the normal failure path.
335
+ */
336
+ async function tryAnchorOnlyVerify(
337
+ out: Out,
338
+ ctx: VerifyReportContext,
339
+ artifacts: { genesisCbor: Uint8Array | undefined; receiptCbor: Uint8Array },
340
+ result: ReceiptVerifyResult,
341
+ anchorLeaf: AnchorLeaf,
342
+ ): Promise<boolean> {
343
+ let recomputed: Awaited<ReturnType<typeof recomputeReceiptPeak>>;
344
+ try {
345
+ const inner = await anchorLeaf.inner();
346
+ recomputed = await recomputeReceiptPeak({
347
+ receiptCbor: artifacts.receiptCbor,
348
+ idtimestampBe8: anchorLeaf.idtimestampBe8,
349
+ inner,
350
+ });
351
+ } catch {
352
+ return false; // unparsable leaf material — report the offline failure
353
+ }
354
+ if (!recomputed.inclusionOk) return false;
355
+
356
+ let anchor: AnchorCheck & Partial<SnapshotAnchorCheck>;
357
+ try {
358
+ anchor =
359
+ ctx.anchor === "accumulator"
360
+ ? await checkSnapshotAnchor(
361
+ ctx,
362
+ artifacts.receiptCbor,
363
+ anchorLeaf,
364
+ recomputed.peak,
365
+ )
366
+ : await checkReceiptAnchored({
367
+ genesisCbor: artifacts.genesisCbor,
368
+ receiptCbor: artifacts.receiptCbor,
369
+ univocity: ctx.univocity!,
370
+ logId: ctx.logId!,
371
+ rpcUrl: ctx.rpcUrl!,
372
+ recomputedPeak: recomputed.peak,
373
+ });
374
+ } catch (err) {
375
+ reportRunError(out, ctx, "anchor", err);
376
+ return true;
377
+ }
378
+ if (
379
+ !anchor.anchored &&
380
+ ctx.anchor === "chain" &&
381
+ anchor.reason === "peak_not_current"
382
+ ) {
383
+ // FOR-368 Phase 2: buried peak — consult the anchored history. A scan
384
+ // failure degrades to the classified Phase 0 failure (never a run
385
+ // error): the honest-vs-tamper distinction must not be masked by a
386
+ // provider hiccup on eth_getLogs.
387
+ try {
388
+ const historical = await tryHistoricalAnchor(ctx, recomputed.peak);
389
+ if (historical !== null) anchor = historical;
390
+ } catch (err) {
391
+ out.warn(
392
+ "verify: history scan unavailable (%s); reporting peak_not_current",
393
+ err instanceof Error ? err.message : String(err),
394
+ );
395
+ }
396
+ }
397
+ if (!anchor.anchored) {
398
+ // Not anchored — the offline failure stands. For a snapshot anchor,
399
+ // narrate WHY on stderr first (a newer-than-snapshot receipt carries the
400
+ // "refresh the accumulator" remedy; --json output stays the structured
401
+ // offline failure).
402
+ if (ctx.anchor === "accumulator" && ctx.json !== true) {
403
+ printAnchorRow(out, ctx, anchor);
404
+ }
405
+ return false;
406
+ }
407
+
408
+ const isSnapshot = ctx.anchor === "accumulator";
409
+ const okResult: ReceiptVerifyResult = { ok: true, stage: "binding" };
410
+ if (ctx.json) {
411
+ const report = buildVerifyReport({
412
+ ok: true,
413
+ result: okResult,
414
+ mode: isSnapshot ? "accumulator-anchored" : "chain-anchored",
415
+ anchor,
416
+ univocity: ctx.univocity,
417
+ logId: ctx.logId,
418
+ stagesOverride: anchorOnlyStageRows(),
419
+ });
420
+ out.out(JSON.stringify(report, null, 2));
421
+ } else {
422
+ for (const row of anchorOnlyStageRows()) {
423
+ const detail = row.reason ?? STAGE_NARRATION[row.stage];
424
+ out.print(
425
+ "verify: %s %s — %s",
426
+ row.stage.padEnd(9),
427
+ row.status.padEnd(7),
428
+ detail,
429
+ );
430
+ }
431
+ printAnchorRow(out, ctx, anchor);
432
+ out.out(
433
+ isSnapshot
434
+ ? `PASS: receipt verified against the known accumulator (anchored size ${anchor.size}, read from chain at block ${anchor.blockNumber}; signature enforced by univocity at publish)`
435
+ : `PASS: receipt verified against the on-chain accumulator (anchored size ${anchor.size}; signature enforced by univocity at publish)`,
436
+ );
437
+ }
438
+ process.exitCode = 0;
439
+ return true;
440
+ }
441
+
442
+ /**
443
+ * Shared tail: optional chain-anchor check, then render the stage report and
444
+ * set the exit code. Used by both `verify` (payload) and `verify-grant`.
445
+ */
446
+ /**
447
+ * FOR-368 Phase 2: when the live accumulator no longer holds the peak
448
+ * (peak_not_current — burial), scan the CheckpointPublished history. A
449
+ * match at ANY anchored state proves the receipt: univocity's consistency
450
+ * gating commits every anchored accumulator forward to the present.
451
+ * Public-chain-data-only — no tiles, no holder cache.
452
+ */
453
+ async function tryHistoricalAnchor(
454
+ ctx: VerifyReportContext,
455
+ peak: Uint8Array,
456
+ ): Promise<(AnchorCheck & HistoricalAnchorFields) | null> {
457
+ const hit = await findPeakInPublishedHistory({
458
+ peak,
459
+ univocity: ctx.univocity!,
460
+ logId: ctx.logId!,
461
+ rpcUrl: ctx.rpcUrl!,
462
+ fromBlock: ctx.fromBlock,
463
+ });
464
+ if (hit === null) return null;
465
+ return {
466
+ accumulator: hit.accumulator,
467
+ size: hit.size,
468
+ anchored: true,
469
+ matchedPeak: hit.matchedPeak,
470
+ historical: true,
471
+ anchoredAtBlock: hit.blockNumber,
472
+ txHash: hit.txHash,
473
+ };
474
+ }
475
+
476
+ type HistoricalAnchorFields = {
477
+ historical: true;
478
+ anchoredAtBlock: bigint;
479
+ txHash: string;
480
+ };
481
+
482
+ async function finishVerify(
483
+ out: Out,
484
+ ctx: VerifyReportContext,
485
+ artifacts: { genesisCbor: Uint8Array | undefined; receiptCbor: Uint8Array },
486
+ result: ReceiptVerifyResult,
487
+ anchorLeaf?: AnchorLeaf,
488
+ ): Promise<void> {
489
+ // FOR-297 approach C: in a chain-anchored mode (live read OR cached
490
+ // known-accumulator snapshot), a delegation the offline verifier cannot
491
+ // resolve is not the end — the anchored accumulator IS the split-view
492
+ // authority, so verify against it without the signature.
493
+ if (
494
+ (ctx.anchor === "chain" || ctx.anchor === "accumulator") &&
495
+ !result.ok &&
496
+ result.stage === "signature" &&
497
+ result.reason === "delegation_invalid" &&
498
+ anchorLeaf !== undefined
499
+ ) {
500
+ const handled = await tryAnchorOnlyVerify(
501
+ out,
502
+ ctx,
503
+ artifacts,
504
+ result,
505
+ anchorLeaf,
506
+ );
507
+ if (handled) return;
508
+ }
509
+
510
+ let anchor: (AnchorCheck & Partial<SnapshotAnchorCheck>) | undefined;
511
+ if (ctx.anchor !== "offline" && result.ok) {
512
+ try {
513
+ // Under a caller-known key there may be no genesis to locate a detached
514
+ // peak by trust-key trial — recompute the peak from leaf + proof
515
+ // instead (same strategy as the anchor-only path). The snapshot and
516
+ // checkpoint-chain checks always work from the recomputed peak.
517
+ let recomputedPeak: Uint8Array | undefined;
518
+ if (
519
+ (ctx.knownLogKey !== undefined || ctx.anchor !== "chain") &&
520
+ anchorLeaf !== undefined
521
+ ) {
522
+ const recomputed = await recomputeReceiptPeak({
523
+ receiptCbor: artifacts.receiptCbor,
524
+ idtimestampBe8: anchorLeaf.idtimestampBe8,
525
+ inner: await anchorLeaf.inner(),
526
+ });
527
+ if (recomputed.inclusionOk) recomputedPeak = recomputed.peak;
528
+ }
529
+ if (ctx.anchor === "accumulator") {
530
+ if (anchorLeaf === undefined || recomputedPeak === undefined) {
531
+ throw new Error(
532
+ "cannot recompute the receipt peak for the known-accumulator check",
533
+ );
534
+ }
535
+ anchor = await checkSnapshotAnchor(
536
+ ctx,
537
+ artifacts.receiptCbor,
538
+ anchorLeaf,
539
+ recomputedPeak,
540
+ );
541
+ } else if (ctx.anchor === "checkpoints") {
542
+ if (anchorLeaf === undefined || recomputedPeak === undefined) {
543
+ throw new Error(
544
+ "cannot recompute the receipt peak for the checkpoint-chain check",
545
+ );
546
+ }
547
+ anchor = await checkCheckpointChainAnchor(
548
+ ctx,
549
+ artifacts.genesisCbor,
550
+ artifacts.receiptCbor,
551
+ recomputedPeak,
552
+ );
553
+ } else {
554
+ anchor = await checkReceiptAnchored({
555
+ genesisCbor: artifacts.genesisCbor,
556
+ receiptCbor: artifacts.receiptCbor,
557
+ univocity: ctx.univocity!,
558
+ logId: ctx.logId!,
559
+ rpcUrl: ctx.rpcUrl!,
560
+ ...(recomputedPeak !== undefined ? { recomputedPeak } : {}),
561
+ });
562
+ if (!anchor.anchored && anchor.reason === "peak_not_current") {
563
+ // FOR-368 Phase 2: buried peak — the anchored HISTORY proves
564
+ // honest receipts. Recompute the peak if the genesis-trust-key
565
+ // strategy left it implicit.
566
+ if (recomputedPeak === undefined && anchorLeaf !== undefined) {
567
+ const recomputed = await recomputeReceiptPeak({
568
+ receiptCbor: artifacts.receiptCbor,
569
+ idtimestampBe8: anchorLeaf.idtimestampBe8,
570
+ inner: await anchorLeaf.inner(),
571
+ });
572
+ if (recomputed.inclusionOk) recomputedPeak = recomputed.peak;
573
+ }
574
+ if (recomputedPeak !== undefined) {
575
+ try {
576
+ const historical = await tryHistoricalAnchor(
577
+ ctx,
578
+ recomputedPeak,
579
+ );
580
+ if (historical !== null) anchor = historical;
581
+ } catch (err) {
582
+ out.warn(
583
+ "verify: history scan unavailable (%s); reporting peak_not_current",
584
+ err instanceof Error ? err.message : String(err),
585
+ );
586
+ }
587
+ }
588
+ }
589
+ }
590
+ } catch (err) {
591
+ reportRunError(out, ctx, "anchor", err);
592
+ return;
593
+ }
594
+ }
595
+
596
+ const ok =
597
+ result.ok && (ctx.anchor === "offline" || anchor?.anchored === true);
598
+ const rows =
599
+ ctx.knownLogKey !== undefined ? knownKeyStageRows(result) : stageRows(result);
600
+
601
+ if (ctx.json) {
602
+ const report = buildVerifyReport({
603
+ ok,
604
+ result,
605
+ mode:
606
+ ctx.anchor === "chain"
607
+ ? "chain-anchored"
608
+ : ctx.anchor === "accumulator"
609
+ ? "accumulator-anchored"
610
+ : ctx.anchor === "checkpoints"
611
+ ? "checkpoint-chain-anchored"
612
+ : "offline",
613
+ anchor,
614
+ univocity: ctx.univocity,
615
+ logId: ctx.logId,
616
+ ...(ctx.knownLogKey !== undefined ? { stagesOverride: rows } : {}),
617
+ });
618
+ out.out(JSON.stringify(report, null, 2));
619
+ } else {
620
+ for (const row of rows) {
621
+ const detail =
622
+ row.status === "failed"
623
+ ? (row.reason ?? "unknown")
624
+ : row.status === "skipped"
625
+ ? (row.reason ?? "not evaluated")
626
+ : (row.reason ?? STAGE_NARRATION[row.stage]);
627
+ out.print(
628
+ "verify: %s %s — %s",
629
+ row.stage.padEnd(9),
630
+ row.status.padEnd(7),
631
+ detail,
632
+ );
633
+ }
634
+ if (anchor !== undefined) {
635
+ printAnchorRow(out, ctx, anchor);
636
+ }
637
+ if (ok) {
638
+ if (anchor !== undefined) {
639
+ out.out(
640
+ ctx.anchor === "accumulator"
641
+ ? `PASS: receipt verified offline and matched the known accumulator (anchored size ${anchor.size}, read from chain at block ${anchor.blockNumber})`
642
+ : ctx.anchor === "checkpoints"
643
+ ? `PASS: receipt verified offline and anchored in the retained checkpoint chain (link sealed size ${anchor.size}; no tiles, no RPC)`
644
+ : `PASS: receipt verified offline and anchored on-chain (anchored size ${anchor.size})`,
645
+ );
646
+ } else if (ctx.knownLogKey !== undefined) {
647
+ out.out(
648
+ "PASS: receipt verified offline under the caller-known log key (not genesis-derived)",
649
+ );
650
+ } else {
651
+ out.out("PASS: receipt verified offline against the cached checkpoint");
652
+ }
653
+ } else if (!result.ok) {
654
+ out.out(
655
+ `FAIL: stage=${result.stage} reason=${result.reason ?? "unknown"}`,
656
+ );
657
+ } else {
658
+ out.out(`FAIL: stage=anchor reason=${anchor?.reason ?? "unknown"}`);
659
+ }
660
+ }
661
+
662
+ process.exitCode = ok ? 0 : 1;
663
+ }
664
+
665
+ /**
666
+ * `forestrie verify` (FOR-347): the generic, SCITT-compatible offline verify.
667
+ * The leaf commits `SHA-256(idtimestamp ‖ SHA-256(payload))`; the caller passes
668
+ * the EXACT registered payload (`--payload`, e.g. a signed statement COSE). No
669
+ * network during the core verify; add `--univocity/--log-id/--rpc-url` to also
670
+ * check the on-chain accumulator.
671
+ */
672
+ export async function runVerify(
673
+ out: Out,
674
+ options: VerifyOptions,
675
+ ): Promise<void> {
676
+ let artifacts: ReturnType<typeof loadPayloadVerifyArtifacts>;
677
+ try {
678
+ artifacts = loadPayloadVerifyArtifacts(options);
679
+ } catch (err) {
680
+ reportRunError(out, options, "input", err);
681
+ return;
682
+ }
683
+
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({
691
+ receiptCbor: artifacts.receiptCbor,
692
+ payload: artifacts.payload,
693
+ idtimestampBe8: artifacts.idtimestampBe8,
694
+ 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
+ }
704
+ } catch (err) {
705
+ reportRunError(out, options, "verify", err);
706
+ return;
707
+ }
708
+
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
+ });
717
+ }
718
+
719
+ /**
720
+ * `forestrie verify-grant` (FOR-347): verify a forestrie authority/grant
721
+ * receipt. A thin wrapper over the same core — it derives the grant commitment
722
+ * preimage from a structured grant and verifies it as the leaf payload. The
723
+ * receipt is a standard COSE Receipt; only the payload is forestrie-specific
724
+ * (chosen to match the on-chain univocity accumulator).
725
+ */
726
+ export async function runVerifyGrant(
727
+ out: Out,
728
+ options: VerifyGrantOptions,
729
+ ): Promise<void> {
730
+ let artifacts: ReturnType<typeof loadVerifyArtifacts>;
731
+ try {
732
+ artifacts = loadVerifyArtifacts(options);
733
+ } catch (err) {
734
+ reportRunError(out, options, "input", err);
735
+ return;
736
+ }
737
+
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({
745
+ receiptCbor: artifacts.receiptCbor,
746
+ grant: artifacts.grant,
747
+ idtimestampBe8: artifacts.idtimestampBe8,
748
+ 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
+ }
758
+ } catch (err) {
759
+ reportRunError(out, options, "verify", err);
760
+ return;
761
+ }
762
+
763
+ await finishVerify(out, options, artifacts, result, {
764
+ idtimestampBe8: artifacts.idtimestampBe8,
765
+ inner: () => grantCommitmentHashFromGrant(artifacts.grant),
766
+ });
767
+ }