@forestrie/receipt-verify 0.6.3 → 0.7.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.
@@ -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;AAeF,gEAAgE;AAChE,wBAAgB,0BAA0B,CACxC,eAAe,EAAE,UAAU,GAC1B,0BAA0B,CAiC5B;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,207 @@
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
+ if (typeof v === "bigint")
30
+ return v;
31
+ if (typeof v === "number" && Number.isSafeInteger(v))
32
+ return BigInt(v);
33
+ throw new Error(`${what}: expected an unsigned integer`);
34
+ }
35
+ function asBytesArray(v, what) {
36
+ if (!Array.isArray(v) || v.some((e) => !(e instanceof Uint8Array))) {
37
+ throw new Error(`${what}: expected an array of byte strings`);
38
+ }
39
+ return v;
40
+ }
41
+ /** Decode the embedded consistency proof (`vdp` 396 key -2). */
42
+ export function checkpointConsistencyProof(checkpointBytes) {
43
+ const { unprotected } = parseCheckpoint(checkpointBytes);
44
+ const vdpRaw = unprotected.get(VDS_COSE_RECEIPT_PROOFS_TAG);
45
+ if (!(vdpRaw instanceof Map)) {
46
+ throw new Error("checkpoint carries no verifiable-proofs header (396)");
47
+ }
48
+ const proofBstr = vdpRaw.get(VDP_CONSISTENCY_PROOF_KEY);
49
+ if (!(proofBstr instanceof Uint8Array)) {
50
+ throw new Error("checkpoint carries no consistency proof (vdp key -2)");
51
+ }
52
+ const proof = decodeCborDeterministic(proofBstr);
53
+ if (!Array.isArray(proof) || proof.length < 4) {
54
+ throw new Error("consistency proof must be [tree-size-1, tree-size-2, paths, right-peaks]");
55
+ }
56
+ const pathsRaw = proof[2];
57
+ if (!Array.isArray(pathsRaw) ||
58
+ pathsRaw.some((p) => !Array.isArray(p) || p.some((n) => !(n instanceof Uint8Array)))) {
59
+ throw new Error("consistency paths must be arrays of byte strings");
60
+ }
61
+ return {
62
+ treeSize1: asBigint(proof[0], "tree-size-1"),
63
+ treeSize2: asBigint(proof[1], "tree-size-2"),
64
+ paths: pathsRaw,
65
+ rightPeaks: asBytesArray(proof[3], "right-peaks"),
66
+ };
67
+ }
68
+ /**
69
+ * One fold step: from the trusted accumulator at `proof.treeSize1`,
70
+ * produce the `treeSize2` accumulator (draft: `consistent_roots` output
71
+ * plus the supplied right-peaks). Structural sanity: the result must have
72
+ * exactly the peak count of a size-`treeSize2` MMR.
73
+ */
74
+ export async function computeCheckpointAccumulator(proof, accumulatorFrom) {
75
+ const hasher = new SubtleHasher();
76
+ const proven = proof.treeSize1 === 0n
77
+ ? []
78
+ : await consistentRoots(hasher, proof.treeSize1 - 1n, accumulatorFrom, proof.paths);
79
+ const accumulator = [...proven, ...proof.rightPeaks];
80
+ const expected = peakMMRIndexes(proof.treeSize2 - 1n).length;
81
+ if (accumulator.length !== expected) {
82
+ throw new Error(`computed accumulator has ${accumulator.length} peaks; size ${proof.treeSize2} requires ${expected}`);
83
+ }
84
+ return accumulator;
85
+ }
86
+ /** Detached payload the checkpoint signature covers (ADR-0046): the raw
87
+ * concatenation of the accumulator peaks in contract order. */
88
+ export function accumulatorPayload(accumulator) {
89
+ const out = new Uint8Array(accumulator.reduce((s, p) => s + p.length, 0));
90
+ let offset = 0;
91
+ for (const p of accumulator) {
92
+ out.set(p, offset);
93
+ offset += p.length;
94
+ }
95
+ return out;
96
+ }
97
+ /**
98
+ * Verify a retained checkpoint chain (ascending massif order) and fold out
99
+ * the final authenticated accumulator.
100
+ *
101
+ * - The first link must be boundary-based from 0 (a whole-log chain), or
102
+ * the caller supplies `accumulatorFrom` matching its base (a suffix
103
+ * chain rooted in an already-trusted accumulator).
104
+ * - Every subsequent link's base must equal the previous link's sealed
105
+ * size — a mismatch is the legacy (pre-FOR-410) drift signature and is
106
+ * permanent for that log (`legacy_chain_break`).
107
+ * - Each link's signature is checked over its computed accumulator via
108
+ * the injected verifier (the caller owns trust resolution — genesis
109
+ * roots, caller-known keys, or the label-1000 delegation path).
110
+ */
111
+ export async function verifyCheckpointChain(opts) {
112
+ const links = [];
113
+ if (opts.checkpoints.length === 0) {
114
+ return {
115
+ ok: false,
116
+ reason: "empty_chain",
117
+ at: 0,
118
+ detail: "no checkpoints supplied",
119
+ links,
120
+ };
121
+ }
122
+ let accumulator = opts.accumulatorFrom ?? [];
123
+ let expectedBase = null;
124
+ for (let i = 0; i < opts.checkpoints.length; i++) {
125
+ const bytes = opts.checkpoints[i];
126
+ let proof;
127
+ try {
128
+ proof = checkpointConsistencyProof(bytes);
129
+ }
130
+ catch (err) {
131
+ return {
132
+ ok: false,
133
+ reason: "proof_malformed",
134
+ at: i,
135
+ detail: err instanceof Error ? err.message : String(err),
136
+ links,
137
+ };
138
+ }
139
+ if (expectedBase === null) {
140
+ // First link: base 0 for a whole-log chain, else the caller's
141
+ // trusted accumulator must be FOR this base (peak-count check).
142
+ if (proof.treeSize1 !== 0n) {
143
+ const wanted = peakMMRIndexes(proof.treeSize1 - 1n).length;
144
+ if (opts.accumulatorFrom === undefined) {
145
+ return {
146
+ ok: false,
147
+ reason: "legacy_chain_break",
148
+ at: i,
149
+ detail: `first checkpoint base ${proof.treeSize1} != 0 and no trusted base accumulator was supplied`,
150
+ links,
151
+ };
152
+ }
153
+ if (accumulator.length !== wanted) {
154
+ return {
155
+ ok: false,
156
+ reason: "proof_malformed",
157
+ at: i,
158
+ detail: `trusted base accumulator has ${accumulator.length} peaks; base size ${proof.treeSize1} requires ${wanted}`,
159
+ links,
160
+ };
161
+ }
162
+ }
163
+ }
164
+ else if (proof.treeSize1 !== expectedBase) {
165
+ return {
166
+ ok: false,
167
+ reason: "legacy_chain_break",
168
+ at: i,
169
+ detail: `checkpoint ${i} base ${proof.treeSize1} != previous sealed size ${expectedBase} — ` +
170
+ "pre-FOR-410 drifted chain (permanent for this log); fall back to the event scan, tile extension, or a holder cache",
171
+ links,
172
+ };
173
+ }
174
+ let computed;
175
+ try {
176
+ computed = await computeCheckpointAccumulator(proof, accumulator);
177
+ }
178
+ catch (err) {
179
+ return {
180
+ ok: false,
181
+ reason: "proof_malformed",
182
+ at: i,
183
+ detail: err instanceof Error ? err.message : String(err),
184
+ links,
185
+ };
186
+ }
187
+ const signatureOk = await opts.verifySignature(bytes, accumulatorPayload(computed));
188
+ links.push({
189
+ treeSize1: proof.treeSize1,
190
+ treeSize2: proof.treeSize2,
191
+ accumulator: computed,
192
+ signatureOk,
193
+ });
194
+ if (!signatureOk) {
195
+ return {
196
+ ok: false,
197
+ reason: "signature",
198
+ at: i,
199
+ detail: `checkpoint ${i} signature does not cover the computed size-${proof.treeSize2} accumulator`,
200
+ links,
201
+ };
202
+ }
203
+ accumulator = computed;
204
+ expectedBase = proof.treeSize2;
205
+ }
206
+ return { ok: true, links, accumulator };
207
+ }
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.0",
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.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "esbuild": "^0.24.0",
@@ -0,0 +1,274 @@
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
+ if (typeof v === "bigint") return v;
42
+ if (typeof v === "number" && Number.isSafeInteger(v)) return BigInt(v);
43
+ throw new Error(`${what}: expected an unsigned integer`);
44
+ }
45
+
46
+ function asBytesArray(v: unknown, what: string): Uint8Array[] {
47
+ if (!Array.isArray(v) || v.some((e) => !(e instanceof Uint8Array))) {
48
+ throw new Error(`${what}: expected an array of byte strings`);
49
+ }
50
+ return v as Uint8Array[];
51
+ }
52
+
53
+ /** Decode the embedded consistency proof (`vdp` 396 key -2). */
54
+ export function checkpointConsistencyProof(
55
+ checkpointBytes: Uint8Array,
56
+ ): CheckpointConsistencyProof {
57
+ const { unprotected } = parseCheckpoint(checkpointBytes);
58
+ const vdpRaw = unprotected.get(VDS_COSE_RECEIPT_PROOFS_TAG);
59
+ if (!(vdpRaw instanceof Map)) {
60
+ throw new Error("checkpoint carries no verifiable-proofs header (396)");
61
+ }
62
+ const proofBstr = (vdpRaw as Map<number, unknown>).get(
63
+ VDP_CONSISTENCY_PROOF_KEY,
64
+ );
65
+ if (!(proofBstr instanceof Uint8Array)) {
66
+ throw new Error("checkpoint carries no consistency proof (vdp key -2)");
67
+ }
68
+ const proof = decodeCborDeterministic(proofBstr);
69
+ if (!Array.isArray(proof) || proof.length < 4) {
70
+ throw new Error(
71
+ "consistency proof must be [tree-size-1, tree-size-2, paths, right-peaks]",
72
+ );
73
+ }
74
+ const pathsRaw = proof[2];
75
+ if (
76
+ !Array.isArray(pathsRaw) ||
77
+ pathsRaw.some(
78
+ (p) => !Array.isArray(p) || p.some((n) => !(n instanceof Uint8Array)),
79
+ )
80
+ ) {
81
+ throw new Error("consistency paths must be arrays of byte strings");
82
+ }
83
+ return {
84
+ treeSize1: asBigint(proof[0], "tree-size-1"),
85
+ treeSize2: asBigint(proof[1], "tree-size-2"),
86
+ paths: pathsRaw as Uint8Array[][],
87
+ rightPeaks: asBytesArray(proof[3], "right-peaks"),
88
+ };
89
+ }
90
+
91
+ /**
92
+ * One fold step: from the trusted accumulator at `proof.treeSize1`,
93
+ * produce the `treeSize2` accumulator (draft: `consistent_roots` output
94
+ * plus the supplied right-peaks). Structural sanity: the result must have
95
+ * exactly the peak count of a size-`treeSize2` MMR.
96
+ */
97
+ export async function computeCheckpointAccumulator(
98
+ proof: CheckpointConsistencyProof,
99
+ accumulatorFrom: Uint8Array[],
100
+ ): Promise<Uint8Array[]> {
101
+ const hasher = new SubtleHasher();
102
+ const proven =
103
+ proof.treeSize1 === 0n
104
+ ? []
105
+ : await consistentRoots(
106
+ hasher,
107
+ proof.treeSize1 - 1n,
108
+ accumulatorFrom,
109
+ proof.paths,
110
+ );
111
+ const accumulator = [...proven, ...proof.rightPeaks];
112
+ const expected = peakMMRIndexes(proof.treeSize2 - 1n).length;
113
+ if (accumulator.length !== expected) {
114
+ throw new Error(
115
+ `computed accumulator has ${accumulator.length} peaks; size ${proof.treeSize2} requires ${expected}`,
116
+ );
117
+ }
118
+ return accumulator;
119
+ }
120
+
121
+ /** Detached payload the checkpoint signature covers (ADR-0046): the raw
122
+ * concatenation of the accumulator peaks in contract order. */
123
+ export function accumulatorPayload(accumulator: Uint8Array[]): Uint8Array {
124
+ const out = new Uint8Array(accumulator.reduce((s, p) => s + p.length, 0));
125
+ let offset = 0;
126
+ for (const p of accumulator) {
127
+ out.set(p, offset);
128
+ offset += p.length;
129
+ }
130
+ return out;
131
+ }
132
+
133
+ export type CheckpointChainLink = {
134
+ treeSize1: bigint;
135
+ treeSize2: bigint;
136
+ accumulator: Uint8Array[];
137
+ signatureOk: boolean;
138
+ };
139
+
140
+ export type CheckpointChainResult =
141
+ | { ok: true; links: CheckpointChainLink[]; accumulator: Uint8Array[] }
142
+ | {
143
+ ok: false;
144
+ reason:
145
+ | "empty_chain"
146
+ | "legacy_chain_break"
147
+ | "signature"
148
+ | "proof_malformed";
149
+ /** Index of the offending checkpoint. */
150
+ at: number;
151
+ detail: string;
152
+ links: CheckpointChainLink[];
153
+ };
154
+
155
+ /**
156
+ * Verify a retained checkpoint chain (ascending massif order) and fold out
157
+ * the final authenticated accumulator.
158
+ *
159
+ * - The first link must be boundary-based from 0 (a whole-log chain), or
160
+ * the caller supplies `accumulatorFrom` matching its base (a suffix
161
+ * chain rooted in an already-trusted accumulator).
162
+ * - Every subsequent link's base must equal the previous link's sealed
163
+ * size — a mismatch is the legacy (pre-FOR-410) drift signature and is
164
+ * permanent for that log (`legacy_chain_break`).
165
+ * - Each link's signature is checked over its computed accumulator via
166
+ * the injected verifier (the caller owns trust resolution — genesis
167
+ * roots, caller-known keys, or the label-1000 delegation path).
168
+ */
169
+ export async function verifyCheckpointChain(opts: {
170
+ checkpoints: Uint8Array[];
171
+ verifySignature: (
172
+ checkpointBytes: Uint8Array,
173
+ detachedPayload: Uint8Array,
174
+ ) => Promise<boolean>;
175
+ /** Trusted base accumulator for a suffix chain (absent: base must be 0). */
176
+ accumulatorFrom?: Uint8Array[];
177
+ }): Promise<CheckpointChainResult> {
178
+ const links: CheckpointChainLink[] = [];
179
+ if (opts.checkpoints.length === 0) {
180
+ return {
181
+ ok: false,
182
+ reason: "empty_chain",
183
+ at: 0,
184
+ detail: "no checkpoints supplied",
185
+ links,
186
+ };
187
+ }
188
+ let accumulator = opts.accumulatorFrom ?? [];
189
+ let expectedBase: bigint | null = null;
190
+ for (let i = 0; i < opts.checkpoints.length; i++) {
191
+ const bytes = opts.checkpoints[i]!;
192
+ let proof: CheckpointConsistencyProof;
193
+ try {
194
+ proof = checkpointConsistencyProof(bytes);
195
+ } catch (err) {
196
+ return {
197
+ ok: false,
198
+ reason: "proof_malformed",
199
+ at: i,
200
+ detail: err instanceof Error ? err.message : String(err),
201
+ links,
202
+ };
203
+ }
204
+ if (expectedBase === null) {
205
+ // First link: base 0 for a whole-log chain, else the caller's
206
+ // trusted accumulator must be FOR this base (peak-count check).
207
+ if (proof.treeSize1 !== 0n) {
208
+ const wanted = peakMMRIndexes(proof.treeSize1 - 1n).length;
209
+ if (opts.accumulatorFrom === undefined) {
210
+ return {
211
+ ok: false,
212
+ reason: "legacy_chain_break",
213
+ at: i,
214
+ detail: `first checkpoint base ${proof.treeSize1} != 0 and no trusted base accumulator was supplied`,
215
+ links,
216
+ };
217
+ }
218
+ if (accumulator.length !== wanted) {
219
+ return {
220
+ ok: false,
221
+ reason: "proof_malformed",
222
+ at: i,
223
+ detail: `trusted base accumulator has ${accumulator.length} peaks; base size ${proof.treeSize1} requires ${wanted}`,
224
+ links,
225
+ };
226
+ }
227
+ }
228
+ } else if (proof.treeSize1 !== expectedBase) {
229
+ return {
230
+ ok: false,
231
+ reason: "legacy_chain_break",
232
+ at: i,
233
+ detail:
234
+ `checkpoint ${i} base ${proof.treeSize1} != previous sealed size ${expectedBase} — ` +
235
+ "pre-FOR-410 drifted chain (permanent for this log); fall back to the event scan, tile extension, or a holder cache",
236
+ links,
237
+ };
238
+ }
239
+ let computed: Uint8Array[];
240
+ try {
241
+ computed = await computeCheckpointAccumulator(proof, accumulator);
242
+ } catch (err) {
243
+ return {
244
+ ok: false,
245
+ reason: "proof_malformed",
246
+ at: i,
247
+ detail: err instanceof Error ? err.message : String(err),
248
+ links,
249
+ };
250
+ }
251
+ const signatureOk = await opts.verifySignature(
252
+ bytes,
253
+ accumulatorPayload(computed),
254
+ );
255
+ links.push({
256
+ treeSize1: proof.treeSize1,
257
+ treeSize2: proof.treeSize2,
258
+ accumulator: computed,
259
+ signatureOk,
260
+ });
261
+ if (!signatureOk) {
262
+ return {
263
+ ok: false,
264
+ reason: "signature",
265
+ at: i,
266
+ detail: `checkpoint ${i} signature does not cover the computed size-${proof.treeSize2} accumulator`,
267
+ links,
268
+ };
269
+ }
270
+ accumulator = computed;
271
+ expectedBase = proof.treeSize2;
272
+ }
273
+ return { ok: true, links, accumulator };
274
+ }
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";