@forestrie/receipt-verify 1.1.0 → 2.1.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.
@@ -1,145 +1,213 @@
1
1
  /**
2
- * Retained-checkpoint chain verification (FOR-368 Phase 3, plan-2607-29).
2
+ * Retained-checkpoint chain verification (FOR-368 Phase 3, plan-2607-29;
3
+ * FOR-568/ADR-0066 signed size-2, plan-2609-10 §4.4, amended 2026-09-20).
3
4
  *
4
5
  * Post-FOR-410 (ADR-0056) every checkpoint's embedded consistency proof
5
6
  * spans its massif's ENTRY BOUNDARY to its seal, so the store's retained
6
7
  * `.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.
8
+ * chain via the SIZE-DRIVEN {@link consistentRootsForSizes} (ADR-0066 D5;
9
+ * `@forestrie/merklelog`) reconstructs each link's tree-size-2 accumulator —
10
+ * which is exactly the detached payload its signature covers (ADR-0046:
11
+ * concat of the accumulator in descending height order). The fold therefore
12
+ * yields, with NO tile access and NO RPC: an authenticated accumulator at
13
+ * every retained seal, and the final state to check a receipt's recomputed
14
+ * peak against.
15
+ *
16
+ * Only `tree-size-2` is SIGNED (ADR-0066 D1 as amended: protected header
17
+ * label -65933) — the value folded here is the one the checkpoint's own
18
+ * signature covers, not merely the unprotected consistency proof's declared
19
+ * value, which an unsigned checkpoint could otherwise restate freely (the
20
+ * "keyless first checkpoint" case). `tree-size-1` stays unsigned prover
21
+ * context: the publisher relays several sealed steps and may re-base a step
22
+ * under the head checkpoint's signature, so the declared base of a
23
+ * checkpoint can differ from what the sealer had (D2 chain semantics is
24
+ * withdrawn) — a signed size-1 comparison would reject every re-based
25
+ * publish and every multi-link catch-up. A checkpoint without the signed
26
+ * size-2 label, or whose signed size-2 disagrees with its declared proof,
27
+ * is rejected before any fold is attempted.
13
28
  *
14
29
  * This rung depends only on the public log store — the complement of the
15
30
  * `CheckpointPublished` event scan (public chain data only); see the
16
31
  * recorded both-paths decision in plan-2607-29.
17
32
  *
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.
33
+ * No pre-FOR-410 state is supported (ADR-0066 D6): the affected logs are
34
+ * re-anchored, so there is no drift condition to signal and no fallback to
35
+ * select. A declared `tree-size-1` that does not continue the state being
36
+ * folded is `size_mismatch` like any other size disagreement — and it has to
37
+ * be, because that value is unsigned: a relaying party can set it without
38
+ * the key, so no reason string chosen from it may mean anything more than
39
+ * "these two sizes differ".
21
40
  */
22
- import { decodeCborDeterministic } from "@forestrie/encoding";
23
- import { consistentRoots, peakMMRIndexes } from "@forestrie/merklelog";
41
+ import {
42
+ COSE_ALG_ES256,
43
+ extractAlgFromProtected,
44
+ isLowS,
45
+ readProtectedTreeSize2,
46
+ } from "@forestrie/encoding";
47
+ import {
48
+ consistentRootsForSizes,
49
+ mmrSizeForLeafCount,
50
+ peakMMRIndexes,
51
+ peaksBitmap,
52
+ } from "@forestrie/merklelog";
24
53
  import { SubtleHasher } from "./subtle-hasher.js";
25
54
  import { parseCheckpoint } from "./build-receipt-offline.js";
55
+ import { decodeConsistencyProofFromUnprotected } from "./decode-checkpoint-consistency-proof.js";
26
56
 
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. */
57
+ /** Draft-bryce consistency proof embedded in a v3 checkpoint. `treeSize2` is
58
+ * cross-checked against the checkpoint's SIGNED tree-size-2 (ADR-0066 D1 as
59
+ * amended); `treeSize1` is unsigned prover context, not cross-checked here —
60
+ * see {@link verifyCheckpointChain}, which compares it with the trusted
61
+ * origin instead. */
31
62
  export type CheckpointConsistencyProof = {
32
63
  treeSize1: bigint;
33
64
  treeSize2: bigint;
65
+ /** Signed `tree-size-2` (protected header label -65933); equal to
66
+ * {@link treeSize2} — {@link checkpointConsistencyProof} enforces this. */
67
+ signedTreeSize2: bigint;
34
68
  /** One inclusion path per tree-size-1 peak, proven at tree-size-2. */
35
69
  paths: Uint8Array[][];
36
70
  /** New peaks not covered by the proven roots (draft `right-peaks`). */
37
71
  rightPeaks: Uint8Array[];
38
72
  };
39
73
 
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);
74
+ /**
75
+ * The checkpoint's SIGNED `tree-size-2` (protected header, ADR-0066 D1 as
76
+ * amended) differs from the declared `tree-size-2` of its embedded
77
+ * (unprotected) consistency proof. Distinct from a structurally malformed
78
+ * proof: {@link verifyCheckpointChain} reports this as `"size_mismatch"`,
79
+ * not `"proof_malformed"`.
80
+ */
81
+ export class CheckpointSignedSizeMismatchError extends Error {
82
+ constructor(message: string) {
83
+ super(message);
84
+ this.name = "CheckpointSignedSizeMismatchError";
51
85
  }
52
- throw new Error(`${what}: expected an unsigned integer`);
53
86
  }
54
87
 
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`);
88
+ /**
89
+ * The checkpoint's ES256 signature is the malleable high-s twin (`s > n/2`,
90
+ * `n` the P-256 group order): go-merklelog rejects these for checkpoint
91
+ * COSE_Sign1 signatures because the univocity contract's P-256 verifier
92
+ * does, so a receipt that verified here while carrying a high-s signature
93
+ * could be one the chain refuses (FOR-568 rollout item 4). Checked here,
94
+ * before any WebCrypto verify is attempted, so a rejected signature never
95
+ * reaches {@link verifyCheckpointChain}'s `verifySignature` callback. Scoped
96
+ * to the checkpoint receipt path only — this module never touches the WebAuthn
97
+ * (-65800) or session-key-endorsement (-65801) signature paths, which stay
98
+ * governed by their own canonical-form rules.
99
+ */
100
+ export class CheckpointHighSSignatureError extends Error {
101
+ constructor(message: string) {
102
+ super(message);
103
+ this.name = "CheckpointHighSSignatureError";
61
104
  }
62
- return v as Uint8Array[];
63
105
  }
64
106
 
65
- /** Decode the embedded consistency proof (`vdp` 396 key -2). */
107
+ /**
108
+ * Decode the embedded consistency proof (`vdp` 396 key -2) and require its
109
+ * declared `tree-size-2` to equal the checkpoint's SIGNED `tree-size-2` from
110
+ * the protected header (ADR-0066 D1 as amended, D5.5, label -65933).
111
+ * `tree-size-1` is not signed and is not checked here (D2 is withdrawn); see
112
+ * {@link verifyCheckpointChain} for its comparison against the trusted
113
+ * origin.
114
+ *
115
+ * Also rejects a malleable high-s ES256 signature (see
116
+ * {@link CheckpointHighSSignatureError}) before any fold or WebCrypto verify
117
+ * work — a checkpoint signed with any other algorithm (e.g. KS256) is not
118
+ * subject to this check, since it does not go through the P-256 WebCrypto
119
+ * path this guards.
120
+ *
121
+ * @throws {Error} when the protected header carries no consistency proof,
122
+ * the proof is structurally malformed (see
123
+ * {@link decodeConsistencyProofFromUnprotected}), or the protected header
124
+ * carries no signed tree-size-2 label
125
+ * @throws {CheckpointSignedSizeMismatchError} when the signed tree-size-2
126
+ * differs from the declared proof's tree-size-2
127
+ * @throws {CheckpointHighSSignatureError} when the checkpoint is ES256-signed
128
+ * with a high-s (malleable) signature
129
+ */
66
130
  export function checkpointConsistencyProof(
67
131
  checkpointBytes: Uint8Array,
68
132
  ): 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)");
133
+ const { coseSign1, unprotected } = parseCheckpoint(checkpointBytes);
134
+ const alg = extractAlgFromProtected(coseSign1[0]);
135
+ const signature = coseSign1[3];
136
+ if (alg === COSE_ALG_ES256 && signature.length === 64 && !isLowS(signature)) {
137
+ throw new CheckpointHighSSignatureError(
138
+ "checkpoint ES256 signature is not low-s canonical (s > n/2); rejected " +
139
+ "to match the univocity contract's P-256 verifier and go-merklelog",
140
+ );
73
141
  }
74
- const proofBstr = (vdpRaw as Map<number, unknown>).get(
75
- VDP_CONSISTENCY_PROOF_KEY,
76
- );
77
- if (!(proofBstr instanceof Uint8Array)) {
142
+ const declared = decodeConsistencyProofFromUnprotected(unprotected);
143
+ if (declared === null) {
78
144
  throw new Error("checkpoint carries no consistency proof (vdp key -2)");
79
145
  }
80
- const proof = decodeCborDeterministic(proofBstr);
81
- if (!Array.isArray(proof) || proof.length < 4) {
146
+ const signedTreeSize2 = readProtectedTreeSize2(coseSign1[0]);
147
+ if (signedTreeSize2 === null) {
82
148
  throw new Error(
83
- "consistency proof must be [tree-size-1, tree-size-2, paths, right-peaks]",
149
+ "checkpoint protected header carries no signed tree-size-2 (-65933)",
84
150
  );
85
151
  }
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}`,
152
+ if (signedTreeSize2 !== declared.treeSize2) {
153
+ throw new CheckpointSignedSizeMismatchError(
154
+ `signed tree-size-2 (-65933) ${signedTreeSize2} != declared consistency-proof tree-size-2 ${declared.treeSize2}`,
105
155
  );
106
156
  }
107
157
  return {
108
- treeSize1,
109
- treeSize2,
110
- paths: pathsRaw as Uint8Array[][],
111
- rightPeaks: asBytesArray(proof[3], "right-peaks"),
158
+ treeSize1: declared.treeSize1,
159
+ treeSize2: declared.treeSize2,
160
+ signedTreeSize2,
161
+ paths: declared.paths,
162
+ rightPeaks: declared.rightPeaks,
112
163
  };
113
164
  }
114
165
 
115
166
  /**
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.
167
+ * One fold step: from the CALLER-TRUSTED accumulator at `sizeFrom`, produce
168
+ * the `proof.treeSize2` accumulator via the size-driven
169
+ * {@link consistentRootsForSizes} (ADR-0066 D5) — `roots` (the proven
170
+ * prefix) followed by the proof's supplied right-peaks (the target peaks no
171
+ * path reaches).
172
+ *
173
+ * `sizeFrom` is a parameter, not read off `proof`, because the fold must run
174
+ * against a size the CALLER already trusts (the previous link's verified
175
+ * `treeSize2`, or the caller's anchor for a first link) — reading it from
176
+ * the proof instead would let an unsigned or substituted proof dictate its
177
+ * own starting point. `proof.treeSize1` must equal it regardless: the two
178
+ * disagreeing means this proof does not continue from the state being
179
+ * folded, not a mere shape defect, so it is checked before any fold work.
180
+ *
181
+ * @throws {Error} when `proof.treeSize1 !== sizeFrom`, or when the proof
182
+ * supplies a right-peaks count other than
183
+ * {@link consistentRootsForSizes}'s `expectedRight`
184
+ * @throws {ConsistencyShapeError} (`@forestrie/merklelog`) when the proof
185
+ * does not have the shape MMR(sizeFrom) -> MMR(proof.treeSize2) implies
120
186
  */
121
187
  export async function computeCheckpointAccumulator(
122
188
  proof: CheckpointConsistencyProof,
123
189
  accumulatorFrom: Uint8Array[],
190
+ sizeFrom: bigint,
124
191
  ): Promise<Uint8Array[]> {
192
+ if (proof.treeSize1 !== sizeFrom) {
193
+ throw new Error(
194
+ `consistency proof base tree-size-1 ${proof.treeSize1} does not match the trusted size ${sizeFrom}`,
195
+ );
196
+ }
125
197
  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) {
198
+ const { roots, expectedRight } = await consistentRootsForSizes(
199
+ hasher,
200
+ sizeFrom,
201
+ proof.treeSize2,
202
+ accumulatorFrom,
203
+ proof.paths,
204
+ );
205
+ if (proof.rightPeaks.length !== expectedRight) {
138
206
  throw new Error(
139
- `computed accumulator has ${accumulator.length} peaks; size ${proof.treeSize2} requires ${expected}`,
207
+ `checkpoint supplies ${proof.rightPeaks.length} right-peaks; size ${proof.treeSize2} requires ${expectedRight}`,
140
208
  );
141
209
  }
142
- return accumulator;
210
+ return [...roots, ...proof.rightPeaks];
143
211
  }
144
212
 
145
213
  /** Detached payload the checkpoint signature covers (ADR-0046): the raw
@@ -157,7 +225,11 @@ export function accumulatorPayload(accumulator: Uint8Array[]): Uint8Array {
157
225
  export type CheckpointChainLink = {
158
226
  treeSize1: bigint;
159
227
  treeSize2: bigint;
228
+ /** Signed `tree-size-2` (protected header label -65933; ADR-0066 D1 as
229
+ * amended). */
230
+ signedTreeSize2: bigint;
160
231
  accumulator: Uint8Array[];
232
+ /** Always `true`: a link is recorded only after its signature verified. */
161
233
  signatureOk: boolean;
162
234
  };
163
235
 
@@ -167,9 +239,25 @@ export type CheckpointChainResult =
167
239
  ok: false;
168
240
  reason:
169
241
  | "empty_chain"
170
- | "legacy_chain_break"
171
242
  | "signature"
172
- | "proof_malformed";
243
+ /**
244
+ * The checkpoint's ES256 signature is the malleable high-s twin (see
245
+ * {@link CheckpointHighSSignatureError}) — rejected before any
246
+ * WebCrypto verify, so it is distinct from `"signature"` (a
247
+ * canonical-form low-s signature that did not verify).
248
+ */
249
+ | "signature_malleable"
250
+ | "proof_malformed"
251
+ /**
252
+ * Two sizes that must be equal are not. Either a checkpoint's SIGNED
253
+ * `tree-size-2` disagrees with its declared consistency-proof
254
+ * `tree-size-2` (ADR-0066 D1 as amended, D5.5), or a link's declared
255
+ * `tree-size-1` disagrees with the size the fold starts from — the
256
+ * caller's `trustedBase.size` (0 with no `trustedBase`) for the
257
+ * first link, the previous link's `tree-size-2` after that.
258
+ * `detail` names both sizes.
259
+ */
260
+ | "size_mismatch";
173
261
  /** Index of the offending checkpoint. */
174
262
  at: number;
175
263
  detail: string;
@@ -180,15 +268,34 @@ export type CheckpointChainResult =
180
268
  * Verify a retained checkpoint chain (ascending massif order) and fold out
181
269
  * the final authenticated accumulator.
182
270
  *
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`).
271
+ * - The first link's trusted starting size is `trustedBase?.size ?? 0n`
272
+ * (base 0 for a whole-log chain) with `trustedBase?.accumulator ?? []`
273
+ * as the fold's starting accumulator (a suffix chain rooted in an
274
+ * already-trusted accumulator supplies both).
275
+ * - A supplied `trustedBase` must describe a state an MMR can be in: its
276
+ * `size` a complete MMR size, and its `accumulator` holding one peak per
277
+ * peak of that size. `peaksBitmap` rounds an incomplete size DOWN to the
278
+ * largest MMR below it, so without the completeness check a size of 5
279
+ * folds the 4 -> N shape while every link reports a base of 5 — a node
280
+ * count no MMR has. Both are `proof_malformed`.
281
+ * - The first link's declared `tree-size-1` must equal that trusted
282
+ * starting size, and every subsequent link's must equal the previous
283
+ * link's sealed `tree-size-2`; either disagreement is `size_mismatch`.
284
+ * `tree-size-1` itself is never compared with a signed value (D2 is
285
+ * withdrawn): only this trusted-origin comparison applies. Because it is
286
+ * unsigned, the reason it produces carries no more meaning than the size
287
+ * disagreement itself (ADR-0066 D6: no pre-FOR-410 state is supported, so
288
+ * there is no drift condition to fall back from).
289
+ * - Each checkpoint's SIGNED `tree-size-2` (ADR-0066 D1 as amended) must
290
+ * equal its declared consistency-proof `tree-size-2`
291
+ * ({@link checkpointConsistencyProof}); a disagreement is
292
+ * `size_mismatch`.
189
293
  * - Each link's signature is checked over its computed accumulator via
190
294
  * the injected verifier (the caller owns trust resolution — genesis
191
- * roots, caller-known keys, or the label-1000 delegation path).
295
+ * roots, caller-known keys, or the label-1000 delegation path). A link
296
+ * joins `links` only once its signature has verified, so on any failure
297
+ * `links` is the verified prefix and never holds an accumulator nothing
298
+ * attested.
192
299
  */
193
300
  export async function verifyCheckpointChain(opts: {
194
301
  checkpoints: Uint8Array[];
@@ -196,8 +303,9 @@ export async function verifyCheckpointChain(opts: {
196
303
  checkpointBytes: Uint8Array,
197
304
  detachedPayload: Uint8Array,
198
305
  ) => Promise<boolean>;
199
- /** Trusted base accumulator for a suffix chain (absent: base must be 0). */
200
- accumulatorFrom?: Uint8Array[];
306
+ /** Trusted base for a suffix chain (a whole-log chain has base size 0 and
307
+ * an empty accumulator, which is also the default when this is absent). */
308
+ trustedBase?: { size: bigint; accumulator: Uint8Array[] };
201
309
  }): Promise<CheckpointChainResult> {
202
310
  const links: CheckpointChainLink[] = [];
203
311
  if (opts.checkpoints.length === 0) {
@@ -209,60 +317,91 @@ export async function verifyCheckpointChain(opts: {
209
317
  links,
210
318
  };
211
319
  }
212
- let accumulator = opts.accumulatorFrom ?? [];
213
- let expectedBase: bigint | null = null;
320
+ const trustedBase = opts.trustedBase;
321
+ if (trustedBase !== undefined) {
322
+ // A size that is not a complete MMR size describes no state: the fold
323
+ // would silently use the largest MMR below it (`peaksBitmap` rounds
324
+ // down) while every link reported the supplied value as its base.
325
+ if (
326
+ trustedBase.size < 0n ||
327
+ mmrSizeForLeafCount(peaksBitmap(trustedBase.size)) !== trustedBase.size
328
+ ) {
329
+ return {
330
+ ok: false,
331
+ reason: "proof_malformed",
332
+ at: 0,
333
+ detail: `trusted base size ${trustedBase.size} is not a complete MMR size`,
334
+ links,
335
+ };
336
+ }
337
+ // …and the accumulator must hold exactly the peaks that size has, which
338
+ // the fold otherwise only compares against the rounded-down count.
339
+ const basePeaks =
340
+ trustedBase.size === 0n
341
+ ? 0
342
+ : peakMMRIndexes(trustedBase.size - 1n).length;
343
+ if (trustedBase.accumulator.length !== basePeaks) {
344
+ return {
345
+ ok: false,
346
+ reason: "proof_malformed",
347
+ at: 0,
348
+ detail: `trusted base accumulator has ${trustedBase.accumulator.length} peaks; size ${trustedBase.size} has ${basePeaks}`,
349
+ links,
350
+ };
351
+ }
352
+ }
353
+ let accumulator = trustedBase?.accumulator ?? [];
354
+ let expectedBase = trustedBase?.size ?? 0n;
214
355
  for (let i = 0; i < opts.checkpoints.length; i++) {
215
356
  const bytes = opts.checkpoints[i]!;
216
357
  let proof: CheckpointConsistencyProof;
217
358
  try {
218
359
  proof = checkpointConsistencyProof(bytes);
219
360
  } catch (err) {
361
+ const reason =
362
+ err instanceof CheckpointSignedSizeMismatchError
363
+ ? "size_mismatch"
364
+ : err instanceof CheckpointHighSSignatureError
365
+ ? "signature_malleable"
366
+ : "proof_malformed";
220
367
  return {
221
368
  ok: false,
222
- reason: "proof_malformed",
369
+ reason,
223
370
  at: i,
224
371
  detail: err instanceof Error ? err.message : String(err),
225
372
  links,
226
373
  };
227
374
  }
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
- }
375
+ if (proof.treeSize1 !== expectedBase) {
376
+ if (i === 0) {
377
+ return {
378
+ ok: false,
379
+ reason: "size_mismatch",
380
+ at: i,
381
+ detail:
382
+ trustedBase === undefined
383
+ ? `first checkpoint declared tree-size-1 ${proof.treeSize1} != whole-log base size ${expectedBase} and no trusted base was supplied`
384
+ : `first checkpoint declared tree-size-1 ${proof.treeSize1} != trusted base size ${expectedBase}`,
385
+ links,
386
+ };
251
387
  }
252
- } else if (proof.treeSize1 !== expectedBase) {
253
388
  return {
254
389
  ok: false,
255
- reason: "legacy_chain_break",
390
+ reason: "size_mismatch",
256
391
  at: i,
257
392
  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",
393
+ `checkpoint ${i} declared tree-size-1 ${proof.treeSize1} != the previous link's tree-size-2 ${expectedBase} — ` +
394
+ "the declared origin is unsigned, so the chain is treated as not continuous",
260
395
  links,
261
396
  };
262
397
  }
263
398
  let computed: Uint8Array[];
264
399
  try {
265
- computed = await computeCheckpointAccumulator(proof, accumulator);
400
+ computed = await computeCheckpointAccumulator(
401
+ proof,
402
+ accumulator,
403
+ expectedBase,
404
+ );
266
405
  } catch (err) {
267
406
  return {
268
407
  ok: false,
@@ -276,13 +415,10 @@ export async function verifyCheckpointChain(opts: {
276
415
  bytes,
277
416
  accumulatorPayload(computed),
278
417
  );
279
- links.push({
280
- treeSize1: proof.treeSize1,
281
- treeSize2: proof.treeSize2,
282
- accumulator: computed,
283
- signatureOk,
284
- });
285
418
  if (!signatureOk) {
419
+ // `links` stays the verified prefix: the computed accumulator of a
420
+ // link whose signature did not verify is attested by nothing, so it
421
+ // is not handed back.
286
422
  return {
287
423
  ok: false,
288
424
  reason: "signature",
@@ -291,6 +427,13 @@ export async function verifyCheckpointChain(opts: {
291
427
  links,
292
428
  };
293
429
  }
430
+ links.push({
431
+ treeSize1: proof.treeSize1,
432
+ treeSize2: proof.treeSize2,
433
+ signedTreeSize2: proof.signedTreeSize2,
434
+ accumulator: computed,
435
+ signatureOk,
436
+ });
294
437
  accumulator = computed;
295
438
  expectedBase = proof.treeSize2;
296
439
  }