@mikeargento/bitgraph 1.7.0 → 1.8.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/__tests__/fuse-set-sdk.test.js +43 -0
- package/dist/__tests__/fuse-set-sdk.test.js.map +1 -1
- package/dist/__tests__/fuse-set.test.js +6 -1
- package/dist/__tests__/fuse-set.test.js.map +1 -1
- package/dist/__tests__/fuse-set2.test.d.ts +2 -0
- package/dist/__tests__/fuse-set2.test.d.ts.map +1 -0
- package/dist/__tests__/fuse-set2.test.js +294 -0
- package/dist/__tests__/fuse-set2.test.js.map +1 -0
- package/dist/fuse.d.ts +23 -5
- package/dist/fuse.d.ts.map +1 -1
- package/dist/fuse.js +60 -17
- package/dist/fuse.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/fuse-set-sdk.test.ts +46 -2
- package/src/__tests__/fuse-set.test.ts +6 -1
- package/src/__tests__/fuse-set2.test.ts +343 -0
- package/src/fuse.ts +83 -31
- package/src/index.ts +1 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BitGraph Sets, set/2: a Merkle root over the rows instead of the list.
|
|
5
|
+
* The tree, the root document, a member's evidence, and the verifier's
|
|
6
|
+
* verdicts for a member with its path, without it, and with a stolen one.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test } from "node:test";
|
|
10
|
+
import * as assert from "node:assert/strict";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { signAsync } from "@noble/ed25519";
|
|
14
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
15
|
+
import {
|
|
16
|
+
PLACEMENTS,
|
|
17
|
+
SET_METADATA_KEY,
|
|
18
|
+
SET_MEMBER_METADATA_KEY,
|
|
19
|
+
SET2_PLACEMENT_ID,
|
|
20
|
+
MAX_SET2_MEMBERS,
|
|
21
|
+
MerkleTree,
|
|
22
|
+
buildSetManifest,
|
|
23
|
+
buildSetMemberProof,
|
|
24
|
+
buildSetRoot,
|
|
25
|
+
buildSetTree,
|
|
26
|
+
bytesEqual,
|
|
27
|
+
bytesToBase64,
|
|
28
|
+
bytesToHex,
|
|
29
|
+
canonicalSlotBody,
|
|
30
|
+
canonicalize,
|
|
31
|
+
computeSlotCommitment,
|
|
32
|
+
fuseAttribution,
|
|
33
|
+
getPlacement,
|
|
34
|
+
hexToBytes,
|
|
35
|
+
merkleLeafHash,
|
|
36
|
+
merklePath,
|
|
37
|
+
merkleRoot,
|
|
38
|
+
merkleRootFromPath,
|
|
39
|
+
parseSetManifest,
|
|
40
|
+
parseSetMemberProof,
|
|
41
|
+
parseSetRoot,
|
|
42
|
+
setLeaf,
|
|
43
|
+
setMemberPath,
|
|
44
|
+
setRootFromMember,
|
|
45
|
+
verifyFuse,
|
|
46
|
+
verifyFuseMember,
|
|
47
|
+
} from "@mikeargento/bitgraph-verify";
|
|
48
|
+
import type { BitGraphProof, SlotAllocation, Attribution, SetMember, SetMemberProof } from "@mikeargento/bitgraph-verify";
|
|
49
|
+
import { makeKey, signBody, b64, utf8 } from "./audit-fixtures.js";
|
|
50
|
+
import type { ManualKey } from "./audit-fixtures.js";
|
|
51
|
+
|
|
52
|
+
const FIX = fileURLToPath(new URL("../../src/__tests__/fuse-fixtures/", import.meta.url));
|
|
53
|
+
const bytes = (name: string) => new Uint8Array(readFileSync(FIX + name));
|
|
54
|
+
const original = bytes("original.txt");
|
|
55
|
+
const png = bytes("image.png");
|
|
56
|
+
const dec = (b: Uint8Array) => new TextDecoder().decode(b);
|
|
57
|
+
const EPOCH = bytesToBase64(new Uint8Array(32).fill(0x5e));
|
|
58
|
+
|
|
59
|
+
async function allocateSlot(key: ManualKey, counter: string): Promise<SlotAllocation> {
|
|
60
|
+
const body = { version: "bitgraph/slot/1" as const, nonceB64: b64(crypto.getRandomValues(new Uint8Array(32))), counter, epochId: EPOCH, publicKeyB64: key.publicKeyB64, chainId: "bitgraph:main" };
|
|
61
|
+
return { ...body, signatureB64: b64(await signAsync(canonicalize(body), key.privateKey)) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A set/2 proof: the root document is the artifact, set/2 the signed title, the slot consumed. */
|
|
65
|
+
async function mintRootProof(o: { key: ManualKey; slot: SlotAllocation; commitCounter: string; rootDoc: Uint8Array; withMetadata?: boolean; attribution?: Attribution | null; member?: SetMemberProof }): Promise<BitGraphProof> {
|
|
66
|
+
const commit: BitGraphProof["commit"] = { nonceB64: o.slot.nonceB64, counter: o.commitCounter, epochId: o.slot.epochId, slotCounter: o.slot.counter, slotHashB64: b64(sha256(canonicalize(canonicalSlotBody(o.slot)))) };
|
|
67
|
+
(commit as unknown as Record<string, unknown>)["chainId"] = "bitgraph:main";
|
|
68
|
+
const attribution = o.attribution === undefined ? fuseAttribution(SET2_PLACEMENT_ID) : o.attribution;
|
|
69
|
+
const proof = await signBody(o.key, { hashAlg: "sha256", digestB64: b64(sha256(o.rootDoc)) }, commit, "test-measurement-set2", attribution === null ? undefined : { attribution });
|
|
70
|
+
proof.slotAllocation = o.slot;
|
|
71
|
+
if (o.withMetadata !== false) proof.metadata = { [SET_METADATA_KEY]: JSON.parse(dec(o.rootDoc)) as Record<string, unknown>, ...(o.member !== undefined ? { [SET_MEMBER_METADATA_KEY]: o.member } : {}) };
|
|
72
|
+
return proof;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Fused { name: string; original: Uint8Array; placement: "trailer/1" | "container/1" | "container/2"; bytes: Uint8Array; row: SetMember }
|
|
76
|
+
function fuseWith(commitment: Uint8Array, original: Uint8Array, placement: Fused["placement"], name: string): Fused {
|
|
77
|
+
const fused = getPlacement(placement)!.build({ original, commitment });
|
|
78
|
+
return { name, original, placement, bytes: fused, row: { artifact: sha256(fused), origin: sha256(original), placement } };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A set/2 of five members across the three member placements, minted under a fresh slot. */
|
|
82
|
+
async function makeSet2() {
|
|
83
|
+
const key = await makeKey();
|
|
84
|
+
const slot = await allocateSlot(key, "40");
|
|
85
|
+
const commitment = computeSlotCommitment(slot);
|
|
86
|
+
const fused: Fused[] = [
|
|
87
|
+
fuseWith(commitment, original, "trailer/1", "original.txt"),
|
|
88
|
+
fuseWith(commitment, png, "container/1", "image.png"),
|
|
89
|
+
fuseWith(commitment, utf8("a plain note for the tree\n"), "container/2", "note.txt"),
|
|
90
|
+
fuseWith(commitment, utf8("a fourth member\n"), "trailer/1", "fourth.txt"),
|
|
91
|
+
fuseWith(commitment, utf8("the fifth, in the new container\n"), "container/2", "fifth.txt"),
|
|
92
|
+
];
|
|
93
|
+
const tree = buildSetTree(fused.map((f) => f.row));
|
|
94
|
+
const rootDoc = buildSetRoot(commitment, tree.sorted.length, tree.root);
|
|
95
|
+
const proof = await mintRootProof({ key, slot, commitCounter: "41", rootDoc });
|
|
96
|
+
const evidenceOf = (f: Fused): SetMemberProof => {
|
|
97
|
+
const k = tree.sorted.findIndex((r) => bytesEqual(r.artifact, f.row.artifact));
|
|
98
|
+
return buildSetMemberProof(f.row, k, tree.sorted.length, tree.tree.path(k));
|
|
99
|
+
};
|
|
100
|
+
return { key, slot, commitment, fused, tree, rootDoc, proof, evidenceOf };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const leafInputs = ["", "00", "10", "2021", "3031", "40414243", "5051525354555657", "606162636465666768696a6b6c6d6e6f"].map((h) => hexToBytes(h) ?? new Uint8Array(0));
|
|
104
|
+
|
|
105
|
+
describe("the tree (RFC 6962)", () => {
|
|
106
|
+
test("1. the RFC 6962 vectors: one leaf, and the eight-leaf root", () => {
|
|
107
|
+
const hashes = leafInputs.map(merkleLeafHash);
|
|
108
|
+
assert.equal(bytesToHex(merkleRoot(hashes.slice(0, 1))), "6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d");
|
|
109
|
+
assert.equal(bytesToHex(merkleRoot(hashes)), "5dc9da79a70659a9ad559cb701ded9a2ab9d823aad2f4960cfe370eff4604328");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("2. every path of every tree up to 40 leaves recomputes the root, through both the recursive and the memoized tree", () => {
|
|
113
|
+
for (let n = 1; n <= 40; n++) {
|
|
114
|
+
const hashes = Array.from({ length: n }, (_, i) => merkleLeafHash(utf8(`leaf ${i} of ${n}`)));
|
|
115
|
+
const root = merkleRoot(hashes);
|
|
116
|
+
const tree = new MerkleTree(hashes);
|
|
117
|
+
assert.deepEqual(tree.root, root, `tree root n=${n}`);
|
|
118
|
+
for (let i = 0; i < n; i++) {
|
|
119
|
+
const path = merklePath(hashes, i);
|
|
120
|
+
assert.deepEqual(tree.path(i), path, `path n=${n} i=${i}`);
|
|
121
|
+
assert.ok(path.length <= (n === 1 ? 0 : Math.ceil(Math.log2(n))), `path length n=${n} i=${i}`);
|
|
122
|
+
assert.deepEqual(merkleRootFromPath(hashes[i]!, i, n, path), root, `recompute n=${n} i=${i}`);
|
|
123
|
+
// A different index, a different size, a truncated or extended path, or a changed node never reaches the root.
|
|
124
|
+
if (n > 1) {
|
|
125
|
+
const other = (i + 1) % n;
|
|
126
|
+
assert.notDeepEqual(merkleRootFromPath(hashes[i]!, other, n, path), root, `wrong index n=${n} i=${i}`);
|
|
127
|
+
assert.notDeepEqual(merkleRootFromPath(hashes[i]!, i, n, path.slice(0, -1)) ?? new Uint8Array(0), root, `short path n=${n} i=${i}`);
|
|
128
|
+
const tampered = path.map((p, j) => (j === 0 ? new Uint8Array(p.map((b, x) => (x === 0 ? b ^ 1 : b))) : p));
|
|
129
|
+
assert.notDeepEqual(merkleRootFromPath(hashes[i]!, i, n, tampered), root, `tampered node n=${n} i=${i}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// A path says nothing about the tree's size on its own: the same
|
|
133
|
+
// siblings can close a tree of another size to the same root (three
|
|
134
|
+
// leaves and a fourth whose subtree hash equals the third leaf). The
|
|
135
|
+
// size is committed in the root document and the verifier binds the
|
|
136
|
+
// evidence's count to it before the path is read.
|
|
137
|
+
assert.equal(merkleRootFromPath(hashes[0]!, n, n, []), null, "index out of range");
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("3. 20,000 leaves: the memoized tree builds and every path verifies in well under a few seconds", () => {
|
|
142
|
+
const n = 20_000;
|
|
143
|
+
const hashes = Array.from({ length: n }, (_, i) => merkleLeafHash(utf8(`leaf ${i}`)));
|
|
144
|
+
const t0 = performance.now();
|
|
145
|
+
const tree = new MerkleTree(hashes);
|
|
146
|
+
let ok = 0;
|
|
147
|
+
for (let i = 0; i < n; i++) if (bytesEqual(merkleRootFromPath(hashes[i]!, i, n, tree.path(i))!, tree.root)) ok++;
|
|
148
|
+
const sec = (performance.now() - t0) / 1000;
|
|
149
|
+
assert.equal(ok, n);
|
|
150
|
+
assert.ok(sec < 10, `took ${sec.toFixed(1)} s`);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("the root document and a member's evidence", () => {
|
|
155
|
+
const commitment = new Uint8Array(32).fill(7);
|
|
156
|
+
// Hex with letters in it, so an uppercase variant is a real change.
|
|
157
|
+
const root = new Uint8Array(32).fill(0xab);
|
|
158
|
+
|
|
159
|
+
test("4. buildSetRoot / parseSetRoot round trip; anything not byte-equal to its own rebuild is refused", () => {
|
|
160
|
+
const doc = buildSetRoot(commitment, 44_000, root);
|
|
161
|
+
assert.equal(dec(doc), `{"count":44000,"placement":"set/2","root":{"algorithm":"sha256","digest":"${bytesToHex(root)}"},"slotCommitment":{"algorithm":"sha256","digest":"${bytesToHex(commitment)}"},"type":"bitgraph-fuse/1"}`);
|
|
162
|
+
const parsed = parseSetRoot(doc)!;
|
|
163
|
+
assert.deepEqual(parsed.commitment, commitment);
|
|
164
|
+
assert.deepEqual(parsed.root, root);
|
|
165
|
+
assert.equal(parsed.count, 44_000);
|
|
166
|
+
for (const bad of [
|
|
167
|
+
dec(doc).replace("}", " }"),
|
|
168
|
+
dec(doc).replace('"set/2"', '"set/1"'),
|
|
169
|
+
dec(doc).replace('"count":44000', '"count":0'),
|
|
170
|
+
dec(doc).replace('"count":44000', `"count":${MAX_SET2_MEMBERS + 1}`),
|
|
171
|
+
dec(doc).replace('"count":44000', '"count":44000,"extra":1'),
|
|
172
|
+
dec(doc).replace(bytesToHex(root), bytesToHex(root).toUpperCase()),
|
|
173
|
+
]) assert.equal(parseSetRoot(utf8(bad)), null, bad.slice(0, 60));
|
|
174
|
+
assert.throws(() => buildSetRoot(commitment, 0, root));
|
|
175
|
+
assert.throws(() => buildSetRoot(commitment, MAX_SET2_MEMBERS + 1, root));
|
|
176
|
+
assert.throws(() => buildSetRoot(new Uint8Array(31), 1, root));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("5. a member's evidence round-trips, and a malformed one reads as null", () => {
|
|
180
|
+
const rows: SetMember[] = [0, 1, 2].map((i) => ({ artifact: new Uint8Array(32).fill(10 + i), origin: new Uint8Array(32).fill(20 + i), placement: "trailer/1" }));
|
|
181
|
+
const tree = buildSetTree(rows);
|
|
182
|
+
const ev = buildSetMemberProof(tree.sorted[1]!, 1, 3, tree.tree.path(1));
|
|
183
|
+
assert.equal(ev.placement, "set/2");
|
|
184
|
+
assert.equal(ev.count, 3);
|
|
185
|
+
assert.equal(ev.index, 1);
|
|
186
|
+
const back = parseSetMemberProof(JSON.parse(JSON.stringify(ev)))!;
|
|
187
|
+
assert.deepEqual(back.member, tree.sorted[1]);
|
|
188
|
+
assert.deepEqual(back.path, tree.tree.path(1));
|
|
189
|
+
assert.deepEqual(setRootFromMember(back.member, back.index, back.count, back.path), tree.root);
|
|
190
|
+
for (const mutate of [
|
|
191
|
+
(o: Record<string, unknown>) => { o["index"] = 3; },
|
|
192
|
+
(o: Record<string, unknown>) => { o["count"] = 0; },
|
|
193
|
+
(o: Record<string, unknown>) => { (o["path"] as string[])[0] = "zz"; },
|
|
194
|
+
(o: Record<string, unknown>) => { o["placement"] = "set/1"; },
|
|
195
|
+
(o: Record<string, unknown>) => { (o["member"] as Record<string, unknown>)["placement"] = "set/2"; },
|
|
196
|
+
(o: Record<string, unknown>) => { o["extra"] = true; },
|
|
197
|
+
]) {
|
|
198
|
+
const o = JSON.parse(JSON.stringify(ev)) as Record<string, unknown>;
|
|
199
|
+
mutate(o);
|
|
200
|
+
assert.equal(parseSetMemberProof(o), null);
|
|
201
|
+
}
|
|
202
|
+
assert.throws(() => buildSetMemberProof(rows[0]!, 3, 3, []));
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("6. set/2 resolves through getPlacement as Form C and is not in PLACEMENTS; the attribution carries no origin; a manifest refuses a set/2 row", () => {
|
|
206
|
+
assert.deepEqual(PLACEMENTS.map((p) => p.id), ["trailer/1", "container/1", "container/2", "produced/1"]);
|
|
207
|
+
const p = getPlacement("set/2")!;
|
|
208
|
+
assert.equal(p.form, "C");
|
|
209
|
+
assert.equal(p.byteExact, false);
|
|
210
|
+
const doc = buildSetRoot(commitment, 3, root);
|
|
211
|
+
assert.deepEqual(p.locate(doc)!.commitment, commitment);
|
|
212
|
+
assert.equal(p.locate(utf8("{}")), null);
|
|
213
|
+
assert.throws(() => p.build({ commitment }));
|
|
214
|
+
assert.deepEqual(fuseAttribution("set/2"), { name: "bitgraph-fuse/1", title: "set/2" });
|
|
215
|
+
assert.throws(() => fuseAttribution("set/2", commitment), /no single origin/);
|
|
216
|
+
const row: SetMember = { artifact: new Uint8Array(32).fill(1), origin: new Uint8Array(32).fill(2), placement: "set/2" };
|
|
217
|
+
assert.throws(() => buildSetManifest(commitment, [row]), /cannot list a set/);
|
|
218
|
+
assert.throws(() => setLeaf(row));
|
|
219
|
+
const okRow: SetMember = { ...row, placement: "trailer/1" };
|
|
220
|
+
const manifest = buildSetManifest(commitment, [okRow]);
|
|
221
|
+
assert.ok(parseSetManifest(manifest));
|
|
222
|
+
assert.equal(parseSetManifest(utf8(dec(manifest).replace('"placement":"trailer/1"', '"placement":"set/2"'))), null);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("verifyFuseMember for a set/2 member", () => {
|
|
227
|
+
test("7. every member: direct with its evidence, from its original with its evidence, and with the evidence riding under proof.metadata", async () => {
|
|
228
|
+
const s = await makeSet2();
|
|
229
|
+
for (const f of s.fused) {
|
|
230
|
+
const ev = s.evidenceOf(f);
|
|
231
|
+
const direct = await verifyFuseMember({ proof: s.proof, bytes: f.bytes, member: ev });
|
|
232
|
+
assert.equal(direct.category, "SET_MEMBER_DIRECT", `${f.name}: ${direct.reason}`);
|
|
233
|
+
assert.equal(direct.set!.kind, "set/2");
|
|
234
|
+
assert.equal(direct.set!.memberCount, 5);
|
|
235
|
+
assert.equal(direct.set!.memberSource, "argument");
|
|
236
|
+
assert.equal(direct.set!.treeRootB64, bytesToBase64(s.tree.root));
|
|
237
|
+
assert.equal(direct.set!.member!.index, ev.index);
|
|
238
|
+
assert.equal(direct.placement, f.placement);
|
|
239
|
+
assert.ok(direct.statements.some((t) => t.includes(`member ${ev.index + 1} of 5`)), direct.statements.join(" | "));
|
|
240
|
+
assert.ok(direct.statements.some((t) => t.includes("recompute the committed root")));
|
|
241
|
+
const fromOrigin = await verifyFuseMember({ proof: s.proof, bytes: f.original, member: ev });
|
|
242
|
+
assert.equal(fromOrigin.category, "SET_MEMBER_FROM_ORIGIN", `${f.name}: ${fromOrigin.reason}`);
|
|
243
|
+
assert.equal(fromOrigin.set!.member!.index, ev.index);
|
|
244
|
+
const riding = await mintRootProof({ key: s.key, slot: s.slot, commitCounter: "41", rootDoc: s.rootDoc, member: ev });
|
|
245
|
+
const viaMetadata = await verifyFuseMember({ proof: riding, bytes: f.bytes });
|
|
246
|
+
assert.equal(viaMetadata.category, "SET_MEMBER_DIRECT");
|
|
247
|
+
assert.equal(viaMetadata.set!.memberSource, "metadata");
|
|
248
|
+
}
|
|
249
|
+
// The root document itself is not a member; verifyFuse answers for it.
|
|
250
|
+
const self = await verifyFuseMember({ proof: s.proof, bytes: s.rootDoc });
|
|
251
|
+
assert.equal(self.category, "NO_MATCH");
|
|
252
|
+
assert.match(self.reason ?? "", /root document itself/);
|
|
253
|
+
const asFuse = await verifyFuse({ proof: s.proof, bytes: s.rootDoc });
|
|
254
|
+
assert.equal(asFuse.category, "FUSED_DIRECT");
|
|
255
|
+
assert.equal(asFuse.placement, "set/2");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("8. without evidence the floor still shows but membership does not: SET_MEMBERSHIP_UNPROVEN for a member's own bytes and for the 51st file alike; an original without evidence is NO_MATCH", async () => {
|
|
259
|
+
const s = await makeSet2();
|
|
260
|
+
const own = await verifyFuseMember({ proof: s.proof, bytes: s.fused[0]!.bytes });
|
|
261
|
+
assert.equal(own.category, "SET_MEMBERSHIP_UNPROVEN");
|
|
262
|
+
assert.deepEqual(own.statements, []);
|
|
263
|
+
assert.match(own.reason ?? "", /made after that slot existed/);
|
|
264
|
+
assert.match(own.reason ?? "", /no member evidence/);
|
|
265
|
+
assert.equal(own.set!.kind, "set/2");
|
|
266
|
+
assert.equal(own.set!.member, null);
|
|
267
|
+
const stray = getPlacement("trailer/1")!.build({ original: utf8("the 51st file\n"), commitment: s.commitment });
|
|
268
|
+
const strayVerdict = await verifyFuseMember({ proof: s.proof, bytes: stray });
|
|
269
|
+
assert.equal(strayVerdict.category, "SET_MEMBERSHIP_UNPROVEN");
|
|
270
|
+
const plain = await verifyFuseMember({ proof: s.proof, bytes: s.fused[0]!.original });
|
|
271
|
+
assert.equal(plain.category, "NO_MATCH");
|
|
272
|
+
const foreign = getPlacement("trailer/1")!.build({ original: utf8("x\n"), commitment: new Uint8Array(32).fill(3) });
|
|
273
|
+
assert.equal((await verifyFuseMember({ proof: s.proof, bytes: foreign })).category, "NO_MATCH");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("9. a stolen path: the 51st file with a real member's evidence is INVALID_SET_PATH, as are a forged node, a wrong count, and evidence for another set", async () => {
|
|
277
|
+
const s = await makeSet2();
|
|
278
|
+
const ev = s.evidenceOf(s.fused[2]!);
|
|
279
|
+
const stray = getPlacement("container/2")!.build({ original: utf8("the 51st file\n"), commitment: s.commitment });
|
|
280
|
+
const stolen = await verifyFuseMember({ proof: s.proof, bytes: stray, member: ev });
|
|
281
|
+
assert.equal(stolen.category, "INVALID_SET_PATH");
|
|
282
|
+
assert.match(stolen.reason ?? "", /describes a different member/);
|
|
283
|
+
assert.deepEqual(stolen.statements, []);
|
|
284
|
+
const forged = JSON.parse(JSON.stringify(ev)) as SetMemberProof;
|
|
285
|
+
if (forged.path.length > 0) forged.path[0] = forged.path[0]!.replace(/^[0-9a-f]/, (c) => (c === "a" ? "b" : "a"));
|
|
286
|
+
else forged.index = 0;
|
|
287
|
+
const forgedVerdict = await verifyFuseMember({ proof: s.proof, bytes: s.fused[2]!.bytes, member: forged });
|
|
288
|
+
assert.equal(forgedVerdict.category, "INVALID_SET_PATH");
|
|
289
|
+
const wrongCount = { ...JSON.parse(JSON.stringify(ev)), count: 6 } as SetMemberProof;
|
|
290
|
+
assert.equal((await verifyFuseMember({ proof: s.proof, bytes: s.fused[2]!.bytes, member: wrongCount })).category, "INVALID_SET_PATH");
|
|
291
|
+
const other = await makeSet2();
|
|
292
|
+
const otherEv = other.evidenceOf(other.fused[2]!);
|
|
293
|
+
assert.equal((await verifyFuseMember({ proof: s.proof, bytes: s.fused[2]!.bytes, member: otherEv })).category, "INVALID_SET_PATH");
|
|
294
|
+
assert.equal((await verifyFuseMember({ proof: s.proof, bytes: s.fused[2]!.bytes, member: { nonsense: true } })).category, "INVALID_SET_PATH");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("10. the root document is bound before any row: a wrong commitment, a stripped document, or a set/1 title over a root document are refused", async () => {
|
|
298
|
+
const s = await makeSet2();
|
|
299
|
+
const ev = s.evidenceOf(s.fused[0]!);
|
|
300
|
+
const otherSlot = await allocateSlot(s.key, "50");
|
|
301
|
+
const wrongDoc = buildSetRoot(computeSlotCommitment(otherSlot), 5, s.tree.root);
|
|
302
|
+
const wrongCommitment = await mintRootProof({ key: s.key, slot: s.slot, commitCounter: "41", rootDoc: wrongDoc });
|
|
303
|
+
assert.equal((await verifyFuseMember({ proof: wrongCommitment, bytes: s.fused[0]!.bytes, member: ev })).category, "INVALID_SLOT_COMMITMENT");
|
|
304
|
+
const stripped = await mintRootProof({ key: s.key, slot: s.slot, commitCounter: "41", rootDoc: s.rootDoc, withMetadata: false });
|
|
305
|
+
const noDoc = await verifyFuseMember({ proof: stripped, bytes: s.fused[0]!.bytes, member: ev });
|
|
306
|
+
assert.equal(noDoc.category, "INVALID_SET_MANIFEST");
|
|
307
|
+
assert.match(noDoc.reason ?? "", /root document/);
|
|
308
|
+
const explicit = await verifyFuseMember({ proof: stripped, bytes: s.fused[0]!.bytes, member: ev, manifest: s.rootDoc });
|
|
309
|
+
assert.equal(explicit.category, "SET_MEMBER_DIRECT", "explicit root document bytes bind a stripped proof");
|
|
310
|
+
const asSet1 = await mintRootProof({ key: s.key, slot: s.slot, commitCounter: "41", rootDoc: s.rootDoc, attribution: fuseAttribution("set/1") });
|
|
311
|
+
assert.equal((await verifyFuseMember({ proof: asSet1, bytes: s.fused[0]!.bytes, member: ev })).category, "INVALID_SET_MANIFEST");
|
|
312
|
+
const tampered = JSON.parse(JSON.stringify(s.proof)) as BitGraphProof;
|
|
313
|
+
(tampered.metadata![SET_METADATA_KEY] as Record<string, unknown>)["count"] = 6;
|
|
314
|
+
assert.equal((await verifyFuseMember({ proof: tampered, bytes: s.fused[0]!.bytes, member: ev })).category, "INVALID_SET_MANIFEST");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("11. an unregistered row placement in valid evidence is UNDETERMINED_PLACEMENT: the path proves the row, the bytes cannot be read", async () => {
|
|
318
|
+
const key = await makeKey();
|
|
319
|
+
const slot = await allocateSlot(key, "60");
|
|
320
|
+
const commitment = computeSlotCommitment(slot);
|
|
321
|
+
const real = fuseWith(commitment, original, "trailer/1", "a");
|
|
322
|
+
const odd: SetMember = { artifact: new Uint8Array(32).fill(5), origin: sha256(utf8("odd")), placement: "xmp/9" };
|
|
323
|
+
const tree = buildSetTree([real.row, odd]);
|
|
324
|
+
const proof = await mintRootProof({ key, slot, commitCounter: "61", rootDoc: buildSetRoot(commitment, 2, tree.root) });
|
|
325
|
+
const k = tree.sorted.findIndex((r) => r.placement === "xmp/9");
|
|
326
|
+
const ev = buildSetMemberProof(odd, k, 2, tree.tree.path(k));
|
|
327
|
+
// Bytes that hash to the odd row's artifact cannot exist here; a direct hit is not constructible, so the from-origin path reports the unregistered placement.
|
|
328
|
+
const v = await verifyFuseMember({ proof, bytes: utf8("odd"), member: ev });
|
|
329
|
+
assert.equal(v.category, "UNDETERMINED_PLACEMENT");
|
|
330
|
+
const good = await verifyFuseMember({ proof, bytes: real.bytes, member: buildSetMemberProof(real.row, 1 - k, 2, tree.tree.path(1 - k)) });
|
|
331
|
+
assert.equal(good.category, "SET_MEMBER_DIRECT");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("12. paths from the tree helpers agree with the verifier: setMemberPath, setLeaf, setRootFromMember", async () => {
|
|
335
|
+
const s = await makeSet2();
|
|
336
|
+
for (let k = 0; k < s.tree.sorted.length; k++) {
|
|
337
|
+
const row = s.tree.sorted[k]!;
|
|
338
|
+
const path = setMemberPath(s.tree.tree, k);
|
|
339
|
+
assert.deepEqual(merkleRootFromPath(setLeaf(row), k, 5, path), s.tree.root);
|
|
340
|
+
assert.deepEqual(setRootFromMember(row, k, 5, path), s.tree.root);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
});
|
package/src/fuse.ts
CHANGED
|
@@ -39,6 +39,14 @@ import { sha256 } from "@noble/hashes/sha256";
|
|
|
39
39
|
import {
|
|
40
40
|
buildFrame,
|
|
41
41
|
buildSetManifest,
|
|
42
|
+
buildSetMemberProof,
|
|
43
|
+
buildSetRoot,
|
|
44
|
+
buildSetTree,
|
|
45
|
+
parseSetRoot,
|
|
46
|
+
SET2_PLACEMENT_ID,
|
|
47
|
+
SET_PLACEMENT_ID as SET_PLACEMENT_ID_LOCAL,
|
|
48
|
+
setRootFromMember,
|
|
49
|
+
MAX_SET2_MEMBERS,
|
|
42
50
|
bytesEqual,
|
|
43
51
|
bytesToBase64,
|
|
44
52
|
bytesToHex,
|
|
@@ -55,18 +63,7 @@ import {
|
|
|
55
63
|
verifyFuseMember,
|
|
56
64
|
base64ToBytes,
|
|
57
65
|
} from "@mikeargento/bitgraph-verify";
|
|
58
|
-
import type {
|
|
59
|
-
BitGraphProof,
|
|
60
|
-
FuseFrame,
|
|
61
|
-
FuseMemberResult,
|
|
62
|
-
FuseVerifyResult,
|
|
63
|
-
Located,
|
|
64
|
-
Placement,
|
|
65
|
-
PlacementId,
|
|
66
|
-
SetManifest,
|
|
67
|
-
SetMember,
|
|
68
|
-
SlotAllocation,
|
|
69
|
-
} from "@mikeargento/bitgraph-verify";
|
|
66
|
+
import type { BitGraphProof, FuseFrame, FuseMemberResult, FuseVerifyResult, Located, Placement, PlacementId, SetManifest, SetMember, SetMemberProof, SetRoot, SlotAllocation } from "@mikeargento/bitgraph-verify";
|
|
70
67
|
|
|
71
68
|
/**
|
|
72
69
|
* SHA-256 over bytes: the platform's native hasher when one is present
|
|
@@ -561,15 +558,25 @@ export interface FuseSetProgress {
|
|
|
561
558
|
/**
|
|
562
559
|
* "hash": each member checked before any request (a bytes member's origin digest is taken here).
|
|
563
560
|
* "fuse": each member's fused digest taken, after the slot is held.
|
|
561
|
+
* "tree": set/2 only, 0 of 1 before the tree is built, 1 of 1 after.
|
|
564
562
|
* "commit": 0 of 1 before the request, 1 of 1 when the proof is back.
|
|
565
563
|
* "verify": only with verifyMembers, one per member.
|
|
566
564
|
*/
|
|
567
|
-
phase: "hash" | "fuse" | "commit" | "verify";
|
|
565
|
+
phase: "hash" | "fuse" | "tree" | "commit" | "verify";
|
|
568
566
|
done: number;
|
|
569
567
|
total: number;
|
|
570
568
|
}
|
|
571
569
|
|
|
572
570
|
export interface FuseSetOptions {
|
|
571
|
+
/**
|
|
572
|
+
* "set/1" (default): the committed artifact is the canonical manifest of
|
|
573
|
+
* every member, which rides in the commit and in the proof; at most
|
|
574
|
+
* MAX_SET_MEMBERS. "set/2": the committed artifact is the root of a Merkle
|
|
575
|
+
* tree over the same rows, a few hundred bytes whatever N is; each member
|
|
576
|
+
* gets its leaf index and path (see FuseSetMemberResult.path), which a
|
|
577
|
+
* reader needs beside the proof; at most MAX_SET2_MEMBERS.
|
|
578
|
+
*/
|
|
579
|
+
set?: "set/1" | "set/2";
|
|
573
580
|
/** Return each member's fused bytes. Default false: they are virtual, rebuilt from the original and the proof. A hashed member has none to return. */
|
|
574
581
|
keepFused?: boolean;
|
|
575
582
|
/**
|
|
@@ -606,14 +613,22 @@ export interface FuseSetMemberResult {
|
|
|
606
613
|
fusedBytes?: Uint8Array;
|
|
607
614
|
/** Present only with verifyMembers: the verifier's own verdict against this member's fused bytes. Always SET_MEMBER_DIRECT on success, with set.manifestSource "argument". */
|
|
608
615
|
verification?: FuseMemberResult;
|
|
616
|
+
/** set/2 only: the member's inclusion path, siblings from the leaf up; with manifestIndex and the set's count it is the member's evidence (memberProof). */
|
|
617
|
+
path?: Uint8Array[];
|
|
618
|
+
/** set/2 only: the member's evidence as its JSON object, ready to ride beside the proof or under proof.metadata[SET_MEMBER_METADATA_KEY]. */
|
|
619
|
+
memberProof?: SetMemberProof;
|
|
609
620
|
}
|
|
610
621
|
|
|
611
622
|
export interface FuseSetResult {
|
|
623
|
+
/** Which set kind was made. */
|
|
624
|
+
set: "set/1" | "set/2";
|
|
612
625
|
proof: BitGraphProof;
|
|
613
|
-
/** The committed artifact. Keep it beside the proof. */
|
|
626
|
+
/** The committed artifact: the set/1 manifest, or the set/2 root document. Keep it beside the proof. */
|
|
614
627
|
manifestBytes: Uint8Array;
|
|
615
|
-
/** JSON.parse of manifestBytes; the exact object sent under metadata. */
|
|
616
|
-
manifest: SetManifest;
|
|
628
|
+
/** JSON.parse of manifestBytes; the exact object sent under metadata. For set/2 a SetRoot. */
|
|
629
|
+
manifest: SetManifest | SetRoot;
|
|
630
|
+
/** set/2 only: the tree root, standard base64. */
|
|
631
|
+
treeRootB64?: string;
|
|
617
632
|
/** SHA-256 of manifestBytes; equals proof.artifact.digestB64. */
|
|
618
633
|
artifactDigestB64: string;
|
|
619
634
|
slotCommitmentB64: string;
|
|
@@ -638,7 +653,10 @@ export interface FuseSetResult {
|
|
|
638
653
|
export async function fuseSet(members: readonly FuseSetMember[], options: FuseSetOptions = {}): Promise<FuseSetResult> {
|
|
639
654
|
// 0. validate, before any request. A refusal here burns nothing.
|
|
640
655
|
if (!Array.isArray(members) || members.length === 0) throw new FuseError("bad-input", "a set lists at least one member");
|
|
641
|
-
|
|
656
|
+
const setKind = options.set ?? "set/1";
|
|
657
|
+
if (setKind !== "set/1" && setKind !== "set/2") throw new FuseError("bad-input", `set must be "set/1" or "set/2" (got ${String(setKind)})`);
|
|
658
|
+
const cap = setKind === "set/1" ? MAX_SET_MEMBERS : MAX_SET2_MEMBERS;
|
|
659
|
+
if (members.length > cap) throw new FuseError("bad-input", `a ${setKind} set lists at most ${cap} members (got ${members.length})`);
|
|
642
660
|
const keep = options.keepFused === true;
|
|
643
661
|
const verifyMembers = options.verifyMembers === true;
|
|
644
662
|
type Kind = "bytes" | "loaded" | "hashed";
|
|
@@ -774,23 +792,32 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
|
|
|
774
792
|
report("fuse", i + 1, checked.length);
|
|
775
793
|
}
|
|
776
794
|
|
|
777
|
-
// 3. hash: the canonical manifest
|
|
795
|
+
// 3. hash: the committed artifact. set/1: the canonical manifest of every
|
|
796
|
+
// row. set/2: the root document over the Merkle tree of the same rows.
|
|
778
797
|
let manifestBytes: Uint8Array;
|
|
798
|
+
let tree: ReturnType<typeof buildSetTree> | null = null;
|
|
779
799
|
try {
|
|
780
|
-
|
|
800
|
+
if (setKind === "set/1") {
|
|
801
|
+
manifestBytes = buildSetManifest(commitment, rows);
|
|
802
|
+
} else {
|
|
803
|
+
report("tree", 0, 1);
|
|
804
|
+
tree = buildSetTree(rows);
|
|
805
|
+
manifestBytes = buildSetRoot(commitment, tree.sorted.length, tree.root);
|
|
806
|
+
report("tree", 1, 1);
|
|
807
|
+
}
|
|
781
808
|
} catch (err) {
|
|
782
|
-
throw new FuseError("bad-input", `the set manifest could not be built: ${err instanceof Error ? err.message : String(err)}; ${expiring}`);
|
|
809
|
+
throw new FuseError("bad-input", `the set ${setKind === "set/1" ? "manifest" : "root document"} could not be built: ${err instanceof Error ? err.message : String(err)}; ${expiring}`);
|
|
783
810
|
}
|
|
784
811
|
const artifactDigestB64 = bytesToBase64(await digest(manifestBytes));
|
|
785
|
-
const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as SetManifest;
|
|
812
|
+
const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as SetManifest | SetRoot;
|
|
786
813
|
|
|
787
|
-
// 4. fill: one commit, the parsed
|
|
814
|
+
// 4. fill: one commit, the parsed artifact riding along as unsigned metadata
|
|
788
815
|
const body: Record<string, unknown> = {
|
|
789
816
|
digests: [{ digestB64: artifactDigestB64, hashAlg: "sha256" }],
|
|
790
817
|
slotId: slot.nonceB64,
|
|
791
818
|
slot,
|
|
792
819
|
chainId: "bitgraph:main",
|
|
793
|
-
attribution: fuseAttribution("set/1"),
|
|
820
|
+
attribution: fuseAttribution(setKind === "set/1" ? SET_PLACEMENT_ID_LOCAL : SET2_PLACEMENT_ID),
|
|
794
821
|
metadata: { [SET_METADATA_KEY]: manifest },
|
|
795
822
|
};
|
|
796
823
|
if (options.agency !== undefined) body.agency = options.agency;
|
|
@@ -798,10 +825,10 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
|
|
|
798
825
|
const { proof, recovered } = await commitUnderSlot(t, body, artifactDigestB64, slot);
|
|
799
826
|
report("commit", 1, 1);
|
|
800
827
|
|
|
801
|
-
// The
|
|
828
|
+
// The committed artifact is verified by a reader before the proof is called a set proof.
|
|
802
829
|
const verification = await verifyFuse({ proof, bytes: manifestBytes });
|
|
803
|
-
if (verification.category !== "FUSED_DIRECT" || verification.placement !==
|
|
804
|
-
throw new FuseError("verification-failed", `the returned proof does not verify as a set: ${verification.category}${verification.reason ? ` (${verification.reason})` : ""}`);
|
|
830
|
+
if (verification.category !== "FUSED_DIRECT" || verification.placement !== setKind) {
|
|
831
|
+
throw new FuseError("verification-failed", `the returned proof does not verify as a ${setKind} set: ${verification.category}${verification.reason ? ` (${verification.reason})` : ""}`);
|
|
805
832
|
}
|
|
806
833
|
// The echo is unsigned and advisory. Absent is normal for a boundary that
|
|
807
834
|
// drops metadata on a held-slot commit (enclaves before v6, and a proxy
|
|
@@ -818,18 +845,39 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
|
|
|
818
845
|
// member's bytes are read again. With verifyMembers the full verifier runs
|
|
819
846
|
// over each member's fused bytes as well, against the explicit manifest
|
|
820
847
|
// bytes so no verdict depends on the echo, and its verdict is returned.
|
|
821
|
-
|
|
822
|
-
|
|
848
|
+
// set/1: the strictly parsed manifest lists each computed row. set/2: the
|
|
849
|
+
// strictly parsed root document states the count and the root the tree
|
|
850
|
+
// over these rows computed, and each member's path recomputes that root
|
|
851
|
+
// (the verifier's own check, run here once per member).
|
|
852
|
+
let listedRows: readonly SetMember[];
|
|
853
|
+
if (setKind === "set/1") {
|
|
854
|
+
const parsed = parseSetManifest(manifestBytes);
|
|
855
|
+
if (parsed === null) throw new FuseError("verification-failed", "the committed manifest does not parse as a set manifest");
|
|
856
|
+
listedRows = parsed.members;
|
|
857
|
+
} else {
|
|
858
|
+
const doc = parseSetRoot(manifestBytes);
|
|
859
|
+
if (doc === null || tree === null) throw new FuseError("verification-failed", "the committed root document does not parse as a set root");
|
|
860
|
+
if (doc.count !== tree.sorted.length || !bytesEqual(doc.root, tree.root)) throw new FuseError("verification-failed", "the committed root document does not state this tree's count and root");
|
|
861
|
+
listedRows = tree.sorted;
|
|
862
|
+
}
|
|
823
863
|
const rowIndex = new Map<string, number>();
|
|
824
|
-
|
|
864
|
+
listedRows.forEach((row, k) => rowIndex.set(bytesToHex(row.artifact), k));
|
|
825
865
|
const results: FuseSetMemberResult[] = [];
|
|
826
866
|
for (let i = 0; i < checked.length; i++) {
|
|
827
867
|
const c = checked[i]!;
|
|
828
868
|
const row = rows[i]!;
|
|
829
869
|
const k = rowIndex.get(bytesToHex(row.artifact));
|
|
830
|
-
const listed = k !== undefined ?
|
|
870
|
+
const listed = k !== undefined ? listedRows[k] : undefined;
|
|
831
871
|
if (k === undefined || listed === undefined || !bytesEqual(listed.origin, row.origin) || listed.placement !== row.placement) {
|
|
832
|
-
throw new FuseError("verification-failed", `member ${i}: the committed manifest does not list this member's fused digest with its origin and placement`, null, i);
|
|
872
|
+
throw new FuseError("verification-failed", `member ${i}: the committed ${setKind === "set/1" ? "manifest" : "tree"} does not list this member's fused digest with its origin and placement`, null, i);
|
|
873
|
+
}
|
|
874
|
+
let path: Uint8Array[] | undefined;
|
|
875
|
+
let memberProof: SetMemberProof | undefined;
|
|
876
|
+
if (tree !== null) {
|
|
877
|
+
path = tree.tree.path(k);
|
|
878
|
+
const reached = setRootFromMember(row, k, tree.sorted.length, path);
|
|
879
|
+
if (reached === null || !bytesEqual(reached, tree.root)) throw new FuseError("verification-failed", `member ${i}: its path does not recompute the committed root`, null, i);
|
|
880
|
+
memberProof = buildSetMemberProof(row, k, tree.sorted.length, path);
|
|
833
881
|
}
|
|
834
882
|
const memberArtifactB64 = bytesToBase64(row.artifact);
|
|
835
883
|
let verification: FuseMemberResult | undefined;
|
|
@@ -854,12 +902,16 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
|
|
|
854
902
|
frameName: names?.frameName ?? null,
|
|
855
903
|
...(keep && held !== null ? { fusedBytes: held } : {}),
|
|
856
904
|
...(verification !== undefined ? { verification } : {}),
|
|
905
|
+
...(path !== undefined ? { path } : {}),
|
|
906
|
+
...(memberProof !== undefined ? { memberProof } : {}),
|
|
857
907
|
});
|
|
858
908
|
}
|
|
859
909
|
return {
|
|
910
|
+
set: setKind,
|
|
860
911
|
proof,
|
|
861
912
|
manifestBytes,
|
|
862
913
|
manifest,
|
|
914
|
+
...(tree !== null ? { treeRootB64: bytesToBase64(tree.root) } : {}),
|
|
863
915
|
artifactDigestB64,
|
|
864
916
|
slotCommitmentB64: bytesToBase64(commitment),
|
|
865
917
|
members: results,
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ export { Constructor } from "./constructor.js";
|
|
|
41
41
|
// slot, write a commitment to it into the artifact, hash, commit under the
|
|
42
42
|
// same slot. The resulting proof is ordinary bitgraph/1.
|
|
43
43
|
export { fuse, fuseSet, MAX_SET_MEMBERS, trailerBytesFor, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
|
|
44
|
+
export { MAX_SET2_MEMBERS, SET2_PLACEMENT_ID, SET_MEMBER_METADATA_KEY } from "@mikeargento/bitgraph-verify";
|
|
44
45
|
export type { FuseBuilder, BuilderInput, FuseOptions, FuseResult, FuseTransport, FuseErrorCode } from "./fuse.js";
|
|
45
46
|
export type { FuseSetMember, FuseSetBytesMember, FuseSetLoadedMember, FuseSetHashedMember, FusedDigestInput, SetMemberPlacement, FuseSetOptions, FuseSetProgress, FuseSetMemberResult, FuseSetResult } from "./fuse.js";
|
|
46
47
|
// The verify-package types those results are made of, so the core entry names everything it returns.
|