@forestrie/receipt-verify 2.1.0 → 3.0.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.
@@ -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
@@ -38,11 +42,35 @@
38
42
  * the key, so no reason string chosen from it may mean anything more than
39
43
  * "these two sizes differ".
40
44
  */
41
- import { COSE_ALG_ES256, extractAlgFromProtected, isLowS, readProtectedTreeSize2, } from "@forestrie/encoding";
45
+ import { COSE_ALG_ES256, ProtectedHeaderAlgError, isLowS, readProtectedAlg, readProtectedTreeSize2, } from "@forestrie/encoding";
42
46
  import { consistentRootsForSizes, mmrSizeForLeafCount, peakMMRIndexes, peaksBitmap, } from "@forestrie/merklelog";
43
47
  import { SubtleHasher } from "./subtle-hasher.js";
44
48
  import { parseCheckpoint } from "./build-receipt-offline.js";
45
- import { decodeConsistencyProofFromUnprotected } from "./decode-checkpoint-consistency-proof.js";
49
+ import { decodeConsistencyProofsFromUnprotected, EmptyConsistencyProofsError, } from "./decode-checkpoint-consistency-proof.js";
50
+ /**
51
+ * A relayed consistency-proof chain does not join up: a proof's declared
52
+ * `tree-size-1` is not the size the fold has reached — the caller's trusted
53
+ * size for the first proof, the previous proof's `tree-size-2` after that —
54
+ * or, having applied every proof, the fold reaches a size other than the
55
+ * chain link's own declared `tree-size-2` (F3: a caller-assembled link —
56
+ * `freshenReceipt` callers build these from on-chain calldata, never
57
+ * through {@link checkpointConsistencyProof} — can set `proofs` and
58
+ * `treeSize2` independently, which `checkpointConsistencyProof` itself
59
+ * never allows to disagree). go-merklelog reuses its equivalent
60
+ * `ErrProofChainNotContiguous` for this same end-of-chain comparison
61
+ * (`checkpointverify.go:245-251`, folded size vs. signed size). Reported as
62
+ * `"size_mismatch"` by {@link verifyCheckpointChain}, the same as any other
63
+ * size disagreement, because the sizes it names are unsigned (ADR-0066 D2):
64
+ * nothing distinguishes a relay assembled in the wrong order from one
65
+ * assembled over a different log.
66
+ */
67
+ export class ConsistencyChainNotContiguousError extends Error {
68
+ constructor(message) {
69
+ super(message);
70
+ this.name = "ConsistencyChainNotContiguousError";
71
+ }
72
+ }
73
+ export { EmptyConsistencyProofsError };
46
74
  /**
47
75
  * The checkpoint's SIGNED `tree-size-2` (protected header, ADR-0066 D1 as
48
76
  * amended) differs from the declared `tree-size-2` of its embedded
@@ -75,12 +103,32 @@ export class CheckpointHighSSignatureError extends Error {
75
103
  }
76
104
  }
77
105
  /**
78
- * Decode the embedded consistency proof (`vdp` 396 key -2) and require its
79
- * declared `tree-size-2` to equal the checkpoint's SIGNED `tree-size-2` from
80
- * the protected header (ADR-0066 D1 as amended, D5.5, label -65933).
81
- * `tree-size-1` is not signed and is not checked here (D2 is withdrawn); see
82
- * {@link verifyCheckpointChain} for its comparison against the trusted
83
- * origin.
106
+ * The checkpoint's protected header carries no integer `alg` (label 1).
107
+ *
108
+ * The univocity contract rejects such a header outright — the structural
109
+ * walk finds the size, and the `alg` requirement in the same call raises
110
+ * `ClaimNotFound(1)` or `UnexpectedMajorType`. Off-chain the header used to
111
+ * read as "no algorithm stated", which both turned OFF the high-s rejection
112
+ * below (gated on the algorithm being ES256) and still yielded a signed size
113
+ * to fold from — so a checkpoint the chain will never anchor verified here
114
+ * under weaker rules than a well-formed one (review finding S-1). Reported
115
+ * as `"proof_malformed"` by {@link verifyCheckpointChain}.
116
+ */
117
+ export class CheckpointProtectedHeaderAlgError extends Error {
118
+ constructor(message) {
119
+ super(message);
120
+ this.name = "CheckpointProtectedHeaderAlgError";
121
+ }
122
+ }
123
+ /**
124
+ * Decode the embedded consistency proofs (`vdp` 396 key -2, one or more in
125
+ * relay order) and require the LAST proof's declared `tree-size-2` to equal
126
+ * the checkpoint's SIGNED `tree-size-2` from the protected header (ADR-0066
127
+ * D1 as amended, D2, D5.5, label -65933). The earlier proofs' sizes are not
128
+ * signed: the fold checks them against each other
129
+ * ({@link computeCheckpointAccumulator}). `tree-size-1` is not signed
130
+ * either; see {@link verifyCheckpointChain} for its comparison against the
131
+ * trusted origin.
84
132
  *
85
133
  * Also rejects a malleable high-s ES256 signature (see
86
134
  * {@link CheckpointHighSSignatureError}) before any fold or WebCrypto verify
@@ -88,73 +136,135 @@ export class CheckpointHighSSignatureError extends Error {
88
136
  * subject to this check, since it does not go through the P-256 WebCrypto
89
137
  * path this guards.
90
138
  *
91
- * @throws {Error} when the protected header carries no consistency proof,
92
- * the proof is structurally malformed (see
93
- * {@link decodeConsistencyProofFromUnprotected}), or the protected header
139
+ * @throws {Error} when the unprotected header carries no consistency proof,
140
+ * a proof is structurally malformed (see
141
+ * {@link decodeConsistencyProofsFromUnprotected}), or the protected header
94
142
  * carries no signed tree-size-2 label
143
+ * @throws {EmptyConsistencyProofsError} when the consistency-proofs array is
144
+ * present but empty
95
145
  * @throws {CheckpointSignedSizeMismatchError} when the signed tree-size-2
96
- * differs from the declared proof's tree-size-2
146
+ * differs from the LAST declared proof's tree-size-2
97
147
  * @throws {CheckpointHighSSignatureError} when the checkpoint is ES256-signed
98
148
  * with a high-s (malleable) signature
149
+ * @throws {CheckpointProtectedHeaderAlgError} when the protected header
150
+ * carries no integer `alg` (label 1)
99
151
  */
100
152
  export function checkpointConsistencyProof(checkpointBytes) {
101
153
  const { coseSign1, unprotected } = parseCheckpoint(checkpointBytes);
102
- const alg = extractAlgFromProtected(coseSign1[0]);
154
+ // Strict: a header with no integer alg is one the contract rejects, and
155
+ // reading it leniently would switch the high-s rejection below off while
156
+ // the signed size was still taken from it (S-1).
157
+ let alg;
158
+ try {
159
+ alg = readProtectedAlg(coseSign1[0]);
160
+ }
161
+ catch (err) {
162
+ if (err instanceof ProtectedHeaderAlgError) {
163
+ throw new CheckpointProtectedHeaderAlgError(`checkpoint protected header carries no integer alg (label 1): ${err.message}`);
164
+ }
165
+ throw err;
166
+ }
103
167
  const signature = coseSign1[3];
104
168
  if (alg === COSE_ALG_ES256 && signature.length === 64 && !isLowS(signature)) {
105
169
  throw new CheckpointHighSSignatureError("checkpoint ES256 signature is not low-s canonical (s > n/2); rejected " +
106
170
  "to match the univocity contract's P-256 verifier and go-merklelog");
107
171
  }
108
- const declared = decodeConsistencyProofFromUnprotected(unprotected);
109
- if (declared === null) {
172
+ const proofs = decodeConsistencyProofsFromUnprotected(unprotected);
173
+ if (proofs === null) {
110
174
  throw new Error("checkpoint carries no consistency proof (vdp key -2)");
111
175
  }
176
+ const last = proofs[proofs.length - 1];
112
177
  const signedTreeSize2 = readProtectedTreeSize2(coseSign1[0]);
113
178
  if (signedTreeSize2 === null) {
114
179
  throw new Error("checkpoint protected header carries no signed tree-size-2 (-65933)");
115
180
  }
116
- if (signedTreeSize2 !== declared.treeSize2) {
117
- throw new CheckpointSignedSizeMismatchError(`signed tree-size-2 (-65933) ${signedTreeSize2} != declared consistency-proof tree-size-2 ${declared.treeSize2}`);
181
+ // The signature covers the size the LAST proof reaches, and only that
182
+ // size: a relay may hold any number of steps before it, none of them
183
+ // signed (ADR-0066 D2).
184
+ if (signedTreeSize2 !== last.treeSize2) {
185
+ throw new CheckpointSignedSizeMismatchError(`signed tree-size-2 (-65933) ${signedTreeSize2} != declared consistency-proof tree-size-2 ${last.treeSize2}`);
118
186
  }
119
187
  return {
120
- treeSize1: declared.treeSize1,
121
- treeSize2: declared.treeSize2,
188
+ proofs,
189
+ treeSize1: proofs[0].treeSize1,
190
+ treeSize2: last.treeSize2,
122
191
  signedTreeSize2,
123
- paths: declared.paths,
124
- rightPeaks: declared.rightPeaks,
125
192
  };
126
193
  }
127
194
  /**
128
- * One fold step: from the CALLER-TRUSTED accumulator at `sizeFrom`, produce
129
- * the `proof.treeSize2` accumulator via the size-driven
130
- * {@link consistentRootsForSizes} (ADR-0066 D5) — `roots` (the proven
131
- * prefix) followed by the proof's supplied right-peaks (the target peaks no
132
- * path reaches).
195
+ * Fold a checkpoint's relayed consistency proofs, in order, from the
196
+ * CALLER-TRUSTED accumulator at `sizeFrom` to the accumulator at the last
197
+ * proof's `tree-size-2` — the value the checkpoint's signature covers.
198
+ *
199
+ * Each proof is applied by the size-driven {@link consistentRootsForSizes}
200
+ * (ADR-0066 D5), which yields `roots` (the proven prefix); the proof's own
201
+ * right-peaks (the target peaks no path reaches) complete that step's
202
+ * accumulator, and it becomes the next step's input. A checkpoint sealing
203
+ * one step carries the chain of one and runs the same loop once.
133
204
  *
134
- * `sizeFrom` is a parameter, not read off `proof`, because the fold must run
135
- * against a size the CALLER already trusts (the previous link's verified
136
- * `treeSize2`, or the caller's anchor for a first link) — reading it from
137
- * the proof instead would let an unsigned or substituted proof dictate its
138
- * own starting point. `proof.treeSize1` must equal it regardless: the two
139
- * disagreeing means this proof does not continue from the state being
140
- * folded, not a mere shape defect, so it is checked before any fold work.
205
+ * `sizeFrom` is a parameter, not read off the proofs, because the fold must
206
+ * start from a size the CALLER already trusts (the previous checkpoint's
207
+ * verified `treeSize2`, or the caller's anchor for a first link) — reading
208
+ * it from the relay instead would let an unsigned or substituted proof
209
+ * dictate its own starting point (ADR-0066 D5.4). Every proof after the
210
+ * first is held to the size the previous one reached for the same reason:
211
+ * only the last step's size is signed, so the intermediate sizes are worth
212
+ * no more than their agreement with each other.
141
213
  *
142
- * @throws {Error} when `proof.treeSize1 !== sizeFrom`, or when the proof
143
- * supplies a right-peaks count other than
214
+ * `proof.proofs` must hold at least one step and, once every step has been
215
+ * applied, the fold must have reached exactly `proof.treeSize2` (F3).
216
+ * `checkpointConsistencyProof` already guarantees both — the decode rejects
217
+ * an empty `consistency-proofs` array (`EmptyConsistencyProofsError`) and
218
+ * sets `treeSize2` from the last decoded proof — but a caller-assembled
219
+ * link (`freshenReceipt` callers building from on-chain calldata) is not
220
+ * decoded through it, so both are re-checked here rather than trusted from
221
+ * the type.
222
+ *
223
+ * @throws {EmptyConsistencyProofsError} when `proof.proofs` is empty — an
224
+ * already-decoded link the caller assembled themselves rather than one
225
+ * `checkpointConsistencyProof` produced, which never returns one
226
+ * @throws {ConsistencyChainNotContiguousError} when the first proof's
227
+ * `treeSize1` is not `sizeFrom`, a later proof's `treeSize1` is not the
228
+ * previous proof's `treeSize2`, or the size the fold reaches after every
229
+ * proof is not `proof.treeSize2`
230
+ * @throws {Error} when a proof supplies a right-peaks count other than
144
231
  * {@link consistentRootsForSizes}'s `expectedRight`
145
- * @throws {ConsistencyShapeError} (`@forestrie/merklelog`) when the proof
146
- * does not have the shape MMR(sizeFrom) -> MMR(proof.treeSize2) implies
232
+ * @throws {ConsistencyShapeError} (`@forestrie/merklelog`) when a proof does
233
+ * not have the shape its two sizes imply
147
234
  */
148
235
  export async function computeCheckpointAccumulator(proof, accumulatorFrom, sizeFrom) {
149
- if (proof.treeSize1 !== sizeFrom) {
150
- throw new Error(`consistency proof base tree-size-1 ${proof.treeSize1} does not match the trusted size ${sizeFrom}`);
236
+ if (proof.proofs.length === 0) {
237
+ throw new EmptyConsistencyProofsError("consistency proof relays no proofs (empty proofs array); at least " +
238
+ "one is required to fold");
151
239
  }
152
240
  const hasher = new SubtleHasher();
153
- const { roots, expectedRight } = await consistentRootsForSizes(hasher, sizeFrom, proof.treeSize2, accumulatorFrom, proof.paths);
154
- if (proof.rightPeaks.length !== expectedRight) {
155
- throw new Error(`checkpoint supplies ${proof.rightPeaks.length} right-peaks; size ${proof.treeSize2} requires ${expectedRight}`);
241
+ let accumulator = accumulatorFrom;
242
+ let size = sizeFrom;
243
+ for (let i = 0; i < proof.proofs.length; i++) {
244
+ const step = proof.proofs[i];
245
+ if (step.treeSize1 !== size) {
246
+ throw new ConsistencyChainNotContiguousError(i === 0
247
+ ? `consistency proof base tree-size-1 ${step.treeSize1} does not match the trusted size ${size}`
248
+ : `consistency-proofs entry ${i} declares tree-size-1 ${step.treeSize1}; the previous proof reached ${size}`);
249
+ }
250
+ const { roots, expectedRight } = await consistentRootsForSizes(hasher, size, step.treeSize2, accumulator, step.paths);
251
+ if (step.rightPeaks.length !== expectedRight) {
252
+ throw new Error(`checkpoint supplies ${step.rightPeaks.length} right-peaks; size ${step.treeSize2} requires ${expectedRight}`);
253
+ }
254
+ accumulator = [...roots, ...step.rightPeaks];
255
+ size = step.treeSize2;
256
+ }
257
+ // The fold must land exactly on the size the LINK itself declares —
258
+ // `proof.treeSize2` — not merely on whatever size its last proof happened
259
+ // to reach: a caller-assembled link can set the two independently (e.g.
260
+ // proofs folding 1 -> 3 alongside a declared treeSize2 of 7), which would
261
+ // otherwise fold to 3 and be reported as size 7 to every downstream
262
+ // caller reading the declared field instead of the fold. Mirrors
263
+ // go-merklelog's own end-of-chain check (checkpointverify.go:245-251).
264
+ if (size !== proof.treeSize2) {
265
+ throw new ConsistencyChainNotContiguousError(`consistency proof folds to tree-size-2 ${size}; the chain's declared tree-size-2 is ${proof.treeSize2}`);
156
266
  }
157
- return [...roots, ...proof.rightPeaks];
267
+ return accumulator;
158
268
  }
159
269
  /** Detached payload the checkpoint signature covers (ADR-0046): the raw
160
270
  * concatenation of the accumulator peaks in contract order. */
@@ -176,23 +286,31 @@ export function accumulatorPayload(accumulator) {
176
286
  * as the fold's starting accumulator (a suffix chain rooted in an
177
287
  * already-trusted accumulator supplies both).
178
288
  * - A supplied `trustedBase` must describe a state an MMR can be in: its
179
- * `size` a complete MMR size, and its `accumulator` holding one peak per
180
- * peak of that size. `peaksBitmap` rounds an incomplete size DOWN to the
181
- * largest MMR below it, so without the completeness check a size of 5
182
- * folds the 4 -> N shape while every link reports a base of 5 — a node
183
- * count no MMR has. Both are `proof_malformed`.
289
+ * `size` a complete MMR size, its `accumulator` holding one peak per peak
290
+ * of that size, and every one of those peaks a 32-byte node value.
291
+ * `peaksBitmap` rounds an incomplete size DOWN to the largest MMR below
292
+ * it, so without the completeness check a size of 5 folds the 4 -> N shape
293
+ * while every link reports a base of 5 — a node count no MMR has; and
294
+ * without the byte-length check an origin peak of any length is copied
295
+ * through the empty-path branch into the detached payload. All three are
296
+ * `proof_malformed`.
184
297
  * - The first link's declared `tree-size-1` must equal that trusted
185
298
  * starting size, and every subsequent link's must equal the previous
186
299
  * link's sealed `tree-size-2`; either disagreement is `size_mismatch`.
187
- * `tree-size-1` itself is never compared with a signed value (D2 is
188
- * withdrawn): only this trusted-origin comparison applies. Because it is
300
+ * `tree-size-1` itself is never compared with a signed value (the signed
301
+ * origin ADR-0066 D2 first proposed was withdrawn): only this
302
+ * trusted-origin comparison applies. Because it is
189
303
  * unsigned, the reason it produces carries no more meaning than the size
190
304
  * disagreement itself (ADR-0066 D6: no pre-FOR-410 state is supported, so
191
305
  * there is no drift condition to fall back from).
192
- * - Each checkpoint's SIGNED `tree-size-2` (ADR-0066 D1 as amended) must
193
- * equal its declared consistency-proof `tree-size-2`
194
- * ({@link checkpointConsistencyProof}); a disagreement is
195
- * `size_mismatch`.
306
+ * - A checkpoint may relay SEVERAL consistency proofs under one signature
307
+ * (ADR-0066 D2; draft `consistency-proofs = [ + consistency-proof ]`).
308
+ * Its SIGNED `tree-size-2` (ADR-0066 D1 as amended) must equal the LAST
309
+ * proof's declared `tree-size-2` ({@link checkpointConsistencyProof}),
310
+ * and each relayed proof must continue the one before it
311
+ * ({@link computeCheckpointAccumulator}); either disagreement is
312
+ * `size_mismatch`. A checkpoint sealing one step is the relay of one and
313
+ * takes the same path.
196
314
  * - Each link's signature is checked over its computed accumulator via
197
315
  * the injected verifier (the caller owns trust resolution — genesis
198
316
  * roots, caller-known keys, or the label-1000 delegation path). A link
@@ -240,6 +358,25 @@ export async function verifyCheckpointChain(opts) {
240
358
  links,
241
359
  };
242
360
  }
361
+ // …and every peak must be a 32-byte node value, the check arbor's
362
+ // producer applies to both path elements and right-peaks (`toNode32`).
363
+ // The count alone does not reach it: on the empty-path branch an origin
364
+ // peak is copied into the result verbatim, so a 0/31/33/64-byte peak
365
+ // reaches `accumulatorPayload` and shortens or lengthens the detached
366
+ // payload, with only the signature left to reject it (review finding
367
+ // I2, canopy C6).
368
+ for (let i = 0; i < trustedBase.accumulator.length; i++) {
369
+ const peak = trustedBase.accumulator[i];
370
+ if (!(peak instanceof Uint8Array) || peak.length !== 32) {
371
+ return {
372
+ ok: false,
373
+ reason: "proof_malformed",
374
+ at: 0,
375
+ detail: `trusted base accumulator peak ${i} is not a 32-byte node value (${describePeak(peak)})`,
376
+ links,
377
+ };
378
+ }
379
+ }
243
380
  }
244
381
  let accumulator = trustedBase?.accumulator ?? [];
245
382
  let expectedBase = trustedBase?.size ?? 0n;
@@ -289,9 +426,14 @@ export async function verifyCheckpointChain(opts) {
289
426
  computed = await computeCheckpointAccumulator(proof, accumulator, expectedBase);
290
427
  }
291
428
  catch (err) {
429
+ // A relay that does not join up is a size disagreement like any
430
+ // other: every size it names but the last is unsigned, so the reason
431
+ // can say no more than that two sizes differ.
292
432
  return {
293
433
  ok: false,
294
- reason: "proof_malformed",
434
+ reason: err instanceof ConsistencyChainNotContiguousError
435
+ ? "size_mismatch"
436
+ : "proof_malformed",
295
437
  at: i,
296
438
  detail: err instanceof Error ? err.message : String(err),
297
439
  links,
@@ -322,3 +464,13 @@ export async function verifyCheckpointChain(opts) {
322
464
  }
323
465
  return { ok: true, links, accumulator };
324
466
  }
467
+ /** Name what was found where a 32-byte accumulator peak was required. */
468
+ function describePeak(peak) {
469
+ if (peak instanceof Uint8Array)
470
+ return `${peak.length} bytes`;
471
+ if (peak === null)
472
+ return "null";
473
+ if (Array.isArray(peak))
474
+ return "an array";
475
+ return typeof peak;
476
+ }
@@ -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,10 +22,11 @@
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
- /** The declared (unprotected, unsigned) consistency proof of a checkpoint. */
29
+ /** One declared (unprotected, unsigned) consistency proof of a checkpoint. */
16
30
  export type DecodedConsistencyProof = {
17
31
  treeSize1: bigint;
18
32
  treeSize2: bigint;
@@ -22,17 +36,35 @@ export type DecodedConsistencyProof = {
22
36
  rightPeaks: Uint8Array[];
23
37
  };
24
38
  /**
25
- * Decode the embedded consistency proof from a checkpoint's UNPROTECTED
26
- * header map. Returns `null` when the checkpoint carries no verifiable-proofs
27
- * header (396) or no consistency-proof bstr there (key -2) — an ABSENT
28
- * proof, not a malformed one.
39
+ * The verifiable-proofs header carries the consistency-proof key with an
40
+ * EMPTY array. Distinct from an absent proof (no -2 key at all, which
41
+ * decodes to `null`): the key is present and claims to relay a chain, but
42
+ * the chain has no links, so there is nothing to fold and no last proof for
43
+ * the signed `tree-size-2` to equal. `consistency-proofs = [ + ... ]`
44
+ * requires at least one.
45
+ */
46
+ export declare class EmptyConsistencyProofsError extends Error {
47
+ constructor(message: string);
48
+ }
49
+ /**
50
+ * Decode the embedded consistency proofs from a checkpoint's UNPROTECTED
51
+ * header map, in the order they are relayed. Returns `null` when the
52
+ * checkpoint carries no verifiable-proofs header (396) or no
53
+ * consistency-proof entry there (key -2) — an ABSENT proof, not a malformed
54
+ * one. A returned array always holds at least one proof.
55
+ *
56
+ * This decode establishes each proof's shape only. Nothing here relates one
57
+ * proof to the next, or to a signed size: the chain has to be checked
58
+ * against state the CALLER trusts, which is `computeCheckpointAccumulator`
59
+ * and `checkpointConsistencyProof` (ADR-0066 D5.4).
29
60
  *
30
- * @throws When a consistency-proof bstr IS present but its contents are not
31
- * the shape `[tree-size-1, tree-size-2, paths, right-peaks]`, either size
32
- * is not an unsigned integer, the proof does not grow the tree
61
+ * @throws {EmptyConsistencyProofsError} when the -2 entry is an empty array
62
+ * @throws When a consistency proof IS present but its contents are not the
63
+ * shape `[tree-size-1, tree-size-2, paths, right-peaks]`, either size is
64
+ * not an unsigned integer, a proof does not grow the tree
33
65
  * (`tree-size-2 <= tree-size-1`), a path element or a right-peak is not a
34
66
  * 32-byte string — or when header 396 is present but is not map-valued,
35
- * or its `-2` entry is present but not a byte string.
67
+ * or its `-2` entry is neither a byte string nor an array of byte strings.
36
68
  */
37
- export declare function decodeConsistencyProofFromUnprotected(unprotected: Map<number, unknown>): DecodedConsistencyProof | null;
69
+ export declare function decodeConsistencyProofsFromUnprotected(unprotected: Map<number, unknown>): DecodedConsistencyProof[] | null;
38
70
  //# sourceMappingURL=decode-checkpoint-consistency-proof.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"decode-checkpoint-consistency-proof.d.ts","sourceRoot":"","sources":["../src/decode-checkpoint-consistency-proof.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAQH,8EAA8E;AAC9E,MAAM,MAAM,uBAAuB,GAAG;IACpC,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;AA6BF;;;;;;;;;;;;GAYG;AACH,wBAAgB,qCAAqC,CACnD,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,uBAAuB,GAAG,IAAI,CA8DhC"}
1
+ {"version":3,"file":"decode-checkpoint-consistency-proof.d.ts","sourceRoot":"","sources":["../src/decode-checkpoint-consistency-proof.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAQH,8EAA8E;AAC9E,MAAM,MAAM,uBAAuB,GAAG;IACpC,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;;;;;;;GAOG;AACH,qBAAa,2BAA4B,SAAQ,KAAK;gBACxC,OAAO,EAAE,MAAM;CAI5B;AA8GD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,sCAAsC,CACpD,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,uBAAuB,EAAE,GAAG,IAAI,CA6BlC"}
@@ -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,10 +22,25 @@
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
  import { COSE_LABEL_VDP, VDP_CONSISTENCY_PROOF_KEY, decodeCborDeterministic, } from "@forestrie/encoding";
30
+ /**
31
+ * The verifiable-proofs header carries the consistency-proof key with an
32
+ * EMPTY array. Distinct from an absent proof (no -2 key at all, which
33
+ * decodes to `null`): the key is present and claims to relay a chain, but
34
+ * the chain has no links, so there is nothing to fold and no last proof for
35
+ * the signed `tree-size-2` to equal. `consistency-proofs = [ + ... ]`
36
+ * requires at least one.
37
+ */
38
+ export class EmptyConsistencyProofsError extends Error {
39
+ constructor(message) {
40
+ super(message);
41
+ this.name = "EmptyConsistencyProofsError";
42
+ }
43
+ }
16
44
  function asBigint(v, what) {
17
45
  // Unsigned only: a negative size flows into peakMMRIndexes /
18
46
  // consistentRootsForSizes, which reject or spin on a non-positive
@@ -36,35 +64,15 @@ function asBytesArray(v, what) {
36
64
  }
37
65
  return v;
38
66
  }
39
- /**
40
- * Decode the embedded consistency proof from a checkpoint's UNPROTECTED
41
- * header map. Returns `null` when the checkpoint carries no verifiable-proofs
42
- * header (396) or no consistency-proof bstr there (key -2) — an ABSENT
43
- * proof, not a malformed one.
44
- *
45
- * @throws When a consistency-proof bstr IS present but its contents are not
46
- * the shape `[tree-size-1, tree-size-2, paths, right-peaks]`, either size
47
- * is not an unsigned integer, the proof does not grow the tree
48
- * (`tree-size-2 <= tree-size-1`), a path element or a right-peak is not a
49
- * 32-byte string — or when header 396 is present but is not map-valued,
50
- * or its `-2` entry is present but not a byte string.
51
- */
52
- export function decodeConsistencyProofFromUnprotected(unprotected) {
53
- const vdpRaw = unprotected.get(COSE_LABEL_VDP);
54
- if (vdpRaw === undefined || vdpRaw === null)
55
- return null;
56
- if (!(vdpRaw instanceof Map)) {
57
- throw new Error("checkpoint carries no verifiable-proofs header (396)");
58
- }
59
- const proofBstr = vdpRaw.get(VDP_CONSISTENCY_PROOF_KEY);
60
- if (proofBstr === undefined || proofBstr === null)
61
- return null;
62
- if (!(proofBstr instanceof Uint8Array)) {
63
- throw new Error("checkpoint carries no consistency proof (vdp key -2)");
64
- }
67
+ /** Decode one `bstr .cbor [tree-size-1, tree-size-2, paths, right-peaks]`. */
68
+ function decodeOneProof(proofBstr) {
65
69
  const proof = decodeCborDeterministic(proofBstr);
66
- if (!Array.isArray(proof) || proof.length < 4) {
67
- throw new Error("consistency proof must be [tree-size-1, tree-size-2, paths, right-peaks]");
70
+ // Exactly 4 — the draft's CDDL names a fixed-arity array, and
71
+ // go-merklelog's decoder rejects any other length (F2). A 5th element
72
+ // (e.g. another proof tuple, mistaken for a chain of two) is as malformed
73
+ // as a 3rd missing.
74
+ if (!Array.isArray(proof) || proof.length !== 4) {
75
+ throw new Error(`consistency proof must be [tree-size-1, tree-size-2, paths, right-peaks] (4 elements), got ${Array.isArray(proof) ? proof.length : typeof proof}`);
68
76
  }
69
77
  const pathsRaw = proof[2];
70
78
  if (!Array.isArray(pathsRaw)) {
@@ -106,3 +114,67 @@ export function decodeConsistencyProofFromUnprotected(unprotected) {
106
114
  rightPeaks: asBytesArray(proof[3], "right-peaks"),
107
115
  };
108
116
  }
117
+ /**
118
+ * Decode proof `at` of a relayed chain, naming its position in the message
119
+ * so a chain of several says which link is malformed. A header carrying a
120
+ * single proof names no position: its message is the one a single-proof
121
+ * checkpoint has always produced.
122
+ */
123
+ function decodeProofAt(proofBstr, at) {
124
+ if (at === null)
125
+ return decodeOneProof(proofBstr);
126
+ try {
127
+ return decodeOneProof(proofBstr);
128
+ }
129
+ catch (err) {
130
+ throw new Error(`consistency-proofs entry ${at}: ${err instanceof Error ? err.message : String(err)}`);
131
+ }
132
+ }
133
+ /**
134
+ * Decode the embedded consistency proofs from a checkpoint's UNPROTECTED
135
+ * header map, in the order they are relayed. Returns `null` when the
136
+ * checkpoint carries no verifiable-proofs header (396) or no
137
+ * consistency-proof entry there (key -2) — an ABSENT proof, not a malformed
138
+ * one. A returned array always holds at least one proof.
139
+ *
140
+ * This decode establishes each proof's shape only. Nothing here relates one
141
+ * proof to the next, or to a signed size: the chain has to be checked
142
+ * against state the CALLER trusts, which is `computeCheckpointAccumulator`
143
+ * and `checkpointConsistencyProof` (ADR-0066 D5.4).
144
+ *
145
+ * @throws {EmptyConsistencyProofsError} when the -2 entry is an empty array
146
+ * @throws When a consistency proof IS present but its contents are not the
147
+ * shape `[tree-size-1, tree-size-2, paths, right-peaks]`, either size is
148
+ * not an unsigned integer, a proof does not grow the tree
149
+ * (`tree-size-2 <= tree-size-1`), a path element or a right-peak is not a
150
+ * 32-byte string — or when header 396 is present but is not map-valued,
151
+ * or its `-2` entry is neither a byte string nor an array of byte strings.
152
+ */
153
+ export function decodeConsistencyProofsFromUnprotected(unprotected) {
154
+ const vdpRaw = unprotected.get(COSE_LABEL_VDP);
155
+ if (vdpRaw === undefined || vdpRaw === null)
156
+ return null;
157
+ if (!(vdpRaw instanceof Map)) {
158
+ throw new Error("checkpoint carries no verifiable-proofs header (396)");
159
+ }
160
+ const entry = vdpRaw.get(VDP_CONSISTENCY_PROOF_KEY);
161
+ if (entry === undefined || entry === null)
162
+ return null;
163
+ if (entry instanceof Uint8Array) {
164
+ // The pre-array shape: a single proof written straight under -2. It is
165
+ // the array of one, and is reported as such.
166
+ return [decodeOneProof(entry)];
167
+ }
168
+ if (!Array.isArray(entry)) {
169
+ throw new Error("checkpoint carries no consistency proof (vdp key -2)");
170
+ }
171
+ if (entry.length === 0) {
172
+ throw new EmptyConsistencyProofsError("consistency-proofs (vdp key -2) is empty; at least one proof is required");
173
+ }
174
+ return entry.map((proofBstr, i) => {
175
+ if (!(proofBstr instanceof Uint8Array)) {
176
+ throw new Error(`consistency-proofs entry ${i} is not a byte string (vdp key -2)`);
177
+ }
178
+ return decodeProofAt(proofBstr, entry.length === 1 ? null : i);
179
+ });
180
+ }