@forestrie/receipt-verify 2.2.0 → 3.0.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.
@@ -20,11 +20,15 @@
20
20
  * "keyless first checkpoint" case). `tree-size-1` stays unsigned prover
21
21
  * context: the publisher relays several sealed steps and may re-base a step
22
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.
23
+ * checkpoint can differ from what the sealer had (the signed origin
24
+ * ADR-0066 D2 first proposed was withdrawn) — a signed size-1 comparison
25
+ * would reject every re-based publish and every multi-link catch-up. One
26
+ * checkpoint may itself relay SEVERAL sealed steps (ADR-0066 D2): the
27
+ * draft carries them under vdp key -2 as
28
+ * `consistency-proofs = [ + consistency-proof ]`, folded here in order,
29
+ * with only the last step's size signed. A checkpoint without the signed
30
+ * size-2 label, or whose signed size-2 disagrees with the last proof it
31
+ * relays, is rejected before any fold is attempted.
28
32
  *
29
33
  * This rung depends only on the public log store — the complement of the
30
34
  * `CheckpointPublished` event scan (public chain data only); see the
@@ -53,25 +57,64 @@ import {
53
57
  } from "@forestrie/merklelog";
54
58
  import { SubtleHasher } from "./subtle-hasher.js";
55
59
  import { parseCheckpoint } from "./build-receipt-offline.js";
56
- import { decodeConsistencyProofFromUnprotected } from "./decode-checkpoint-consistency-proof.js";
60
+ import {
61
+ decodeConsistencyProofsFromUnprotected,
62
+ EmptyConsistencyProofsError,
63
+ type DecodedConsistencyProof,
64
+ } from "./decode-checkpoint-consistency-proof.js";
57
65
 
58
- /** Draft-bryce consistency proof embedded in a v3 checkpoint. `treeSize2` is
59
- * cross-checked against the checkpoint's SIGNED tree-size-2 (ADR-0066 D1 as
60
- * amended); `treeSize1` is unsigned prover context, not cross-checked here —
61
- * see {@link verifyCheckpointChain}, which compares it with the trusted
62
- * origin instead. */
66
+ /**
67
+ * The draft-bryce consistency proofs embedded in a v3 checkpoint: one or
68
+ * more, in relay order (`consistency-proofs = [ + consistency-proof ]`,
69
+ * ADR-0066 D2). A checkpoint sealing a single step carries the chain of
70
+ * one; there is no separate single-proof shape.
71
+ *
72
+ * {@link treeSize2} — the LAST proof's — is cross-checked against the
73
+ * checkpoint's SIGNED tree-size-2 (ADR-0066 D1 as amended, D5.5).
74
+ * {@link treeSize1} — the FIRST proof's — is unsigned prover context, not
75
+ * cross-checked here; see {@link verifyCheckpointChain}, which compares it
76
+ * with the trusted origin instead. The sizes between the two are named by
77
+ * no signature: {@link computeCheckpointAccumulator} holds the chain
78
+ * together by requiring each proof to continue the one before it.
79
+ */
63
80
  export type CheckpointConsistencyProof = {
81
+ /** The relayed proofs, in chain order; never empty. */
82
+ proofs: DecodedConsistencyProof[];
83
+ /** `tree-size-1` of the FIRST proof: the size the chain continues from. */
64
84
  treeSize1: bigint;
85
+ /** `tree-size-2` of the LAST proof: the size the chain reaches. */
65
86
  treeSize2: bigint;
66
87
  /** Signed `tree-size-2` (protected header label -65933); equal to
67
88
  * {@link treeSize2} — {@link checkpointConsistencyProof} enforces this. */
68
89
  signedTreeSize2: bigint;
69
- /** One inclusion path per tree-size-1 peak, proven at tree-size-2. */
70
- paths: Uint8Array[][];
71
- /** New peaks not covered by the proven roots (draft `right-peaks`). */
72
- rightPeaks: Uint8Array[];
73
90
  };
74
91
 
92
+ /**
93
+ * A relayed consistency-proof chain does not join up: a proof's declared
94
+ * `tree-size-1` is not the size the fold has reached — the caller's trusted
95
+ * size for the first proof, the previous proof's `tree-size-2` after that —
96
+ * or, having applied every proof, the fold reaches a size other than the
97
+ * chain link's own declared `tree-size-2` (F3: a caller-assembled link —
98
+ * `freshenReceipt` callers build these from on-chain calldata, never
99
+ * through {@link checkpointConsistencyProof} — can set `proofs` and
100
+ * `treeSize2` independently, which `checkpointConsistencyProof` itself
101
+ * never allows to disagree). go-merklelog reuses its equivalent
102
+ * `ErrProofChainNotContiguous` for this same end-of-chain comparison
103
+ * (`checkpointverify.go:245-251`, folded size vs. signed size). Reported as
104
+ * `"size_mismatch"` by {@link verifyCheckpointChain}, the same as any other
105
+ * size disagreement, because the sizes it names are unsigned (ADR-0066 D2):
106
+ * nothing distinguishes a relay assembled in the wrong order from one
107
+ * assembled over a different log.
108
+ */
109
+ export class ConsistencyChainNotContiguousError extends Error {
110
+ constructor(message: string) {
111
+ super(message);
112
+ this.name = "ConsistencyChainNotContiguousError";
113
+ }
114
+ }
115
+
116
+ export { EmptyConsistencyProofsError };
117
+
75
118
  /**
76
119
  * The checkpoint's SIGNED `tree-size-2` (protected header, ADR-0066 D1 as
77
120
  * amended) differs from the declared `tree-size-2` of its embedded
@@ -125,12 +168,14 @@ export class CheckpointProtectedHeaderAlgError extends Error {
125
168
  }
126
169
 
127
170
  /**
128
- * Decode the embedded consistency proof (`vdp` 396 key -2) and require its
129
- * declared `tree-size-2` to equal the checkpoint's SIGNED `tree-size-2` from
130
- * the protected header (ADR-0066 D1 as amended, D5.5, label -65933).
131
- * `tree-size-1` is not signed and is not checked here (D2 is withdrawn); see
132
- * {@link verifyCheckpointChain} for its comparison against the trusted
133
- * origin.
171
+ * Decode the embedded consistency proofs (`vdp` 396 key -2, one or more in
172
+ * relay order) and require the LAST proof's declared `tree-size-2` to equal
173
+ * the checkpoint's SIGNED `tree-size-2` from the protected header (ADR-0066
174
+ * D1 as amended, D2, D5.5, label -65933). The earlier proofs' sizes are not
175
+ * signed: the fold checks them against each other
176
+ * ({@link computeCheckpointAccumulator}). `tree-size-1` is not signed
177
+ * either; see {@link verifyCheckpointChain} for its comparison against the
178
+ * trusted origin.
134
179
  *
135
180
  * Also rejects a malleable high-s ES256 signature (see
136
181
  * {@link CheckpointHighSSignatureError}) before any fold or WebCrypto verify
@@ -138,12 +183,14 @@ export class CheckpointProtectedHeaderAlgError extends Error {
138
183
  * subject to this check, since it does not go through the P-256 WebCrypto
139
184
  * path this guards.
140
185
  *
141
- * @throws {Error} when the protected header carries no consistency proof,
142
- * the proof is structurally malformed (see
143
- * {@link decodeConsistencyProofFromUnprotected}), or the protected header
186
+ * @throws {Error} when the unprotected header carries no consistency proof,
187
+ * a proof is structurally malformed (see
188
+ * {@link decodeConsistencyProofsFromUnprotected}), or the protected header
144
189
  * carries no signed tree-size-2 label
190
+ * @throws {EmptyConsistencyProofsError} when the consistency-proofs array is
191
+ * present but empty
145
192
  * @throws {CheckpointSignedSizeMismatchError} when the signed tree-size-2
146
- * differs from the declared proof's tree-size-2
193
+ * differs from the LAST declared proof's tree-size-2
147
194
  * @throws {CheckpointHighSSignatureError} when the checkpoint is ES256-signed
148
195
  * with a high-s (malleable) signature
149
196
  * @throws {CheckpointProtectedHeaderAlgError} when the protected header
@@ -174,75 +221,125 @@ export function checkpointConsistencyProof(
174
221
  "to match the univocity contract's P-256 verifier and go-merklelog",
175
222
  );
176
223
  }
177
- const declared = decodeConsistencyProofFromUnprotected(unprotected);
178
- if (declared === null) {
224
+ const proofs = decodeConsistencyProofsFromUnprotected(unprotected);
225
+ if (proofs === null) {
179
226
  throw new Error("checkpoint carries no consistency proof (vdp key -2)");
180
227
  }
228
+ const last = proofs[proofs.length - 1]!;
181
229
  const signedTreeSize2 = readProtectedTreeSize2(coseSign1[0]);
182
230
  if (signedTreeSize2 === null) {
183
231
  throw new Error(
184
232
  "checkpoint protected header carries no signed tree-size-2 (-65933)",
185
233
  );
186
234
  }
187
- if (signedTreeSize2 !== declared.treeSize2) {
235
+ // The signature covers the size the LAST proof reaches, and only that
236
+ // size: a relay may hold any number of steps before it, none of them
237
+ // signed (ADR-0066 D2).
238
+ if (signedTreeSize2 !== last.treeSize2) {
188
239
  throw new CheckpointSignedSizeMismatchError(
189
- `signed tree-size-2 (-65933) ${signedTreeSize2} != declared consistency-proof tree-size-2 ${declared.treeSize2}`,
240
+ `signed tree-size-2 (-65933) ${signedTreeSize2} != declared consistency-proof tree-size-2 ${last.treeSize2}`,
190
241
  );
191
242
  }
192
243
  return {
193
- treeSize1: declared.treeSize1,
194
- treeSize2: declared.treeSize2,
244
+ proofs,
245
+ treeSize1: proofs[0]!.treeSize1,
246
+ treeSize2: last.treeSize2,
195
247
  signedTreeSize2,
196
- paths: declared.paths,
197
- rightPeaks: declared.rightPeaks,
198
248
  };
199
249
  }
200
250
 
201
251
  /**
202
- * One fold step: from the CALLER-TRUSTED accumulator at `sizeFrom`, produce
203
- * the `proof.treeSize2` accumulator via the size-driven
204
- * {@link consistentRootsForSizes} (ADR-0066 D5) — `roots` (the proven
205
- * prefix) followed by the proof's supplied right-peaks (the target peaks no
206
- * path reaches).
252
+ * Fold a checkpoint's relayed consistency proofs, in order, from the
253
+ * CALLER-TRUSTED accumulator at `sizeFrom` to the accumulator at the last
254
+ * proof's `tree-size-2` — the value the checkpoint's signature covers.
207
255
  *
208
- * `sizeFrom` is a parameter, not read off `proof`, because the fold must run
209
- * against a size the CALLER already trusts (the previous link's verified
210
- * `treeSize2`, or the caller's anchor for a first link) — reading it from
211
- * the proof instead would let an unsigned or substituted proof dictate its
212
- * own starting point. `proof.treeSize1` must equal it regardless: the two
213
- * disagreeing means this proof does not continue from the state being
214
- * folded, not a mere shape defect, so it is checked before any fold work.
256
+ * Each proof is applied by the size-driven {@link consistentRootsForSizes}
257
+ * (ADR-0066 D5), which yields `roots` (the proven prefix); the proof's own
258
+ * right-peaks (the target peaks no path reaches) complete that step's
259
+ * accumulator, and it becomes the next step's input. A checkpoint sealing
260
+ * one step carries the chain of one and runs the same loop once.
215
261
  *
216
- * @throws {Error} when `proof.treeSize1 !== sizeFrom`, or when the proof
217
- * supplies a right-peaks count other than
262
+ * `sizeFrom` is a parameter, not read off the proofs, because the fold must
263
+ * start from a size the CALLER already trusts (the previous checkpoint's
264
+ * verified `treeSize2`, or the caller's anchor for a first link) — reading
265
+ * it from the relay instead would let an unsigned or substituted proof
266
+ * dictate its own starting point (ADR-0066 D5.4). Every proof after the
267
+ * first is held to the size the previous one reached for the same reason:
268
+ * only the last step's size is signed, so the intermediate sizes are worth
269
+ * no more than their agreement with each other.
270
+ *
271
+ * `proof.proofs` must hold at least one step and, once every step has been
272
+ * applied, the fold must have reached exactly `proof.treeSize2` (F3).
273
+ * `checkpointConsistencyProof` already guarantees both — the decode rejects
274
+ * an empty `consistency-proofs` array (`EmptyConsistencyProofsError`) and
275
+ * sets `treeSize2` from the last decoded proof — but a caller-assembled
276
+ * link (`freshenReceipt` callers building from on-chain calldata) is not
277
+ * decoded through it, so both are re-checked here rather than trusted from
278
+ * the type.
279
+ *
280
+ * @throws {EmptyConsistencyProofsError} when `proof.proofs` is empty — an
281
+ * already-decoded link the caller assembled themselves rather than one
282
+ * `checkpointConsistencyProof` produced, which never returns one
283
+ * @throws {ConsistencyChainNotContiguousError} when the first proof's
284
+ * `treeSize1` is not `sizeFrom`, a later proof's `treeSize1` is not the
285
+ * previous proof's `treeSize2`, or the size the fold reaches after every
286
+ * proof is not `proof.treeSize2`
287
+ * @throws {Error} when a proof supplies a right-peaks count other than
218
288
  * {@link consistentRootsForSizes}'s `expectedRight`
219
- * @throws {ConsistencyShapeError} (`@forestrie/merklelog`) when the proof
220
- * does not have the shape MMR(sizeFrom) -> MMR(proof.treeSize2) implies
289
+ * @throws {ConsistencyShapeError} (`@forestrie/merklelog`) when a proof does
290
+ * not have the shape its two sizes imply
221
291
  */
222
292
  export async function computeCheckpointAccumulator(
223
293
  proof: CheckpointConsistencyProof,
224
294
  accumulatorFrom: Uint8Array[],
225
295
  sizeFrom: bigint,
226
296
  ): Promise<Uint8Array[]> {
227
- if (proof.treeSize1 !== sizeFrom) {
228
- throw new Error(
229
- `consistency proof base tree-size-1 ${proof.treeSize1} does not match the trusted size ${sizeFrom}`,
297
+ if (proof.proofs.length === 0) {
298
+ throw new EmptyConsistencyProofsError(
299
+ "consistency proof relays no proofs (empty proofs array); at least " +
300
+ "one is required to fold",
230
301
  );
231
302
  }
232
303
  const hasher = new SubtleHasher();
233
- const { roots, expectedRight } = await consistentRootsForSizes(
234
- hasher,
235
- sizeFrom,
236
- proof.treeSize2,
237
- accumulatorFrom,
238
- proof.paths,
239
- );
240
- if (proof.rightPeaks.length !== expectedRight) {
241
- throw new Error(
242
- `checkpoint supplies ${proof.rightPeaks.length} right-peaks; size ${proof.treeSize2} requires ${expectedRight}`,
304
+ let accumulator = accumulatorFrom;
305
+ let size = sizeFrom;
306
+ for (let i = 0; i < proof.proofs.length; i++) {
307
+ const step = proof.proofs[i]!;
308
+ if (step.treeSize1 !== size) {
309
+ throw new ConsistencyChainNotContiguousError(
310
+ i === 0
311
+ ? `consistency proof base tree-size-1 ${step.treeSize1} does not match the trusted size ${size}`
312
+ : `consistency-proofs entry ${i} declares tree-size-1 ${step.treeSize1}; the previous proof reached ${size}`,
313
+ );
314
+ }
315
+ const { roots, expectedRight } = await consistentRootsForSizes(
316
+ hasher,
317
+ size,
318
+ step.treeSize2,
319
+ accumulator,
320
+ step.paths,
243
321
  );
322
+ if (step.rightPeaks.length !== expectedRight) {
323
+ throw new Error(
324
+ `checkpoint supplies ${step.rightPeaks.length} right-peaks; size ${step.treeSize2} requires ${expectedRight}`,
325
+ );
326
+ }
327
+ accumulator = [...roots, ...step.rightPeaks];
328
+ size = step.treeSize2;
244
329
  }
245
- return [...roots, ...proof.rightPeaks];
330
+ // The fold must land exactly on the size the LINK itself declares —
331
+ // `proof.treeSize2` — not merely on whatever size its last proof happened
332
+ // to reach: a caller-assembled link can set the two independently (e.g.
333
+ // proofs folding 1 -> 3 alongside a declared treeSize2 of 7), which would
334
+ // otherwise fold to 3 and be reported as size 7 to every downstream
335
+ // caller reading the declared field instead of the fold. Mirrors
336
+ // go-merklelog's own end-of-chain check (checkpointverify.go:245-251).
337
+ if (size !== proof.treeSize2) {
338
+ throw new ConsistencyChainNotContiguousError(
339
+ `consistency proof folds to tree-size-2 ${size}; the chain's declared tree-size-2 is ${proof.treeSize2}`,
340
+ );
341
+ }
342
+ return accumulator;
246
343
  }
247
344
 
248
345
  /** Detached payload the checkpoint signature covers (ADR-0046): the raw
@@ -289,8 +386,10 @@ export type CheckpointChainResult =
289
386
  * `tree-size-2` (ADR-0066 D1 as amended, D5.5), or a link's declared
290
387
  * `tree-size-1` disagrees with the size the fold starts from — the
291
388
  * caller's `trustedBase.size` (0 with no `trustedBase`) for the
292
- * first link, the previous link's `tree-size-2` after that.
293
- * `detail` names both sizes.
389
+ * first link, the previous link's `tree-size-2` after that — or a
390
+ * relayed proof WITHIN a checkpoint disagrees with the size the
391
+ * proof before it reached ({@link
392
+ * ConsistencyChainNotContiguousError}). `detail` names both sizes.
294
393
  */
295
394
  | "size_mismatch";
296
395
  /** Index of the offending checkpoint. */
@@ -319,15 +418,20 @@ export type CheckpointChainResult =
319
418
  * - The first link's declared `tree-size-1` must equal that trusted
320
419
  * starting size, and every subsequent link's must equal the previous
321
420
  * link's sealed `tree-size-2`; either disagreement is `size_mismatch`.
322
- * `tree-size-1` itself is never compared with a signed value (D2 is
323
- * withdrawn): only this trusted-origin comparison applies. Because it is
421
+ * `tree-size-1` itself is never compared with a signed value (the signed
422
+ * origin ADR-0066 D2 first proposed was withdrawn): only this
423
+ * trusted-origin comparison applies. Because it is
324
424
  * unsigned, the reason it produces carries no more meaning than the size
325
425
  * disagreement itself (ADR-0066 D6: no pre-FOR-410 state is supported, so
326
426
  * there is no drift condition to fall back from).
327
- * - Each checkpoint's SIGNED `tree-size-2` (ADR-0066 D1 as amended) must
328
- * equal its declared consistency-proof `tree-size-2`
329
- * ({@link checkpointConsistencyProof}); a disagreement is
330
- * `size_mismatch`.
427
+ * - A checkpoint may relay SEVERAL consistency proofs under one signature
428
+ * (ADR-0066 D2; draft `consistency-proofs = [ + consistency-proof ]`).
429
+ * Its SIGNED `tree-size-2` (ADR-0066 D1 as amended) must equal the LAST
430
+ * proof's declared `tree-size-2` ({@link checkpointConsistencyProof}),
431
+ * and each relayed proof must continue the one before it
432
+ * ({@link computeCheckpointAccumulator}); either disagreement is
433
+ * `size_mismatch`. A checkpoint sealing one step is the relay of one and
434
+ * takes the same path.
331
435
  * - Each link's signature is checked over its computed accumulator via
332
436
  * the injected verifier (the caller owns trust resolution — genesis
333
437
  * roots, caller-known keys, or the label-1000 delegation path). A link
@@ -460,9 +564,15 @@ export async function verifyCheckpointChain(opts: {
460
564
  expectedBase,
461
565
  );
462
566
  } catch (err) {
567
+ // A relay that does not join up is a size disagreement like any
568
+ // other: every size it names but the last is unsigned, so the reason
569
+ // can say no more than that two sizes differ.
463
570
  return {
464
571
  ok: false,
465
- reason: "proof_malformed",
572
+ reason:
573
+ err instanceof ConsistencyChainNotContiguousError
574
+ ? "size_mismatch"
575
+ : "proof_malformed",
466
576
  at: i,
467
577
  detail: err instanceof Error ? err.message : String(err),
468
578
  links,
@@ -1,7 +1,20 @@
1
1
  /**
2
- * Decode the draft-bryce consistency proof `[tree-size-1, tree-size-2, paths,
3
- * right-peaks]` carried under a checkpoint's verifiable-proofs UNPROTECTED
4
- * header (draft-bryce label 396, key -2 = `VDP_CONSISTENCY_PROOF_KEY`).
2
+ * Decode the draft-bryce consistency proofs carried under a checkpoint's
3
+ * verifiable-proofs UNPROTECTED header (draft-bryce label 396, key -2 =
4
+ * `VDP_CONSISTENCY_PROOF_KEY`).
5
+ *
6
+ * The draft's CDDL is
7
+ * `consistency-proofs = [ + consistency-proof ]`, with each
8
+ * `consistency-proof = bstr .cbor [tree-size-1, tree-size-2, paths,
9
+ * right-peaks]` — one or more proofs, relayed in chain order under a single
10
+ * signature (ADR-0066 D2). Both shapes are accepted under the -2 key:
11
+ *
12
+ * - an ARRAY of one or more proof bstrs — the draft's wire form, and the
13
+ * only form that can carry a relayed chain;
14
+ * - a BARE proof bstr — the shape every checkpoint sealed before the array
15
+ * form carries, and the shape the pinned `checkpoint-receipt-kat39.json`
16
+ * vector's `conventions.receipt` still states. It decodes to the array of
17
+ * one, so nothing downstream distinguishes it.
5
18
  *
6
19
  * Single source of truth for this decode, shared by `parseCheckpoint`
7
20
  * (build-receipt-offline.ts, lenient: an absent or malformed proof yields a
@@ -9,8 +22,9 @@
9
22
  * (checkpoint-chain.ts, full validation: an absent proof or a malformed
10
23
  * shape throws, and the SIGNED `tree-size-2` from the protected header —
11
24
  * read separately via `readProtectedTreeSize2`, ADR-0066 D1 as amended —
12
- * must match the `tree-size-2` decoded here; `tree-size-1` is unsigned
13
- * prover context and is not cross-checked against a signed value).
25
+ * must match the `tree-size-2` of the LAST proof decoded here;
26
+ * `tree-size-1` is unsigned prover context and is not cross-checked against
27
+ * a signed value).
14
28
  */
15
29
 
16
30
  import {
@@ -19,7 +33,7 @@ import {
19
33
  decodeCborDeterministic,
20
34
  } from "@forestrie/encoding";
21
35
 
22
- /** The declared (unprotected, unsigned) consistency proof of a checkpoint. */
36
+ /** One declared (unprotected, unsigned) consistency proof of a checkpoint. */
23
37
  export type DecodedConsistencyProof = {
24
38
  treeSize1: bigint;
25
39
  treeSize2: bigint;
@@ -29,6 +43,21 @@ export type DecodedConsistencyProof = {
29
43
  rightPeaks: Uint8Array[];
30
44
  };
31
45
 
46
+ /**
47
+ * The verifiable-proofs header carries the consistency-proof key with an
48
+ * EMPTY array. Distinct from an absent proof (no -2 key at all, which
49
+ * decodes to `null`): the key is present and claims to relay a chain, but
50
+ * the chain has no links, so there is nothing to fold and no last proof for
51
+ * the signed `tree-size-2` to equal. `consistency-proofs = [ + ... ]`
52
+ * requires at least one.
53
+ */
54
+ export class EmptyConsistencyProofsError extends Error {
55
+ constructor(message: string) {
56
+ super(message);
57
+ this.name = "EmptyConsistencyProofsError";
58
+ }
59
+ }
60
+
32
61
  function asBigint(v: unknown, what: string): bigint {
33
62
  // Unsigned only: a negative size flows into peakMMRIndexes /
34
63
  // consistentRootsForSizes, which reject or spin on a non-positive
@@ -56,36 +85,18 @@ function asBytesArray(v: unknown, what: string): Uint8Array[] {
56
85
  return v as Uint8Array[];
57
86
  }
58
87
 
59
- /**
60
- * Decode the embedded consistency proof from a checkpoint's UNPROTECTED
61
- * header map. Returns `null` when the checkpoint carries no verifiable-proofs
62
- * header (396) or no consistency-proof bstr there (key -2) — an ABSENT
63
- * proof, not a malformed one.
64
- *
65
- * @throws When a consistency-proof bstr IS present but its contents are not
66
- * the shape `[tree-size-1, tree-size-2, paths, right-peaks]`, either size
67
- * is not an unsigned integer, the proof does not grow the tree
68
- * (`tree-size-2 <= tree-size-1`), a path element or a right-peak is not a
69
- * 32-byte string — or when header 396 is present but is not map-valued,
70
- * or its `-2` entry is present but not a byte string.
71
- */
72
- export function decodeConsistencyProofFromUnprotected(
73
- unprotected: Map<number, unknown>,
74
- ): DecodedConsistencyProof | null {
75
- const vdpRaw = unprotected.get(COSE_LABEL_VDP);
76
- if (vdpRaw === undefined || vdpRaw === null) return null;
77
- if (!(vdpRaw instanceof Map)) {
78
- throw new Error("checkpoint carries no verifiable-proofs header (396)");
79
- }
80
- const proofBstr = vdpRaw.get(VDP_CONSISTENCY_PROOF_KEY);
81
- if (proofBstr === undefined || proofBstr === null) return null;
82
- if (!(proofBstr instanceof Uint8Array)) {
83
- throw new Error("checkpoint carries no consistency proof (vdp key -2)");
84
- }
88
+ /** Decode one `bstr .cbor [tree-size-1, tree-size-2, paths, right-peaks]`. */
89
+ function decodeOneProof(proofBstr: Uint8Array): DecodedConsistencyProof {
85
90
  const proof = decodeCborDeterministic(proofBstr);
86
- if (!Array.isArray(proof) || proof.length < 4) {
91
+ // Exactly 4 — the draft's CDDL names a fixed-arity array, and
92
+ // go-merklelog's decoder rejects any other length (F2). A 5th element
93
+ // (e.g. another proof tuple, mistaken for a chain of two) is as malformed
94
+ // as a 3rd missing.
95
+ if (!Array.isArray(proof) || proof.length !== 4) {
87
96
  throw new Error(
88
- "consistency proof must be [tree-size-1, tree-size-2, paths, right-peaks]",
97
+ `consistency proof must be [tree-size-1, tree-size-2, paths, right-peaks] (4 elements), got ${
98
+ Array.isArray(proof) ? proof.length : typeof proof
99
+ }`,
89
100
  );
90
101
  }
91
102
  const pathsRaw = proof[2];
@@ -134,3 +145,76 @@ export function decodeConsistencyProofFromUnprotected(
134
145
  rightPeaks: asBytesArray(proof[3], "right-peaks"),
135
146
  };
136
147
  }
148
+
149
+ /**
150
+ * Decode proof `at` of a relayed chain, naming its position in the message
151
+ * so a chain of several says which link is malformed. A header carrying a
152
+ * single proof names no position: its message is the one a single-proof
153
+ * checkpoint has always produced.
154
+ */
155
+ function decodeProofAt(
156
+ proofBstr: Uint8Array,
157
+ at: number | null,
158
+ ): DecodedConsistencyProof {
159
+ if (at === null) return decodeOneProof(proofBstr);
160
+ try {
161
+ return decodeOneProof(proofBstr);
162
+ } catch (err) {
163
+ throw new Error(
164
+ `consistency-proofs entry ${at}: ${err instanceof Error ? err.message : String(err)}`,
165
+ );
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Decode the embedded consistency proofs from a checkpoint's UNPROTECTED
171
+ * header map, in the order they are relayed. Returns `null` when the
172
+ * checkpoint carries no verifiable-proofs header (396) or no
173
+ * consistency-proof entry there (key -2) — an ABSENT proof, not a malformed
174
+ * one. A returned array always holds at least one proof.
175
+ *
176
+ * This decode establishes each proof's shape only. Nothing here relates one
177
+ * proof to the next, or to a signed size: the chain has to be checked
178
+ * against state the CALLER trusts, which is `computeCheckpointAccumulator`
179
+ * and `checkpointConsistencyProof` (ADR-0066 D5.4).
180
+ *
181
+ * @throws {EmptyConsistencyProofsError} when the -2 entry is an empty array
182
+ * @throws When a consistency proof IS present but its contents are not the
183
+ * shape `[tree-size-1, tree-size-2, paths, right-peaks]`, either size is
184
+ * not an unsigned integer, a proof does not grow the tree
185
+ * (`tree-size-2 <= tree-size-1`), a path element or a right-peak is not a
186
+ * 32-byte string — or when header 396 is present but is not map-valued,
187
+ * or its `-2` entry is neither a byte string nor an array of byte strings.
188
+ */
189
+ export function decodeConsistencyProofsFromUnprotected(
190
+ unprotected: Map<number, unknown>,
191
+ ): DecodedConsistencyProof[] | null {
192
+ const vdpRaw = unprotected.get(COSE_LABEL_VDP);
193
+ if (vdpRaw === undefined || vdpRaw === null) return null;
194
+ if (!(vdpRaw instanceof Map)) {
195
+ throw new Error("checkpoint carries no verifiable-proofs header (396)");
196
+ }
197
+ const entry = vdpRaw.get(VDP_CONSISTENCY_PROOF_KEY);
198
+ if (entry === undefined || entry === null) return null;
199
+ if (entry instanceof Uint8Array) {
200
+ // The pre-array shape: a single proof written straight under -2. It is
201
+ // the array of one, and is reported as such.
202
+ return [decodeOneProof(entry)];
203
+ }
204
+ if (!Array.isArray(entry)) {
205
+ throw new Error("checkpoint carries no consistency proof (vdp key -2)");
206
+ }
207
+ if (entry.length === 0) {
208
+ throw new EmptyConsistencyProofsError(
209
+ "consistency-proofs (vdp key -2) is empty; at least one proof is required",
210
+ );
211
+ }
212
+ return entry.map((proofBstr, i) => {
213
+ if (!(proofBstr instanceof Uint8Array)) {
214
+ throw new Error(
215
+ `consistency-proofs entry ${i} is not a byte string (vdp key -2)`,
216
+ );
217
+ }
218
+ return decodeProofAt(proofBstr, entry.length === 1 ? null : i);
219
+ });
220
+ }
@@ -81,11 +81,13 @@ export type FreshenReceiptInput = {
81
81
  * value `verify` recomputes from the entry (caller derives it). */
82
82
  leafValue: Uint8Array;
83
83
  /** Consistency-proof chain covering [0 or the trusted base] → the latest
84
- * sealed size, in ascending contiguous order (the raw per-checkpoint
85
- * proofs, with `paths`). Every link's `signedTreeSize2` must equal its
86
- * `treeSize2`, as `checkpointConsistencyProof` requires of the checkpoint
87
- * it decoded each link from. The chain's last link must end at the
88
- * checkpoint's sealed size. */
84
+ * sealed size, in ascending contiguous order: one entry per checkpoint,
85
+ * each holding the proofs that checkpoint relays (`proofs`, one or more —
86
+ * ADR-0066 D2). Every link's `signedTreeSize2` must equal its
87
+ * `treeSize2` — the size its LAST relayed proof reaches — as
88
+ * `checkpointConsistencyProof` requires of the checkpoint it decoded each
89
+ * link from. The chain's last link must end at the checkpoint's sealed
90
+ * size. */
89
91
  consistencyProofs: readonly CheckpointConsistencyProof[];
90
92
  /** Trusted base for a suffix chain — the size the caller already trusts
91
93
  * and that size's accumulator; omit for a chain from base 0 (size 0, an
@@ -196,7 +198,9 @@ export async function freshenReceipt(
196
198
  `first consistency proof declares tree-size-1 ${firstLink.treeSize1}; the trusted base size is ${baseSize}`,
197
199
  );
198
200
  }
199
- // Contiguity: each link continues where the previous one sealed.
201
+ // Contiguity BETWEEN checkpoints: each link continues where the previous
202
+ // one sealed. Contiguity WITHIN a link — between the proofs one
203
+ // checkpoint relays — is `computeCheckpointAccumulator`'s, below.
200
204
  for (let i = 1; i < links.length; i++) {
201
205
  if (links[i]!.treeSize1 !== links[i - 1]!.treeSize2) {
202
206
  throw new Error(
@@ -212,10 +216,11 @@ export async function freshenReceipt(
212
216
  );
213
217
  }
214
218
 
215
- // Fold the chain to the latest accumulator (self-check target). Each step
219
+ // Fold the chain to the latest accumulator (self-check target). Each link
216
220
  // runs against a size the caller trusts, never one read off the link being
217
221
  // folded: the trusted base for the first link, and the size the previous
218
- // link was just folded TO for every link after it.
222
+ // link was just folded TO for every link after it. A link relaying several
223
+ // proofs folds them all, in order, and ends at its last one.
219
224
  let accumulator = baseAccumulator;
220
225
  let sizeFrom = baseSize;
221
226
  for (const p of links) {
@@ -259,18 +264,21 @@ export async function freshenReceipt(
259
264
  for (let k = 0; k < oldPath.length; k++) {
260
265
  store.set(fullIndices[k]!, oldPath[k]!);
261
266
  }
262
- for (const link of links) {
263
- // A base-0 link (treeSize1 === 0) has no from-peaks to climb — a 0→N
267
+ // Every relayed proof contributes, not just one per checkpoint: a link
268
+ // that relays several sealed steps (ADR-0066 D2) carries one set of paths
269
+ // per step, each addressed by that step's own two sizes.
270
+ for (const step of links.flatMap((link) => link.proofs)) {
271
+ // A base-0 step (treeSize1 === 0) has no from-peaks to climb — a 0→N
264
272
  // consistency proof carries `paths: []` (the whole accumulator is its
265
273
  // right-peaks). Skip it: it contributes no store nodes, and calling
266
274
  // `peakMMRIndexes(-1n)` would throw (`posHeight(0)`, FOR-414). A genesis-
267
- // rooted `.sth` chain always starts with such a link.
268
- if (link.treeSize1 === 0n) continue;
269
- const fromPeaks = peakMMRIndexes(link.treeSize1 - 1n);
275
+ // rooted `.sth` chain always starts with such a step.
276
+ if (step.treeSize1 === 0n) continue;
277
+ const fromPeaks = peakMMRIndexes(step.treeSize1 - 1n);
270
278
  fromPeaks.forEach((peakIndex, j) => {
271
- const climb = link.paths[j];
279
+ const climb = step.paths[j];
272
280
  if (climb === undefined) return;
273
- const climbIndices = inclusionProofPath(link.treeSize2 - 1n, peakIndex);
281
+ const climbIndices = inclusionProofPath(step.treeSize2 - 1n, peakIndex);
274
282
  climbIndices.forEach((ix, e) => {
275
283
  const v = climb[e];
276
284
  if (v !== undefined) store.set(ix, v);
package/src/index.ts CHANGED
@@ -158,10 +158,15 @@ export {
158
158
  CheckpointHighSSignatureError,
159
159
  CheckpointProtectedHeaderAlgError,
160
160
  CheckpointSignedSizeMismatchError,
161
+ ConsistencyChainNotContiguousError,
162
+ EmptyConsistencyProofsError,
161
163
  type CheckpointChainLink,
162
164
  type CheckpointChainResult,
163
165
  type CheckpointConsistencyProof,
164
166
  } from "./checkpoint-chain.js";
167
+ /** One relayed consistency proof; `CheckpointConsistencyProof.proofs` holds
168
+ * the chain of them a checkpoint carries (ADR-0066 D2). */
169
+ export type { DecodedConsistencyProof } from "./decode-checkpoint-consistency-proof.js";
165
170
  /**
166
171
  * Univocity leaf commitment hash. Was CLI-private (forestrie-cli's own
167
172
  * mirror, "hoist to the library when the FOR-297 multi-hop resolver lands");