@forestrie/receipt-verify 1.1.0 → 2.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.
@@ -0,0 +1,136 @@
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
+
16
+ import {
17
+ COSE_LABEL_VDP,
18
+ VDP_CONSISTENCY_PROOF_KEY,
19
+ decodeCborDeterministic,
20
+ } from "@forestrie/encoding";
21
+
22
+ /** The declared (unprotected, unsigned) consistency proof of a checkpoint. */
23
+ export type DecodedConsistencyProof = {
24
+ treeSize1: bigint;
25
+ treeSize2: bigint;
26
+ /** One inclusion path per tree-size-1 peak, proven at tree-size-2. */
27
+ paths: Uint8Array[][];
28
+ /** New peaks not covered by the proven roots (draft `right-peaks`). */
29
+ rightPeaks: Uint8Array[];
30
+ };
31
+
32
+ function asBigint(v: unknown, what: string): bigint {
33
+ // Unsigned only: a negative size flows into peakMMRIndexes /
34
+ // consistentRootsForSizes, which reject or spin on a non-positive
35
+ // argument — a malformed `.sth` must be rejected in bounded time, before
36
+ // any such call (FOR-414).
37
+ if (typeof v === "bigint") {
38
+ if (v < 0n) {
39
+ throw new Error(`${what}: must be an unsigned integer, got ${v}`);
40
+ }
41
+ return v;
42
+ }
43
+ if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0) {
44
+ return BigInt(v);
45
+ }
46
+ throw new Error(`${what}: expected an unsigned integer`);
47
+ }
48
+
49
+ function asBytesArray(v: unknown, what: string): Uint8Array[] {
50
+ if (
51
+ !Array.isArray(v) ||
52
+ v.some((e) => !(e instanceof Uint8Array) || e.length !== 32)
53
+ ) {
54
+ throw new Error(`${what}: expected an array of 32-byte strings`);
55
+ }
56
+ return v as Uint8Array[];
57
+ }
58
+
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
+ }
85
+ const proof = decodeCborDeterministic(proofBstr);
86
+ if (!Array.isArray(proof) || proof.length < 4) {
87
+ throw new Error(
88
+ "consistency proof must be [tree-size-1, tree-size-2, paths, right-peaks]",
89
+ );
90
+ }
91
+ const pathsRaw = proof[2];
92
+ if (!Array.isArray(pathsRaw)) {
93
+ throw new Error("consistency paths must be arrays of 32-byte strings");
94
+ }
95
+ // Every path element is an MMR node, so it is 32 bytes — the same rule
96
+ // `asBytesArray` applies to right-peaks. Both reach the same places: the
97
+ // fold hashes them, and the peaks that come out are concatenated by
98
+ // `accumulatorPayload` with no length delimiter, so a node of any other
99
+ // length makes that payload ambiguous. Checking it here also bounds the
100
+ // work an unauthenticated `.sth` can ask for before its signature is
101
+ // consulted.
102
+ for (let i = 0; i < pathsRaw.length; i++) {
103
+ const path = pathsRaw[i] as unknown;
104
+ if (!Array.isArray(path)) {
105
+ throw new Error(
106
+ `consistency path ${i}: expected an array of 32-byte strings`,
107
+ );
108
+ }
109
+ for (let j = 0; j < path.length; j++) {
110
+ const node = path[j] as unknown;
111
+ if (!(node instanceof Uint8Array) || node.length !== 32) {
112
+ throw new Error(
113
+ `consistency path ${i} element ${j}: expected a 32-byte string`,
114
+ );
115
+ }
116
+ }
117
+ }
118
+ const treeSize1 = asBigint(proof[0], "tree-size-1");
119
+ const treeSize2 = asBigint(proof[1], "tree-size-2");
120
+ // A consistency proof strictly grows the tree; enforce `0 <= ts1 < ts2`
121
+ // (ts1 == 0 is a legitimate base-0 first link). This is the primary guard
122
+ // that keeps sizes non-negative and growing before they reach
123
+ // `consistentRootsForSizes` (FOR-414); the unsigned check in `asBigint`
124
+ // and `SizeMustIncrease` in `@forestrie/merklelog` are defence-in-depth.
125
+ if (treeSize2 <= treeSize1) {
126
+ throw new Error(
127
+ `consistency proof must grow the tree: tree-size-1 ${treeSize1} < tree-size-2 ${treeSize2}`,
128
+ );
129
+ }
130
+ return {
131
+ treeSize1,
132
+ treeSize2,
133
+ paths: pathsRaw as Uint8Array[][],
134
+ rightPeaks: asBytesArray(proof[3], "right-peaks"),
135
+ };
136
+ }
@@ -48,8 +48,10 @@
48
48
  import {
49
49
  calculateRoot,
50
50
  inclusionProofPath,
51
+ mmrSizeForLeafCount,
51
52
  peakIndexForLeafProof,
52
53
  peakMMRIndexes,
54
+ peaksBitmap,
53
55
  } from "@forestrie/merklelog";
54
56
  import {
55
57
  assembleReceiptFromProof,
@@ -62,19 +64,33 @@ import {
62
64
  import { parseReceipt } from "./parse-receipt.js";
63
65
  import { SubtleHasher } from "./subtle-hasher.js";
64
66
 
67
+ /**
68
+ * BREAKING (within the 2.0.0 major already in flight): the sizeless
69
+ * `accumulatorFrom?: Uint8Array[]` seed is replaced by `trustedBase`, which
70
+ * carries the seed's SIZE alongside its peaks. Without a size the fold ran
71
+ * each link against the size read off that link's own proof, which is what
72
+ * {@link computeCheckpointAccumulator} exists to prevent (ADR-0066 D5.4) —
73
+ * a seed could only ever be checked against the peak count the proof's own
74
+ * declared base implies, and many sizes share a peak count.
75
+ */
65
76
  export type FreshenReceiptInput = {
66
77
  /** The stale receipt (COSE Sign1 with a 396 inclusion proof). */
67
78
  oldReceiptBytes: Uint8Array;
68
79
  /** The leaf's committed value: `SHA-256(idtimestamp ‖ inner)` — the same
69
80
  * value `verify` recomputes from the entry (caller derives it). */
70
81
  leafValue: Uint8Array;
71
- /** Consistency-proof chain covering [0 or a trusted seed] → the latest sealed
72
- * size, in ascending contiguous order (the raw per-checkpoint proofs, with
73
- * `paths`). The chain's last link must end at the checkpoint's sealed size. */
82
+ /** Consistency-proof chain covering [0 or the trusted base] → the latest
83
+ * sealed size, in ascending contiguous order (the raw per-checkpoint
84
+ * proofs, with `paths`). The chain's last link must end at the
85
+ * checkpoint's sealed size. */
74
86
  consistencyProofs: readonly CheckpointConsistencyProof[];
75
- /** Trusted accumulator seed for a suffix chain; omit for a chain from base 0.
76
- * Its peak count must match the first link's tree-size-1. */
77
- accumulatorFrom?: Uint8Array[];
87
+ /** Trusted base for a suffix chain — the size the caller already trusts
88
+ * and that size's accumulator; omit for a chain from base 0 (size 0, an
89
+ * empty accumulator). The size must be a complete MMR size, the
90
+ * accumulator must hold one peak per peak of that size, and the first
91
+ * link's declared `tree-size-1` must equal the size. Same shape as
92
+ * `verifyCheckpointChain`'s `trustedBase`. */
93
+ trustedBase?: { size: bigint; accumulator: Uint8Array[] };
78
94
  /** The latest checkpoint (`.sth`): its pre-signed peak receipts + delegation
79
95
  * cert become the freshened receipt's signature. */
80
96
  latestCheckpointBytes: Uint8Array;
@@ -134,22 +150,32 @@ export async function freshenReceipt(
134
150
  );
135
151
  }
136
152
  const firstLink = links[0]!;
137
- // Base: a base-0 chain starts from an empty accumulator; a suffix chain's
138
- // trusted seed must have the peak count of its tree-size-1.
139
- const baseCount = input.accumulatorFrom?.length ?? 0;
140
- if (firstLink.treeSize1 === 0n) {
141
- if (baseCount !== 0) {
142
- throw new Error(
143
- "base-0 consistency chain must start from an empty accumulator seed",
144
- );
145
- }
146
- } else {
147
- const wanted = peakMMRIndexes(firstLink.treeSize1 - 1n).length;
148
- if (baseCount !== wanted) {
149
- throw new Error(
150
- `base accumulator has ${baseCount} peaks; first link size ${firstLink.treeSize1} requires ${wanted}`,
151
- );
152
- }
153
+ // Base: the CALLER's trusted size and its accumulator (size 0 and an empty
154
+ // accumulator for a whole-log chain). The size has to be a size an MMR can
155
+ // have — `peaksBitmap` rounds an incomplete one DOWN, so a size of 5 would
156
+ // fold the 4 -> N shape — and the accumulator has to hold that size's
157
+ // peaks, which is the check the proof's own declared base used to stand in
158
+ // for.
159
+ const baseSize = input.trustedBase?.size ?? 0n;
160
+ const baseAccumulator = input.trustedBase?.accumulator ?? [];
161
+ if (
162
+ baseSize < 0n ||
163
+ mmrSizeForLeafCount(peaksBitmap(baseSize)) !== baseSize
164
+ ) {
165
+ throw new Error(`trusted base size ${baseSize} is not a complete MMR size`);
166
+ }
167
+ const basePeaks = baseSize === 0n ? 0 : peakMMRIndexes(baseSize - 1n).length;
168
+ if (baseAccumulator.length !== basePeaks) {
169
+ throw new Error(
170
+ `base accumulator has ${baseAccumulator.length} peaks; trusted base size ${baseSize} has ${basePeaks}`,
171
+ );
172
+ }
173
+ // The first link must continue from that size, not from a size it names
174
+ // itself (ADR-0066 D5.4).
175
+ if (firstLink.treeSize1 !== baseSize) {
176
+ throw new Error(
177
+ `first consistency proof declares tree-size-1 ${firstLink.treeSize1}; the trusted base size is ${baseSize}`,
178
+ );
153
179
  }
154
180
  // Contiguity: each link continues where the previous one sealed.
155
181
  for (let i = 1; i < links.length; i++) {
@@ -167,10 +193,15 @@ export async function freshenReceipt(
167
193
  );
168
194
  }
169
195
 
170
- // Fold the chain to the latest accumulator (self-check target).
171
- let accumulator = input.accumulatorFrom ?? [];
196
+ // Fold the chain to the latest accumulator (self-check target). Each step
197
+ // runs against a size the caller trusts, never one read off the link being
198
+ // folded: the trusted base for the first link, and the size the previous
199
+ // link was just folded TO for every link after it.
200
+ let accumulator = baseAccumulator;
201
+ let sizeFrom = baseSize;
172
202
  for (const p of links) {
173
- accumulator = await computeCheckpointAccumulator(p, accumulator);
203
+ accumulator = await computeCheckpointAccumulator(p, accumulator, sizeFrom);
204
+ sizeFrom = p.treeSize2;
174
205
  }
175
206
  const aLatest = accumulator;
176
207
 
package/src/index.ts CHANGED
@@ -155,6 +155,8 @@ export {
155
155
  checkpointConsistencyProof,
156
156
  computeCheckpointAccumulator,
157
157
  verifyCheckpointChain,
158
+ CheckpointHighSSignatureError,
159
+ CheckpointSignedSizeMismatchError,
158
160
  type CheckpointChainLink,
159
161
  type CheckpointChainResult,
160
162
  type CheckpointConsistencyProof,
@@ -1,8 +1,6 @@
1
- import { decodeCborDeterministic } from "@forestrie/encoding";
1
+ import { COSE_LABEL_VDP, decodeCborDeterministic } from "@forestrie/encoding";
2
2
  import type { Proof } from "@forestrie/merklelog";
3
3
 
4
- const VDS_COSE_RECEIPT_PROOFS_TAG = 396;
5
-
6
4
  export type CoseSign1 = [
7
5
  protectedHeader: Uint8Array,
8
6
  unprotectedHeader: Map<number, unknown> | Record<string, unknown>,
@@ -81,7 +79,7 @@ export function parseReceipt(receiptBytes: Uint8Array): {
81
79
  }
82
80
 
83
81
  const unprotected = toHeaderMap(coseSign1[1]);
84
- const proofsRaw = unprotected.get(VDS_COSE_RECEIPT_PROOFS_TAG);
82
+ const proofsRaw = unprotected.get(COSE_LABEL_VDP);
85
83
  if (!proofsRaw || typeof proofsRaw !== "object") {
86
84
  throw new Error("Receipt missing header 396 (inclusion proof)");
87
85
  }