@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,105 +1,160 @@
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 { COSE_ALG_ES256, extractAlgFromProtected, isLowS, readProtectedTreeSize2, } from "@forestrie/encoding";
42
+ import { consistentRootsForSizes, mmrSizeForLeafCount, peakMMRIndexes, peaksBitmap, } from "@forestrie/merklelog";
24
43
  import { SubtleHasher } from "./subtle-hasher.js";
25
44
  import { parseCheckpoint } from "./build-receipt-offline.js";
26
- const VDS_COSE_RECEIPT_PROOFS_TAG = 396;
27
- const VDP_CONSISTENCY_PROOF_KEY = -2;
28
- function asBigint(v, what) {
29
- // Unsigned only: a negative size flows into `peakMMRIndexes`, whose
30
- // `posHeight` spins forever on a non-positive argument — a malformed `.sth`
31
- // must be rejected in bounded time, before any such call (FOR-414).
32
- if (typeof v === "bigint") {
33
- if (v < 0n)
34
- throw new Error(`${what}: must be an unsigned integer, got ${v}`);
35
- return v;
36
- }
37
- if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0) {
38
- return BigInt(v);
45
+ import { decodeConsistencyProofFromUnprotected } from "./decode-checkpoint-consistency-proof.js";
46
+ /**
47
+ * The checkpoint's SIGNED `tree-size-2` (protected header, ADR-0066 D1 as
48
+ * amended) differs from the declared `tree-size-2` of its embedded
49
+ * (unprotected) consistency proof. Distinct from a structurally malformed
50
+ * proof: {@link verifyCheckpointChain} reports this as `"size_mismatch"`,
51
+ * not `"proof_malformed"`.
52
+ */
53
+ export class CheckpointSignedSizeMismatchError extends Error {
54
+ constructor(message) {
55
+ super(message);
56
+ this.name = "CheckpointSignedSizeMismatchError";
39
57
  }
40
- throw new Error(`${what}: expected an unsigned integer`);
41
58
  }
42
- function asBytesArray(v, what) {
43
- if (!Array.isArray(v) ||
44
- v.some((e) => !(e instanceof Uint8Array) || e.length !== 32)) {
45
- throw new Error(`${what}: expected an array of 32-byte strings`);
59
+ /**
60
+ * The checkpoint's ES256 signature is the malleable high-s twin (`s > n/2`,
61
+ * `n` the P-256 group order): go-merklelog rejects these for checkpoint
62
+ * COSE_Sign1 signatures because the univocity contract's P-256 verifier
63
+ * does, so a receipt that verified here while carrying a high-s signature
64
+ * could be one the chain refuses (FOR-568 rollout item 4). Checked here,
65
+ * before any WebCrypto verify is attempted, so a rejected signature never
66
+ * reaches {@link verifyCheckpointChain}'s `verifySignature` callback. Scoped
67
+ * to the checkpoint receipt path only — this module never touches the WebAuthn
68
+ * (-65800) or session-key-endorsement (-65801) signature paths, which stay
69
+ * governed by their own canonical-form rules.
70
+ */
71
+ export class CheckpointHighSSignatureError extends Error {
72
+ constructor(message) {
73
+ super(message);
74
+ this.name = "CheckpointHighSSignatureError";
46
75
  }
47
- return v;
48
76
  }
49
- /** Decode the embedded consistency proof (`vdp` 396 key -2). */
77
+ /**
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.
84
+ *
85
+ * Also rejects a malleable high-s ES256 signature (see
86
+ * {@link CheckpointHighSSignatureError}) before any fold or WebCrypto verify
87
+ * work — a checkpoint signed with any other algorithm (e.g. KS256) is not
88
+ * subject to this check, since it does not go through the P-256 WebCrypto
89
+ * path this guards.
90
+ *
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
94
+ * carries no signed tree-size-2 label
95
+ * @throws {CheckpointSignedSizeMismatchError} when the signed tree-size-2
96
+ * differs from the declared proof's tree-size-2
97
+ * @throws {CheckpointHighSSignatureError} when the checkpoint is ES256-signed
98
+ * with a high-s (malleable) signature
99
+ */
50
100
  export function checkpointConsistencyProof(checkpointBytes) {
51
- const { unprotected } = parseCheckpoint(checkpointBytes);
52
- const vdpRaw = unprotected.get(VDS_COSE_RECEIPT_PROOFS_TAG);
53
- if (!(vdpRaw instanceof Map)) {
54
- throw new Error("checkpoint carries no verifiable-proofs header (396)");
101
+ const { coseSign1, unprotected } = parseCheckpoint(checkpointBytes);
102
+ const alg = extractAlgFromProtected(coseSign1[0]);
103
+ const signature = coseSign1[3];
104
+ if (alg === COSE_ALG_ES256 && signature.length === 64 && !isLowS(signature)) {
105
+ throw new CheckpointHighSSignatureError("checkpoint ES256 signature is not low-s canonical (s > n/2); rejected " +
106
+ "to match the univocity contract's P-256 verifier and go-merklelog");
55
107
  }
56
- const proofBstr = vdpRaw.get(VDP_CONSISTENCY_PROOF_KEY);
57
- if (!(proofBstr instanceof Uint8Array)) {
108
+ const declared = decodeConsistencyProofFromUnprotected(unprotected);
109
+ if (declared === null) {
58
110
  throw new Error("checkpoint carries no consistency proof (vdp key -2)");
59
111
  }
60
- const proof = decodeCborDeterministic(proofBstr);
61
- if (!Array.isArray(proof) || proof.length < 4) {
62
- throw new Error("consistency proof must be [tree-size-1, tree-size-2, paths, right-peaks]");
63
- }
64
- const pathsRaw = proof[2];
65
- if (!Array.isArray(pathsRaw) ||
66
- pathsRaw.some((p) => !Array.isArray(p) || p.some((n) => !(n instanceof Uint8Array)))) {
67
- throw new Error("consistency paths must be arrays of byte strings");
112
+ const signedTreeSize2 = readProtectedTreeSize2(coseSign1[0]);
113
+ if (signedTreeSize2 === null) {
114
+ throw new Error("checkpoint protected header carries no signed tree-size-2 (-65933)");
68
115
  }
69
- const treeSize1 = asBigint(proof[0], "tree-size-1");
70
- const treeSize2 = asBigint(proof[1], "tree-size-2");
71
- // A consistency proof strictly grows the tree; enforce `0 <= ts1 < ts2`
72
- // (ts1 == 0 is a legitimate base-0 first link). This is the primary guard
73
- // that keeps `treeSize2 - 1` / `treeSize1 - 1` non-negative before they
74
- // reach `peakMMRIndexes` (FOR-414); the unsigned check in `asBigint` and
75
- // the `posHeight` guard in @forestrie/merklelog are defence-in-depth.
76
- if (treeSize2 <= treeSize1) {
77
- throw new Error(`consistency proof must grow the tree: tree-size-1 ${treeSize1} < tree-size-2 ${treeSize2}`);
116
+ if (signedTreeSize2 !== declared.treeSize2) {
117
+ throw new CheckpointSignedSizeMismatchError(`signed tree-size-2 (-65933) ${signedTreeSize2} != declared consistency-proof tree-size-2 ${declared.treeSize2}`);
78
118
  }
79
119
  return {
80
- treeSize1,
81
- treeSize2,
82
- paths: pathsRaw,
83
- rightPeaks: asBytesArray(proof[3], "right-peaks"),
120
+ treeSize1: declared.treeSize1,
121
+ treeSize2: declared.treeSize2,
122
+ signedTreeSize2,
123
+ paths: declared.paths,
124
+ rightPeaks: declared.rightPeaks,
84
125
  };
85
126
  }
86
127
  /**
87
- * One fold step: from the trusted accumulator at `proof.treeSize1`,
88
- * produce the `treeSize2` accumulator (draft: `consistent_roots` output
89
- * plus the supplied right-peaks). Structural sanity: the result must have
90
- * exactly the peak count of a size-`treeSize2` MMR.
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).
133
+ *
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.
141
+ *
142
+ * @throws {Error} when `proof.treeSize1 !== sizeFrom`, or when the proof
143
+ * supplies a right-peaks count other than
144
+ * {@link consistentRootsForSizes}'s `expectedRight`
145
+ * @throws {ConsistencyShapeError} (`@forestrie/merklelog`) when the proof
146
+ * does not have the shape MMR(sizeFrom) -> MMR(proof.treeSize2) implies
91
147
  */
92
- export async function computeCheckpointAccumulator(proof, accumulatorFrom) {
148
+ 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}`);
151
+ }
93
152
  const hasher = new SubtleHasher();
94
- const proven = proof.treeSize1 === 0n
95
- ? []
96
- : await consistentRoots(hasher, proof.treeSize1 - 1n, accumulatorFrom, proof.paths);
97
- const accumulator = [...proven, ...proof.rightPeaks];
98
- const expected = peakMMRIndexes(proof.treeSize2 - 1n).length;
99
- if (accumulator.length !== expected) {
100
- throw new Error(`computed accumulator has ${accumulator.length} peaks; size ${proof.treeSize2} requires ${expected}`);
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}`);
101
156
  }
102
- return accumulator;
157
+ return [...roots, ...proof.rightPeaks];
103
158
  }
104
159
  /** Detached payload the checkpoint signature covers (ADR-0046): the raw
105
160
  * concatenation of the accumulator peaks in contract order. */
@@ -116,15 +171,34 @@ export function accumulatorPayload(accumulator) {
116
171
  * Verify a retained checkpoint chain (ascending massif order) and fold out
117
172
  * the final authenticated accumulator.
118
173
  *
119
- * - The first link must be boundary-based from 0 (a whole-log chain), or
120
- * the caller supplies `accumulatorFrom` matching its base (a suffix
121
- * chain rooted in an already-trusted accumulator).
122
- * - Every subsequent link's base must equal the previous link's sealed
123
- * size — a mismatch is the legacy (pre-FOR-410) drift signature and is
124
- * permanent for that log (`legacy_chain_break`).
174
+ * - The first link's trusted starting size is `trustedBase?.size ?? 0n`
175
+ * (base 0 for a whole-log chain) with `trustedBase?.accumulator ?? []`
176
+ * as the fold's starting accumulator (a suffix chain rooted in an
177
+ * already-trusted accumulator supplies both).
178
+ * - 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`.
184
+ * - The first link's declared `tree-size-1` must equal that trusted
185
+ * starting size, and every subsequent link's must equal the previous
186
+ * 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
189
+ * unsigned, the reason it produces carries no more meaning than the size
190
+ * disagreement itself (ADR-0066 D6: no pre-FOR-410 state is supported, so
191
+ * 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`.
125
196
  * - Each link's signature is checked over its computed accumulator via
126
197
  * the injected verifier (the caller owns trust resolution — genesis
127
- * roots, caller-known keys, or the label-1000 delegation path).
198
+ * roots, caller-known keys, or the label-1000 delegation path). A link
199
+ * joins `links` only once its signature has verified, so on any failure
200
+ * `links` is the verified prefix and never holds an accumulator nothing
201
+ * attested.
128
202
  */
129
203
  export async function verifyCheckpointChain(opts) {
130
204
  const links = [];
@@ -137,8 +211,38 @@ export async function verifyCheckpointChain(opts) {
137
211
  links,
138
212
  };
139
213
  }
140
- let accumulator = opts.accumulatorFrom ?? [];
141
- let expectedBase = null;
214
+ const trustedBase = opts.trustedBase;
215
+ if (trustedBase !== undefined) {
216
+ // A size that is not a complete MMR size describes no state: the fold
217
+ // would silently use the largest MMR below it (`peaksBitmap` rounds
218
+ // down) while every link reported the supplied value as its base.
219
+ if (trustedBase.size < 0n ||
220
+ mmrSizeForLeafCount(peaksBitmap(trustedBase.size)) !== trustedBase.size) {
221
+ return {
222
+ ok: false,
223
+ reason: "proof_malformed",
224
+ at: 0,
225
+ detail: `trusted base size ${trustedBase.size} is not a complete MMR size`,
226
+ links,
227
+ };
228
+ }
229
+ // …and the accumulator must hold exactly the peaks that size has, which
230
+ // the fold otherwise only compares against the rounded-down count.
231
+ const basePeaks = trustedBase.size === 0n
232
+ ? 0
233
+ : peakMMRIndexes(trustedBase.size - 1n).length;
234
+ if (trustedBase.accumulator.length !== basePeaks) {
235
+ return {
236
+ ok: false,
237
+ reason: "proof_malformed",
238
+ at: 0,
239
+ detail: `trusted base accumulator has ${trustedBase.accumulator.length} peaks; size ${trustedBase.size} has ${basePeaks}`,
240
+ links,
241
+ };
242
+ }
243
+ }
244
+ let accumulator = trustedBase?.accumulator ?? [];
245
+ let expectedBase = trustedBase?.size ?? 0n;
142
246
  for (let i = 0; i < opts.checkpoints.length; i++) {
143
247
  const bytes = opts.checkpoints[i];
144
248
  let proof;
@@ -146,52 +250,43 @@ export async function verifyCheckpointChain(opts) {
146
250
  proof = checkpointConsistencyProof(bytes);
147
251
  }
148
252
  catch (err) {
253
+ const reason = err instanceof CheckpointSignedSizeMismatchError
254
+ ? "size_mismatch"
255
+ : err instanceof CheckpointHighSSignatureError
256
+ ? "signature_malleable"
257
+ : "proof_malformed";
149
258
  return {
150
259
  ok: false,
151
- reason: "proof_malformed",
260
+ reason,
152
261
  at: i,
153
262
  detail: err instanceof Error ? err.message : String(err),
154
263
  links,
155
264
  };
156
265
  }
157
- if (expectedBase === null) {
158
- // First link: base 0 for a whole-log chain, else the caller's
159
- // trusted accumulator must be FOR this base (peak-count check).
160
- if (proof.treeSize1 !== 0n) {
161
- const wanted = peakMMRIndexes(proof.treeSize1 - 1n).length;
162
- if (opts.accumulatorFrom === undefined) {
163
- return {
164
- ok: false,
165
- reason: "legacy_chain_break",
166
- at: i,
167
- detail: `first checkpoint base ${proof.treeSize1} != 0 and no trusted base accumulator was supplied`,
168
- links,
169
- };
170
- }
171
- if (accumulator.length !== wanted) {
172
- return {
173
- ok: false,
174
- reason: "proof_malformed",
175
- at: i,
176
- detail: `trusted base accumulator has ${accumulator.length} peaks; base size ${proof.treeSize1} requires ${wanted}`,
177
- links,
178
- };
179
- }
266
+ if (proof.treeSize1 !== expectedBase) {
267
+ if (i === 0) {
268
+ return {
269
+ ok: false,
270
+ reason: "size_mismatch",
271
+ at: i,
272
+ detail: trustedBase === undefined
273
+ ? `first checkpoint declared tree-size-1 ${proof.treeSize1} != whole-log base size ${expectedBase} and no trusted base was supplied`
274
+ : `first checkpoint declared tree-size-1 ${proof.treeSize1} != trusted base size ${expectedBase}`,
275
+ links,
276
+ };
180
277
  }
181
- }
182
- else if (proof.treeSize1 !== expectedBase) {
183
278
  return {
184
279
  ok: false,
185
- reason: "legacy_chain_break",
280
+ reason: "size_mismatch",
186
281
  at: i,
187
- detail: `checkpoint ${i} base ${proof.treeSize1} != previous sealed size ${expectedBase} — ` +
188
- "pre-FOR-410 drifted chain (permanent for this log); fall back to the event scan, tile extension, or a holder cache",
282
+ detail: `checkpoint ${i} declared tree-size-1 ${proof.treeSize1} != the previous link's tree-size-2 ${expectedBase} — ` +
283
+ "the declared origin is unsigned, so the chain is treated as not continuous",
189
284
  links,
190
285
  };
191
286
  }
192
287
  let computed;
193
288
  try {
194
- computed = await computeCheckpointAccumulator(proof, accumulator);
289
+ computed = await computeCheckpointAccumulator(proof, accumulator, expectedBase);
195
290
  }
196
291
  catch (err) {
197
292
  return {
@@ -203,13 +298,10 @@ export async function verifyCheckpointChain(opts) {
203
298
  };
204
299
  }
205
300
  const signatureOk = await opts.verifySignature(bytes, accumulatorPayload(computed));
206
- links.push({
207
- treeSize1: proof.treeSize1,
208
- treeSize2: proof.treeSize2,
209
- accumulator: computed,
210
- signatureOk,
211
- });
212
301
  if (!signatureOk) {
302
+ // `links` stays the verified prefix: the computed accumulator of a
303
+ // link whose signature did not verify is attested by nothing, so it
304
+ // is not handed back.
213
305
  return {
214
306
  ok: false,
215
307
  reason: "signature",
@@ -218,6 +310,13 @@ export async function verifyCheckpointChain(opts) {
218
310
  links,
219
311
  };
220
312
  }
313
+ links.push({
314
+ treeSize1: proof.treeSize1,
315
+ treeSize2: proof.treeSize2,
316
+ signedTreeSize2: proof.signedTreeSize2,
317
+ accumulator: computed,
318
+ signatureOk,
319
+ });
221
320
  accumulator = computed;
222
321
  expectedBase = proof.treeSize2;
223
322
  }
@@ -0,0 +1,38 @@
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`).
5
+ *
6
+ * Single source of truth for this decode, shared by `parseCheckpoint`
7
+ * (build-receipt-offline.ts, lenient: an absent or malformed proof yields a
8
+ * `null` sealed size rather than a throw) and `checkpointConsistencyProof`
9
+ * (checkpoint-chain.ts, full validation: an absent proof or a malformed
10
+ * shape throws, and the SIGNED `tree-size-2` from the protected header —
11
+ * 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).
14
+ */
15
+ /** The declared (unprotected, unsigned) consistency proof of a checkpoint. */
16
+ export type DecodedConsistencyProof = {
17
+ treeSize1: bigint;
18
+ treeSize2: bigint;
19
+ /** One inclusion path per tree-size-1 peak, proven at tree-size-2. */
20
+ paths: Uint8Array[][];
21
+ /** New peaks not covered by the proven roots (draft `right-peaks`). */
22
+ rightPeaks: Uint8Array[];
23
+ };
24
+ /**
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.
29
+ *
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
33
+ * (`tree-size-2 <= tree-size-1`), a path element or a right-peak is not a
34
+ * 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.
36
+ */
37
+ export declare function decodeConsistencyProofFromUnprotected(unprotected: Map<number, unknown>): DecodedConsistencyProof | null;
38
+ //# sourceMappingURL=decode-checkpoint-consistency-proof.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,108 @@
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`).
5
+ *
6
+ * Single source of truth for this decode, shared by `parseCheckpoint`
7
+ * (build-receipt-offline.ts, lenient: an absent or malformed proof yields a
8
+ * `null` sealed size rather than a throw) and `checkpointConsistencyProof`
9
+ * (checkpoint-chain.ts, full validation: an absent proof or a malformed
10
+ * shape throws, and the SIGNED `tree-size-2` from the protected header —
11
+ * 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).
14
+ */
15
+ import { COSE_LABEL_VDP, VDP_CONSISTENCY_PROOF_KEY, decodeCborDeterministic, } from "@forestrie/encoding";
16
+ function asBigint(v, what) {
17
+ // Unsigned only: a negative size flows into peakMMRIndexes /
18
+ // consistentRootsForSizes, which reject or spin on a non-positive
19
+ // argument — a malformed `.sth` must be rejected in bounded time, before
20
+ // any such call (FOR-414).
21
+ if (typeof v === "bigint") {
22
+ if (v < 0n) {
23
+ throw new Error(`${what}: must be an unsigned integer, got ${v}`);
24
+ }
25
+ return v;
26
+ }
27
+ if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0) {
28
+ return BigInt(v);
29
+ }
30
+ throw new Error(`${what}: expected an unsigned integer`);
31
+ }
32
+ function asBytesArray(v, what) {
33
+ if (!Array.isArray(v) ||
34
+ v.some((e) => !(e instanceof Uint8Array) || e.length !== 32)) {
35
+ throw new Error(`${what}: expected an array of 32-byte strings`);
36
+ }
37
+ return v;
38
+ }
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
+ }
65
+ 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]");
68
+ }
69
+ const pathsRaw = proof[2];
70
+ if (!Array.isArray(pathsRaw)) {
71
+ throw new Error("consistency paths must be arrays of 32-byte strings");
72
+ }
73
+ // Every path element is an MMR node, so it is 32 bytes — the same rule
74
+ // `asBytesArray` applies to right-peaks. Both reach the same places: the
75
+ // fold hashes them, and the peaks that come out are concatenated by
76
+ // `accumulatorPayload` with no length delimiter, so a node of any other
77
+ // length makes that payload ambiguous. Checking it here also bounds the
78
+ // work an unauthenticated `.sth` can ask for before its signature is
79
+ // consulted.
80
+ for (let i = 0; i < pathsRaw.length; i++) {
81
+ const path = pathsRaw[i];
82
+ if (!Array.isArray(path)) {
83
+ throw new Error(`consistency path ${i}: expected an array of 32-byte strings`);
84
+ }
85
+ for (let j = 0; j < path.length; j++) {
86
+ const node = path[j];
87
+ if (!(node instanceof Uint8Array) || node.length !== 32) {
88
+ throw new Error(`consistency path ${i} element ${j}: expected a 32-byte string`);
89
+ }
90
+ }
91
+ }
92
+ const treeSize1 = asBigint(proof[0], "tree-size-1");
93
+ const treeSize2 = asBigint(proof[1], "tree-size-2");
94
+ // A consistency proof strictly grows the tree; enforce `0 <= ts1 < ts2`
95
+ // (ts1 == 0 is a legitimate base-0 first link). This is the primary guard
96
+ // that keeps sizes non-negative and growing before they reach
97
+ // `consistentRootsForSizes` (FOR-414); the unsigned check in `asBigint`
98
+ // and `SizeMustIncrease` in `@forestrie/merklelog` are defence-in-depth.
99
+ if (treeSize2 <= treeSize1) {
100
+ throw new Error(`consistency proof must grow the tree: tree-size-1 ${treeSize1} < tree-size-2 ${treeSize2}`);
101
+ }
102
+ return {
103
+ treeSize1,
104
+ treeSize2,
105
+ paths: pathsRaw,
106
+ rightPeaks: asBytesArray(proof[3], "right-peaks"),
107
+ };
108
+ }
@@ -1,17 +1,34 @@
1
1
  import { type CheckpointConsistencyProof } from "./checkpoint-chain.js";
2
+ /**
3
+ * BREAKING (within the 2.0.0 major already in flight): the sizeless
4
+ * `accumulatorFrom?: Uint8Array[]` seed is replaced by `trustedBase`, which
5
+ * carries the seed's SIZE alongside its peaks. Without a size the fold ran
6
+ * each link against the size read off that link's own proof, which is what
7
+ * {@link computeCheckpointAccumulator} exists to prevent (ADR-0066 D5.4) —
8
+ * a seed could only ever be checked against the peak count the proof's own
9
+ * declared base implies, and many sizes share a peak count.
10
+ */
2
11
  export type FreshenReceiptInput = {
3
12
  /** The stale receipt (COSE Sign1 with a 396 inclusion proof). */
4
13
  oldReceiptBytes: Uint8Array;
5
14
  /** The leaf's committed value: `SHA-256(idtimestamp ‖ inner)` — the same
6
15
  * value `verify` recomputes from the entry (caller derives it). */
7
16
  leafValue: Uint8Array;
8
- /** Consistency-proof chain covering [0 or a trusted seed] → the latest sealed
9
- * size, in ascending contiguous order (the raw per-checkpoint proofs, with
10
- * `paths`). The chain's last link must end at the checkpoint's sealed size. */
17
+ /** Consistency-proof chain covering [0 or the trusted base] → the latest
18
+ * sealed size, in ascending contiguous order (the raw per-checkpoint
19
+ * proofs, with `paths`). The chain's last link must end at the
20
+ * checkpoint's sealed size. */
11
21
  consistencyProofs: readonly CheckpointConsistencyProof[];
12
- /** Trusted accumulator seed for a suffix chain; omit for a chain from base 0.
13
- * Its peak count must match the first link's tree-size-1. */
14
- accumulatorFrom?: Uint8Array[];
22
+ /** Trusted base for a suffix chain — the size the caller already trusts
23
+ * and that size's accumulator; omit for a chain from base 0 (size 0, an
24
+ * empty accumulator). The size must be a complete MMR size, the
25
+ * accumulator must hold one peak per peak of that size, and the first
26
+ * link's declared `tree-size-1` must equal the size. Same shape as
27
+ * `verifyCheckpointChain`'s `trustedBase`. */
28
+ trustedBase?: {
29
+ size: bigint;
30
+ accumulator: Uint8Array[];
31
+ };
15
32
  /** The latest checkpoint (`.sth`): its pre-signed peak receipts + delegation
16
33
  * cert become the freshened receipt's signature. */
17
34
  latestCheckpointBytes: Uint8Array;
@@ -1 +1 @@
1
- {"version":3,"file":"freshen-receipt.d.ts","sourceRoot":"","sources":["../src/freshen-receipt.ts"],"names":[],"mappings":"AAyDA,OAAO,EAEL,KAAK,0BAA0B,EAChC,MAAM,uBAAuB,CAAC;AAI/B,MAAM,MAAM,mBAAmB,GAAG;IAChC,iEAAiE;IACjE,eAAe,EAAE,UAAU,CAAC;IAC5B;uEACmE;IACnE,SAAS,EAAE,UAAU,CAAC;IACtB;;mFAE+E;IAC/E,iBAAiB,EAAE,SAAS,0BAA0B,EAAE,CAAC;IACzD;iEAC6D;IAC7D,eAAe,CAAC,EAAE,UAAU,EAAE,CAAC;IAC/B;wDACoD;IACpD,qBAAqB,EAAE,UAAU,CAAC;CACnC,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,wEAAwE;IACxE,OAAO,EAAE,UAAU,CAAC;IACpB,wDAAwD;IACxD,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AASF;;;;;GAKG;AACH,wBAAsB,cAAc,CAClC,KAAK,EAAE,mBAAmB,GACzB,OAAO,CAAC,oBAAoB,CAAC,CAyJ/B"}
1
+ {"version":3,"file":"freshen-receipt.d.ts","sourceRoot":"","sources":["../src/freshen-receipt.ts"],"names":[],"mappings":"AA2DA,OAAO,EAEL,KAAK,0BAA0B,EAChC,MAAM,uBAAuB,CAAC;AAI/B;;;;;;;;GAQG;AACH,MAAM,MAAM,mBAAmB,GAAG;IAChC,iEAAiE;IACjE,eAAe,EAAE,UAAU,CAAC;IAC5B;uEACmE;IACnE,SAAS,EAAE,UAAU,CAAC;IACtB;;;mCAG+B;IAC/B,iBAAiB,EAAE,SAAS,0BAA0B,EAAE,CAAC;IACzD;;;;;kDAK8C;IAC9C,WAAW,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,UAAU,EAAE,CAAA;KAAE,CAAC;IAC1D;wDACoD;IACpD,qBAAqB,EAAE,UAAU,CAAC;CACnC,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,wEAAwE;IACxE,OAAO,EAAE,UAAU,CAAC;IACpB,wDAAwD;IACxD,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AASF;;;;;GAKG;AACH,wBAAsB,cAAc,CAClC,KAAK,EAAE,mBAAmB,GACzB,OAAO,CAAC,oBAAoB,CAAC,CAwK/B"}