@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.
- package/LICENSE +21 -0
- package/README.md +425 -0
- package/TRUST-MODEL.md +8 -0
- package/dist/cli.js +33811 -0
- package/dist/decode-receipt.d.ts +19 -0
- package/dist/decode-receipt.d.ts.map +1 -0
- package/dist/decode-receipt.js +19 -0
- package/dist/decode-receipt.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/decode-receipt-cbor.d.ts +26 -0
- package/dist/lib/decode-receipt-cbor.d.ts.map +1 -0
- package/dist/lib/decode-receipt-cbor.js +182 -0
- package/dist/lib/decode-receipt-cbor.js.map +1 -0
- package/dist/lib/decode-receipt-decode.d.ts +103 -0
- package/dist/lib/decode-receipt-decode.d.ts.map +1 -0
- package/dist/lib/decode-receipt-decode.js +237 -0
- package/dist/lib/decode-receipt-decode.js.map +1 -0
- package/dist/lib/decode-receipt-labels.d.ts +54 -0
- package/dist/lib/decode-receipt-labels.d.ts.map +1 -0
- package/dist/lib/decode-receipt-labels.js +117 -0
- package/dist/lib/decode-receipt-labels.js.map +1 -0
- package/dist/lib/decode-receipt-render.d.ts +11 -0
- package/dist/lib/decode-receipt-render.d.ts.map +1 -0
- package/dist/lib/decode-receipt-render.js +242 -0
- package/dist/lib/decode-receipt-render.js.map +1 -0
- package/package.json +83 -0
- package/src/cli.ts +5 -0
- package/src/command.ts +51 -0
- package/src/commands/admin-onboard-token.ts +51 -0
- package/src/commands/admin.ts +20 -0
- package/src/commands/complete-grant.ts +41 -0
- package/src/commands/create-consistency-proof.ts +44 -0
- package/src/commands/create-log.ts +95 -0
- package/src/commands/decode-receipt.ts +20 -0
- package/src/commands/delegate.ts +55 -0
- package/src/commands/deploy.ts +72 -0
- package/src/commands/fetch-accumulator.ts +49 -0
- package/src/commands/onboard-genesis.ts +70 -0
- package/src/commands/onboard-request.ts +59 -0
- package/src/commands/register-grant.ts +74 -0
- package/src/commands/register.ts +54 -0
- package/src/commands/resolve-receipt.ts +101 -0
- package/src/commands/sign-statement.ts +53 -0
- package/src/commands/verify-grant.ts +89 -0
- package/src/commands/verify.ts +92 -0
- package/src/commoncli.ts +34 -0
- package/src/decode-receipt.ts +56 -0
- package/src/index.ts +10 -0
- package/src/lib/bytes.ts +7 -0
- package/src/lib/checkpoint-provider.ts +297 -0
- package/src/lib/complete-grant-derive.ts +237 -0
- package/src/lib/consistency-proof.ts +271 -0
- package/src/lib/create-log-build.ts +168 -0
- package/src/lib/create-log-prepare.ts +75 -0
- package/src/lib/create-receipt-chain-verify.ts +267 -0
- package/src/lib/create-receipt-derive.ts +208 -0
- package/src/lib/create-receipt-inputs.ts +79 -0
- package/src/lib/decode-checkpoint-calldata.ts +188 -0
- package/src/lib/decode-receipt-cbor.ts +202 -0
- package/src/lib/decode-receipt-decode.ts +333 -0
- package/src/lib/decode-receipt-labels.ts +132 -0
- package/src/lib/decode-receipt-render.ts +284 -0
- package/src/lib/delegate-flow.ts +289 -0
- package/src/lib/delegate-voucher.ts +122 -0
- package/src/lib/deploy-artifact.ts +79 -0
- package/src/lib/deploy-bootstrap.ts +100 -0
- package/src/lib/deploy-flow.ts +184 -0
- package/src/lib/deploy-genesis-log-id.ts +29 -0
- package/src/lib/deploy-rpc.ts +40 -0
- package/src/lib/deployment-record.ts +65 -0
- package/src/lib/fsio.ts +91 -0
- package/src/lib/grant-flags-local.ts +19 -0
- package/src/lib/onboard-attestation.ts +120 -0
- package/src/lib/onboard-genesis-body.ts +94 -0
- package/src/lib/openssl-error-queue.ts +56 -0
- package/src/lib/register-flow.ts +292 -0
- package/src/lib/register-grant-build.ts +173 -0
- package/src/lib/register-grant-complete.ts +47 -0
- package/src/lib/register-grant-flow.ts +258 -0
- package/src/lib/resolve-receipt-freshen.ts +221 -0
- package/src/lib/sign-statement-build.ts +117 -0
- package/src/lib/sign-statement-key.ts +145 -0
- package/src/lib/verify-anchored.ts +326 -0
- package/src/lib/verify-checkpoint-chain.ts +160 -0
- package/src/lib/verify-eventscan.ts +214 -0
- package/src/lib/verify-inputs.ts +143 -0
- package/src/lib/verify-known-accumulator.ts +161 -0
- package/src/lib/verify-report.ts +265 -0
- package/src/lib/x402-payment.ts +115 -0
- package/src/main/admin-onboard-token.ts +216 -0
- package/src/main/complete-grant.ts +265 -0
- package/src/main/create-consistency-proof.ts +83 -0
- package/src/main/create-log.ts +347 -0
- package/src/main/create-receipt.ts +763 -0
- package/src/main/decode-receipt.ts +72 -0
- package/src/main/delegate.ts +141 -0
- package/src/main/deploy.ts +273 -0
- package/src/main/fetch-accumulator.ts +181 -0
- package/src/main/not-implemented.ts +43 -0
- package/src/main/onboard-genesis.ts +192 -0
- package/src/main/onboard-request.ts +333 -0
- package/src/main/register-grant.ts +270 -0
- package/src/main/register.ts +243 -0
- package/src/main/sign-statement.ts +168 -0
- package/src/main/verify.ts +767 -0
- package/src/options/admin-onboard-token.ts +48 -0
- package/src/options/common.ts +69 -0
- package/src/options/complete-grant.ts +40 -0
- package/src/options/create-consistency-proof.ts +63 -0
- package/src/options/create-log.ts +99 -0
- package/src/options/create-receipt.ts +243 -0
- package/src/options/decode-receipt.ts +25 -0
- package/src/options/delegate.ts +93 -0
- package/src/options/deploy.ts +105 -0
- package/src/options/fetch-accumulator.ts +62 -0
- package/src/options/onboard-genesis.ts +74 -0
- package/src/options/onboard-request.ts +57 -0
- package/src/options/register-grant.ts +72 -0
- package/src/options/register.ts +71 -0
- package/src/options/sign-statement.ts +64 -0
- package/src/options/verify.ts +250 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkpoint-chain providers (FOR-418 Phase 2, plan-2607-32).
|
|
3
|
+
*
|
|
4
|
+
* A *provider* yields the log's ordered chain of consistency-proof links folded
|
|
5
|
+
* into the accumulator at each seal, so consumers (verify, `resolve-receipt`)
|
|
6
|
+
* work over `CheckpointLink[]` regardless of source. Two tile-free sources:
|
|
7
|
+
*
|
|
8
|
+
* - **chain calldata** (`calldataCheckpointChain`) — the `publishCheckpoint`
|
|
9
|
+
* transactions carry the whole `ConsistencyReceipt` (Phase 1).
|
|
10
|
+
* - **retained `.sth`** (`sthCheckpointChain`) — the store's signed checkpoints.
|
|
11
|
+
*
|
|
12
|
+
* TRUST (important — see the Phase 2 review, plan-2607-32 R1): a `CheckpointLink`
|
|
13
|
+
* accumulator here is **RPC-/store-asserted, NOT authenticated by this module**.
|
|
14
|
+
* The provider folds and does a cheap self-consistency cross-check (calldata
|
|
15
|
+
* fold vs the `CheckpointPublished` event accumulator), but it does **not**
|
|
16
|
+
* verify the COSE signature. So that the consumer *can*, each directly-signed
|
|
17
|
+
* link carries its `seal` (the signature + delegation for calldata; the
|
|
18
|
+
* checkpoint bytes for `.sth`); the consumer applies the genesis/known-key trust
|
|
19
|
+
* root against those seals (Phase 4). Both sources decode to the identical
|
|
20
|
+
* `CheckpointConsistencyProof` shape and fold identically — the parity that
|
|
21
|
+
* makes them interchangeable (see the tests).
|
|
22
|
+
*
|
|
23
|
+
* Each link also carries the raw `proof` it was folded from (its per-peak
|
|
24
|
+
* `paths`), so a consumer that needs the climb material rather than just the
|
|
25
|
+
* folded accumulator — `resolve-receipt`'s freshen (Phase 3c), which extends an
|
|
26
|
+
* old inclusion path to the latest peak — gets it in one pass with no re-decode
|
|
27
|
+
* and no second RPC sweep: `links.map((l) => l.proof)` is exactly freshen's
|
|
28
|
+
* ordered `consistencyProofs`. Consumers that only fold (verify) ignore it.
|
|
29
|
+
*/
|
|
30
|
+
import {
|
|
31
|
+
computeCheckpointAccumulator,
|
|
32
|
+
checkpointConsistencyProof,
|
|
33
|
+
type CheckpointConsistencyProof,
|
|
34
|
+
} from "@forestrie/receipt-verify";
|
|
35
|
+
import { fetchPublishedCheckpoints } from "./verify-eventscan.js";
|
|
36
|
+
import {
|
|
37
|
+
fetchPublishCheckpointCalldata,
|
|
38
|
+
type CalldataDelegation,
|
|
39
|
+
} from "./decode-checkpoint-calldata.js";
|
|
40
|
+
import { bytesEqual } from "./bytes.js";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The signed checkpoint a link's accumulator is sealed by, retained so the
|
|
44
|
+
* consumer can verify it (this module does not). Present on the directly-signed
|
|
45
|
+
* link of each unit — every `.sth`; the FINAL link of each calldata tx's proof
|
|
46
|
+
* segment (intermediate links in a multi-proof tx are unsigned, transitively
|
|
47
|
+
* trusted via the fold to the next sealed link).
|
|
48
|
+
*/
|
|
49
|
+
export type CheckpointSeal =
|
|
50
|
+
| {
|
|
51
|
+
kind: "calldata";
|
|
52
|
+
protectedHeader: Uint8Array;
|
|
53
|
+
signature: Uint8Array;
|
|
54
|
+
delegation: CalldataDelegation;
|
|
55
|
+
}
|
|
56
|
+
| { kind: "sth"; checkpointBytes: Uint8Array };
|
|
57
|
+
|
|
58
|
+
/** One folded link: the accumulator committed at `treeSize2`. */
|
|
59
|
+
export type CheckpointLink = {
|
|
60
|
+
treeSize1: bigint;
|
|
61
|
+
treeSize2: bigint;
|
|
62
|
+
/** Folded accumulator at `treeSize2` (descending-height / contract order). */
|
|
63
|
+
accumulator: Uint8Array[];
|
|
64
|
+
/** The raw consistency proof this link was folded from — its per-peak `paths`
|
|
65
|
+
* (the tile-free climb material). Retained so a path-extending consumer
|
|
66
|
+
* (freshen) gets it without a re-decode; folding consumers ignore it. */
|
|
67
|
+
proof: CheckpointConsistencyProof;
|
|
68
|
+
/** The signature material to verify this link's accumulator, when directly
|
|
69
|
+
* signed (see {@link CheckpointSeal}). The provider does NOT verify it. */
|
|
70
|
+
seal?: CheckpointSeal;
|
|
71
|
+
/** Where this link came from (a tx hash, an `.sth` name) — for narration. */
|
|
72
|
+
sourceRef?: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fold an ordered consistency-proof chain into per-link accumulators. Each
|
|
77
|
+
* link's base must equal the previous link's sealed size — a mismatch is the
|
|
78
|
+
* legacy / non-contiguous signature and throws. For a suffix chain, pass BOTH
|
|
79
|
+
* `accumulatorFrom` and `accumulatorFromSize` (the size that seed is for); the
|
|
80
|
+
* first link's `treeSize1` is bound to it (R2). Without a seed the chain must
|
|
81
|
+
* start at base 0.
|
|
82
|
+
*/
|
|
83
|
+
export async function foldProofChain(
|
|
84
|
+
proofs: readonly CheckpointConsistencyProof[],
|
|
85
|
+
opts: {
|
|
86
|
+
accumulatorFrom?: Uint8Array[];
|
|
87
|
+
accumulatorFromSize?: bigint;
|
|
88
|
+
seals?: readonly (CheckpointSeal | undefined)[];
|
|
89
|
+
sourceRefs?: readonly string[];
|
|
90
|
+
} = {},
|
|
91
|
+
): Promise<CheckpointLink[]> {
|
|
92
|
+
let accumulator = opts.accumulatorFrom ?? [];
|
|
93
|
+
let expectedBase: bigint;
|
|
94
|
+
if (opts.accumulatorFrom !== undefined) {
|
|
95
|
+
if (opts.accumulatorFromSize === undefined) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
"foldProofChain: accumulatorFrom requires accumulatorFromSize (the size the seed accumulator is for)",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
expectedBase = opts.accumulatorFromSize;
|
|
101
|
+
} else {
|
|
102
|
+
expectedBase = 0n;
|
|
103
|
+
}
|
|
104
|
+
const links: CheckpointLink[] = [];
|
|
105
|
+
for (let i = 0; i < proofs.length; i++) {
|
|
106
|
+
const p = proofs[i]!;
|
|
107
|
+
if (p.treeSize1 !== expectedBase) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`checkpoint chain is not contiguous at link ${i}: base ${p.treeSize1} != expected ${expectedBase}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
accumulator = await computeCheckpointAccumulator(p, accumulator);
|
|
113
|
+
const link: CheckpointLink = {
|
|
114
|
+
treeSize1: p.treeSize1,
|
|
115
|
+
treeSize2: p.treeSize2,
|
|
116
|
+
accumulator,
|
|
117
|
+
proof: p,
|
|
118
|
+
};
|
|
119
|
+
const seal = opts.seals?.[i];
|
|
120
|
+
if (seal !== undefined) link.seal = seal;
|
|
121
|
+
const ref = opts.sourceRefs?.[i];
|
|
122
|
+
if (ref !== undefined) link.sourceRef = ref;
|
|
123
|
+
links.push(link);
|
|
124
|
+
expectedBase = p.treeSize2;
|
|
125
|
+
}
|
|
126
|
+
return links;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Retained-`.sth` provider: decode each checkpoint's embedded consistency proof
|
|
131
|
+
* and fold. `checkpoints` are the raw `.sth` bytes in ascending massif order;
|
|
132
|
+
* `sourceRefs` optionally names them (e.g. filenames) for narration.
|
|
133
|
+
*/
|
|
134
|
+
export async function sthCheckpointChain(
|
|
135
|
+
checkpoints: readonly Uint8Array[],
|
|
136
|
+
opts: {
|
|
137
|
+
accumulatorFrom?: Uint8Array[];
|
|
138
|
+
accumulatorFromSize?: bigint;
|
|
139
|
+
sourceRefs?: readonly string[];
|
|
140
|
+
} = {},
|
|
141
|
+
): Promise<CheckpointLink[]> {
|
|
142
|
+
const proofs = checkpoints.map((bytes) => checkpointConsistencyProof(bytes));
|
|
143
|
+
const seals: CheckpointSeal[] = checkpoints.map((bytes) => ({
|
|
144
|
+
kind: "sth",
|
|
145
|
+
checkpointBytes: bytes,
|
|
146
|
+
}));
|
|
147
|
+
return foldProofChain(proofs, {
|
|
148
|
+
seals,
|
|
149
|
+
...(opts.accumulatorFrom !== undefined
|
|
150
|
+
? { accumulatorFrom: opts.accumulatorFrom }
|
|
151
|
+
: {}),
|
|
152
|
+
...(opts.accumulatorFromSize !== undefined
|
|
153
|
+
? { accumulatorFromSize: opts.accumulatorFromSize }
|
|
154
|
+
: {}),
|
|
155
|
+
...(opts.sourceRefs !== undefined ? { sourceRefs: opts.sourceRefs } : {}),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Run `fn` over `items` with bounded concurrency, preserving order (R5). */
|
|
160
|
+
async function mapBounded<T, R>(
|
|
161
|
+
items: readonly T[],
|
|
162
|
+
limit: number,
|
|
163
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
164
|
+
): Promise<R[]> {
|
|
165
|
+
const out: R[] = new Array(items.length);
|
|
166
|
+
let next = 0;
|
|
167
|
+
async function worker(): Promise<void> {
|
|
168
|
+
for (;;) {
|
|
169
|
+
const i = next++;
|
|
170
|
+
if (i >= items.length) return;
|
|
171
|
+
out[i] = await fn(items[i]!, i);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const n = Math.min(Math.max(1, limit), items.length || 1);
|
|
175
|
+
await Promise.all(Array.from({ length: n }, () => worker()));
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function accumulatorsEqual(a: Uint8Array[], b: Uint8Array[]): boolean {
|
|
180
|
+
if (a.length !== b.length) return false;
|
|
181
|
+
for (let i = 0; i < a.length; i++) if (!bytesEqual(a[i]!, b[i]!)) return false;
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Chain-calldata provider: find the log's `CheckpointPublished` transactions
|
|
187
|
+
* (ascending), read each `publishCheckpoint`'s calldata, concatenate the
|
|
188
|
+
* embedded consistency-proof chains, and fold. Cross-checks each tx's folded
|
|
189
|
+
* accumulator against the `CheckpointPublished` event's accumulator (R3), and
|
|
190
|
+
* retains each tx's seal for the consumer to verify (R1); it does NOT verify
|
|
191
|
+
* signatures here (Phase 4). A bounded `--from-block` scan is only valid with a
|
|
192
|
+
* trusted seed at that block (R2): pass `accumulatorFrom` + `accumulatorFromSize`.
|
|
193
|
+
*/
|
|
194
|
+
export async function calldataCheckpointChain(opts: {
|
|
195
|
+
univocity: string;
|
|
196
|
+
logId: string;
|
|
197
|
+
rpcUrl: string;
|
|
198
|
+
fromBlock?: bigint | undefined;
|
|
199
|
+
accumulatorFrom?: Uint8Array[] | undefined;
|
|
200
|
+
accumulatorFromSize?: bigint | undefined;
|
|
201
|
+
fetchImpl?: typeof fetch;
|
|
202
|
+
}): Promise<CheckpointLink[]> {
|
|
203
|
+
if (opts.fromBlock !== undefined && opts.accumulatorFrom === undefined) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
"calldataCheckpointChain: --from-block must be paired with a trusted seed (accumulatorFrom + accumulatorFromSize) — a bounded scan starts mid-chain and cannot fold from base 0",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const published = await fetchPublishedCheckpoints({
|
|
209
|
+
univocity: opts.univocity,
|
|
210
|
+
logId: opts.logId,
|
|
211
|
+
rpcUrl: opts.rpcUrl,
|
|
212
|
+
fromBlock: opts.fromBlock,
|
|
213
|
+
...(opts.fetchImpl !== undefined ? { fetchImpl: opts.fetchImpl } : {}),
|
|
214
|
+
});
|
|
215
|
+
const decoded = await mapBounded(published, 8, (cp) =>
|
|
216
|
+
fetchPublishCheckpointCalldata({
|
|
217
|
+
rpcUrl: opts.rpcUrl,
|
|
218
|
+
txHash: cp.txHash,
|
|
219
|
+
...(opts.fetchImpl !== undefined ? { fetchImpl: opts.fetchImpl } : {}),
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const proofs: CheckpointConsistencyProof[] = [];
|
|
224
|
+
const seals: (CheckpointSeal | undefined)[] = [];
|
|
225
|
+
const sourceRefs: string[] = [];
|
|
226
|
+
const finalLinkIndex: number[] = []; // link index of each tx's last proof
|
|
227
|
+
for (let t = 0; t < published.length; t++) {
|
|
228
|
+
const d = decoded[t]!;
|
|
229
|
+
const n = d.consistencyProofs.length;
|
|
230
|
+
d.consistencyProofs.forEach((p, k) => {
|
|
231
|
+
proofs.push(p);
|
|
232
|
+
sourceRefs.push(published[t]!.txHash);
|
|
233
|
+
seals.push(
|
|
234
|
+
k === n - 1
|
|
235
|
+
? {
|
|
236
|
+
kind: "calldata",
|
|
237
|
+
protectedHeader: d.protectedHeader,
|
|
238
|
+
signature: d.signature,
|
|
239
|
+
delegation: d.delegation,
|
|
240
|
+
}
|
|
241
|
+
: undefined,
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
finalLinkIndex.push(proofs.length - 1);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const links = await foldProofChain(proofs, {
|
|
248
|
+
seals,
|
|
249
|
+
sourceRefs,
|
|
250
|
+
...(opts.accumulatorFrom !== undefined
|
|
251
|
+
? { accumulatorFrom: opts.accumulatorFrom }
|
|
252
|
+
: {}),
|
|
253
|
+
...(opts.accumulatorFromSize !== undefined
|
|
254
|
+
? { accumulatorFromSize: opts.accumulatorFromSize }
|
|
255
|
+
: {}),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// R3: the folded accumulator at each tx's seal MUST equal the accumulator the
|
|
259
|
+
// CheckpointPublished event reported (both from the RPC — this catches a fold
|
|
260
|
+
// bug or an RPC serving inconsistent event/calldata, not a fully malicious
|
|
261
|
+
// RPC; the signature seal is the real anchor, applied by the consumer).
|
|
262
|
+
for (let t = 0; t < published.length; t++) {
|
|
263
|
+
const link = links[finalLinkIndex[t]!]!;
|
|
264
|
+
const cp = published[t]!;
|
|
265
|
+
if (link.treeSize2 !== cp.size) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
`calldata size ${link.treeSize2} disagrees with CheckpointPublished size ${cp.size} (tx ${cp.txHash})`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (!accumulatorsEqual(link.accumulator, cp.accumulator)) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`folded calldata accumulator disagrees with the CheckpointPublished event at size ${cp.size} (tx ${cp.txHash}) — inconsistent RPC data`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return links;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Find the newest link whose folded accumulator contains `peak` (a receipt's
|
|
281
|
+
* recomputed peak). Newest-first: a match at any authenticated link proves the
|
|
282
|
+
* receipt — later links' proofs commit it forward. Null when no link covers it.
|
|
283
|
+
*/
|
|
284
|
+
export function findPeakInChain(
|
|
285
|
+
links: readonly CheckpointLink[],
|
|
286
|
+
peak: Uint8Array,
|
|
287
|
+
): { link: CheckpointLink; matchedPeak: number } | null {
|
|
288
|
+
for (let i = links.length - 1; i >= 0; i--) {
|
|
289
|
+
const link = links[i]!;
|
|
290
|
+
for (let j = 0; j < link.accumulator.length; j++) {
|
|
291
|
+
if (bytesEqual(peak, link.accumulator[j]!)) {
|
|
292
|
+
return { link, matchedPeak: j };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FOR-344 offline grant completion. `register-grant` gets the receipt +
|
|
3
|
+
* idtimestamp from the operator (register → 303 → poll → receipt);
|
|
4
|
+
* `complete-grant` instead DERIVES them locally from the checkpoint + massif,
|
|
5
|
+
* then runs the exact same merge (`completeGrantBase64`). Proving grants are
|
|
6
|
+
* *derivable from log data*, not operator-issued.
|
|
7
|
+
*
|
|
8
|
+
* Pure over bytes: decode the grant, locate its leaf in the massif by content
|
|
9
|
+
* hash (`findGrantLeafInMassif` — recovers mmrIndex + the sequenced
|
|
10
|
+
* idtimestamp), rebuild the leaf's inclusion receipt against the checkpoint's
|
|
11
|
+
* pre-signed peak (`deriveCheckpointReceipt`), and attach receipt (396) +
|
|
12
|
+
* idtimestamp (−65537) into the grant's unprotected headers with NO re-signing.
|
|
13
|
+
* No network, no key.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
decodeForestrieGrantCose,
|
|
17
|
+
findGrantLeafInMassif,
|
|
18
|
+
MissingIndexError,
|
|
19
|
+
} from "@forestrie/receipt-verify";
|
|
20
|
+
import { base64ToBytes } from "@forestrie/grant-builder";
|
|
21
|
+
import { completeGrantBase64 } from "./register-grant-complete.js";
|
|
22
|
+
import {
|
|
23
|
+
CreateReceiptFailure,
|
|
24
|
+
deriveCheckpointReceipt,
|
|
25
|
+
} from "./create-receipt-derive.js";
|
|
26
|
+
|
|
27
|
+
/** Where offline completion broke. */
|
|
28
|
+
export type CompleteGrantStage = "decode" | "locate" | "parse" | "derive";
|
|
29
|
+
|
|
30
|
+
/** Stable snake_case reasons — the `--json` error shape is a contract. */
|
|
31
|
+
export type CompleteGrantReason =
|
|
32
|
+
| "grant_decode_failed"
|
|
33
|
+
| "grant_leaf_not_found"
|
|
34
|
+
| "massif_missing_index"
|
|
35
|
+
| "idtimestamp_unresolved"
|
|
36
|
+
// Passed through from the receipt derivation (create-receipt taxonomy).
|
|
37
|
+
| "massif_parse_failed"
|
|
38
|
+
| "checkpoint_parse_failed"
|
|
39
|
+
| "checkpoint_missing_peak_receipts"
|
|
40
|
+
| "checkpoint_missing_sealed_size"
|
|
41
|
+
| "checkpoint_does_not_cover_leaf"
|
|
42
|
+
| "leaf_not_in_massif"
|
|
43
|
+
| "derive_failed";
|
|
44
|
+
|
|
45
|
+
export class CompleteGrantFailure extends Error {
|
|
46
|
+
readonly stage: CompleteGrantStage;
|
|
47
|
+
readonly reason: CompleteGrantReason;
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
stage: CompleteGrantStage,
|
|
51
|
+
reason: CompleteGrantReason,
|
|
52
|
+
message: string,
|
|
53
|
+
) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "CompleteGrantFailure";
|
|
56
|
+
this.stage = stage;
|
|
57
|
+
this.reason = reason;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Which input supplied the entry-id idtimestamp half. */
|
|
62
|
+
export type IdtimestampSource = "massif" | "override" | "grant";
|
|
63
|
+
|
|
64
|
+
export type DeriveCompletedGrantInput = {
|
|
65
|
+
/** Registered (uncompleted) grant, Forestrie-Grant header base64 (trimmed). */
|
|
66
|
+
grantBase64: string;
|
|
67
|
+
massifBytes: Uint8Array;
|
|
68
|
+
checkpointBytes: Uint8Array;
|
|
69
|
+
/** 8-byte idtimestamp override (`--idtimestamp`), used only as a fallback. */
|
|
70
|
+
idtimestampOverride?: Uint8Array | undefined;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type CompletedGrant = {
|
|
74
|
+
/** Completed grant, Forestrie-Grant header base64. */
|
|
75
|
+
completedBase64: string;
|
|
76
|
+
/** Permanent entry id (`idtimestamp_be8 || mmrIndex_be8`), 32 hex chars. */
|
|
77
|
+
entryIdHex: string;
|
|
78
|
+
mmrIndex: bigint;
|
|
79
|
+
idtimestampBe8: Uint8Array;
|
|
80
|
+
idtimestampSource: IdtimestampSource;
|
|
81
|
+
receiptBytes: number;
|
|
82
|
+
proofLength: number;
|
|
83
|
+
peakIndex: number;
|
|
84
|
+
peakCount: number;
|
|
85
|
+
sealedSize: bigint;
|
|
86
|
+
certCopied: boolean;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function bytesToHex(bytes: Uint8Array): string {
|
|
90
|
+
let s = "";
|
|
91
|
+
for (const b of bytes) s += b.toString(16).padStart(2, "0");
|
|
92
|
+
return s;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function mmrIndexToBe8Hex(mmrIndex: bigint): string {
|
|
96
|
+
const out = new Uint8Array(8);
|
|
97
|
+
new DataView(out.buffer).setBigUint64(0, mmrIndex & 0xffffffffffffffffn, false);
|
|
98
|
+
return bytesToHex(out);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isAllZero(bytes: Uint8Array): boolean {
|
|
102
|
+
for (const b of bytes) if (b !== 0) return false;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the idtimestamp that goes into the entry id. The massif leaf key is
|
|
108
|
+
* ground truth — it is exactly what the sequencer committed, so verification
|
|
109
|
+
* (`leafHash = H(idtimestamp || contentHash)`) only reproduces the anchored
|
|
110
|
+
* leaf when this value is used. `--idtimestamp` / an already-embedded grant
|
|
111
|
+
* idtimestamp are fallbacks for the (unusual) case where the massif index
|
|
112
|
+
* region did not record the key.
|
|
113
|
+
*/
|
|
114
|
+
function resolveIdtimestamp(
|
|
115
|
+
fromMassif: Uint8Array,
|
|
116
|
+
override: Uint8Array | undefined,
|
|
117
|
+
fromGrant: Uint8Array,
|
|
118
|
+
): { idtimestampBe8: Uint8Array; source: IdtimestampSource } {
|
|
119
|
+
if (!isAllZero(fromMassif)) {
|
|
120
|
+
return { idtimestampBe8: fromMassif, source: "massif" };
|
|
121
|
+
}
|
|
122
|
+
if (override !== undefined && !isAllZero(override)) {
|
|
123
|
+
return { idtimestampBe8: override, source: "override" };
|
|
124
|
+
}
|
|
125
|
+
if (!isAllZero(fromGrant)) {
|
|
126
|
+
return { idtimestampBe8: fromGrant, source: "grant" };
|
|
127
|
+
}
|
|
128
|
+
throw new CompleteGrantFailure(
|
|
129
|
+
"parse",
|
|
130
|
+
"idtimestamp_unresolved",
|
|
131
|
+
"could not resolve the leaf idtimestamp from the massif, --idtimestamp, " +
|
|
132
|
+
"or the grant — supply --idtimestamp",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Derive the completed grant from local artefacts. Throws
|
|
138
|
+
* {@link CompleteGrantFailure} with a stable stage + reason on any failure.
|
|
139
|
+
*/
|
|
140
|
+
export async function deriveCompletedGrant(
|
|
141
|
+
input: DeriveCompletedGrantInput,
|
|
142
|
+
): Promise<CompletedGrant> {
|
|
143
|
+
// 1. Decode the signed grant statement into its Grant payload (+ any
|
|
144
|
+
// already-embedded idtimestamp).
|
|
145
|
+
let grant: ReturnType<typeof decodeForestrieGrantCose>["grant"];
|
|
146
|
+
let grantIdtimestampBe8: Uint8Array;
|
|
147
|
+
try {
|
|
148
|
+
({ grant, idtimestampBe8: grantIdtimestampBe8 } = decodeForestrieGrantCose(
|
|
149
|
+
base64ToBytes(input.grantBase64),
|
|
150
|
+
));
|
|
151
|
+
} catch (err) {
|
|
152
|
+
throw new CompleteGrantFailure(
|
|
153
|
+
"decode",
|
|
154
|
+
"grant_decode_failed",
|
|
155
|
+
`not a Forestrie-Grant COSE Sign1: ${errorMessage(err)}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 2. Locate the grant's leaf in the massif by its commitment hash — recovers
|
|
160
|
+
// both the mmrIndex and the sequenced idtimestamp, offline.
|
|
161
|
+
let located: Awaited<ReturnType<typeof findGrantLeafInMassif>>;
|
|
162
|
+
try {
|
|
163
|
+
located = await findGrantLeafInMassif(input.massifBytes, grant);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
if (err instanceof MissingIndexError) {
|
|
166
|
+
throw new CompleteGrantFailure(
|
|
167
|
+
"locate",
|
|
168
|
+
"massif_missing_index",
|
|
169
|
+
`massif has no index region to search: ${err.message}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
throw new CompleteGrantFailure("locate", "grant_leaf_not_found", errorMessage(err));
|
|
173
|
+
}
|
|
174
|
+
if (located === null) {
|
|
175
|
+
throw new CompleteGrantFailure(
|
|
176
|
+
"locate",
|
|
177
|
+
"grant_leaf_not_found",
|
|
178
|
+
"this grant's commitment hash is not a leaf in the supplied massif — " +
|
|
179
|
+
"wrong massif, or the grant is not sequenced yet",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const { idtimestampBe8, source } = resolveIdtimestamp(
|
|
184
|
+
located.idtimestampBe8,
|
|
185
|
+
input.idtimestampOverride,
|
|
186
|
+
grantIdtimestampBe8,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// 3. Rebuild the leaf's inclusion receipt against the checkpoint's pre-signed
|
|
190
|
+
// peak (identical derivation to `create-receipt`).
|
|
191
|
+
let derived: Awaited<ReturnType<typeof deriveCheckpointReceipt>>;
|
|
192
|
+
try {
|
|
193
|
+
derived = await deriveCheckpointReceipt({
|
|
194
|
+
massifBytes: input.massifBytes,
|
|
195
|
+
checkpointBytes: input.checkpointBytes,
|
|
196
|
+
mmrIndex: located.mmrIndex,
|
|
197
|
+
});
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (err instanceof CreateReceiptFailure) {
|
|
200
|
+
// Reuse the create-receipt reason taxonomy verbatim (it is a subset of
|
|
201
|
+
// CompleteGrantReason).
|
|
202
|
+
throw new CompleteGrantFailure(
|
|
203
|
+
err.stage === "parse" ? "parse" : "derive",
|
|
204
|
+
err.reason,
|
|
205
|
+
err.message,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
throw new CompleteGrantFailure("derive", "derive_failed", errorMessage(err));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 4. Merge receipt (396) + idtimestamp (−65537) into the grant's unprotected
|
|
212
|
+
// headers — no re-signing — and emit the completed grant base64.
|
|
213
|
+
const entryIdHex = bytesToHex(idtimestampBe8) + mmrIndexToBe8Hex(located.mmrIndex);
|
|
214
|
+
const completedBase64 = completeGrantBase64(
|
|
215
|
+
input.grantBase64,
|
|
216
|
+
derived.receiptCbor,
|
|
217
|
+
entryIdHex,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
completedBase64,
|
|
222
|
+
entryIdHex,
|
|
223
|
+
mmrIndex: located.mmrIndex,
|
|
224
|
+
idtimestampBe8,
|
|
225
|
+
idtimestampSource: source,
|
|
226
|
+
receiptBytes: derived.receiptCbor.length,
|
|
227
|
+
proofLength: derived.details.proofLength,
|
|
228
|
+
peakIndex: derived.details.peakIndex,
|
|
229
|
+
peakCount: derived.details.peakCount,
|
|
230
|
+
sealedSize: derived.details.sealedSize,
|
|
231
|
+
certCopied: derived.details.certCopied,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function errorMessage(err: unknown): string {
|
|
236
|
+
return err instanceof Error ? err.message : String(err);
|
|
237
|
+
}
|