@forestrie/receipt-verify 0.6.3 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ /** Draft-bryce consistency proof embedded in a v3 checkpoint. */
2
+ export type CheckpointConsistencyProof = {
3
+ treeSize1: bigint;
4
+ treeSize2: bigint;
5
+ /** One inclusion path per tree-size-1 peak, proven at tree-size-2. */
6
+ paths: Uint8Array[][];
7
+ /** New peaks not covered by the proven roots (draft `right-peaks`). */
8
+ rightPeaks: Uint8Array[];
9
+ };
10
+ /** Decode the embedded consistency proof (`vdp` 396 key -2). */
11
+ export declare function checkpointConsistencyProof(checkpointBytes: Uint8Array): CheckpointConsistencyProof;
12
+ /**
13
+ * One fold step: from the trusted accumulator at `proof.treeSize1`,
14
+ * produce the `treeSize2` accumulator (draft: `consistent_roots` output
15
+ * plus the supplied right-peaks). Structural sanity: the result must have
16
+ * exactly the peak count of a size-`treeSize2` MMR.
17
+ */
18
+ export declare function computeCheckpointAccumulator(proof: CheckpointConsistencyProof, accumulatorFrom: Uint8Array[]): Promise<Uint8Array[]>;
19
+ /** Detached payload the checkpoint signature covers (ADR-0046): the raw
20
+ * concatenation of the accumulator peaks in contract order. */
21
+ export declare function accumulatorPayload(accumulator: Uint8Array[]): Uint8Array;
22
+ export type CheckpointChainLink = {
23
+ treeSize1: bigint;
24
+ treeSize2: bigint;
25
+ accumulator: Uint8Array[];
26
+ signatureOk: boolean;
27
+ };
28
+ export type CheckpointChainResult = {
29
+ ok: true;
30
+ links: CheckpointChainLink[];
31
+ accumulator: Uint8Array[];
32
+ } | {
33
+ ok: false;
34
+ reason: "empty_chain" | "legacy_chain_break" | "signature" | "proof_malformed";
35
+ /** Index of the offending checkpoint. */
36
+ at: number;
37
+ detail: string;
38
+ links: CheckpointChainLink[];
39
+ };
40
+ /**
41
+ * Verify a retained checkpoint chain (ascending massif order) and fold out
42
+ * the final authenticated accumulator.
43
+ *
44
+ * - The first link must be boundary-based from 0 (a whole-log chain), or
45
+ * the caller supplies `accumulatorFrom` matching its base (a suffix
46
+ * chain rooted in an already-trusted accumulator).
47
+ * - Every subsequent link's base must equal the previous link's sealed
48
+ * size — a mismatch is the legacy (pre-FOR-410) drift signature and is
49
+ * permanent for that log (`legacy_chain_break`).
50
+ * - Each link's signature is checked over its computed accumulator via
51
+ * the injected verifier (the caller owns trust resolution — genesis
52
+ * roots, caller-known keys, or the label-1000 delegation path).
53
+ */
54
+ export declare function verifyCheckpointChain(opts: {
55
+ checkpoints: Uint8Array[];
56
+ verifySignature: (checkpointBytes: Uint8Array, detachedPayload: Uint8Array) => Promise<boolean>;
57
+ /** Trusted base accumulator for a suffix chain (absent: base must be 0). */
58
+ accumulatorFrom?: Uint8Array[];
59
+ }): Promise<CheckpointChainResult>;
60
+ //# sourceMappingURL=checkpoint-chain.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"checkpoint-chain.d.ts","sourceRoot":"","sources":["../src/checkpoint-chain.ts"],"names":[],"mappings":"AA6BA,iEAAiE;AACjE,MAAM,MAAM,0BAA0B,GAAG;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,sEAAsE;IACtE,KAAK,EAAE,UAAU,EAAE,EAAE,CAAC;IACtB,uEAAuE;IACvE,UAAU,EAAE,UAAU,EAAE,CAAC;CAC1B,CAAC;AA2BF,gEAAgE;AAChE,wBAAgB,0BAA0B,CACxC,eAAe,EAAE,UAAU,GAC1B,0BAA0B,CA6C5B;AAED;;;;;GAKG;AACH,wBAAsB,4BAA4B,CAChD,KAAK,EAAE,0BAA0B,EACjC,eAAe,EAAE,UAAU,EAAE,GAC5B,OAAO,CAAC,UAAU,EAAE,CAAC,CAmBvB;AAED;+DAC+D;AAC/D,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,UAAU,EAAE,GAAG,UAAU,CAQxE;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,WAAW,EAAE,OAAO,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAC7B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,mBAAmB,EAAE,CAAC;IAAC,WAAW,EAAE,UAAU,EAAE,CAAA;CAAE,GACrE;IACE,EAAE,EAAE,KAAK,CAAC;IACV,MAAM,EACF,aAAa,GACb,oBAAoB,GACpB,WAAW,GACX,iBAAiB,CAAC;IACtB,yCAAyC;IACzC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,mBAAmB,EAAE,CAAC;CAC9B,CAAC;AAEN;;;;;;;;;;;;;GAaG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE;IAChD,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,eAAe,EAAE,CACf,eAAe,EAAE,UAAU,EAC3B,eAAe,EAAE,UAAU,KACxB,OAAO,CAAC,OAAO,CAAC,CAAC;IACtB,4EAA4E;IAC5E,eAAe,CAAC,EAAE,UAAU,EAAE,CAAC;CAChC,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAiGjC"}
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Retained-checkpoint chain verification (FOR-368 Phase 3, plan-2607-29).
3
+ *
4
+ * Post-FOR-410 (ADR-0056) every checkpoint's embedded consistency proof
5
+ * spans its massif's ENTRY BOUNDARY to its seal, so the store's retained
6
+ * `.sth` objects form a contiguous chain `0 → S₁ → S₂ → …`. Folding the
7
+ * chain per the draft ("Chained proofs" / `consistent_roots`) reconstructs
8
+ * each link's tree-size-2 accumulator — which is exactly the detached
9
+ * payload its signature covers (ADR-0046: concat of the accumulator in
10
+ * descending height order). The fold therefore yields, with NO tile access
11
+ * and NO RPC: an authenticated accumulator at every retained seal, and the
12
+ * final state to check a receipt's recomputed peak against.
13
+ *
14
+ * This rung depends only on the public log store — the complement of the
15
+ * `CheckpointPublished` event scan (public chain data only); see the
16
+ * recorded both-paths decision in plan-2607-29.
17
+ *
18
+ * Legacy (pre-FOR-410) chains surface as a contiguity break
19
+ * (`legacy_chain_break`): a permanent per-log condition — fall back to the
20
+ * event scan, tile extension, or a holder cache.
21
+ */
22
+ import { decodeCborDeterministic } from "@forestrie/encoding";
23
+ import { consistentRoots, peakMMRIndexes } from "@forestrie/merklelog";
24
+ import { SubtleHasher } from "./subtle-hasher.js";
25
+ import { parseCheckpoint } from "./build-receipt-offline.js";
26
+ const VDS_COSE_RECEIPT_PROOFS_TAG = 396;
27
+ const VDP_CONSISTENCY_PROOF_KEY = -2;
28
+ function asBigint(v, what) {
29
+ // Unsigned only: a negative size flows into `peakMMRIndexes`, whose
30
+ // `posHeight` spins forever on a non-positive argument — a malformed `.sth`
31
+ // must be rejected in bounded time, before any such call (FOR-414).
32
+ if (typeof v === "bigint") {
33
+ if (v < 0n)
34
+ throw new Error(`${what}: must be an unsigned integer, got ${v}`);
35
+ return v;
36
+ }
37
+ if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0) {
38
+ return BigInt(v);
39
+ }
40
+ throw new Error(`${what}: expected an unsigned integer`);
41
+ }
42
+ function asBytesArray(v, what) {
43
+ if (!Array.isArray(v) ||
44
+ v.some((e) => !(e instanceof Uint8Array) || e.length !== 32)) {
45
+ throw new Error(`${what}: expected an array of 32-byte strings`);
46
+ }
47
+ return v;
48
+ }
49
+ /** Decode the embedded consistency proof (`vdp` 396 key -2). */
50
+ export function checkpointConsistencyProof(checkpointBytes) {
51
+ const { unprotected } = parseCheckpoint(checkpointBytes);
52
+ const vdpRaw = unprotected.get(VDS_COSE_RECEIPT_PROOFS_TAG);
53
+ if (!(vdpRaw instanceof Map)) {
54
+ throw new Error("checkpoint carries no verifiable-proofs header (396)");
55
+ }
56
+ const proofBstr = vdpRaw.get(VDP_CONSISTENCY_PROOF_KEY);
57
+ if (!(proofBstr instanceof Uint8Array)) {
58
+ throw new Error("checkpoint carries no consistency proof (vdp key -2)");
59
+ }
60
+ const proof = decodeCborDeterministic(proofBstr);
61
+ if (!Array.isArray(proof) || proof.length < 4) {
62
+ throw new Error("consistency proof must be [tree-size-1, tree-size-2, paths, right-peaks]");
63
+ }
64
+ const pathsRaw = proof[2];
65
+ if (!Array.isArray(pathsRaw) ||
66
+ pathsRaw.some((p) => !Array.isArray(p) || p.some((n) => !(n instanceof Uint8Array)))) {
67
+ throw new Error("consistency paths must be arrays of byte strings");
68
+ }
69
+ const treeSize1 = asBigint(proof[0], "tree-size-1");
70
+ const treeSize2 = asBigint(proof[1], "tree-size-2");
71
+ // A consistency proof strictly grows the tree; enforce `0 <= ts1 < ts2`
72
+ // (ts1 == 0 is a legitimate base-0 first link). This is the primary guard
73
+ // that keeps `treeSize2 - 1` / `treeSize1 - 1` non-negative before they
74
+ // reach `peakMMRIndexes` (FOR-414); the unsigned check in `asBigint` and
75
+ // the `posHeight` guard in @forestrie/merklelog are defence-in-depth.
76
+ if (treeSize2 <= treeSize1) {
77
+ throw new Error(`consistency proof must grow the tree: tree-size-1 ${treeSize1} < tree-size-2 ${treeSize2}`);
78
+ }
79
+ return {
80
+ treeSize1,
81
+ treeSize2,
82
+ paths: pathsRaw,
83
+ rightPeaks: asBytesArray(proof[3], "right-peaks"),
84
+ };
85
+ }
86
+ /**
87
+ * One fold step: from the trusted accumulator at `proof.treeSize1`,
88
+ * produce the `treeSize2` accumulator (draft: `consistent_roots` output
89
+ * plus the supplied right-peaks). Structural sanity: the result must have
90
+ * exactly the peak count of a size-`treeSize2` MMR.
91
+ */
92
+ export async function computeCheckpointAccumulator(proof, accumulatorFrom) {
93
+ const hasher = new SubtleHasher();
94
+ const proven = proof.treeSize1 === 0n
95
+ ? []
96
+ : await consistentRoots(hasher, proof.treeSize1 - 1n, accumulatorFrom, proof.paths);
97
+ const accumulator = [...proven, ...proof.rightPeaks];
98
+ const expected = peakMMRIndexes(proof.treeSize2 - 1n).length;
99
+ if (accumulator.length !== expected) {
100
+ throw new Error(`computed accumulator has ${accumulator.length} peaks; size ${proof.treeSize2} requires ${expected}`);
101
+ }
102
+ return accumulator;
103
+ }
104
+ /** Detached payload the checkpoint signature covers (ADR-0046): the raw
105
+ * concatenation of the accumulator peaks in contract order. */
106
+ export function accumulatorPayload(accumulator) {
107
+ const out = new Uint8Array(accumulator.reduce((s, p) => s + p.length, 0));
108
+ let offset = 0;
109
+ for (const p of accumulator) {
110
+ out.set(p, offset);
111
+ offset += p.length;
112
+ }
113
+ return out;
114
+ }
115
+ /**
116
+ * Verify a retained checkpoint chain (ascending massif order) and fold out
117
+ * the final authenticated accumulator.
118
+ *
119
+ * - The first link must be boundary-based from 0 (a whole-log chain), or
120
+ * the caller supplies `accumulatorFrom` matching its base (a suffix
121
+ * chain rooted in an already-trusted accumulator).
122
+ * - Every subsequent link's base must equal the previous link's sealed
123
+ * size — a mismatch is the legacy (pre-FOR-410) drift signature and is
124
+ * permanent for that log (`legacy_chain_break`).
125
+ * - Each link's signature is checked over its computed accumulator via
126
+ * the injected verifier (the caller owns trust resolution — genesis
127
+ * roots, caller-known keys, or the label-1000 delegation path).
128
+ */
129
+ export async function verifyCheckpointChain(opts) {
130
+ const links = [];
131
+ if (opts.checkpoints.length === 0) {
132
+ return {
133
+ ok: false,
134
+ reason: "empty_chain",
135
+ at: 0,
136
+ detail: "no checkpoints supplied",
137
+ links,
138
+ };
139
+ }
140
+ let accumulator = opts.accumulatorFrom ?? [];
141
+ let expectedBase = null;
142
+ for (let i = 0; i < opts.checkpoints.length; i++) {
143
+ const bytes = opts.checkpoints[i];
144
+ let proof;
145
+ try {
146
+ proof = checkpointConsistencyProof(bytes);
147
+ }
148
+ catch (err) {
149
+ return {
150
+ ok: false,
151
+ reason: "proof_malformed",
152
+ at: i,
153
+ detail: err instanceof Error ? err.message : String(err),
154
+ links,
155
+ };
156
+ }
157
+ if (expectedBase === null) {
158
+ // First link: base 0 for a whole-log chain, else the caller's
159
+ // trusted accumulator must be FOR this base (peak-count check).
160
+ if (proof.treeSize1 !== 0n) {
161
+ const wanted = peakMMRIndexes(proof.treeSize1 - 1n).length;
162
+ if (opts.accumulatorFrom === undefined) {
163
+ return {
164
+ ok: false,
165
+ reason: "legacy_chain_break",
166
+ at: i,
167
+ detail: `first checkpoint base ${proof.treeSize1} != 0 and no trusted base accumulator was supplied`,
168
+ links,
169
+ };
170
+ }
171
+ if (accumulator.length !== wanted) {
172
+ return {
173
+ ok: false,
174
+ reason: "proof_malformed",
175
+ at: i,
176
+ detail: `trusted base accumulator has ${accumulator.length} peaks; base size ${proof.treeSize1} requires ${wanted}`,
177
+ links,
178
+ };
179
+ }
180
+ }
181
+ }
182
+ else if (proof.treeSize1 !== expectedBase) {
183
+ return {
184
+ ok: false,
185
+ reason: "legacy_chain_break",
186
+ at: i,
187
+ detail: `checkpoint ${i} base ${proof.treeSize1} != previous sealed size ${expectedBase} — ` +
188
+ "pre-FOR-410 drifted chain (permanent for this log); fall back to the event scan, tile extension, or a holder cache",
189
+ links,
190
+ };
191
+ }
192
+ let computed;
193
+ try {
194
+ computed = await computeCheckpointAccumulator(proof, accumulator);
195
+ }
196
+ catch (err) {
197
+ return {
198
+ ok: false,
199
+ reason: "proof_malformed",
200
+ at: i,
201
+ detail: err instanceof Error ? err.message : String(err),
202
+ links,
203
+ };
204
+ }
205
+ const signatureOk = await opts.verifySignature(bytes, accumulatorPayload(computed));
206
+ links.push({
207
+ treeSize1: proof.treeSize1,
208
+ treeSize2: proof.treeSize2,
209
+ accumulator: computed,
210
+ signatureOk,
211
+ });
212
+ if (!signatureOk) {
213
+ return {
214
+ ok: false,
215
+ reason: "signature",
216
+ at: i,
217
+ detail: `checkpoint ${i} signature does not cover the computed size-${proof.treeSize2} accumulator`,
218
+ links,
219
+ };
220
+ }
221
+ accumulator = computed;
222
+ expectedBase = proof.treeSize2;
223
+ }
224
+ return { ok: true, links, accumulator };
225
+ }
package/dist/index.d.ts CHANGED
@@ -38,4 +38,5 @@ export { findGrantLeafInMassif } from "./find-grant-leaf.js";
38
38
  export type { LocatedLeaf } from "./find-grant-leaf.js";
39
39
  /** Re-exported so callers of findGrantLeafInMassif can catch it (FOR-344). */
40
40
  export { MissingIndexError } from "@forestrie/merklelog";
41
+ export { accumulatorPayload, checkpointConsistencyProof, computeCheckpointAccumulator, verifyCheckpointChain, type CheckpointChainLink, type CheckpointChainResult, type CheckpointConsistencyProof, } from "./checkpoint-chain.js";
41
42
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,mBAAmB,EACnB,kBAAkB,GACnB,MAAM,4BAA4B,CAAC;AACpC,oFAAoF;AACpF,YAAY,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AACjD,YAAY,EAAE,8BAA8B,EAAE,MAAM,mCAAmC,CAAC;AACxF,YAAY,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AACnF,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,YAAY,EACV,wBAAwB,EACxB,uBAAuB,EACvB,eAAe,EACf,gBAAgB,GACjB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,mBAAmB,EACnB,sBAAsB,EACtB,mBAAmB,EACnB,eAAe,GAChB,MAAM,4BAA4B,CAAC;AACpC;;;GAGG;AACH,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,qCAAqC,CAAC;AACjF,OAAO,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,OAAO,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AACzE;;;;GAIG;AACH,OAAO,EACL,iCAAiC,EACjC,4BAA4B,GAC7B,MAAM,mCAAmC,CAAC;AAC3C,YAAY,EACV,sCAAsC,EACtC,iCAAiC,GAClC,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,YAAY,EAAE,mBAAmB,EAAE,MAAM,mCAAmC,CAAC;AAC7E,yEAAyE;AACzE,OAAO,EAAE,qCAAqC,EAAE,MAAM,6BAA6B,CAAC;AACpF,OAAO,EAAE,wBAAwB,EAAE,MAAM,kCAAkC,CAAC;AAC5E,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAE3E,0EAA0E;AAC1E,OAAO,EACL,cAAc,EACd,iDAAiD,GAClD,MAAM,2CAA2C,CAAC;AACnD,OAAO,EAAE,gBAAgB,EAAE,0BAA0B,EAAE,MAAM,eAAe,CAAC;AAC7E,YAAY,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD,6EAA6E;AAC7E,OAAO,EAAE,4BAA4B,EAAE,MAAM,uBAAuB,CAAC;AACrE,oEAAoE;AACpE,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,YAAY,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,8EAA8E;AAC9E,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,mBAAmB,EACnB,kBAAkB,GACnB,MAAM,4BAA4B,CAAC;AACpC,oFAAoF;AACpF,YAAY,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AACjD,YAAY,EAAE,8BAA8B,EAAE,MAAM,mCAAmC,CAAC;AACxF,YAAY,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AACnF,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,YAAY,EACV,wBAAwB,EACxB,uBAAuB,EACvB,eAAe,EACf,gBAAgB,GACjB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,mBAAmB,EACnB,sBAAsB,EACtB,mBAAmB,EACnB,eAAe,GAChB,MAAM,4BAA4B,CAAC;AACpC;;;GAGG;AACH,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,qCAAqC,CAAC;AACjF,OAAO,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,OAAO,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AACzE;;;;GAIG;AACH,OAAO,EACL,iCAAiC,EACjC,4BAA4B,GAC7B,MAAM,mCAAmC,CAAC;AAC3C,YAAY,EACV,sCAAsC,EACtC,iCAAiC,GAClC,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,YAAY,EAAE,mBAAmB,EAAE,MAAM,mCAAmC,CAAC;AAC7E,yEAAyE;AACzE,OAAO,EAAE,qCAAqC,EAAE,MAAM,6BAA6B,CAAC;AACpF,OAAO,EAAE,wBAAwB,EAAE,MAAM,kCAAkC,CAAC;AAC5E,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAE3E,0EAA0E;AAC1E,OAAO,EACL,cAAc,EACd,iDAAiD,GAClD,MAAM,2CAA2C,CAAC;AACnD,OAAO,EAAE,gBAAgB,EAAE,0BAA0B,EAAE,MAAM,eAAe,CAAC;AAC7E,YAAY,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD,6EAA6E;AAC7E,OAAO,EAAE,4BAA4B,EAAE,MAAM,uBAAuB,CAAC;AACrE,oEAAoE;AACpE,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,YAAY,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,8EAA8E;AAC9E,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,EACL,kBAAkB,EAClB,0BAA0B,EAC1B,4BAA4B,EAC5B,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,qBAAqB,EAC1B,KAAK,0BAA0B,GAChC,MAAM,uBAAuB,CAAC"}
package/dist/index.js CHANGED
@@ -28,3 +28,4 @@ export { grantCommitmentHashFromGrant } from "./grant-commitment.js";
28
28
  export { findGrantLeafInMassif } from "./find-grant-leaf.js";
29
29
  /** Re-exported so callers of findGrantLeafInMassif can catch it (FOR-344). */
30
30
  export { MissingIndexError } from "@forestrie/merklelog";
31
+ export { accumulatorPayload, checkpointConsistencyProof, computeCheckpointAccumulator, verifyCheckpointChain, } from "./checkpoint-chain.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forestrie/receipt-verify",
3
- "version": "0.6.3",
3
+ "version": "0.7.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Offline SCITT grant receipt verification (ADR-0045)",
@@ -29,7 +29,7 @@
29
29
  "dependencies": {
30
30
  "@noble/hashes": "^1.7.1",
31
31
  "@forestrie/encoding": "0.5.0",
32
- "@forestrie/merklelog": "0.1.1"
32
+ "@forestrie/merklelog": "0.2.1"
33
33
  },
34
34
  "devDependencies": {
35
35
  "esbuild": "^0.24.0",
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Retained-checkpoint chain verification (FOR-368 Phase 3, plan-2607-29).
3
+ *
4
+ * Post-FOR-410 (ADR-0056) every checkpoint's embedded consistency proof
5
+ * spans its massif's ENTRY BOUNDARY to its seal, so the store's retained
6
+ * `.sth` objects form a contiguous chain `0 → S₁ → S₂ → …`. Folding the
7
+ * chain per the draft ("Chained proofs" / `consistent_roots`) reconstructs
8
+ * each link's tree-size-2 accumulator — which is exactly the detached
9
+ * payload its signature covers (ADR-0046: concat of the accumulator in
10
+ * descending height order). The fold therefore yields, with NO tile access
11
+ * and NO RPC: an authenticated accumulator at every retained seal, and the
12
+ * final state to check a receipt's recomputed peak against.
13
+ *
14
+ * This rung depends only on the public log store — the complement of the
15
+ * `CheckpointPublished` event scan (public chain data only); see the
16
+ * recorded both-paths decision in plan-2607-29.
17
+ *
18
+ * Legacy (pre-FOR-410) chains surface as a contiguity break
19
+ * (`legacy_chain_break`): a permanent per-log condition — fall back to the
20
+ * event scan, tile extension, or a holder cache.
21
+ */
22
+ import { decodeCborDeterministic } from "@forestrie/encoding";
23
+ import { consistentRoots, peakMMRIndexes } from "@forestrie/merklelog";
24
+ import { SubtleHasher } from "./subtle-hasher.js";
25
+ import { parseCheckpoint } from "./build-receipt-offline.js";
26
+
27
+ const VDS_COSE_RECEIPT_PROOFS_TAG = 396;
28
+ const VDP_CONSISTENCY_PROOF_KEY = -2;
29
+
30
+ /** Draft-bryce consistency proof embedded in a v3 checkpoint. */
31
+ export type CheckpointConsistencyProof = {
32
+ treeSize1: bigint;
33
+ treeSize2: bigint;
34
+ /** One inclusion path per tree-size-1 peak, proven at tree-size-2. */
35
+ paths: Uint8Array[][];
36
+ /** New peaks not covered by the proven roots (draft `right-peaks`). */
37
+ rightPeaks: Uint8Array[];
38
+ };
39
+
40
+ function asBigint(v: unknown, what: string): bigint {
41
+ // Unsigned only: a negative size flows into `peakMMRIndexes`, whose
42
+ // `posHeight` spins forever on a non-positive argument — a malformed `.sth`
43
+ // must be rejected in bounded time, before any such call (FOR-414).
44
+ if (typeof v === "bigint") {
45
+ if (v < 0n)
46
+ throw new Error(`${what}: must be an unsigned integer, got ${v}`);
47
+ return v;
48
+ }
49
+ if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0) {
50
+ return BigInt(v);
51
+ }
52
+ throw new Error(`${what}: expected an unsigned integer`);
53
+ }
54
+
55
+ function asBytesArray(v: unknown, what: string): Uint8Array[] {
56
+ if (
57
+ !Array.isArray(v) ||
58
+ v.some((e) => !(e instanceof Uint8Array) || e.length !== 32)
59
+ ) {
60
+ throw new Error(`${what}: expected an array of 32-byte strings`);
61
+ }
62
+ return v as Uint8Array[];
63
+ }
64
+
65
+ /** Decode the embedded consistency proof (`vdp` 396 key -2). */
66
+ export function checkpointConsistencyProof(
67
+ checkpointBytes: Uint8Array,
68
+ ): CheckpointConsistencyProof {
69
+ const { unprotected } = parseCheckpoint(checkpointBytes);
70
+ const vdpRaw = unprotected.get(VDS_COSE_RECEIPT_PROOFS_TAG);
71
+ if (!(vdpRaw instanceof Map)) {
72
+ throw new Error("checkpoint carries no verifiable-proofs header (396)");
73
+ }
74
+ const proofBstr = (vdpRaw as Map<number, unknown>).get(
75
+ VDP_CONSISTENCY_PROOF_KEY,
76
+ );
77
+ if (!(proofBstr instanceof Uint8Array)) {
78
+ throw new Error("checkpoint carries no consistency proof (vdp key -2)");
79
+ }
80
+ const proof = decodeCborDeterministic(proofBstr);
81
+ if (!Array.isArray(proof) || proof.length < 4) {
82
+ throw new Error(
83
+ "consistency proof must be [tree-size-1, tree-size-2, paths, right-peaks]",
84
+ );
85
+ }
86
+ const pathsRaw = proof[2];
87
+ if (
88
+ !Array.isArray(pathsRaw) ||
89
+ pathsRaw.some(
90
+ (p) => !Array.isArray(p) || p.some((n) => !(n instanceof Uint8Array)),
91
+ )
92
+ ) {
93
+ throw new Error("consistency paths must be arrays of byte strings");
94
+ }
95
+ const treeSize1 = asBigint(proof[0], "tree-size-1");
96
+ const treeSize2 = asBigint(proof[1], "tree-size-2");
97
+ // A consistency proof strictly grows the tree; enforce `0 <= ts1 < ts2`
98
+ // (ts1 == 0 is a legitimate base-0 first link). This is the primary guard
99
+ // that keeps `treeSize2 - 1` / `treeSize1 - 1` non-negative before they
100
+ // reach `peakMMRIndexes` (FOR-414); the unsigned check in `asBigint` and
101
+ // the `posHeight` guard in @forestrie/merklelog are defence-in-depth.
102
+ if (treeSize2 <= treeSize1) {
103
+ throw new Error(
104
+ `consistency proof must grow the tree: tree-size-1 ${treeSize1} < tree-size-2 ${treeSize2}`,
105
+ );
106
+ }
107
+ return {
108
+ treeSize1,
109
+ treeSize2,
110
+ paths: pathsRaw as Uint8Array[][],
111
+ rightPeaks: asBytesArray(proof[3], "right-peaks"),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * One fold step: from the trusted accumulator at `proof.treeSize1`,
117
+ * produce the `treeSize2` accumulator (draft: `consistent_roots` output
118
+ * plus the supplied right-peaks). Structural sanity: the result must have
119
+ * exactly the peak count of a size-`treeSize2` MMR.
120
+ */
121
+ export async function computeCheckpointAccumulator(
122
+ proof: CheckpointConsistencyProof,
123
+ accumulatorFrom: Uint8Array[],
124
+ ): Promise<Uint8Array[]> {
125
+ const hasher = new SubtleHasher();
126
+ const proven =
127
+ proof.treeSize1 === 0n
128
+ ? []
129
+ : await consistentRoots(
130
+ hasher,
131
+ proof.treeSize1 - 1n,
132
+ accumulatorFrom,
133
+ proof.paths,
134
+ );
135
+ const accumulator = [...proven, ...proof.rightPeaks];
136
+ const expected = peakMMRIndexes(proof.treeSize2 - 1n).length;
137
+ if (accumulator.length !== expected) {
138
+ throw new Error(
139
+ `computed accumulator has ${accumulator.length} peaks; size ${proof.treeSize2} requires ${expected}`,
140
+ );
141
+ }
142
+ return accumulator;
143
+ }
144
+
145
+ /** Detached payload the checkpoint signature covers (ADR-0046): the raw
146
+ * concatenation of the accumulator peaks in contract order. */
147
+ export function accumulatorPayload(accumulator: Uint8Array[]): Uint8Array {
148
+ const out = new Uint8Array(accumulator.reduce((s, p) => s + p.length, 0));
149
+ let offset = 0;
150
+ for (const p of accumulator) {
151
+ out.set(p, offset);
152
+ offset += p.length;
153
+ }
154
+ return out;
155
+ }
156
+
157
+ export type CheckpointChainLink = {
158
+ treeSize1: bigint;
159
+ treeSize2: bigint;
160
+ accumulator: Uint8Array[];
161
+ signatureOk: boolean;
162
+ };
163
+
164
+ export type CheckpointChainResult =
165
+ | { ok: true; links: CheckpointChainLink[]; accumulator: Uint8Array[] }
166
+ | {
167
+ ok: false;
168
+ reason:
169
+ | "empty_chain"
170
+ | "legacy_chain_break"
171
+ | "signature"
172
+ | "proof_malformed";
173
+ /** Index of the offending checkpoint. */
174
+ at: number;
175
+ detail: string;
176
+ links: CheckpointChainLink[];
177
+ };
178
+
179
+ /**
180
+ * Verify a retained checkpoint chain (ascending massif order) and fold out
181
+ * the final authenticated accumulator.
182
+ *
183
+ * - The first link must be boundary-based from 0 (a whole-log chain), or
184
+ * the caller supplies `accumulatorFrom` matching its base (a suffix
185
+ * chain rooted in an already-trusted accumulator).
186
+ * - Every subsequent link's base must equal the previous link's sealed
187
+ * size — a mismatch is the legacy (pre-FOR-410) drift signature and is
188
+ * permanent for that log (`legacy_chain_break`).
189
+ * - Each link's signature is checked over its computed accumulator via
190
+ * the injected verifier (the caller owns trust resolution — genesis
191
+ * roots, caller-known keys, or the label-1000 delegation path).
192
+ */
193
+ export async function verifyCheckpointChain(opts: {
194
+ checkpoints: Uint8Array[];
195
+ verifySignature: (
196
+ checkpointBytes: Uint8Array,
197
+ detachedPayload: Uint8Array,
198
+ ) => Promise<boolean>;
199
+ /** Trusted base accumulator for a suffix chain (absent: base must be 0). */
200
+ accumulatorFrom?: Uint8Array[];
201
+ }): Promise<CheckpointChainResult> {
202
+ const links: CheckpointChainLink[] = [];
203
+ if (opts.checkpoints.length === 0) {
204
+ return {
205
+ ok: false,
206
+ reason: "empty_chain",
207
+ at: 0,
208
+ detail: "no checkpoints supplied",
209
+ links,
210
+ };
211
+ }
212
+ let accumulator = opts.accumulatorFrom ?? [];
213
+ let expectedBase: bigint | null = null;
214
+ for (let i = 0; i < opts.checkpoints.length; i++) {
215
+ const bytes = opts.checkpoints[i]!;
216
+ let proof: CheckpointConsistencyProof;
217
+ try {
218
+ proof = checkpointConsistencyProof(bytes);
219
+ } catch (err) {
220
+ return {
221
+ ok: false,
222
+ reason: "proof_malformed",
223
+ at: i,
224
+ detail: err instanceof Error ? err.message : String(err),
225
+ links,
226
+ };
227
+ }
228
+ if (expectedBase === null) {
229
+ // First link: base 0 for a whole-log chain, else the caller's
230
+ // trusted accumulator must be FOR this base (peak-count check).
231
+ if (proof.treeSize1 !== 0n) {
232
+ const wanted = peakMMRIndexes(proof.treeSize1 - 1n).length;
233
+ if (opts.accumulatorFrom === undefined) {
234
+ return {
235
+ ok: false,
236
+ reason: "legacy_chain_break",
237
+ at: i,
238
+ detail: `first checkpoint base ${proof.treeSize1} != 0 and no trusted base accumulator was supplied`,
239
+ links,
240
+ };
241
+ }
242
+ if (accumulator.length !== wanted) {
243
+ return {
244
+ ok: false,
245
+ reason: "proof_malformed",
246
+ at: i,
247
+ detail: `trusted base accumulator has ${accumulator.length} peaks; base size ${proof.treeSize1} requires ${wanted}`,
248
+ links,
249
+ };
250
+ }
251
+ }
252
+ } else if (proof.treeSize1 !== expectedBase) {
253
+ return {
254
+ ok: false,
255
+ reason: "legacy_chain_break",
256
+ at: i,
257
+ detail:
258
+ `checkpoint ${i} base ${proof.treeSize1} != previous sealed size ${expectedBase} — ` +
259
+ "pre-FOR-410 drifted chain (permanent for this log); fall back to the event scan, tile extension, or a holder cache",
260
+ links,
261
+ };
262
+ }
263
+ let computed: Uint8Array[];
264
+ try {
265
+ computed = await computeCheckpointAccumulator(proof, accumulator);
266
+ } catch (err) {
267
+ return {
268
+ ok: false,
269
+ reason: "proof_malformed",
270
+ at: i,
271
+ detail: err instanceof Error ? err.message : String(err),
272
+ links,
273
+ };
274
+ }
275
+ const signatureOk = await opts.verifySignature(
276
+ bytes,
277
+ accumulatorPayload(computed),
278
+ );
279
+ links.push({
280
+ treeSize1: proof.treeSize1,
281
+ treeSize2: proof.treeSize2,
282
+ accumulator: computed,
283
+ signatureOk,
284
+ });
285
+ if (!signatureOk) {
286
+ return {
287
+ ok: false,
288
+ reason: "signature",
289
+ at: i,
290
+ detail: `checkpoint ${i} signature does not cover the computed size-${proof.treeSize2} accumulator`,
291
+ links,
292
+ };
293
+ }
294
+ accumulator = computed;
295
+ expectedBase = proof.treeSize2;
296
+ }
297
+ return { ok: true, links, accumulator };
298
+ }
package/src/index.ts CHANGED
@@ -62,3 +62,12 @@ export { findGrantLeafInMassif } from "./find-grant-leaf.js";
62
62
  export type { LocatedLeaf } from "./find-grant-leaf.js";
63
63
  /** Re-exported so callers of findGrantLeafInMassif can catch it (FOR-344). */
64
64
  export { MissingIndexError } from "@forestrie/merklelog";
65
+ export {
66
+ accumulatorPayload,
67
+ checkpointConsistencyProof,
68
+ computeCheckpointAccumulator,
69
+ verifyCheckpointChain,
70
+ type CheckpointChainLink,
71
+ type CheckpointChainResult,
72
+ type CheckpointConsistencyProof,
73
+ } from "./checkpoint-chain.js";