@mikeargento/bitgraph 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikeargento/bitgraph",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "Portable cryptographic proof of construction for digital artifacts.",
6
6
  "type": "module",
@@ -23,12 +23,12 @@
23
23
  "scripts": {
24
24
  "build": "npm run build --workspace=packages/verify && npm run build --workspace=packages/audit && npm run build --workspace=packages/player && npm run build --workspace=packages/mcp && tsc",
25
25
  "typecheck": "npm run build --workspace=packages/verify && npm run build --workspace=packages/audit && npm run typecheck --workspace=packages/mcp && tsc --noEmit",
26
- "test:core": "npm run build && node --test dist/__tests__/canonical.test.js dist/__tests__/constructor.test.js dist/__tests__/verifier.test.js dist/__tests__/proof-integrity.test.js dist/__tests__/audit-ingest.test.js dist/__tests__/audit-tiers.test.js dist/__tests__/audit-reconstruct.test.js dist/__tests__/audit-anomalies.test.js dist/__tests__/audit-anchors.test.js dist/__tests__/audit-witness.test.js dist/__tests__/audit-temporal.test.js dist/__tests__/audit-attestation.test.js dist/__tests__/audit-report.test.js dist/__tests__/audit-cli.test.js dist/__tests__/audit-property.test.js dist/__tests__/audit-real-fixtures.test.js dist/__tests__/audit-dependencies.test.js dist/__tests__/audit-bench-smoke.test.js dist/__tests__/audit-roundtrip.test.js dist/__tests__/audit-real-live.test.js dist/__tests__/chain-hash-anchor.test.js dist/__tests__/audit-boundary.test.js dist/__tests__/audit-witness-real.test.js dist/__tests__/audit-memory-ingest.test.js dist/__tests__/fuse-vectors.test.js dist/__tests__/fuse-roundtrip.test.js dist/__tests__/fuse-verify.test.js dist/__tests__/fuse-sdk.test.js dist/__tests__/fuse-placement.test.js dist/__tests__/fuse-set.test.js dist/__tests__/fuse-set-sdk.test.js",
26
+ "test:core": "npm run build && node --test dist/__tests__/canonical.test.js dist/__tests__/constructor.test.js dist/__tests__/verifier.test.js dist/__tests__/proof-integrity.test.js dist/__tests__/audit-ingest.test.js dist/__tests__/audit-tiers.test.js dist/__tests__/audit-reconstruct.test.js dist/__tests__/audit-anomalies.test.js dist/__tests__/audit-anchors.test.js dist/__tests__/audit-witness.test.js dist/__tests__/audit-temporal.test.js dist/__tests__/audit-attestation.test.js dist/__tests__/audit-report.test.js dist/__tests__/audit-cli.test.js dist/__tests__/audit-property.test.js dist/__tests__/audit-real-fixtures.test.js dist/__tests__/audit-dependencies.test.js dist/__tests__/audit-bench-smoke.test.js dist/__tests__/audit-roundtrip.test.js dist/__tests__/audit-real-live.test.js dist/__tests__/chain-hash-anchor.test.js dist/__tests__/audit-boundary.test.js dist/__tests__/audit-witness-real.test.js dist/__tests__/audit-memory-ingest.test.js dist/__tests__/fuse-vectors.test.js dist/__tests__/fuse-roundtrip.test.js dist/__tests__/fuse-verify.test.js dist/__tests__/fuse-sdk.test.js dist/__tests__/fuse-placement.test.js dist/__tests__/fuse-set.test.js dist/__tests__/fuse-set2.test.js dist/__tests__/fuse-set-sdk.test.js",
27
27
  "test": "npm run test:core",
28
28
  "prepack": "npm run build"
29
29
  },
30
30
  "dependencies": {
31
- "@mikeargento/bitgraph-verify": "^1.4.0",
31
+ "@mikeargento/bitgraph-verify": "^1.7.0",
32
32
  "@noble/ed25519": "^2.1.0",
33
33
  "@noble/hashes": "^1.4.0"
34
34
  },
@@ -15,18 +15,18 @@ describe("placementForBytes: decided from the bytes, never the name", () => {
15
15
  assert.equal(placementForBytes(bytes(0x42, 0x4d)), "trailer/1", "BMP");
16
16
  assert.equal(placementForBytes(bytes(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50)), "trailer/1", "RIFF WebP");
17
17
  });
18
- it("everything else takes container/1", () => {
19
- assert.equal(placementForBytes(new TextEncoder().encode("%PDF-1.7\n")), "container/1", "PDF");
20
- assert.equal(placementForBytes(bytes(0x50, 0x4b, 0x03, 0x04)), "container/1", "ZIP, Office, EPUB");
21
- assert.equal(placementForBytes(bytes(0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70)), "container/1", "ISO base media");
22
- assert.equal(placementForBytes(bytes(0x1a, 0x45, 0xdf, 0xa3)), "container/1", "Matroska");
23
- assert.equal(placementForBytes(bytes(0x49, 0x44, 0x33)), "container/1", "MP3");
24
- assert.equal(placementForBytes(new TextEncoder().encode("# markdown\n")), "container/1", "text");
25
- assert.equal(placementForBytes(new Uint8Array(0)), "container/1", "empty");
18
+ it("everything else takes container/2", () => {
19
+ assert.equal(placementForBytes(new TextEncoder().encode("%PDF-1.7\n")), "container/2", "PDF");
20
+ assert.equal(placementForBytes(bytes(0x50, 0x4b, 0x03, 0x04)), "container/2", "ZIP, Office, EPUB");
21
+ assert.equal(placementForBytes(bytes(0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70)), "container/2", "ISO base media");
22
+ assert.equal(placementForBytes(bytes(0x1a, 0x45, 0xdf, 0xa3)), "container/2", "Matroska");
23
+ assert.equal(placementForBytes(bytes(0x49, 0x44, 0x33)), "container/2", "MP3");
24
+ assert.equal(placementForBytes(new TextEncoder().encode("# markdown\n")), "container/2", "text");
25
+ assert.equal(placementForBytes(new Uint8Array(0)), "container/2", "empty");
26
26
  });
27
27
  it("names keep the extension for trailers and use .tar for containers", () => {
28
28
  assert.deepEqual(fusedNamesFor("IMG_0001.jpg", "trailer/1"), { fusedName: "IMG_0001.fused.jpg", frameName: "IMG_0001.jpg.bitgraph-fuse.json" });
29
- assert.deepEqual(fusedNamesFor("contract.pdf", "container/1"), { fusedName: "contract.fused.tar", frameName: "contract.pdf.bitgraph-fuse.json" });
30
- assert.deepEqual(fusedNamesFor("README", "container/1"), { fusedName: "README.fused.tar", frameName: "README.bitgraph-fuse.json" });
29
+ assert.deepEqual(fusedNamesFor("contract.pdf", "container/2"), { fusedName: "contract.fused.tar", frameName: "contract.pdf.bitgraph-fuse.json" });
30
+ assert.deepEqual(fusedNamesFor("README", "container/2"), { fusedName: "README.fused.tar", frameName: "README.bitgraph-fuse.json" });
31
31
  });
32
32
  });
@@ -20,8 +20,8 @@ const proofOf = (name: string) => JSON.parse(readFileSync(FIX + name, "utf8")) a
20
20
 
21
21
  describe("registry", () => {
22
22
  test("fixed order and ids", () => {
23
- assert.deepEqual(PLACEMENTS.map((p) => p.id), ["trailer/1", "container/1", "produced/1"]);
24
- assert.deepEqual(PLACEMENTS.map((p) => p.byteExact), [true, true, false]);
23
+ assert.deepEqual(PLACEMENTS.map((p) => p.id), ["trailer/1", "container/1", "container/2", "produced/1"]);
24
+ assert.deepEqual(PLACEMENTS.map((p) => p.byteExact), [true, true, true, false]);
25
25
  assert.equal(getPlacement("xmp/9"), undefined);
26
26
  assert.equal(getPlacement(""), undefined);
27
27
  });
@@ -172,3 +172,72 @@ describe("attribution and Frame", () => {
172
172
  assert.throws(() => buildFrame({ proof: proofOf("produced-origin.proof.json"), placement: "produced/1", artifactDigest: new Uint8Array(32), fusedFile: null, fusePayload: new Uint8Array([1]) }), /canonical/);
173
173
  });
174
174
  });
175
+
176
+ describe("container/2: the original first, then the manifest", () => {
177
+ const p = getPlacement("container/2")!;
178
+ const commitment = new Uint8Array(32).fill(0x5a);
179
+ const other = new Uint8Array(32).fill(0xa5);
180
+ const sizes = [0, 1, 511, 512, 513, 1023, 1024, 1025, 100_000, 1_048_579];
181
+ const filled = (n: number) => { const b = new Uint8Array(n); for (let i = 0; i < n; i++) b[i] = (i * 13 + 7) & 0xff; return b; };
182
+
183
+ test("build carries the original whole and first; locate reads it back and each container refuses the other's bytes", () => {
184
+ for (const n of sizes) {
185
+ const original = filled(n);
186
+ const fused = p.build({ original, commitment });
187
+ assert.equal(new TextDecoder().decode(fused.subarray(0, 22)), "bitgraph-fuse/original");
188
+ const l = p.locate(fused);
189
+ assert.ok(l, `locate ${n}`);
190
+ assert.deepEqual(l!.commitment, commitment);
191
+ assert.deepEqual(l!.originDigest, sha256(original));
192
+ assert.deepEqual(l!.originalBytes, original);
193
+ const c1 = getPlacement("container/1")!.build({ original, commitment });
194
+ assert.equal(p.locate(c1), null, `container/2 refuses container/1 bytes (${n})`);
195
+ assert.equal(getPlacement("container/1")!.locate(fused), null, `container/1 refuses container/2 bytes (${n})`);
196
+ const tampered = new Uint8Array(fused); tampered[tampered.length - 600] = (tampered[tampered.length - 600] ?? 0) ^ 1;
197
+ assert.equal(p.locate(tampered), null);
198
+ }
199
+ });
200
+
201
+ test("frame: prefix, original, suffix is the build, byte for byte, for every Form A and B placement and size", () => {
202
+ for (const id of ["trailer/1", "container/1", "container/2"] as const) {
203
+ const q = getPlacement(id)!;
204
+ for (const n of sizes) {
205
+ const original = filled(n);
206
+ const originDigest = sha256(original);
207
+ for (const c of [commitment, other]) {
208
+ const f = q.frame!({ originalSize: n, originDigest, commitment: c });
209
+ const joined = new Uint8Array(f.prefix.length + n + f.suffix.length);
210
+ joined.set(f.prefix, 0); joined.set(original, f.prefix.length); joined.set(f.suffix, f.prefix.length + n);
211
+ assert.deepEqual(joined, q.build({ original, originDigest, commitment: c }), `${id} ${n}`);
212
+ }
213
+ }
214
+ }
215
+ });
216
+
217
+ test("scanPrefix: fixed for trailer/1 (empty) and container/2 (the original's header), null for container/1 whose prefix carries the commitment", () => {
218
+ for (const n of sizes) {
219
+ const originDigest = sha256(filled(n));
220
+ assert.deepEqual(getPlacement("trailer/1")!.scanPrefix!(n), new Uint8Array(0));
221
+ const pre = p.scanPrefix!(n)!;
222
+ assert.equal(pre.length, 512);
223
+ for (const c of [commitment, other]) assert.deepEqual(p.frame!({ originalSize: n, originDigest, commitment: c }).prefix, pre, `the same prefix under any commitment (${n})`);
224
+ assert.equal(getPlacement("container/1")!.scanPrefix!(n), null);
225
+ }
226
+ assert.equal(getPlacement("produced/1")!.frame, undefined);
227
+ assert.equal(getPlacement("set/1")!.frame, undefined);
228
+ });
229
+
230
+ test("a hasher state saved after prefix and original, finished with the suffix, is the fused digest: the file is read once", () => {
231
+ for (const n of [0, 1, 511, 512, 513, 100_000]) {
232
+ const original = filled(n);
233
+ const originDigest = sha256(original);
234
+ const h = sha256.create();
235
+ h.update(p.scanPrefix!(n)!);
236
+ h.update(original);
237
+ for (const c of [commitment, other]) {
238
+ const finished = h.clone().update(p.frame!({ originalSize: n, originDigest, commitment: c }).suffix).digest();
239
+ assert.deepEqual(finished, sha256(p.build({ original, originDigest, commitment: c })), `${n} bytes`);
240
+ }
241
+ }
242
+ });
243
+ });
@@ -33,6 +33,7 @@ import type { BitGraphProof, SlotAllocation, Attribution, SetMember, SetManifest
33
33
  import { makeKey, signBody, b64, utf8 } from "./audit-fixtures.js";
34
34
  import type { ManualKey } from "./audit-fixtures.js";
35
35
  import { fuse, fuseSet, builderFor, placementForBytes, fusedNamesFor, FuseError, MAX_SET_MEMBERS, digest, trailerBytesFor } from "../fuse.js";
36
+ import { parseSetRoot } from "@mikeargento/bitgraph-verify";
36
37
  import type { FuseSetProgress, FuseSetBytesMember, FuseSetLoadedMember, FuseSetHashedMember, FusedDigestInput } from "../fuse.js";
37
38
  import type { FuseSetMember } from "../fuse.js";
38
39
 
@@ -259,7 +260,7 @@ describe("fuseSet(): one slot, N files, the manifest as the artifact", () => {
259
260
  // The by-bytes default: PNG is trailer-safe, plain text goes into a container.
260
261
  const d = await fuseSet([{ original: png }, { original }], { transport: honest(key, slot).transport });
261
262
  assert.deepEqual(d.members.map((m) => m.placement), [placementForBytes(png), placementForBytes(original)]);
262
- assert.deepEqual(d.members.map((m) => m.placement), ["trailer/1", "container/1"]);
263
+ assert.deepEqual(d.members.map((m) => m.placement), ["trailer/1", "container/2"]);
263
264
  });
264
265
 
265
266
  test("4. each original rebuilds its member (SET_MEMBER_FROM_ORIGIN); with the echo stripped and no explicit bytes nothing is bound", async () => {
@@ -376,7 +377,7 @@ describe("fuseSet(): bad input is refused before any request", () => {
376
377
  );
377
378
  assert.equal(calls.length, 0, "no slot was burned");
378
379
  const r = await fuseSet([{ original, placement: "trailer/1" }, { original, placement: "container/1" }], { transport, verifyMembers: true });
379
- assert.equal(r.manifest.members.length, 2);
380
+ assert.equal((r.manifest as SetManifest).members.length, 2);
380
381
  assert.deepEqual(r.members.map((m) => m.placement), ["trailer/1", "container/1"]);
381
382
  assert.equal(r.members[0]!.originDigestB64, r.members[1]!.originDigestB64);
382
383
  assert.notEqual(r.members[0]!.artifactDigestB64, r.members[1]!.artifactDigestB64);
@@ -775,7 +776,7 @@ describe("fuseSet(): loaded and hashed members", () => {
775
776
  assert.ok(!("fusedBytes" in r.members[0]!), "nothing to keep for a hashed member");
776
777
  assert.ok("fusedBytes" in r.members[1]! && "fusedBytes" in r.members[2]!, "bytes and loaded members are kept");
777
778
  assert.equal(r.members.length, 3);
778
- assert.equal(r.manifest.members.length, 3);
779
+ assert.equal((r.manifest as SetManifest).members.length, 3);
779
780
  assert.equal(commits(calls).length, 1);
780
781
  // The real verifier reads the member from the bytes a reader would build, and from its original.
781
782
  const direct = await verifyFuseMember({ proof: r.proof, bytes: built, manifest: r.manifestBytes });
@@ -806,3 +807,46 @@ describe("fuseSet(): loaded and hashed members", () => {
806
807
  assert.deepEqual(seen.map((p) => `${p.phase} ${p.done}/${p.total}`), ["hash 1/3", "hash 2/3", "hash 3/3", "fuse 1/3", "fuse 2/3", "fuse 3/3", "commit 0/1", "commit 1/1"]);
807
808
  });
808
809
  });
810
+
811
+ describe("fuseSet(): set/2, a Merkle root over the rows", () => {
812
+ test("34. set/2 commits the root document, returns every member's leaf index, path and evidence, and the real verifier reads each member with it", async () => {
813
+ const { calls, transport } = honest(key, slot);
814
+ const members: FuseSetBytesMember[] = [{ original, placement: "trailer/1", name: "photo.jpg" }, { original: png, placement: "container/1" }, { original: note, placement: "container/2" }];
815
+ const seen: FuseSetProgress[] = [];
816
+ const r = await fuseSet(members, { transport, set: "set/2", onProgress: (p) => seen.push({ ...p }) });
817
+ assert.equal(r.set, "set/2");
818
+ assert.equal(commits(calls).length, 1);
819
+ assert.equal(r.proof.attribution?.title, "set/2");
820
+ assert.equal(r.verification.category, "FUSED_DIRECT");
821
+ assert.equal(r.verification.placement, "set/2");
822
+ assert.ok(r.treeRootB64);
823
+ const doc = parseSetRoot(r.manifestBytes)!;
824
+ assert.equal(doc.count, 3);
825
+ assert.equal(bytesToBase64(doc.root), r.treeRootB64);
826
+ assert.equal(bytesToBase64(sha256(r.manifestBytes)), r.proof.artifact.digestB64);
827
+ assert.deepEqual(r.proof.metadata?.[SET_METADATA_KEY], r.manifest, "the root document rides under metadata");
828
+ assert.ok(seen.some((p) => p.phase === "tree" && p.done === 1), "a tree phase is reported");
829
+ const commitment = computeSlotCommitment(slot);
830
+ for (const m of r.members) {
831
+ assert.ok(m.path && m.memberProof, `member ${m.index} carries its path and evidence`);
832
+ assert.equal(m.memberProof!.index, m.manifestIndex);
833
+ assert.equal(m.memberProof!.count, 3);
834
+ const built = getPlacement(m.placement)!.build({ original: members[m.index]!.original, commitment });
835
+ assert.equal(bytesToBase64(sha256(built)), m.artifactDigestB64);
836
+ const direct = await verifyFuseMember({ proof: r.proof, bytes: built, member: m.memberProof });
837
+ assert.equal(direct.category, "SET_MEMBER_DIRECT", direct.reason ?? "");
838
+ assert.equal(direct.set!.member!.index, m.manifestIndex);
839
+ const fromOrigin = await verifyFuseMember({ proof: r.proof, bytes: members[m.index]!.original, member: m.memberProof });
840
+ assert.equal(fromOrigin.category, "SET_MEMBER_FROM_ORIGIN", fromOrigin.reason ?? "");
841
+ assert.equal((await verifyFuseMember({ proof: r.proof, bytes: built })).category, "SET_MEMBERSHIP_UNPROVEN", "no evidence in hand");
842
+ }
843
+ // set/1 keeps its cap; set/2 takes more than MAX_SET_MEMBERS.
844
+ await assert.rejects(fuseSet([{ original, placement: "trailer/1" }], { transport: honest(key, slot).transport, set: "set/3" as never }), (e: FuseError) => e.code === "bad-input");
845
+ const many: FuseSetMember[] = Array.from({ length: MAX_SET_MEMBERS + 1 }, (_, i) => ({ originDigest: sha256(utf8(`m${i}`)), placement: "trailer/1", fusedDigest: () => sha256(utf8(`f${i}`)) }));
846
+ await assert.rejects(fuseSet(many, { transport: honest(key, slot).transport }), (e: FuseError) => e.code === "bad-input" && /at most 2000/.test(e.message));
847
+ const big = await fuseSet(many, { transport: honest(key, slot).transport, set: "set/2" });
848
+ assert.equal(big.members.length, MAX_SET_MEMBERS + 1);
849
+ assert.equal(parseSetRoot(big.manifestBytes)!.count, MAX_SET_MEMBERS + 1);
850
+ assert.ok(big.members.every((m) => m.path!.length <= 11), "paths of at most ceil(log2 2001) = 11 nodes");
851
+ });
852
+ });
@@ -352,7 +352,7 @@ describe("codec: parseSetManifest", () => {
352
352
 
353
353
  describe("registry and metadata", () => {
354
354
  test("13. PLACEMENTS is unchanged; set/1 resolves through getPlacement as Form C, not byte-exact, locate-only", () => {
355
- assert.deepEqual(PLACEMENTS.map((p) => p.id), ["trailer/1", "container/1", "produced/1"]);
355
+ assert.deepEqual(PLACEMENTS.map((p) => p.id), ["trailer/1", "container/1", "container/2", "produced/1"]);
356
356
  const set1 = getPlacement("set/1")!;
357
357
  assert.ok(set1);
358
358
  assert.equal(set1.id, SET_PLACEMENT_ID);
@@ -364,7 +364,12 @@ describe("registry and metadata", () => {
364
364
  assert.equal(set1.locate(bytes("produced-bare.json")), null);
365
365
  assert.equal(set1.locate(bytes("fused-trailer.bin")), null);
366
366
  assert.equal(set1.locate(original), null);
367
- assert.equal(getPlacement("set/2"), undefined);
367
+ // set/2 resolves the same way: Form C, locate-only, and equally absent from the undeclared scan.
368
+ const set2 = getPlacement("set/2")!;
369
+ assert.equal(set2.form, "C");
370
+ assert.equal(set2.byteExact, false);
371
+ assert.equal(set2.locate(A.manifest), null, "a set/1 manifest is not a set/2 root document");
372
+ assert.equal(getPlacement("set/3"), undefined);
368
373
  });
369
374
 
370
375
  test("14. readSetMetadata re-canonicalizes a plain object and returns null for anything else", async () => {
@@ -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-cli.ts CHANGED
@@ -28,7 +28,7 @@ import type { FuseTransport } from "./fuse.js";
28
28
 
29
29
  const USAGE = `bitgraph-fuse: BitGraph producer harness (profile bitgraph-fuse/1, working name)
30
30
 
31
- bitgraph-fuse fuse <file> --placement trailer/1|container/1 [options]
31
+ bitgraph-fuse fuse <file> --placement trailer/1|container/1|container/2 [options]
32
32
  Form A or B over an existing file. The file is never modified.
33
33
  bitgraph-fuse produce [--origin <file>] [options]
34
34
  Form C: a canonical payload naming an optional source.
@@ -104,7 +104,7 @@ async function writeOutputs(outDir: string, label: string, frame: unknown, fused
104
104
  async function runFuse(args: Args): Promise<number> {
105
105
  const file = args.positional[0];
106
106
  const placement = args.flags.get("placement");
107
- if (file === undefined || (placement !== "trailer/1" && placement !== "container/1")) { process.stderr.write(USAGE); return 64; }
107
+ if (file === undefined || (placement !== "trailer/1" && placement !== "container/1" && placement !== "container/2")) { process.stderr.write(USAGE); return 64; }
108
108
  const original = new Uint8Array(await readFile(resolve(file)));
109
109
  const label = sanitize(basename(file));
110
110
  const ext = placement === "trailer/1" ? extname(label) : ".tar";