@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,160 @@
1
+ import { bytesEqual } from "./bytes.js";
2
+ import { readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { verifyCoseSign1WithParsedKey } from "@forestrie/encoding";
5
+ import {
6
+ resolveDelegatedVerifyKey,
7
+ type CheckpointChainLink,
8
+ } from "@forestrie/receipt-verify";
9
+ import type { AnchorCheck } from "./verify-anchored.js";
10
+
11
+ /**
12
+ * Retained checkpoint-chain anchor (FOR-368 Phase 3, plan-2607-29).
13
+ *
14
+ * The log store's retained `.sth` objects chain contiguously post-FOR-410
15
+ * (ADR-0056: every checkpoint's embedded consistency proof spans its
16
+ * massif's entry boundary to its seal). `@forestrie/receipt-verify`'s
17
+ * `verifyCheckpointChain` folds that chain from size 0, authenticating the
18
+ * accumulator at EVERY retained seal — this rung depends only on the
19
+ * public log store: no tiles, no RPC, no holder cache. It is the
20
+ * complement of the `CheckpointPublished` event scan (public chain data
21
+ * only); the two paths are independently sufficient and cross-checkable.
22
+ *
23
+ * A receipt whose recomputed peak appears in ANY authenticated link is
24
+ * proven: each later link's signed consistency proof commits the earlier
25
+ * accumulator forward, so burial never turns an honest receipt
26
+ * tamper-shaped.
27
+ */
28
+
29
+ /**
30
+ * Resolve `--checkpoint-chain` to ordered checkpoint files: a directory of
31
+ * `.sth` objects (store naming zero-pads massif indexes, so lexicographic
32
+ * order IS chain order), or an explicit comma-separated list in chain
33
+ * order.
34
+ */
35
+ export function loadCheckpointChainFiles(pathspec: string): {
36
+ files: string[];
37
+ checkpoints: Uint8Array[];
38
+ } {
39
+ let files: string[];
40
+ let isDirectory = false;
41
+ try {
42
+ isDirectory = statSync(pathspec).isDirectory();
43
+ } catch {
44
+ // A comma-separated list is not itself a path; fall through.
45
+ }
46
+ if (isDirectory) {
47
+ files = readdirSync(pathspec)
48
+ .filter((name) => name.endsWith(".sth"))
49
+ .sort()
50
+ .map((name) => join(pathspec, name));
51
+ } else {
52
+ files = pathspec.split(",").map((f) => f.trim()).filter((f) => f !== "");
53
+ }
54
+ if (files.length === 0) {
55
+ throw new Error(
56
+ `--checkpoint-chain matched no .sth checkpoints: '${pathspec}'`,
57
+ );
58
+ }
59
+ return {
60
+ files,
61
+ checkpoints: files.map((f) => new Uint8Array(readFileSync(f))),
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Signature trust for chain links, rooted in the caller's trust anchor
67
+ * (genesis-derived roots or `--known-log-key`): resolve each checkpoint's
68
+ * label-1000 delegation cert under the root keys, then verify the COSE
69
+ * signature over the folded accumulator as detached payload. The payload
70
+ * is COMPUTED by the fold, never read from the checkpoint — a link only
71
+ * authenticates the accumulator its proof actually derives (ADR-0046).
72
+ */
73
+ export function makeCheckpointSignatureVerifier(
74
+ rootKeys: CryptoKey[],
75
+ ): (
76
+ checkpointBytes: Uint8Array,
77
+ detachedPayload: Uint8Array,
78
+ ) => Promise<boolean> {
79
+ return async (checkpointBytes, detachedPayload) => {
80
+ const resolution = await resolveDelegatedVerifyKey(
81
+ checkpointBytes,
82
+ rootKeys,
83
+ );
84
+ if (resolution.kind === "broken") return false;
85
+ const candidates =
86
+ resolution.kind === "resolved"
87
+ ? [resolution.delegatedKey, ...rootKeys]
88
+ : rootKeys;
89
+ for (const key of candidates) {
90
+ if (
91
+ await verifyCoseSign1WithParsedKey(checkpointBytes, key, {
92
+ logPrefix: "checkpoint-chain",
93
+ detachedPayload,
94
+ })
95
+ ) {
96
+ return true;
97
+ }
98
+ }
99
+ return false;
100
+ };
101
+ }
102
+
103
+ export type CheckpointChainAnchorCheck = AnchorCheck & {
104
+ /** Number of authenticated links in the fold. */
105
+ linkCount: number;
106
+ /** Sealed size of the link holding the peak (null when unmatched). */
107
+ matchedLinkSize: bigint | null;
108
+ };
109
+
110
+ /**
111
+ * Find the recomputed receipt peak in the authenticated chain. Newest-first
112
+ * (mirrors the event-scan rung): the freshest cover gives the most useful
113
+ * report, and a match at ANY link is proof — later links' signed
114
+ * consistency proofs commit it forward. Newer-than-chain fails CLOSED with
115
+ * a refresh remedy: retention limits coverage, never validity.
116
+ */
117
+ export function checkReceiptAnchoredToCheckpointChain(opts: {
118
+ links: CheckpointChainLink[];
119
+ recomputedPeak: Uint8Array;
120
+ leafMmrIndex: bigint;
121
+ }): CheckpointChainAnchorCheck {
122
+ const final = opts.links[opts.links.length - 1]!;
123
+ const base = {
124
+ accumulator: final.accumulator,
125
+ size: final.treeSize2,
126
+ linkCount: opts.links.length,
127
+ };
128
+ if (opts.leafMmrIndex >= final.treeSize2) {
129
+ return {
130
+ ...base,
131
+ anchored: false,
132
+ matchedPeak: null,
133
+ matchedLinkSize: null,
134
+ reason: "receipt_newer_than_checkpoint_chain",
135
+ };
136
+ }
137
+ for (let i = opts.links.length - 1; i >= 0; i--) {
138
+ const link = opts.links[i]!;
139
+ for (let p = 0; p < link.accumulator.length; p++) {
140
+ if (bytesEqual(opts.recomputedPeak, link.accumulator[p]!)) {
141
+ return {
142
+ ...base,
143
+ anchored: true,
144
+ matchedPeak: p,
145
+ matchedLinkSize: link.treeSize2,
146
+ accumulator: link.accumulator,
147
+ size: link.treeSize2,
148
+ };
149
+ }
150
+ }
151
+ }
152
+ return {
153
+ ...base,
154
+ anchored: false,
155
+ matchedPeak: null,
156
+ matchedLinkSize: null,
157
+ reason: "peak_not_in_checkpoint_chain",
158
+ };
159
+ }
160
+
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Historical anchor scan over `CheckpointPublished` events (FOR-368
3
+ * Phase 2, plan-2607-29).
4
+ *
5
+ * Univocity's `publishCheckpoint` verifies consistency from the current
6
+ * on-chain state before accepting a new accumulator, so **every anchored
7
+ * accumulator is a committed prefix of every later one**. A receipt whose
8
+ * recomputed peak appears in ANY historical anchored accumulator is
9
+ * therefore proven — consistency to the present is carried by the
10
+ * contract, not by a client-side grow proof. The event log is the durable
11
+ * record of those accumulators (`CheckpointPublished` carries `size` and
12
+ * `accumulator`; the contract stores nothing retrievable historically).
13
+ *
14
+ * This rung depends only on public blockchain data — deliberately
15
+ * independent of the log store (the retained-checkpoint chain rung is its
16
+ * complement with the opposite dependency; see plan-2607-29).
17
+ */
18
+ import { bytesEqual } from "./bytes.js";
19
+ import { normalizeHexAddress } from "@forestrie/chain-rpc";
20
+ import { toContractLogId } from "./verify-anchored.js";
21
+
22
+ /**
23
+ * keccak256 of
24
+ * `CheckpointPublished(bytes32,bytes32,bytes,address,bytes8,uint8,uint64,bytes32[],uint64,bytes32[])`
25
+ * (univocity `IUnivocityEvents.sol`). The contract is immutable per forest,
26
+ * so this signature is stable for every deployed instance.
27
+ */
28
+ export const CHECKPOINT_PUBLISHED_TOPIC0 =
29
+ "0x156942b408823cb05a16027962ea485fa7171d99779ee04094280b2569482426";
30
+
31
+ export type PublishedCheckpoint = {
32
+ /** Anchored MMR size. */
33
+ size: bigint;
34
+ /** Anchored accumulator peaks (32 bytes each), contract order. */
35
+ accumulator: Uint8Array[];
36
+ /** Block the anchor landed in. */
37
+ blockNumber: bigint;
38
+ /** Transaction that published it. */
39
+ txHash: string;
40
+ /** Hash of that block (falsifiability handle for the snapshot). */
41
+ blockHash: string;
42
+ };
43
+
44
+ export type HistoricalAnchor = PublishedCheckpoint & {
45
+ /** Index of the matching accumulator peak. */
46
+ matchedPeak: number;
47
+ };
48
+
49
+ type EthLog = {
50
+ data?: string;
51
+ blockNumber?: string;
52
+ transactionHash?: string;
53
+ blockHash?: string;
54
+ };
55
+
56
+ function readWord(hex: string, wordIndex: number): bigint {
57
+ const start = wordIndex * 64;
58
+ if (hex.length < start + 64) {
59
+ throw new Error(
60
+ `CheckpointPublished data too short: need word ${wordIndex}, have ${hex.length} hex chars`,
61
+ );
62
+ }
63
+ return BigInt("0x" + hex.slice(start, start + 64));
64
+ }
65
+
66
+ function hexToBytes(hex: string): Uint8Array {
67
+ const out = new Uint8Array(hex.length / 2);
68
+ for (let i = 0; i < out.length; i++) {
69
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /**
75
+ * Decode the non-indexed `CheckpointPublished` fields we need. Data layout
76
+ * (ABI, after the three indexed params): word0 `sender`, word1
77
+ * `grantIDTimestampBe` (padded bytes8), word2 `logKind`, word3 `size`,
78
+ * word4 offset→`accumulator`, word5 `grantIndex`, word6 offset→`grantPath`.
79
+ */
80
+ export function decodeCheckpointPublishedData(dataHex: string): {
81
+ size: bigint;
82
+ accumulator: Uint8Array[];
83
+ } {
84
+ const hex = dataHex.replace(/^0x/, "");
85
+ const size = readWord(hex, 3);
86
+ const accumulatorOffset = readWord(hex, 4);
87
+ const arrayBase = Number(accumulatorOffset) / 32;
88
+ const length = Number(readWord(hex, arrayBase));
89
+ const accumulator: Uint8Array[] = [];
90
+ for (let i = 0; i < length; i++) {
91
+ const start = (arrayBase + 1 + i) * 64;
92
+ if (hex.length < start + 64) {
93
+ throw new Error(
94
+ `CheckpointPublished accumulator truncated at peak ${i} of ${length}`,
95
+ );
96
+ }
97
+ accumulator.push(hexToBytes(hex.slice(start, start + 64)));
98
+ }
99
+ return { size, accumulator };
100
+ }
101
+
102
+ /** Minimal JSON-RPC fetch (mirrors fetch-accumulator's raw pattern). */
103
+ async function rpc(
104
+ rpcUrl: string,
105
+ method: string,
106
+ params: unknown[],
107
+ fetchImpl: typeof fetch,
108
+ ): Promise<unknown> {
109
+ const res = await fetchImpl(rpcUrl, {
110
+ method: "POST",
111
+ headers: { "Content-Type": "application/json" },
112
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
113
+ });
114
+ if (!res.ok) {
115
+ throw new Error(`${method}: HTTP ${res.status}`);
116
+ }
117
+ const body = (await res.json()) as { result?: unknown; error?: { message?: string } };
118
+ if (body.error) {
119
+ throw new Error(`${method}: ${body.error.message ?? "rpc error"}`);
120
+ }
121
+ return body.result;
122
+ }
123
+
124
+ /**
125
+ * Fetch every `CheckpointPublished` for a log (topic1 = contract log id),
126
+ * ascending block order as returned by `eth_getLogs`.
127
+ */
128
+ export async function fetchPublishedCheckpoints(opts: {
129
+ univocity: string;
130
+ logId: string;
131
+ rpcUrl: string;
132
+ /** First block to scan (the forest's deploy block bounds the range). */
133
+ fromBlock?: bigint | undefined;
134
+ fetchImpl?: typeof fetch;
135
+ }): Promise<PublishedCheckpoint[]> {
136
+ const address = normalizeHexAddress(opts.univocity);
137
+ if (address === null) {
138
+ throw new Error(`--univocity is not a valid address: '${opts.univocity}'`);
139
+ }
140
+ const logs = (await rpc(
141
+ opts.rpcUrl,
142
+ "eth_getLogs",
143
+ [
144
+ {
145
+ address: `0x${address}`,
146
+ fromBlock:
147
+ opts.fromBlock !== undefined
148
+ ? `0x${opts.fromBlock.toString(16)}`
149
+ : "earliest",
150
+ toBlock: "latest",
151
+ topics: [CHECKPOINT_PUBLISHED_TOPIC0, toContractLogId(opts.logId)],
152
+ },
153
+ ],
154
+ opts.fetchImpl ?? fetch,
155
+ )) as EthLog[] | null;
156
+ const out: PublishedCheckpoint[] = [];
157
+ for (const log of logs ?? []) {
158
+ if (!log.data) continue;
159
+ const { size, accumulator } = decodeCheckpointPublishedData(log.data);
160
+ out.push({
161
+ size,
162
+ accumulator,
163
+ blockNumber: BigInt(log.blockNumber ?? "0x0"),
164
+ txHash: log.transactionHash ?? "",
165
+ blockHash: log.blockHash ?? "",
166
+ });
167
+ }
168
+ return out;
169
+ }
170
+
171
+
172
+ /**
173
+ * Find the recomputed receipt peak in any historically anchored
174
+ * accumulator. Newest-first: recent anchors are the likeliest cover for a
175
+ * recently buried peak, and the freshest match gives the most useful
176
+ * report. A match at ANY anchored state proves the receipt: univocity's
177
+ * consistency gating commits it forward to the present.
178
+ */
179
+ export async function findPeakInPublishedHistory(opts: {
180
+ peak: Uint8Array;
181
+ univocity: string;
182
+ logId: string;
183
+ rpcUrl: string;
184
+ fromBlock?: bigint | undefined;
185
+ fetchImpl?: typeof fetch;
186
+ }): Promise<HistoricalAnchor | null> {
187
+ const published = await fetchPublishedCheckpoints(opts);
188
+ for (let i = published.length - 1; i >= 0; i--) {
189
+ const cp = published[i]!;
190
+ for (let p = 0; p < cp.accumulator.length; p++) {
191
+ if (bytesEqual(opts.peak, cp.accumulator[p]!)) {
192
+ return { ...cp, matchedPeak: p };
193
+ }
194
+ }
195
+ }
196
+ return null;
197
+ }
198
+
199
+ /**
200
+ * Latest anchored state at or before `atBlock` (FOR-368 Phase 2:
201
+ * `fetch-accumulator --at-block`): historical snapshots come from the
202
+ * event record, so no archive-state RPC is needed.
203
+ */
204
+ export function selectPublishedAtBlock(
205
+ published: PublishedCheckpoint[],
206
+ atBlock: bigint,
207
+ ): PublishedCheckpoint | null {
208
+ let best: PublishedCheckpoint | null = null;
209
+ for (const cp of published) {
210
+ if (cp.blockNumber > atBlock) continue;
211
+ if (best === null || cp.blockNumber > best.blockNumber) best = cp;
212
+ }
213
+ return best;
214
+ }
@@ -0,0 +1,143 @@
1
+ import { readFileSync } from "node:fs";
2
+ import {
3
+ decodeForestrieGrantCose,
4
+ decodeGrantPayload,
5
+ entryIdHexToIdtimestampBe8,
6
+ type VerifyGrantReceiptOfflineInput,
7
+ type VerifyReceiptOfflineInput,
8
+ } from "@forestrie/receipt-verify";
9
+
10
+ /** `verify` (generic): the exact registered payload + its SCRAPI entry id. */
11
+ export type PayloadVerifyInputOptions = {
12
+ /** Optional when the trust anchor is a caller-known key (FOR-297 D1). */
13
+ genesis: string | undefined;
14
+ receipt: string;
15
+ payload: string;
16
+ entryId: string;
17
+ };
18
+
19
+ /** Genesis-optional artifact shape (`--known-log-key` runs without genesis). */
20
+ export type LoadedPayloadVerifyArtifacts = Omit<
21
+ VerifyReceiptOfflineInput,
22
+ "genesisCbor"
23
+ > & { genesisCbor: Uint8Array | undefined };
24
+
25
+ /** Load genesis/receipt/payload for the generic (SCITT-compatible) verify. */
26
+ export function loadPayloadVerifyArtifacts(
27
+ options: PayloadVerifyInputOptions,
28
+ ): LoadedPayloadVerifyArtifacts {
29
+ return {
30
+ genesisCbor:
31
+ options.genesis !== undefined
32
+ ? readBytes(options.genesis, "--genesis")
33
+ : undefined,
34
+ receiptCbor: readBytes(options.receipt, "--receipt"),
35
+ payload: readBytes(options.payload, "--payload"),
36
+ idtimestampBe8: entryIdHexToIdtimestampBe8(options.entryId),
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Load and decode `forestrie verify` inputs into the exact
42
+ * `verifyGrantReceiptOffline` input shape (FOR-347). Pure file/decode work
43
+ * — strictly no network.
44
+ *
45
+ * Grant sources (mirrors the canopy FOR-282 tracer):
46
+ * - `--committed-grant`: base64 of either a Forestrie-Grant COSE Sign1
47
+ * (carries the idtimestamp in unprotected header -65537) or a raw grant
48
+ * payload CBOR (keys 1–6; then `--entry-id` must supply the idtimestamp).
49
+ * - `--committed-grant-file` + `--entry-id`: grant CBOR file (COSE or raw
50
+ * payload); the idtimestamp comes from the permanent SCRAPI entry id.
51
+ */
52
+ export type VerifyInputOptions = {
53
+ /** Optional when the trust anchor is a caller-known key (FOR-297 D1). */
54
+ genesis: string | undefined;
55
+ receipt: string;
56
+ committedGrant: string | undefined;
57
+ committedGrantFile: string | undefined;
58
+ entryId: string | undefined;
59
+ };
60
+
61
+ /** Genesis-optional artifact shape (`--known-log-key` runs without genesis). */
62
+ export type LoadedVerifyArtifacts = Omit<
63
+ VerifyGrantReceiptOfflineInput,
64
+ "genesisCbor"
65
+ > & { genesisCbor: Uint8Array | undefined };
66
+
67
+ function readBytes(path: string, flag: string): Uint8Array {
68
+ try {
69
+ return new Uint8Array(readFileSync(path));
70
+ } catch (err) {
71
+ const message = err instanceof Error ? err.message : String(err);
72
+ throw new Error(`cannot read ${flag} '${path}': ${message}`);
73
+ }
74
+ }
75
+
76
+ function decodeBase64(b64: string): Uint8Array {
77
+ const bytes = new Uint8Array(Buffer.from(b64, "base64"));
78
+ if (bytes.length === 0) {
79
+ throw new Error(
80
+ "--committed-grant is not valid base64 (decoded to 0 bytes)",
81
+ );
82
+ }
83
+ return bytes;
84
+ }
85
+
86
+ /** Decode grant bytes: Forestrie-Grant COSE Sign1 first, raw payload CBOR second. */
87
+ function decodeGrantBytes(
88
+ bytes: Uint8Array,
89
+ entryId: string | undefined,
90
+ source: string,
91
+ ): Pick<VerifyGrantReceiptOfflineInput, "grant" | "idtimestampBe8"> {
92
+ try {
93
+ const decoded = decodeForestrieGrantCose(bytes);
94
+ return {
95
+ grant: decoded.grant,
96
+ idtimestampBe8:
97
+ entryId !== undefined
98
+ ? entryIdHexToIdtimestampBe8(entryId)
99
+ : decoded.idtimestampBe8,
100
+ };
101
+ } catch {
102
+ // Not a Forestrie-Grant COSE Sign1 — fall through to raw payload CBOR.
103
+ }
104
+ const grant = decodeGrantPayload(bytes);
105
+ if (entryId === undefined) {
106
+ throw new Error(
107
+ `${source} is a raw grant payload (no embedded idtimestamp) — pass --entry-id`,
108
+ );
109
+ }
110
+ return { grant, idtimestampBe8: entryIdHexToIdtimestampBe8(entryId) };
111
+ }
112
+
113
+ /** Read genesis/receipt/grant artefacts from disk into verify input bytes. */
114
+ export function loadVerifyArtifacts(
115
+ options: VerifyInputOptions,
116
+ ): LoadedVerifyArtifacts {
117
+ const genesisCbor =
118
+ options.genesis !== undefined
119
+ ? readBytes(options.genesis, "--genesis")
120
+ : undefined;
121
+ const receiptCbor = readBytes(options.receipt, "--receipt");
122
+
123
+ let grantBytes: Uint8Array;
124
+ let source: string;
125
+ if (options.committedGrant !== undefined) {
126
+ grantBytes = decodeBase64(options.committedGrant);
127
+ source = "--committed-grant";
128
+ } else if (options.committedGrantFile !== undefined) {
129
+ grantBytes = readBytes(options.committedGrantFile, "--committed-grant-file");
130
+ source = `--committed-grant-file '${options.committedGrantFile}'`;
131
+ } else {
132
+ throw new Error(
133
+ "either --committed-grant or --committed-grant-file is required",
134
+ );
135
+ }
136
+
137
+ const { grant, idtimestampBe8 } = decodeGrantBytes(
138
+ grantBytes,
139
+ options.entryId,
140
+ source,
141
+ );
142
+ return { genesisCbor, receiptCbor, grant, idtimestampBe8 };
143
+ }
@@ -0,0 +1,161 @@
1
+ import { bytesEqual } from "./bytes.js";
2
+ import {
3
+ assertSnapshotBinding,
4
+ computeAccumulatorPeak,
5
+ decodeKnownAccumulator,
6
+ encodeKnownAccumulator,
7
+ parseReceipt,
8
+ univocityLeafHash,
9
+ type KnownAccumulator,
10
+ } from "@forestrie/receipt-verify";
11
+ import { type AnchorCheck } from "./verify-anchored.js";
12
+
13
+ /**
14
+ * Snapshot artifact (type, encode, decode, binding assertion) hoisted to
15
+ * `@forestrie/receipt-verify` (FOR-297 D5, plan-2607-34 slice 02 Part B) so
16
+ * the CLI and other consumers share one implementation. Re-exported here so
17
+ * existing local imports (`from "./verify-known-accumulator.js"`) keep
18
+ * working unchanged.
19
+ */
20
+ export {
21
+ assertSnapshotBinding,
22
+ decodeKnownAccumulator,
23
+ encodeKnownAccumulator,
24
+ type KnownAccumulator,
25
+ };
26
+
27
+ /**
28
+ * Known-accumulator snapshot (FOR-297 D5): a cached, auditable chain read of
29
+ * the log's `logState`, letting chain-anchored verification run fully
30
+ * offline. The artifact type, encode/decode, and binding assertion moved to
31
+ * `@forestrie/receipt-verify` (plan-2607-34 slice 02 Part B) — re-exported
32
+ * above. See that package's `known-accumulator.ts` module doc for the trust
33
+ * model.
34
+ *
35
+ * What stays CLI-local: `checkReceiptAnchoredToSnapshot` below adds
36
+ * proof-path extension for stale snapshots (needs a local massif blob) —
37
+ * a bigger, file-shaped input the package's pure-bytes API doesn't take
38
+ * yet. The package's own `verifyReceiptOfflineAgainstKnownAccumulator`
39
+ * covers the same exact-peak-match case (its case 2) for callers that don't
40
+ * need extension.
41
+ */
42
+
43
+ function bytesToHex(bytes: Uint8Array): string {
44
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
45
+ }
46
+
47
+ export type SnapshotAnchorCheck = AnchorCheck & {
48
+ /** True when the match required proof-path extension via massif nodes. */
49
+ extended: boolean;
50
+ blockNumber: bigint;
51
+ blockHashHex: string;
52
+ };
53
+
54
+ /**
55
+ * Assert the receipt's peak is anchored in the known-accumulator snapshot —
56
+ * fully offline. Strategies, in order:
57
+ *
58
+ * 1. Fail closed when the receipt's leaf postdates the snapshot (refresh).
59
+ * 2. Exact peak match: the recomputed receipt peak is still a snapshot peak.
60
+ * 3. Proof-path extension (needs `massifBytes`): the receipt's peak is an
61
+ * interior node of the snapshot state; rebuild the leaf's inclusion path
62
+ * at the snapshot size from massif nodes and match the covering peak. The
63
+ * massif's leaf value must equal the locally derived leaf commitment —
64
+ * the payload stays bound even though the extension nodes come from the
65
+ * (untrusted) massif: every node on the path is recomputed.
66
+ */
67
+ export async function checkReceiptAnchoredToSnapshot(opts: {
68
+ snapshot: KnownAccumulator;
69
+ receiptCbor: Uint8Array;
70
+ idtimestampBe8: Uint8Array;
71
+ /** Leaf ContentHash: SHA-256(payload) or the grant commitment hash. */
72
+ inner: Uint8Array;
73
+ /** Recomputed receipt peak (leaf + receipt proof path, no signature). */
74
+ recomputedPeak: Uint8Array;
75
+ /** Local massif blob enabling proof-path extension for stale snapshots. */
76
+ massifBytes?: Uint8Array | undefined;
77
+ }): Promise<SnapshotAnchorCheck> {
78
+ const { snapshot } = opts;
79
+ const base = {
80
+ accumulator: snapshot.accumulator,
81
+ size: snapshot.size,
82
+ blockNumber: snapshot.blockNumber,
83
+ blockHashHex: bytesToHex(snapshot.blockHash),
84
+ };
85
+
86
+ const { proof } = parseReceipt(opts.receiptCbor);
87
+ const leafMmrIndex =
88
+ proof.leafIndex !== undefined ? proof.leafIndex : proof.mmrIndex!;
89
+
90
+ // 1. Newer-than-snapshot fails CLOSED — staleness limits coverage, never
91
+ // validity, so the remedy is a refresh, not a pass.
92
+ if (leafMmrIndex >= snapshot.size) {
93
+ return {
94
+ ...base,
95
+ anchored: false,
96
+ matchedPeak: null,
97
+ extended: false,
98
+ reason: "receipt_newer_than_known_accumulator",
99
+ };
100
+ }
101
+
102
+ // 2. Exact peak match — receipt state is a snapshot-covered accumulator.
103
+ for (let i = 0; i < snapshot.accumulator.length; i++) {
104
+ if (bytesEqual(opts.recomputedPeak, snapshot.accumulator[i]!)) {
105
+ return { ...base, anchored: true, matchedPeak: i, extended: false };
106
+ }
107
+ }
108
+
109
+ // 3. Extension: the old peak should be interior to the snapshot state.
110
+ if (opts.massifBytes === undefined) {
111
+ return {
112
+ ...base,
113
+ anchored: false,
114
+ matchedPeak: null,
115
+ extended: false,
116
+ reason: "peak_not_in_known_accumulator",
117
+ };
118
+ }
119
+
120
+ const leafHash = await univocityLeafHash(opts.idtimestampBe8, opts.inner);
121
+ let computed: Awaited<ReturnType<typeof computeAccumulatorPeak>>;
122
+ try {
123
+ computed = await computeAccumulatorPeak({
124
+ massifBytes: opts.massifBytes,
125
+ mmrIndex: leafMmrIndex,
126
+ mmrSize: snapshot.size,
127
+ });
128
+ } catch (err) {
129
+ const message = err instanceof Error ? err.message : String(err);
130
+ throw new Error(
131
+ `proof-path extension failed (massif does not cover the snapshot?): ${message}`,
132
+ );
133
+ }
134
+ if (!bytesEqual(computed.leafValue, leafHash)) {
135
+ // The massif's leaf disagrees with the payload-derived commitment — the
136
+ // extension cannot bind this payload.
137
+ return {
138
+ ...base,
139
+ anchored: false,
140
+ matchedPeak: null,
141
+ extended: false,
142
+ reason: "massif_leaf_mismatch",
143
+ };
144
+ }
145
+ const target = snapshot.accumulator[computed.peakIndex];
146
+ if (target !== undefined && bytesEqual(computed.peak, target)) {
147
+ return {
148
+ ...base,
149
+ anchored: true,
150
+ matchedPeak: computed.peakIndex,
151
+ extended: true,
152
+ };
153
+ }
154
+ return {
155
+ ...base,
156
+ anchored: false,
157
+ matchedPeak: null,
158
+ extended: false,
159
+ reason: "peak_not_in_known_accumulator",
160
+ };
161
+ }