@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.
- package/dist/attach-transparent-statement-receipt.d.ts +5 -1
- package/dist/attach-transparent-statement-receipt.d.ts.map +1 -1
- package/dist/attach-transparent-statement-receipt.js +7 -3
- package/dist/build-receipt-offline.d.ts +8 -1
- package/dist/build-receipt-offline.d.ts.map +1 -1
- package/dist/build-receipt-offline.js +31 -24
- package/dist/checkpoint-chain.d.ts +130 -17
- package/dist/checkpoint-chain.d.ts.map +1 -1
- package/dist/checkpoint-chain.js +216 -117
- package/dist/decode-checkpoint-consistency-proof.d.ts +38 -0
- package/dist/decode-checkpoint-consistency-proof.d.ts.map +1 -0
- package/dist/decode-checkpoint-consistency-proof.js +108 -0
- package/dist/freshen-receipt.d.ts +23 -6
- package/dist/freshen-receipt.d.ts.map +1 -1
- package/dist/freshen-receipt.js +29 -17
- package/dist/grant-codec.d.ts.map +1 -1
- package/dist/grant-codec.js +24 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/parse-receipt.d.ts.map +1 -1
- package/dist/parse-receipt.js +2 -3
- package/package.json +3 -3
- package/src/attach-transparent-statement-receipt.ts +10 -3
- package/src/build-receipt-offline.ts +41 -22
- package/src/checkpoint-chain.ts +279 -136
- package/src/decode-checkpoint-consistency-proof.ts +136 -0
- package/src/freshen-receipt.ts +56 -25
- package/src/grant-codec.ts +30 -1
- package/src/index.ts +2 -0
- package/src/parse-receipt.ts +2 -4
|
@@ -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
|
+
}
|
package/src/freshen-receipt.ts
CHANGED
|
@@ -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
|
|
72
|
-
* size, in ascending contiguous order (the raw per-checkpoint
|
|
73
|
-
* `paths`). The chain's last link must end at the
|
|
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
|
|
76
|
-
*
|
|
77
|
-
|
|
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:
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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/grant-codec.ts
CHANGED
|
@@ -25,6 +25,32 @@ function leftPad(b: Uint8Array, length: number): Uint8Array {
|
|
|
25
25
|
return out;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Grant wire v0 map keys are 0–6; keys 7 (`signer`) and 8 (`kind`) were
|
|
30
|
+
* retired before this package existed (FOR-580 owner ruling) and must be
|
|
31
|
+
* rejected here the same way the encoding and canopy-api codecs already do
|
|
32
|
+
* on the admission path (`packages/shared/encoding/src/grant-codec.ts`,
|
|
33
|
+
* `packages/apps/canopy-api/src/grant/codec.ts`) — this codec previously
|
|
34
|
+
* decoded by key number and silently ignored them.
|
|
35
|
+
*/
|
|
36
|
+
function assertNoObsoleteWireKeys(
|
|
37
|
+
m: Map<number, unknown> | Record<number, unknown>,
|
|
38
|
+
): void {
|
|
39
|
+
const keys =
|
|
40
|
+
m instanceof Map
|
|
41
|
+
? [...m.keys()]
|
|
42
|
+
: Object.keys(m as Record<string, unknown>)
|
|
43
|
+
.map(Number)
|
|
44
|
+
.filter((n) => Number.isFinite(n));
|
|
45
|
+
for (const k of keys) {
|
|
46
|
+
if (k === 7 || k === 8) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
"Grant wire v0: obsolete CBOR keys 7 (signer) and 8 (kind) must not be present; use grantData in the commitment only.",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
28
54
|
function mapToGrant(m: Map<number, unknown> | Record<number, unknown>): Grant {
|
|
29
55
|
const get = (k: number): unknown =>
|
|
30
56
|
m instanceof Map ? m.get(k) : (m as Record<number, unknown>)[k];
|
|
@@ -63,7 +89,9 @@ export function decodeGrantPayload(bytes: Uint8Array): Grant {
|
|
|
63
89
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
64
90
|
throw new Error("Grant payload must be a CBOR map");
|
|
65
91
|
}
|
|
66
|
-
|
|
92
|
+
const m = raw as Map<number, unknown> | Record<number, unknown>;
|
|
93
|
+
assertNoObsoleteWireKeys(m);
|
|
94
|
+
return mapToGrant(m);
|
|
67
95
|
}
|
|
68
96
|
|
|
69
97
|
export function decodeGrantResponse(bytes: Uint8Array): {
|
|
@@ -75,6 +103,7 @@ export function decodeGrantResponse(bytes: Uint8Array): {
|
|
|
75
103
|
throw new Error("Grant response must be a CBOR map");
|
|
76
104
|
}
|
|
77
105
|
const m = raw as Map<number, unknown> | Record<number, unknown>;
|
|
106
|
+
assertNoObsoleteWireKeys(m);
|
|
78
107
|
const get = (k: number): unknown =>
|
|
79
108
|
m instanceof Map ? m.get(k) : (m as Record<number, unknown>)[k];
|
|
80
109
|
const idtimestampVal = get(CBOR_KEY_IDTIMESTAMP);
|
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,
|
package/src/parse-receipt.ts
CHANGED
|
@@ -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(
|
|
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
|
}
|