@mikeargento/bitgraph-verify 1.5.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 +91 -2
- package/dist/fuse.d.ts.map +1 -1
- package/dist/fuse.js +283 -9
- package/dist/fuse.js.map +1 -1
- package/dist/index.d.ts +4 -1
- 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 +306 -9
- package/src/index.ts +4 -1
|
@@ -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
|
+
}
|
package/src/fuse.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { sha256 } from "@noble/hashes/sha256";
|
|
29
|
+
import { merkleLeafHash, merkleRootFromPath, MerkleTree } from "./fuse-merkle.js";
|
|
29
30
|
import { canonicalize } from "./canonical.js";
|
|
30
31
|
import type { Attribution, BitGraphProof, SlotAllocation } from "./types.js";
|
|
31
32
|
|
|
@@ -247,7 +248,7 @@ export function parseFusePayload(bytes: Uint8Array): { commitment: Uint8Array; o
|
|
|
247
248
|
}
|
|
248
249
|
|
|
249
250
|
// ---------------------------------------------------------------------------
|
|
250
|
-
// Minimal deterministic ustar (POSIX.1-1988) for container/1
|
|
251
|
+
// Minimal deterministic ustar (POSIX.1-1988) for container/1 and container/2
|
|
251
252
|
// ---------------------------------------------------------------------------
|
|
252
253
|
|
|
253
254
|
const BLOCK = 512;
|
|
@@ -260,7 +261,7 @@ function octal(n: number, width: number): Uint8Array {
|
|
|
260
261
|
}
|
|
261
262
|
|
|
262
263
|
function ustarHeader(name: string, size: number): Uint8Array {
|
|
263
|
-
if (size > MAX_ENTRY) throw new RangeError("container
|
|
264
|
+
if (size > MAX_ENTRY) throw new RangeError("container entries are limited to 8 GiB");
|
|
264
265
|
const h = new Uint8Array(BLOCK);
|
|
265
266
|
const nameBytes = utf8(name);
|
|
266
267
|
if (nameBytes.length > 100) throw new RangeError("ustar name too long");
|
|
@@ -325,7 +326,7 @@ function parseTar(bytes: Uint8Array): TarEntry[] | null {
|
|
|
325
326
|
// Placement registry
|
|
326
327
|
// ---------------------------------------------------------------------------
|
|
327
328
|
|
|
328
|
-
export type PlacementId = "trailer/1" | "container/1" | "produced/1" | "set/1";
|
|
329
|
+
export type PlacementId = "trailer/1" | "container/1" | "container/2" | "produced/1" | "set/1" | "set/2";
|
|
329
330
|
|
|
330
331
|
export interface Located {
|
|
331
332
|
/** The commitment found in the fused bytes. */
|
|
@@ -336,6 +337,12 @@ export interface Located {
|
|
|
336
337
|
originalBytes?: Uint8Array;
|
|
337
338
|
}
|
|
338
339
|
|
|
340
|
+
/** What a Form A or B placement puts around the original: fused = prefix, original, suffix. */
|
|
341
|
+
export interface FusedFrame {
|
|
342
|
+
prefix: Uint8Array;
|
|
343
|
+
suffix: Uint8Array;
|
|
344
|
+
}
|
|
345
|
+
|
|
339
346
|
export interface Placement {
|
|
340
347
|
readonly id: PlacementId;
|
|
341
348
|
/** A: in-file placement of an existing original; B: container; C: produced artifact. */
|
|
@@ -346,6 +353,19 @@ export interface Placement {
|
|
|
346
353
|
build(input: { original?: Uint8Array; originDigest?: Uint8Array; commitment: Uint8Array }): Uint8Array;
|
|
347
354
|
/** Find the commitment in fused bytes, or null when the marker is absent or malformed. */
|
|
348
355
|
locate(fused: Uint8Array): Located | null;
|
|
356
|
+
/**
|
|
357
|
+
* Forms A and B: the bytes around the original, such that
|
|
358
|
+
* build({original, originDigest, commitment}) is exactly prefix, original,
|
|
359
|
+
* suffix. A producer that streams the original once can hash the prefix and
|
|
360
|
+
* the original as they pass and finish with the suffix later.
|
|
361
|
+
*/
|
|
362
|
+
frame?(input: { originalSize: number; originDigest: Uint8Array; commitment: Uint8Array }): FusedFrame;
|
|
363
|
+
/**
|
|
364
|
+
* The prefix when it depends on the original's size alone, so a scanner can
|
|
365
|
+
* hash it before any slot exists; null when the prefix carries the
|
|
366
|
+
* commitment (container/1) and the fused digest needs the bytes again.
|
|
367
|
+
*/
|
|
368
|
+
scanPrefix?(originalSize: number): Uint8Array | null;
|
|
349
369
|
}
|
|
350
370
|
|
|
351
371
|
const trailer1: Placement = {
|
|
@@ -364,6 +384,13 @@ const trailer1: Placement = {
|
|
|
364
384
|
if (!t.subarray(8, 16).every((b) => b === 0)) return null;
|
|
365
385
|
return { commitment: new Uint8Array(t.subarray(16, 48)), originalBytes: fused.subarray(0, fused.length - TRAILER_LENGTH) };
|
|
366
386
|
},
|
|
387
|
+
frame({ commitment }) {
|
|
388
|
+
if (commitment.length !== 32) throw new TypeError("commitment must be 32 bytes");
|
|
389
|
+
return { prefix: new Uint8Array(0), suffix: concat(utf8(TRAILER_MAGIC), new Uint8Array(8), commitment) };
|
|
390
|
+
},
|
|
391
|
+
scanPrefix() {
|
|
392
|
+
return new Uint8Array(0);
|
|
393
|
+
},
|
|
367
394
|
};
|
|
368
395
|
|
|
369
396
|
const container1: Placement = {
|
|
@@ -387,6 +414,66 @@ const container1: Placement = {
|
|
|
387
414
|
if (!bytesEqual(rebuilt, fused)) return null;
|
|
388
415
|
return { commitment: payload.commitment, originDigest: payload.originDigest, originalBytes: o.data };
|
|
389
416
|
},
|
|
417
|
+
frame({ originalSize, originDigest, commitment }) {
|
|
418
|
+
const manifest = buildFusePayload(commitment, originDigest);
|
|
419
|
+
return {
|
|
420
|
+
prefix: concat(ustarHeader(CONTAINER_MANIFEST_PATH, manifest.length), manifest, new Uint8Array(padTo(manifest.length)), ustarHeader(CONTAINER_ORIGINAL_PATH, originalSize)),
|
|
421
|
+
suffix: concat(new Uint8Array(padTo(originalSize)), new Uint8Array(BLOCK * 2)),
|
|
422
|
+
};
|
|
423
|
+
},
|
|
424
|
+
scanPrefix() {
|
|
425
|
+
// The manifest, and so the commitment, comes before the original.
|
|
426
|
+
return null;
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* container/2: the same archive with the original FIRST. Everything before
|
|
432
|
+
* the original's bytes is its ustar header, which depends on the size alone,
|
|
433
|
+
* so a scanner that hashes header and original as the file streams by can
|
|
434
|
+
* finish the fused digest later with the manifest for whatever slot the
|
|
435
|
+
* set is made under, without reading the file again. Any bytes fit: the
|
|
436
|
+
* original stays byte-exact inside, and the archive is a plain tar.
|
|
437
|
+
*/
|
|
438
|
+
function buildContainer2(original: Uint8Array, manifest: Uint8Array): Uint8Array {
|
|
439
|
+
return concat(
|
|
440
|
+
ustarHeader(CONTAINER_ORIGINAL_PATH, original.length), original, new Uint8Array(padTo(original.length)),
|
|
441
|
+
ustarHeader(CONTAINER_MANIFEST_PATH, manifest.length), manifest, new Uint8Array(padTo(manifest.length)),
|
|
442
|
+
new Uint8Array(BLOCK * 2),
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const container2: Placement = {
|
|
447
|
+
id: "container/2",
|
|
448
|
+
form: "B",
|
|
449
|
+
byteExact: true,
|
|
450
|
+
build({ original, originDigest, commitment }) {
|
|
451
|
+
if (original === undefined) throw new TypeError("container/2 requires the original bytes");
|
|
452
|
+
const digest = originDigest ?? sha256(original);
|
|
453
|
+
return buildContainer2(original, buildFusePayload(commitment, digest));
|
|
454
|
+
},
|
|
455
|
+
locate(fused) {
|
|
456
|
+
const entries = parseTar(fused);
|
|
457
|
+
if (entries === null || entries.length !== 2) return null;
|
|
458
|
+
const [o, m] = entries as [TarEntry, TarEntry];
|
|
459
|
+
if (o.name !== CONTAINER_ORIGINAL_PATH || m.name !== CONTAINER_MANIFEST_PATH) return null;
|
|
460
|
+
const payload = parseFusePayload(m.data);
|
|
461
|
+
if (payload === null || payload.originDigest === undefined) return null;
|
|
462
|
+
// The archive must be the one this module would build: headers included.
|
|
463
|
+
const rebuilt = buildContainer2(o.data, m.data);
|
|
464
|
+
if (!bytesEqual(rebuilt, fused)) return null;
|
|
465
|
+
return { commitment: payload.commitment, originDigest: payload.originDigest, originalBytes: o.data };
|
|
466
|
+
},
|
|
467
|
+
frame({ originalSize, originDigest, commitment }) {
|
|
468
|
+
const manifest = buildFusePayload(commitment, originDigest);
|
|
469
|
+
return {
|
|
470
|
+
prefix: ustarHeader(CONTAINER_ORIGINAL_PATH, originalSize),
|
|
471
|
+
suffix: concat(new Uint8Array(padTo(originalSize)), ustarHeader(CONTAINER_MANIFEST_PATH, manifest.length), manifest, new Uint8Array(padTo(manifest.length)), new Uint8Array(BLOCK * 2)),
|
|
472
|
+
};
|
|
473
|
+
},
|
|
474
|
+
scanPrefix(originalSize) {
|
|
475
|
+
return ustarHeader(CONTAINER_ORIGINAL_PATH, originalSize);
|
|
476
|
+
},
|
|
390
477
|
};
|
|
391
478
|
|
|
392
479
|
const produced1: Placement = {
|
|
@@ -407,7 +494,7 @@ const produced1: Placement = {
|
|
|
407
494
|
};
|
|
408
495
|
|
|
409
496
|
/** Registered placements in the fixed order a verifier tries them when none is declared. */
|
|
410
|
-
export const PLACEMENTS: readonly Placement[] = Object.freeze([trailer1, container1, produced1]);
|
|
497
|
+
export const PLACEMENTS: readonly Placement[] = Object.freeze([trailer1, container1, container2, produced1]);
|
|
411
498
|
|
|
412
499
|
// ---------------------------------------------------------------------------
|
|
413
500
|
// Set manifest (placement set/1): N files fused under ONE slot
|
|
@@ -481,7 +568,7 @@ export function buildSetManifest(commitment: Uint8Array, members: readonly SetMe
|
|
|
481
568
|
if (m.artifact.length !== 32) throw new TypeError("member artifact digest must be 32 bytes");
|
|
482
569
|
if (m.origin.length !== 32) throw new TypeError("member origin digest must be 32 bytes");
|
|
483
570
|
if (!PLACEMENT_ID_PATTERN.test(m.placement)) throw new TypeError(`member placement "${m.placement}" is not a placement id`);
|
|
484
|
-
if (m.placement === SET_PLACEMENT_ID) throw new TypeError("a set cannot list a set as a member");
|
|
571
|
+
if (m.placement === SET_PLACEMENT_ID || m.placement === SET2_PLACEMENT_ID) throw new TypeError("a set cannot list a set as a member");
|
|
485
572
|
const artifact = bytesToHex(m.artifact);
|
|
486
573
|
if (seen.has(artifact)) throw new TypeError(`duplicate member artifact digest ${artifact}`);
|
|
487
574
|
seen.add(artifact);
|
|
@@ -545,7 +632,7 @@ export function parseSetManifest(bytes: Uint8Array): { commitment: Uint8Array; m
|
|
|
545
632
|
const origin = readDigestField(row["origin"]);
|
|
546
633
|
const placement = row["placement"];
|
|
547
634
|
if (artifact === null || origin === null || typeof placement !== "string") return null;
|
|
548
|
-
if (!PLACEMENT_ID_PATTERN.test(placement) || placement === SET_PLACEMENT_ID) return null;
|
|
635
|
+
if (!PLACEMENT_ID_PATTERN.test(placement) || placement === SET_PLACEMENT_ID || placement === SET2_PLACEMENT_ID) return null;
|
|
549
636
|
members.push({ artifact, origin, placement });
|
|
550
637
|
}
|
|
551
638
|
let rebuilt: Uint8Array;
|
|
@@ -589,15 +676,225 @@ const set1: Placement = {
|
|
|
589
676
|
},
|
|
590
677
|
};
|
|
591
678
|
|
|
679
|
+
// ---------------------------------------------------------------------------
|
|
680
|
+
// Merkle set (placement set/2): N files under ONE slot, any N
|
|
681
|
+
// ---------------------------------------------------------------------------
|
|
682
|
+
//
|
|
683
|
+
// set/1 commits the whole member list, which is what caps it: the list rides
|
|
684
|
+
// in the commit body and in every copy of the proof. set/2 commits the ROOT
|
|
685
|
+
// of a Merkle tree over the same rows, so the committed artifact is a few
|
|
686
|
+
// hundred bytes whatever N is, and a member proves its place with a path of
|
|
687
|
+
// ceil(log2 N) siblings. The floor is unchanged: every member's fused bytes
|
|
688
|
+
// still carry the slot's commitment through its own placement. Membership
|
|
689
|
+
// and floor stay inseparable in verifyFuseMember; what changes is where the
|
|
690
|
+
// list lives (with the producer and the reader that serves it) and what a
|
|
691
|
+
// member carries (its row, its index and its path, see SetMemberProof).
|
|
692
|
+
//
|
|
693
|
+
// Leaves are the canonical row bytes (the same {artifact, origin, placement}
|
|
694
|
+
// row set/1 lists), hashed with the RFC 6962 leaf prefix, in the same strict
|
|
695
|
+
// order as a set/1 manifest: ascending by artifact digest, no duplicates.
|
|
696
|
+
// So one root stands for exactly one member list, and a reader holding the
|
|
697
|
+
// list can rebuild the tree; a reader holding one member needs its path.
|
|
698
|
+
|
|
699
|
+
export const SET2_PLACEMENT_ID = "set/2" as const;
|
|
700
|
+
|
|
701
|
+
/** The proof.metadata key under which a member's own evidence (row, index, count, path) may ride, UNSIGNED, beside the root document. */
|
|
702
|
+
export const SET_MEMBER_METADATA_KEY = `${FUSE_PROFILE}/member` as const;
|
|
703
|
+
|
|
704
|
+
/** The most members one set/2 tree lists. A limit stated plainly, not a design constant: the tree and the paths are fine far beyond it. */
|
|
705
|
+
export const MAX_SET2_MEMBERS = 1_000_000;
|
|
706
|
+
|
|
707
|
+
/** The root document as JSON: the type of the value under proof.metadata[SET_METADATA_KEY] for a set/2 proof. */
|
|
708
|
+
export interface SetRoot {
|
|
709
|
+
count: number;
|
|
710
|
+
placement: typeof SET2_PLACEMENT_ID;
|
|
711
|
+
root: { algorithm: "sha256"; digest: string };
|
|
712
|
+
slotCommitment: { algorithm: "sha256"; digest: string };
|
|
713
|
+
type: typeof FUSE_PROFILE;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** One member's evidence as JSON: its row, its leaf index, the tree size, and the sibling path from the leaf up. */
|
|
717
|
+
export interface SetMemberProof {
|
|
718
|
+
count: number;
|
|
719
|
+
index: number;
|
|
720
|
+
member: SetManifest["members"][number];
|
|
721
|
+
path: string[];
|
|
722
|
+
placement: typeof SET2_PLACEMENT_ID;
|
|
723
|
+
type: typeof FUSE_PROFILE;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** The canonical bytes of one row, exactly as a set/1 manifest lists it; the leaf a set/2 tree hashes. */
|
|
727
|
+
export function canonicalSetRow(m: SetMember): Uint8Array {
|
|
728
|
+
if (m.artifact.length !== 32) throw new TypeError("member artifact digest must be 32 bytes");
|
|
729
|
+
if (m.origin.length !== 32) throw new TypeError("member origin digest must be 32 bytes");
|
|
730
|
+
if (!PLACEMENT_ID_PATTERN.test(m.placement) || m.placement === SET_PLACEMENT_ID || m.placement === SET2_PLACEMENT_ID) throw new TypeError(`member placement "${m.placement}" is not a member placement id`);
|
|
731
|
+
return canonicalize({
|
|
732
|
+
artifact: { algorithm: "sha256", digest: bytesToHex(m.artifact) },
|
|
733
|
+
origin: { algorithm: "sha256", digest: bytesToHex(m.origin) },
|
|
734
|
+
placement: m.placement,
|
|
735
|
+
} as unknown as BitGraphProof);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** The leaf hash of one row: SHA-256(0x00, canonical row bytes). */
|
|
739
|
+
export function setLeaf(m: SetMember): Uint8Array {
|
|
740
|
+
return merkleLeafHash(canonicalSetRow(m));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Order members as a set/2 tree lists them: strictly ascending by artifact
|
|
745
|
+
* digest, no duplicate artifact. Throws on a duplicate, an empty list, or a
|
|
746
|
+
* malformed row, like buildSetManifest.
|
|
747
|
+
*/
|
|
748
|
+
export function sortSetMembers(members: readonly SetMember[]): SetMember[] {
|
|
749
|
+
if (members.length === 0) throw new TypeError("a set lists at least one member");
|
|
750
|
+
const sorted = [...members].sort((a, b) => {
|
|
751
|
+
const x = bytesToHex(a.artifact);
|
|
752
|
+
const y = bytesToHex(b.artifact);
|
|
753
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
754
|
+
});
|
|
755
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
756
|
+
canonicalSetRow(sorted[i]!);
|
|
757
|
+
if (i > 0 && bytesEqual(sorted[i]!.artifact, sorted[i - 1]!.artifact)) throw new TypeError(`duplicate member artifact digest ${bytesToHex(sorted[i]!.artifact)}`);
|
|
758
|
+
}
|
|
759
|
+
return sorted;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** The tree over a member list, in tree order (sortSetMembers): the sorted rows, their leaf hashes, the root, and every member's path on demand. */
|
|
763
|
+
export function buildSetTree(members: readonly SetMember[]): { sorted: SetMember[]; leaves: Uint8Array[]; root: Uint8Array; tree: MerkleTree } {
|
|
764
|
+
const sorted = sortSetMembers(members);
|
|
765
|
+
const leaves = sorted.map(setLeaf);
|
|
766
|
+
const tree = new MerkleTree(leaves);
|
|
767
|
+
return { sorted, leaves, root: tree.root, tree };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** The inclusion path of the member at `index` in tree order. */
|
|
771
|
+
export function setMemberPath(tree: MerkleTree, index: number): Uint8Array[] {
|
|
772
|
+
return tree.path(index);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Build the canonical set/2 root document bytes: the committed artifact. */
|
|
776
|
+
export function buildSetRoot(commitment: Uint8Array, count: number, root: Uint8Array): Uint8Array {
|
|
777
|
+
if (commitment.length !== 32) throw new TypeError("commitment must be 32 bytes");
|
|
778
|
+
if (root.length !== 32) throw new TypeError("root must be 32 bytes");
|
|
779
|
+
if (!Number.isInteger(count) || count < 1 || count > MAX_SET2_MEMBERS) throw new TypeError(`count must be an integer from 1 to ${MAX_SET2_MEMBERS}`);
|
|
780
|
+
const doc: SetRoot = {
|
|
781
|
+
count,
|
|
782
|
+
placement: SET2_PLACEMENT_ID,
|
|
783
|
+
root: { algorithm: "sha256", digest: bytesToHex(root) },
|
|
784
|
+
slotCommitment: { algorithm: "sha256", digest: bytesToHex(commitment) },
|
|
785
|
+
type: FUSE_PROFILE,
|
|
786
|
+
};
|
|
787
|
+
return canonicalize(doc as unknown as BitGraphProof);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** Strict parse of set/2 root document bytes: exactly the five keys, the literals, 32-byte digests, a count in range, byte-equal to its own rebuild. */
|
|
791
|
+
export function parseSetRoot(bytes: Uint8Array): { commitment: Uint8Array; count: number; root: Uint8Array } | null {
|
|
792
|
+
let text: string;
|
|
793
|
+
try {
|
|
794
|
+
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
795
|
+
} catch {
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
let parsed: unknown;
|
|
799
|
+
try {
|
|
800
|
+
parsed = JSON.parse(text);
|
|
801
|
+
} catch {
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
if (!isPlainObject(parsed)) return null;
|
|
805
|
+
if (Object.keys(parsed).sort().join(",") !== "count,placement,root,slotCommitment,type") return null;
|
|
806
|
+
if (parsed["type"] !== FUSE_PROFILE || parsed["placement"] !== SET2_PLACEMENT_ID) return null;
|
|
807
|
+
const count = parsed["count"];
|
|
808
|
+
if (typeof count !== "number" || !Number.isInteger(count) || count < 1 || count > MAX_SET2_MEMBERS) return null;
|
|
809
|
+
const commitment = readDigestField(parsed["slotCommitment"]);
|
|
810
|
+
const root = readDigestField(parsed["root"]);
|
|
811
|
+
if (commitment === null || root === null) return null;
|
|
812
|
+
let rebuilt: Uint8Array;
|
|
813
|
+
try {
|
|
814
|
+
rebuilt = buildSetRoot(commitment, count, root);
|
|
815
|
+
} catch {
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
if (!bytesEqual(rebuilt, bytes)) return null;
|
|
819
|
+
return { commitment, count, root };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** Build a member's evidence object (JSON shape) from tree order. */
|
|
823
|
+
export function buildSetMemberProof(member: SetMember, index: number, count: number, path: readonly Uint8Array[]): SetMemberProof {
|
|
824
|
+
if (!Number.isInteger(index) || !Number.isInteger(count) || count < 1 || index < 0 || index >= count) throw new RangeError("member index out of range");
|
|
825
|
+
canonicalSetRow(member);
|
|
826
|
+
return {
|
|
827
|
+
count,
|
|
828
|
+
index,
|
|
829
|
+
member: {
|
|
830
|
+
artifact: { algorithm: "sha256", digest: bytesToHex(member.artifact) },
|
|
831
|
+
origin: { algorithm: "sha256", digest: bytesToHex(member.origin) },
|
|
832
|
+
placement: member.placement,
|
|
833
|
+
},
|
|
834
|
+
path: path.map((p) => {
|
|
835
|
+
if (p.length !== 32) throw new TypeError("a path node is 32 bytes");
|
|
836
|
+
return bytesToHex(p);
|
|
837
|
+
}),
|
|
838
|
+
placement: SET2_PLACEMENT_ID,
|
|
839
|
+
type: FUSE_PROFILE,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Strict read of a member's evidence from a parsed JSON value: shape, literals, 32-byte hex digests and path nodes, index within count. Null on any deviation. UNBOUND: nothing here touches a root. */
|
|
844
|
+
export function parseSetMemberProof(value: unknown): { member: SetMember; index: number; count: number; path: Uint8Array[] } | null {
|
|
845
|
+
if (!isPlainObject(value)) return null;
|
|
846
|
+
if (Object.keys(value).sort().join(",") !== "count,index,member,path,placement,type") return null;
|
|
847
|
+
if (value["type"] !== FUSE_PROFILE || value["placement"] !== SET2_PLACEMENT_ID) return null;
|
|
848
|
+
const count = value["count"];
|
|
849
|
+
const index = value["index"];
|
|
850
|
+
if (typeof count !== "number" || !Number.isInteger(count) || count < 1 || count > MAX_SET2_MEMBERS) return null;
|
|
851
|
+
if (typeof index !== "number" || !Number.isInteger(index) || index < 0 || index >= count) return null;
|
|
852
|
+
const row = value["member"];
|
|
853
|
+
if (!isPlainObject(row) || Object.keys(row).sort().join(",") !== "artifact,origin,placement") return null;
|
|
854
|
+
const artifact = readDigestField(row["artifact"]);
|
|
855
|
+
const origin = readDigestField(row["origin"]);
|
|
856
|
+
const placement = row["placement"];
|
|
857
|
+
if (artifact === null || origin === null || typeof placement !== "string") return null;
|
|
858
|
+
if (!PLACEMENT_ID_PATTERN.test(placement) || placement === SET_PLACEMENT_ID || placement === SET2_PLACEMENT_ID) return null;
|
|
859
|
+
const list = value["path"];
|
|
860
|
+
if (!Array.isArray(list)) return null;
|
|
861
|
+
const path: Uint8Array[] = [];
|
|
862
|
+
for (const node of list as unknown[]) {
|
|
863
|
+
if (typeof node !== "string" || !/^[0-9a-f]{64}$/.test(node)) return null;
|
|
864
|
+
const bytes = hexToBytes(node);
|
|
865
|
+
if (bytes === null) return null;
|
|
866
|
+
path.push(bytes);
|
|
867
|
+
}
|
|
868
|
+
return { member: { artifact, origin, placement }, index, count, path };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** Recompute a root from a member's leaf and path; null when the path does not fit. */
|
|
872
|
+
export function setRootFromMember(member: SetMember, index: number, count: number, path: readonly Uint8Array[]): Uint8Array | null {
|
|
873
|
+
return merkleRootFromPath(setLeaf(member), index, count, path);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const set2: Placement = {
|
|
877
|
+
id: SET2_PLACEMENT_ID,
|
|
878
|
+
form: "C",
|
|
879
|
+
byteExact: false,
|
|
880
|
+
build() {
|
|
881
|
+
throw new TypeError("set/2 is built with buildSetRoot(commitment, count, root)");
|
|
882
|
+
},
|
|
883
|
+
locate(fused) {
|
|
884
|
+
const doc = parseSetRoot(fused);
|
|
885
|
+
return doc === null ? null : { commitment: doc.commitment };
|
|
886
|
+
},
|
|
887
|
+
};
|
|
888
|
+
|
|
592
889
|
/**
|
|
593
|
-
* Resolve a placement by id. set/1
|
|
890
|
+
* Resolve a placement by id. set/1 and set/2 resolve here but are NOT in PLACEMENTS:
|
|
594
891
|
* the undeclared scan is for bytes whose placement was not declared, whereas
|
|
595
892
|
* a set manifest is identified by hashing to the signed artifact digest and
|
|
596
893
|
* by its signed title, so the scan order of every existing fixture is
|
|
597
894
|
* literally unchanged.
|
|
598
895
|
*/
|
|
599
896
|
export function getPlacement(id: string): Placement | undefined {
|
|
600
|
-
return [...PLACEMENTS, set1].find((p) => p.id === id);
|
|
897
|
+
return [...PLACEMENTS, set1, set2].find((p) => p.id === id);
|
|
601
898
|
}
|
|
602
899
|
|
|
603
900
|
// ---------------------------------------------------------------------------
|
|
@@ -612,7 +909,7 @@ export function getPlacement(id: string): Placement | undefined {
|
|
|
612
909
|
*/
|
|
613
910
|
export function fuseAttribution(placement: PlacementId, originDigest?: Uint8Array): Attribution {
|
|
614
911
|
if (originDigest !== undefined && originDigest.length !== 32) throw new TypeError("originDigest must be 32 bytes");
|
|
615
|
-
if (placement === SET_PLACEMENT_ID && originDigest !== undefined) throw new TypeError(
|
|
912
|
+
if ((placement === SET_PLACEMENT_ID || placement === SET2_PLACEMENT_ID) && originDigest !== undefined) throw new TypeError(`${placement} has no single origin; a set marker carries no origin digest`);
|
|
616
913
|
return {
|
|
617
914
|
name: FUSE_ATTRIBUTION_NAME,
|
|
618
915
|
title: placement,
|
package/src/index.ts
CHANGED
|
@@ -64,8 +64,11 @@ export {
|
|
|
64
64
|
hexToBytes,
|
|
65
65
|
bytesEqual,
|
|
66
66
|
} from "./fuse.js";
|
|
67
|
-
export type { PlacementId, Placement, Located, FusePayload, FuseFrame, FuseMarker, MarkerSource, SetMember, SetManifest } from "./fuse.js";
|
|
67
|
+
export type { PlacementId, Placement, Located, FusePayload, FuseFrame, FuseMarker, MarkerSource, SetMember, SetManifest, FusedFrame } from "./fuse.js";
|
|
68
68
|
export { verifyFuse, assembledAfterCommit } from "./fuse-verify.js";
|
|
69
69
|
export type { FuseCategory, FuseSpan, FuseVerifyResult, FuseVerifyOptions } from "./fuse-verify.js";
|
|
70
70
|
export { verifyFuseMember } from "./fuse-member.js";
|
|
71
71
|
export type { FuseMemberCategory, FuseSetEvidence, FuseMemberOptions, FuseMemberResult } from "./fuse-member.js";
|
|
72
|
+
export { SET2_PLACEMENT_ID, SET_MEMBER_METADATA_KEY, MAX_SET2_MEMBERS, canonicalSetRow, setLeaf, sortSetMembers, buildSetTree, setMemberPath, buildSetRoot, parseSetRoot, buildSetMemberProof, parseSetMemberProof, setRootFromMember } from "./fuse.js";
|
|
73
|
+
export type { SetRoot, SetMemberProof } from "./fuse.js";
|
|
74
|
+
export { merkleLeafHash, merkleNodeHash, merkleRoot, merklePath, merkleRootFromPath, MerkleTree } from "./fuse-merkle.js";
|