@mikeargento/bitgraph-verify 1.6.0 → 1.7.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/fuse-member.d.ts +14 -5
- package/dist/fuse-member.d.ts.map +1 -1
- package/dist/fuse-member.js +117 -23
- package/dist/fuse-member.js.map +1 -1
- package/dist/fuse-merkle.d.ts +42 -0
- package/dist/fuse-merkle.d.ts.map +1 -0
- package/dist/fuse-merkle.js +188 -0
- package/dist/fuse-merkle.js.map +1 -0
- package/dist/fuse.d.ts +69 -2
- package/dist/fuse.d.ts.map +1 -1
- package/dist/fuse.js +213 -6
- package/dist/fuse.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/fuse-member.ts +147 -25
- package/src/fuse-merkle.ts +177 -0
- package/src/fuse.ts +217 -6
- package/src/index.ts +3 -0
package/src/fuse-member.ts
CHANGED
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
* canonical set manifest (placement set/1, see fuse.ts) and every member's
|
|
8
8
|
* fused bytes carry the set's commitment via the member's own placement.
|
|
9
9
|
* verifyFuse answers for the manifest; this module answers for a member.
|
|
10
|
+
* A set/2 (fuse.ts) commits the ROOT of a Merkle tree over the same rows
|
|
11
|
+
* instead of the list; its member carries its row, its leaf index and its
|
|
12
|
+
* sibling path, and binding means: root document hashed to the signed
|
|
13
|
+
* artifact and sealed to the slot, then the path reaching that root. From
|
|
14
|
+
* there the two set kinds share every step below.
|
|
10
15
|
*
|
|
11
16
|
* Given a proof whose signed attribution declares set/1 and a file:
|
|
12
17
|
* 1. Verify the proof as an ordinary bitgraph/1 proof. Fail: stop.
|
|
@@ -47,9 +52,14 @@ import {
|
|
|
47
52
|
PLACEMENTS,
|
|
48
53
|
parseSetManifest,
|
|
49
54
|
readFuseAttribution,
|
|
55
|
+
parseSetMemberProof,
|
|
56
|
+
parseSetRoot,
|
|
50
57
|
readSetMetadata,
|
|
58
|
+
SET_MEMBER_METADATA_KEY,
|
|
51
59
|
SET_METADATA_KEY,
|
|
52
60
|
SET_PLACEMENT_ID,
|
|
61
|
+
SET2_PLACEMENT_ID,
|
|
62
|
+
setRootFromMember,
|
|
53
63
|
type Located,
|
|
54
64
|
type Placement,
|
|
55
65
|
type SetMember,
|
|
@@ -60,6 +70,8 @@ export type FuseMemberCategory =
|
|
|
60
70
|
| "SET_MEMBER_DIRECT"
|
|
61
71
|
| "SET_MEMBER_FROM_ORIGIN"
|
|
62
72
|
| "SET_NOT_MEMBER"
|
|
73
|
+
| "SET_MEMBERSHIP_UNPROVEN"
|
|
74
|
+
| "INVALID_SET_PATH"
|
|
63
75
|
| "RECONSTRUCTION_MISMATCH"
|
|
64
76
|
| "INVALID_SET_MANIFEST"
|
|
65
77
|
| "INVALID_SLOT_COMMITMENT"
|
|
@@ -69,20 +81,29 @@ export type FuseMemberCategory =
|
|
|
69
81
|
| "NO_MATCH";
|
|
70
82
|
|
|
71
83
|
export interface FuseSetEvidence {
|
|
72
|
-
/**
|
|
84
|
+
/** "set/1": the whole member list is the committed artifact. "set/2": a Merkle root over the rows is. */
|
|
85
|
+
kind: "set/1" | "set/2";
|
|
86
|
+
/** Where the committed artifact's bytes came from (the set/1 manifest or the set/2 root document): the proof's metadata, or the caller's argument. */
|
|
73
87
|
manifestSource: "metadata" | "argument";
|
|
74
|
-
/** SHA-256 of the bound
|
|
88
|
+
/** SHA-256 of the bound bytes; equals artifactDigestB64 once bound. */
|
|
75
89
|
manifestDigestB64: string;
|
|
90
|
+
/** set/1: the rows listed. set/2: the count the root document states. */
|
|
76
91
|
memberCount: number;
|
|
77
|
-
/**
|
|
92
|
+
/** set/2 only: where the member's evidence (row, index, path) came from; null when none was in hand. */
|
|
93
|
+
memberSource: "metadata" | "argument" | null;
|
|
94
|
+
/** set/2 only: the tree root, standard base64. */
|
|
95
|
+
treeRootB64: string | null;
|
|
96
|
+
/** The matched row. Non-null ONLY on the two member categories. `index` is the row's index in the list (set/1) or its leaf index in the tree (set/2). */
|
|
78
97
|
member: { index: number; placement: string; fusedDigestB64: string; originDigestB64: string } | null;
|
|
79
98
|
}
|
|
80
99
|
|
|
81
100
|
export interface FuseMemberOptions {
|
|
82
101
|
proof: BitGraphProof;
|
|
83
102
|
bytes: Uint8Array;
|
|
84
|
-
/** Canonical set manifest
|
|
103
|
+
/** Canonical bytes of the committed artifact (a set/1 manifest, or a set/2 root document); when given they replace proof.metadata as the source and are never silently replaced by it. */
|
|
85
104
|
manifest?: Uint8Array | null;
|
|
105
|
+
/** set/2: the member's evidence (row, index, count, path) as its JSON object; when given it replaces proof.metadata[SET_MEMBER_METADATA_KEY]. */
|
|
106
|
+
member?: unknown;
|
|
86
107
|
trustAnchors?: VerificationPolicy;
|
|
87
108
|
/** Refuse to accept a span wider than this many positions (M - N). Never affects validity categories. */
|
|
88
109
|
maxPositions?: bigint | number;
|
|
@@ -109,10 +130,18 @@ declare const admittedBrand: unique symbol;
|
|
|
109
130
|
*/
|
|
110
131
|
interface BoundSet {
|
|
111
132
|
readonly [boundBrand]: true;
|
|
133
|
+
readonly kind: "set/1" | "set/2";
|
|
112
134
|
readonly source: "metadata" | "argument";
|
|
113
135
|
readonly digestB64: string;
|
|
114
136
|
readonly commitment: Uint8Array;
|
|
137
|
+
/** set/1: every listed row. set/2: the one row whose path reached the root, or none when no evidence was in hand. */
|
|
115
138
|
readonly rows: readonly SetMember[];
|
|
139
|
+
/** The set's member count: rows.length for set/1, the root document's count for set/2. */
|
|
140
|
+
readonly count: number;
|
|
141
|
+
/** set/2: the leaf index of each of `rows` in the tree; null for set/1, where a row's index is its position in `rows`. */
|
|
142
|
+
readonly treeIndices: readonly number[] | null;
|
|
143
|
+
readonly memberSource: "metadata" | "argument" | null;
|
|
144
|
+
readonly rootB64: string | null;
|
|
116
145
|
/** Lowercase hex of a row's artifact digest to its index. */
|
|
117
146
|
readonly byArtifact: ReadonlyMap<string, number>;
|
|
118
147
|
/** Lowercase hex of an origin digest to the indices listing it, in manifest order. */
|
|
@@ -167,7 +196,64 @@ function bindSetManifest(proof: BitGraphProof, commitment: Uint8Array, bytes: Ui
|
|
|
167
196
|
const origin = bytesToHex(row.origin);
|
|
168
197
|
byOrigin.set(origin, [...(byOrigin.get(origin) ?? []), index]);
|
|
169
198
|
});
|
|
170
|
-
const bound: Omit<BoundSet, typeof boundBrand> = { source, digestB64: bytesToBase64(digest), commitment, rows: manifest.members, byArtifact, byOrigin };
|
|
199
|
+
const bound: Omit<BoundSet, typeof boundBrand> = { kind: "set/1", source, digestB64: bytesToBase64(digest), commitment, rows: manifest.members, count: manifest.members.length, treeIndices: null, memberSource: null, rootB64: null, byArtifact, byOrigin };
|
|
200
|
+
return bound as BoundSet;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The only constructor of a set/2 BoundSet. In order: strict parse of the
|
|
205
|
+
* root document, SHA-256 of its bytes equal to the SIGNED artifact digest,
|
|
206
|
+
* commitment equal to the one recomputed from the proof's own slot record;
|
|
207
|
+
* then, when member evidence is in hand, its strict parse, its count equal
|
|
208
|
+
* to the root document's, and its leaf and path recomputing exactly the
|
|
209
|
+
* committed root. A set bound without evidence lists no row: nothing can be
|
|
210
|
+
* admitted against it, and the caller's verdict is that membership was not
|
|
211
|
+
* shown. Rows never enter a BoundSet without their path reaching the root.
|
|
212
|
+
*/
|
|
213
|
+
function bindSetRoot(
|
|
214
|
+
proof: BitGraphProof,
|
|
215
|
+
commitment: Uint8Array,
|
|
216
|
+
bytes: Uint8Array,
|
|
217
|
+
source: "metadata" | "argument",
|
|
218
|
+
evidence: { value: unknown; from: "metadata" | "argument" } | null,
|
|
219
|
+
): BoundSet | Unbound | { category: "INVALID_SET_PATH"; reason: string } {
|
|
220
|
+
const doc = parseSetRoot(bytes);
|
|
221
|
+
if (doc === null) {
|
|
222
|
+
return { category: "INVALID_SET_MANIFEST", reason: "the set root document is not canonical bitgraph-fuse/1 set/2 bytes" };
|
|
223
|
+
}
|
|
224
|
+
const digest = sha256(bytes);
|
|
225
|
+
const artifact = base64ToBytes(proof.artifact.digestB64);
|
|
226
|
+
if (artifact === null || !bytesEqual(digest, artifact)) {
|
|
227
|
+
return { category: "INVALID_SET_MANIFEST", reason: "the set root document does not hash to the committed artifact digest" };
|
|
228
|
+
}
|
|
229
|
+
if (!bytesEqual(doc.commitment, commitment)) {
|
|
230
|
+
return { category: "INVALID_SLOT_COMMITMENT", reason: "the commitment in the committed set root document does not match the proof's slot record" };
|
|
231
|
+
}
|
|
232
|
+
const common = { kind: "set/2" as const, source, digestB64: bytesToBase64(digest), commitment, count: doc.count, rootB64: bytesToBase64(doc.root) };
|
|
233
|
+
if (evidence === null) {
|
|
234
|
+
const empty: Omit<BoundSet, typeof boundBrand> = { ...common, rows: [], treeIndices: [], memberSource: null, byArtifact: new Map(), byOrigin: new Map() };
|
|
235
|
+
return empty as BoundSet;
|
|
236
|
+
}
|
|
237
|
+
const parsed = parseSetMemberProof(evidence.value);
|
|
238
|
+
if (parsed === null) {
|
|
239
|
+
return { category: "INVALID_SET_PATH", reason: "the member evidence is not a well-formed bitgraph-fuse/1 set/2 member proof" };
|
|
240
|
+
}
|
|
241
|
+
if (parsed.count !== doc.count) {
|
|
242
|
+
return { category: "INVALID_SET_PATH", reason: `the member evidence names a tree of ${parsed.count} leaves; the committed root document states ${doc.count}` };
|
|
243
|
+
}
|
|
244
|
+
const reached = setRootFromMember(parsed.member, parsed.index, parsed.count, parsed.path);
|
|
245
|
+
if (reached === null || !bytesEqual(reached, doc.root)) {
|
|
246
|
+
return { category: "INVALID_SET_PATH", reason: "the member's leaf and path do not recompute the committed root; the row is not shown to be in the set" };
|
|
247
|
+
}
|
|
248
|
+
const row = parsed.member;
|
|
249
|
+
const bound: Omit<BoundSet, typeof boundBrand> = {
|
|
250
|
+
...common,
|
|
251
|
+
rows: [row],
|
|
252
|
+
treeIndices: [parsed.index],
|
|
253
|
+
memberSource: evidence.from,
|
|
254
|
+
byArtifact: new Map([[bytesToHex(row.artifact), 0]]),
|
|
255
|
+
byOrigin: new Map([[bytesToHex(row.origin), [0]]]),
|
|
256
|
+
};
|
|
171
257
|
return bound as BoundSet;
|
|
172
258
|
}
|
|
173
259
|
|
|
@@ -225,6 +311,16 @@ function strayReason(span: FuseSpan | null, memberCount: number): string {
|
|
|
225
311
|
return `these bytes carry the commitment of ${slot}, so they were made after that slot existed, but their digest is not among the ${memberCount} members listed in the set manifest ${commit}; the set proof does not cover them`;
|
|
226
312
|
}
|
|
227
313
|
|
|
314
|
+
/** set/2: the two-part reason when the floor holds but no evidence, or evidence for another member, is in hand. */
|
|
315
|
+
function unprovenReason(span: FuseSpan | null, memberCount: number, otherMember: boolean): string {
|
|
316
|
+
const slot = span !== null ? `the slot allocated at position ${span.slotCounter}` : "its slot";
|
|
317
|
+
const commit = span !== null ? `committed at position ${span.commitCounter}` : "at its commit";
|
|
318
|
+
const shown = otherMember
|
|
319
|
+
? "the member evidence in hand describes a different member, so"
|
|
320
|
+
: "no member evidence (row, index, path) is in hand, so";
|
|
321
|
+
return `these bytes carry the commitment of ${slot}, so they were made after that slot existed, but ${shown} their place among the ${memberCount} members of the set ${commit} is not shown; a set/2 proof covers a member only with its path`;
|
|
322
|
+
}
|
|
323
|
+
|
|
228
324
|
export async function verifyFuseMember(opts: FuseMemberOptions): Promise<FuseMemberResult> {
|
|
229
325
|
const { proof, bytes } = opts;
|
|
230
326
|
const fileDigest = sha256(bytes);
|
|
@@ -274,11 +370,12 @@ export async function verifyFuseMember(opts: FuseMemberOptions): Promise<FuseMem
|
|
|
274
370
|
maxPositions: maxPositions === null ? null : maxPositions.toString(),
|
|
275
371
|
};
|
|
276
372
|
const common = { proof: { valid: true }, marker, span, policy };
|
|
277
|
-
if (marker === null || marker.placement !== SET_PLACEMENT_ID) {
|
|
278
|
-
return base("INVALID_SET_MANIFEST", common, "the proof is not marked set/1; verifyFuse answers for this proof");
|
|
373
|
+
if (marker === null || (marker.placement !== SET_PLACEMENT_ID && marker.placement !== SET2_PLACEMENT_ID)) {
|
|
374
|
+
return base("INVALID_SET_MANIFEST", common, "the proof is not marked set/1 or set/2; verifyFuse answers for this proof");
|
|
279
375
|
}
|
|
376
|
+
const setKind: "set/1" | "set/2" = marker.placement === SET2_PLACEMENT_ID ? "set/2" : "set/1";
|
|
280
377
|
if (marker.originDigest !== undefined) {
|
|
281
|
-
return base("INVALID_SET_MANIFEST", common,
|
|
378
|
+
return base("INVALID_SET_MANIFEST", common, `the proof is marked ${setKind} but its signed attribution names an origin digest; a set has no single origin`);
|
|
282
379
|
}
|
|
283
380
|
|
|
284
381
|
// 3. The commitment, from the proof's own slot record.
|
|
@@ -295,20 +392,36 @@ export async function verifyFuseMember(opts: FuseMemberOptions): Promise<FuseMem
|
|
|
295
392
|
const slotCommitmentB64 = bytesToBase64(commitment);
|
|
296
393
|
const withSlot = { ...common, slotCommitmentB64 };
|
|
297
394
|
|
|
298
|
-
// 4. Bind the
|
|
299
|
-
// and
|
|
395
|
+
// 4. Bind the committed artifact: a set/1 manifest, or a set/2 root
|
|
396
|
+
// document and, when in hand, the member's evidence. An explicit
|
|
397
|
+
// argument replaces the metadata source and is never silently replaced
|
|
398
|
+
// by it.
|
|
300
399
|
const explicit = opts.manifest === undefined || opts.manifest === null ? null : opts.manifest;
|
|
301
400
|
const metadata = explicit === null ? readSetMetadata(proof) : null;
|
|
302
401
|
const source = explicit !== null ? { bytes: explicit, from: "argument" as const } : metadata !== null ? { bytes: metadata, from: "metadata" as const } : null;
|
|
303
402
|
if (source === null) {
|
|
304
|
-
return base("INVALID_SET_MANIFEST", withSlot, `no set manifest: none under proof.metadata["${SET_METADATA_KEY}"] and none supplied`);
|
|
403
|
+
return base("INVALID_SET_MANIFEST", withSlot, `no set ${setKind === "set/1" ? "manifest" : "root document"}: none under proof.metadata["${SET_METADATA_KEY}"] and none supplied`);
|
|
404
|
+
}
|
|
405
|
+
let bound: BoundSet;
|
|
406
|
+
if (setKind === "set/1") {
|
|
407
|
+
const b = bindSetManifest(proof, commitment, source.bytes, source.from);
|
|
408
|
+
if ("category" in b) return base(b.category, withSlot, b.reason);
|
|
409
|
+
bound = b;
|
|
410
|
+
} else {
|
|
411
|
+
const explicitMember = opts.member === undefined || opts.member === null ? null : opts.member;
|
|
412
|
+
const metadataMember = explicitMember === null ? (proof.metadata?.[SET_MEMBER_METADATA_KEY] ?? null) : null;
|
|
413
|
+
const evidenceSource = explicitMember !== null ? { value: explicitMember, from: "argument" as const } : metadataMember !== null ? { value: metadataMember, from: "metadata" as const } : null;
|
|
414
|
+
const b = bindSetRoot(proof, commitment, source.bytes, source.from, evidenceSource);
|
|
415
|
+
if ("category" in b) return base(b.category, withSlot, b.reason);
|
|
416
|
+
bound = b;
|
|
305
417
|
}
|
|
306
|
-
const bound = bindSetManifest(proof, commitment, source.bytes, source.from);
|
|
307
|
-
if ("category" in bound) return base(bound.category, withSlot, bound.reason);
|
|
308
418
|
const evidence = (member: FuseSetEvidence["member"]): FuseSetEvidence => ({
|
|
419
|
+
kind: bound.kind,
|
|
309
420
|
manifestSource: bound.source,
|
|
310
421
|
manifestDigestB64: bound.digestB64,
|
|
311
|
-
memberCount: bound.
|
|
422
|
+
memberCount: bound.count,
|
|
423
|
+
memberSource: bound.memberSource,
|
|
424
|
+
treeRootB64: bound.rootB64,
|
|
312
425
|
member,
|
|
313
426
|
});
|
|
314
427
|
const withSet = { ...withSlot, set: evidence(null) };
|
|
@@ -324,19 +437,23 @@ export async function verifyFuseMember(opts: FuseMemberOptions): Promise<FuseMem
|
|
|
324
437
|
if (bound.byArtifact.get(bytesToHex(row.artifact)) !== index || bound.rows[index] !== row) {
|
|
325
438
|
throw new Error("verifyFuseMember: membership re-assertion failed");
|
|
326
439
|
}
|
|
327
|
-
const count = bound.
|
|
440
|
+
const count = bound.count;
|
|
441
|
+
// set/1: a row's index is its place in the list. set/2: its leaf index in the tree, carried by the bound evidence.
|
|
442
|
+
const shown = bound.treeIndices !== null ? bound.treeIndices[index]! : index;
|
|
443
|
+
const listed = bound.kind === "set/1" ? "listed in the set manifest committed" : "of the set whose Merkle root was committed";
|
|
328
444
|
const statements: string[] = [];
|
|
329
445
|
if (span !== null) {
|
|
330
446
|
if (path === "direct") {
|
|
331
|
-
statements.push(`These exact fused bytes are member ${
|
|
447
|
+
statements.push(`These exact fused bytes are member ${shown + 1} of ${count} ${listed} at position ${span.commitCounter}.`);
|
|
332
448
|
if (admitted.originCompared) {
|
|
333
|
-
statements.push(
|
|
449
|
+
statements.push(`The fused bytes carry an origin digest that matches the set ${bound.kind === "set/1" ? "manifest" : "member evidence"}; the original itself was not supplied and was not checked.`);
|
|
334
450
|
}
|
|
335
451
|
} else {
|
|
336
452
|
statements.push(
|
|
337
|
-
`The supplied original rebuilds member ${
|
|
453
|
+
`The supplied original rebuilds member ${shown + 1} of ${count} of the committed set byte for byte, so these exact original bytes existed no later than commit position ${span.commitCounter}.`,
|
|
338
454
|
);
|
|
339
455
|
}
|
|
456
|
+
if (bound.kind === "set/2") statements.push(`The member's leaf and its ${bound.treeIndices !== null ? "path" : "path"} recompute the committed root, so the row is one of the ${count} the set commits to.`);
|
|
340
457
|
statements.push(floorStatement(span));
|
|
341
458
|
}
|
|
342
459
|
return {
|
|
@@ -352,13 +469,13 @@ export async function verifyFuseMember(opts: FuseMemberOptions): Promise<FuseMem
|
|
|
352
469
|
policy,
|
|
353
470
|
statements,
|
|
354
471
|
reason: null,
|
|
355
|
-
set: evidence({ index, placement: placement.id, fusedDigestB64: bytesToBase64(row.artifact), originDigestB64: bytesToBase64(row.origin) }),
|
|
472
|
+
set: evidence({ index: shown, placement: placement.id, fusedDigestB64: bytesToBase64(row.artifact), originDigestB64: bytesToBase64(row.origin) }),
|
|
356
473
|
};
|
|
357
474
|
};
|
|
358
475
|
|
|
359
|
-
// 5. The bytes are the committed
|
|
476
|
+
// 5. The bytes are the committed artifact itself: verifyFuse answers for it.
|
|
360
477
|
if (fileDigestB64 === artifactDigestB64) {
|
|
361
|
-
return base("NO_MATCH", withSet,
|
|
478
|
+
return base("NO_MATCH", withSet, `these bytes are the committed set ${bound.kind === "set/1" ? "manifest" : "root document"} itself, not a member; verifyFuse answers for it`);
|
|
362
479
|
}
|
|
363
480
|
|
|
364
481
|
// 6. Direct path: the bytes hash to a listed member. Authoritative when it
|
|
@@ -417,14 +534,19 @@ export async function verifyFuseMember(opts: FuseMemberOptions): Promise<FuseMem
|
|
|
417
534
|
);
|
|
418
535
|
}
|
|
419
536
|
|
|
420
|
-
// 8. Stray scan: bytes that carry this slot's commitment yet
|
|
421
|
-
//
|
|
422
|
-
//
|
|
537
|
+
// 8. Stray scan: bytes that carry this slot's commitment yet match no row
|
|
538
|
+
// in hand. set/1 lists every row, so this is the two-part verdict: made
|
|
539
|
+
// after the slot, not part of the committed set. set/2 has in hand at
|
|
540
|
+
// most the one row its evidence proved, so absence is not shown: the
|
|
541
|
+
// floor holds, membership is unproven (no evidence) or the evidence is
|
|
542
|
+
// another member's. No rendered floor in either case.
|
|
423
543
|
for (const p of PLACEMENTS) {
|
|
424
544
|
const l = p.locate(bytes);
|
|
425
545
|
if (l === null) continue;
|
|
426
546
|
if (bytesEqual(l.commitment, commitment)) {
|
|
427
|
-
return base("SET_NOT_MEMBER", { ...withSet, placement: p.id, statements: [] }, strayReason(span, bound.
|
|
547
|
+
if (bound.kind === "set/1") return base("SET_NOT_MEMBER", { ...withSet, placement: p.id, statements: [] }, strayReason(span, bound.count));
|
|
548
|
+
const other = bound.rows.length > 0;
|
|
549
|
+
return base(other ? "INVALID_SET_PATH" : "SET_MEMBERSHIP_UNPROVEN", { ...withSet, placement: p.id, statements: [] }, unprovenReason(span, bound.count, other));
|
|
428
550
|
}
|
|
429
551
|
return base("NO_MATCH", withSet, "these bytes carry a commitment to a different slot; the proof proves nothing about them");
|
|
430
552
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Merkle tree a set/2 commits to: RFC 6962 (Certificate Transparency)
|
|
5
|
+
* hashing over an ordered list of leaf hashes, with the RFC 9162 inclusion
|
|
6
|
+
* proof. Domain separation is the RFC's: a leaf hash is SHA-256 of 0x00 and
|
|
7
|
+
* the leaf's bytes, an inner node SHA-256 of 0x01, left, right. A list of
|
|
8
|
+
* n leaves splits at k, the largest power of two below n, so every list has
|
|
9
|
+
* exactly one root and every leaf exactly one inclusion path of at most
|
|
10
|
+
* ceil(log2 n) siblings. Nothing here knows what a leaf is; fuse.ts says.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
14
|
+
|
|
15
|
+
const LEAF_PREFIX = new Uint8Array([0x00]);
|
|
16
|
+
const NODE_PREFIX = new Uint8Array([0x01]);
|
|
17
|
+
|
|
18
|
+
function concat(...parts: Uint8Array[]): Uint8Array {
|
|
19
|
+
let n = 0;
|
|
20
|
+
for (const p of parts) n += p.length;
|
|
21
|
+
const out = new Uint8Array(n);
|
|
22
|
+
let o = 0;
|
|
23
|
+
for (const p of parts) {
|
|
24
|
+
out.set(p, o);
|
|
25
|
+
o += p.length;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** SHA-256(0x00 || bytes): the hash of one leaf. */
|
|
31
|
+
export function merkleLeafHash(bytes: Uint8Array): Uint8Array {
|
|
32
|
+
return sha256(concat(LEAF_PREFIX, bytes));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** SHA-256(0x01 || left || right): one inner node. */
|
|
36
|
+
export function merkleNodeHash(left: Uint8Array, right: Uint8Array): Uint8Array {
|
|
37
|
+
return sha256(concat(NODE_PREFIX, left, right));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The largest power of two strictly below n (n >= 2). */
|
|
41
|
+
function split(n: number): number {
|
|
42
|
+
let k = 1;
|
|
43
|
+
while (k * 2 < n) k *= 2;
|
|
44
|
+
return k;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The root over leaf HASHES (each already merkleLeafHash of its leaf), in
|
|
49
|
+
* list order. Throws on an empty list; the root of one leaf hash is that
|
|
50
|
+
* hash itself, as in RFC 6962.
|
|
51
|
+
*/
|
|
52
|
+
export function merkleRoot(leafHashes: readonly Uint8Array[]): Uint8Array {
|
|
53
|
+
if (leafHashes.length === 0) throw new TypeError("a Merkle tree needs at least one leaf");
|
|
54
|
+
for (const h of leafHashes) if (h.length !== 32) throw new TypeError("a leaf hash is 32 bytes");
|
|
55
|
+
const build = (lo: number, hi: number): Uint8Array => {
|
|
56
|
+
const n = hi - lo;
|
|
57
|
+
if (n === 1) return leafHashes[lo]!;
|
|
58
|
+
const k = split(n);
|
|
59
|
+
return merkleNodeHash(build(lo, lo + k), build(lo + k, hi));
|
|
60
|
+
};
|
|
61
|
+
return build(0, leafHashes.length);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The inclusion path of the leaf at `index`: its siblings from the leaf's
|
|
66
|
+
* own level up to the root, in that order (RFC 6962 PATH). Empty for a tree
|
|
67
|
+
* of one leaf.
|
|
68
|
+
*/
|
|
69
|
+
export function merklePath(leafHashes: readonly Uint8Array[], index: number): Uint8Array[] {
|
|
70
|
+
const n = leafHashes.length;
|
|
71
|
+
if (n === 0) throw new TypeError("a Merkle tree needs at least one leaf");
|
|
72
|
+
if (!Number.isInteger(index) || index < 0 || index >= n) throw new RangeError("leaf index out of range");
|
|
73
|
+
const path: Uint8Array[] = [];
|
|
74
|
+
const walk = (lo: number, hi: number, m: number): void => {
|
|
75
|
+
const size = hi - lo;
|
|
76
|
+
if (size === 1) return;
|
|
77
|
+
const k = split(size);
|
|
78
|
+
const build = (a: number, b: number): Uint8Array => merkleRoot(leafHashes.slice(a, b));
|
|
79
|
+
if (m < lo + k) {
|
|
80
|
+
walk(lo, lo + k, m);
|
|
81
|
+
path.push(build(lo + k, hi));
|
|
82
|
+
} else {
|
|
83
|
+
walk(lo + k, hi, m);
|
|
84
|
+
path.push(build(lo, lo + k));
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
walk(0, n, index);
|
|
88
|
+
return path;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Recompute the root from one leaf hash, its index, the tree size and its
|
|
93
|
+
* path (RFC 9162 section 2.1.3.2). Null when the path does not fit the
|
|
94
|
+
* index and size: too short, too long, or an index outside the tree. A
|
|
95
|
+
* result that equals the committed root proves the leaf is at `index` in a
|
|
96
|
+
* tree of `size` leaves with that root; nothing else is proven.
|
|
97
|
+
*/
|
|
98
|
+
export function merkleRootFromPath(leafHash: Uint8Array, index: number, size: number, path: readonly Uint8Array[]): Uint8Array | null {
|
|
99
|
+
if (!Number.isInteger(index) || !Number.isInteger(size) || size < 1 || index < 0 || index >= size) return null;
|
|
100
|
+
if (leafHash.length !== 32) return null;
|
|
101
|
+
let fn = index;
|
|
102
|
+
let sn = size - 1;
|
|
103
|
+
let r = leafHash;
|
|
104
|
+
for (const p of path) {
|
|
105
|
+
if (p.length !== 32) return null;
|
|
106
|
+
if (sn === 0) return null;
|
|
107
|
+
if ((fn & 1) === 1 || fn === sn) {
|
|
108
|
+
r = merkleNodeHash(p, r);
|
|
109
|
+
if ((fn & 1) === 0) {
|
|
110
|
+
while ((fn & 1) === 0 && fn !== 0) {
|
|
111
|
+
fn >>>= 1;
|
|
112
|
+
sn >>>= 1;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
r = merkleNodeHash(r, p);
|
|
117
|
+
}
|
|
118
|
+
fn >>>= 1;
|
|
119
|
+
sn >>>= 1;
|
|
120
|
+
}
|
|
121
|
+
if (sn !== 0) return null;
|
|
122
|
+
return r;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The whole tree over a list of leaf hashes, every subtree root computed
|
|
127
|
+
* once, so the paths of all N leaves cost N log N hashes in total instead of
|
|
128
|
+
* N per path. The split rule and the path order are merkleRoot's and
|
|
129
|
+
* merklePath's exactly; a test pins them equal.
|
|
130
|
+
*/
|
|
131
|
+
export class MerkleTree {
|
|
132
|
+
readonly size: number;
|
|
133
|
+
readonly root: Uint8Array;
|
|
134
|
+
private readonly leafHashes: readonly Uint8Array[];
|
|
135
|
+
/** Subtree root by "lo:hi". */
|
|
136
|
+
private readonly memo = new Map<string, Uint8Array>();
|
|
137
|
+
|
|
138
|
+
constructor(leafHashes: readonly Uint8Array[]) {
|
|
139
|
+
if (leafHashes.length === 0) throw new TypeError("a Merkle tree needs at least one leaf");
|
|
140
|
+
for (const h of leafHashes) if (h.length !== 32) throw new TypeError("a leaf hash is 32 bytes");
|
|
141
|
+
this.leafHashes = leafHashes;
|
|
142
|
+
this.size = leafHashes.length;
|
|
143
|
+
this.root = this.subtree(0, this.size);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private subtree(lo: number, hi: number): Uint8Array {
|
|
147
|
+
const n = hi - lo;
|
|
148
|
+
if (n === 1) return this.leafHashes[lo]!;
|
|
149
|
+
const key = `${lo}:${hi}`;
|
|
150
|
+
const known = this.memo.get(key);
|
|
151
|
+
if (known !== undefined) return known;
|
|
152
|
+
const k = split(n);
|
|
153
|
+
const h = merkleNodeHash(this.subtree(lo, lo + k), this.subtree(lo + k, hi));
|
|
154
|
+
this.memo.set(key, h);
|
|
155
|
+
return h;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The inclusion path of the leaf at `index`, siblings from the leaf's level up. */
|
|
159
|
+
path(index: number): Uint8Array[] {
|
|
160
|
+
if (!Number.isInteger(index) || index < 0 || index >= this.size) throw new RangeError("leaf index out of range");
|
|
161
|
+
const out: Uint8Array[] = [];
|
|
162
|
+
const walk = (lo: number, hi: number): void => {
|
|
163
|
+
const n = hi - lo;
|
|
164
|
+
if (n === 1) return;
|
|
165
|
+
const k = split(n);
|
|
166
|
+
if (index < lo + k) {
|
|
167
|
+
walk(lo, lo + k);
|
|
168
|
+
out.push(this.subtree(lo + k, hi));
|
|
169
|
+
} else {
|
|
170
|
+
walk(lo + k, hi);
|
|
171
|
+
out.push(this.subtree(lo, lo + k));
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
walk(0, this.size);
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
}
|