@mikeargento/bitgraph 1.4.0 → 1.6.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 +180 -15
- package/dist/__tests__/fuse-set-sdk.test.js.map +1 -1
- package/dist/fuse-cli.js +3 -2
- package/dist/fuse-cli.js.map +1 -1
- package/dist/fuse.d.ts +92 -7
- package/dist/fuse.d.ts.map +1 -1
- package/dist/fuse.js +189 -52
- package/dist/fuse.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/fuse-set-sdk.test.ts +202 -32
- package/src/fuse-cli.ts +3 -2
- package/src/fuse.ts +267 -58
- package/src/index.ts +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -15,9 +15,9 @@ export { computeProofHash } from "@mikeargento/bitgraph-verify";
|
|
|
15
15
|
export { canonicalize, canonicalizeToString, constantTimeEqual } from "@mikeargento/bitgraph-verify";
|
|
16
16
|
export type { HostCapabilities } from "./host.js";
|
|
17
17
|
export { Constructor } from "./constructor.js";
|
|
18
|
-
export { fuse, fuseSet, MAX_SET_MEMBERS, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
|
|
18
|
+
export { fuse, fuseSet, MAX_SET_MEMBERS, trailerBytesFor, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
|
|
19
19
|
export type { FuseBuilder, BuilderInput, FuseOptions, FuseResult, FuseTransport, FuseErrorCode } from "./fuse.js";
|
|
20
|
-
export type { FuseSetMember, SetMemberPlacement, FuseSetOptions, FuseSetMemberResult, FuseSetResult } from "./fuse.js";
|
|
20
|
+
export type { FuseSetMember, FuseSetBytesMember, FuseSetLoadedMember, FuseSetHashedMember, FusedDigestInput, SetMemberPlacement, FuseSetOptions, FuseSetProgress, FuseSetMemberResult, FuseSetResult } from "./fuse.js";
|
|
21
21
|
export type { FuseFrame, PlacementId, SetManifest, FuseMemberResult, FuseVerifyResult } from "@mikeargento/bitgraph-verify";
|
|
22
22
|
export { parsePolicy, hashPolicy, createPolicyBinding, validateAction, } from "./policy.js";
|
|
23
23
|
export type { PolicyDocument, PolicyRules, ActionValidationResult, } from "./policy.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AAGH,YAAY,EACV,aAAa,EACb,cAAc,EACd,kBAAkB,EAClB,UAAU,EACV,eAAe,EACf,WAAW,EACX,aAAa,EACb,cAAc,EACd,aAAa,EACb,oBAAoB,EACpB,qBAAqB,EACrB,cAAc,GACf,MAAM,8BAA8B,CAAC;AACtC,OAAO,EAAE,MAAM,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAC3E,YAAY,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AACjE,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAChE,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AAGrG,YAAY,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAGlD,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAK/C,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,SAAS,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AAGH,YAAY,EACV,aAAa,EACb,cAAc,EACd,kBAAkB,EAClB,UAAU,EACV,eAAe,EACf,WAAW,EACX,aAAa,EACb,cAAc,EACd,aAAa,EACb,oBAAoB,EACpB,qBAAqB,EACrB,cAAc,GACf,MAAM,8BAA8B,CAAC;AACtC,OAAO,EAAE,MAAM,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAC3E,YAAY,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AACjE,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAChE,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AAGrG,YAAY,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAGlD,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAK/C,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,UAAU,EAAE,SAAS,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AACvJ,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAClH,YAAY,EAAE,aAAa,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,cAAc,EAAE,eAAe,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAExN,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,WAAW,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAG5H,OAAO,EACL,WAAW,EACX,UAAU,EACV,mBAAmB,EACnB,cAAc,GACf,MAAM,aAAa,CAAC;AACrB,YAAY,EACV,cAAc,EACd,WAAW,EACX,sBAAsB,GACvB,MAAM,aAAa,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ export { Constructor } from "./constructor.js";
|
|
|
7
7
|
// The producer profile over the primitive (working name Fuse): allocate a
|
|
8
8
|
// slot, write a commitment to it into the artifact, hash, commit under the
|
|
9
9
|
// same slot. The resulting proof is ordinary bitgraph/1.
|
|
10
|
-
export { fuse, fuseSet, MAX_SET_MEMBERS, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
|
|
10
|
+
export { fuse, fuseSet, MAX_SET_MEMBERS, trailerBytesFor, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
|
|
11
11
|
// Policy parsing, hashing, and validation
|
|
12
12
|
export { parsePolicy, hashPolicy, createPolicyBinding, validateAction, } from "./policy.js";
|
|
13
13
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,gEAAgE;AA4BhE,OAAO,EAAE,MAAM,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAE3E,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAChE,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AAKrG,2BAA2B;AAC3B,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAE/C,0EAA0E;AAC1E,2EAA2E;AAC3E,yDAAyD;AACzD,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,SAAS,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,gEAAgE;AA4BhE,OAAO,EAAE,MAAM,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAE3E,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAChE,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AAKrG,2BAA2B;AAC3B,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAE/C,0EAA0E;AAC1E,2EAA2E;AAC3E,yDAAyD;AACzD,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,UAAU,EAAE,SAAS,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAMvJ,0CAA0C;AAC1C,OAAO,EACL,WAAW,EACX,UAAU,EACV,mBAAmB,EACnB,cAAc,GACf,MAAM,aAAa,CAAC"}
|
package/package.json
CHANGED
|
@@ -32,7 +32,8 @@ import {
|
|
|
32
32
|
import type { BitGraphProof, SlotAllocation, Attribution, SetMember, SetManifest } from "@mikeargento/bitgraph-verify";
|
|
33
33
|
import { makeKey, signBody, b64, utf8 } from "./audit-fixtures.js";
|
|
34
34
|
import type { ManualKey } from "./audit-fixtures.js";
|
|
35
|
-
import { fuse, fuseSet, builderFor, placementForBytes, fusedNamesFor, FuseError, MAX_SET_MEMBERS } from "../fuse.js";
|
|
35
|
+
import { fuse, fuseSet, builderFor, placementForBytes, fusedNamesFor, FuseError, MAX_SET_MEMBERS, digest, trailerBytesFor } from "../fuse.js";
|
|
36
|
+
import type { FuseSetProgress, FuseSetBytesMember, FuseSetLoadedMember, FuseSetHashedMember, FusedDigestInput } from "../fuse.js";
|
|
36
37
|
import type { FuseSetMember } from "../fuse.js";
|
|
37
38
|
|
|
38
39
|
const FIX = fileURLToPath(new URL("../../src/__tests__/fuse-fixtures/", import.meta.url));
|
|
@@ -47,7 +48,7 @@ const unrelated = utf8("a file nobody recorded\n");
|
|
|
47
48
|
// The phase-1 fixtures fuse original.txt as trailer/1 and image.png as
|
|
48
49
|
// container/1, so these members declare those placements; the by-bytes
|
|
49
50
|
// default (placementForBytes) is asserted on its own in test 3.
|
|
50
|
-
const two = ():
|
|
51
|
+
const two = (): FuseSetBytesMember[] => [{ original, placement: "trailer/1" }, { original: png, placement: "container/1" }];
|
|
51
52
|
|
|
52
53
|
// ---------------------------------------------------------------------------
|
|
53
54
|
// Local signer: slots and proofs minted from a received commit body
|
|
@@ -159,9 +160,9 @@ const toUrlSafe = (s: string) => s.replace(/\+/g, "-").replace(/\//g, "_").repla
|
|
|
159
160
|
interface Oracle { commitment: Uint8Array; fused: Uint8Array[]; rows: SetMember[]; manifest: Uint8Array; digestB64: string; sorted: SetMember[] }
|
|
160
161
|
|
|
161
162
|
/** The commitment from the slot, each member built by its placement, the canonical manifest and its digest. */
|
|
162
|
-
function oracle(slot: SlotAllocation, members: readonly
|
|
163
|
+
function oracle(slot: SlotAllocation, members: readonly FuseSetBytesMember[]): Oracle {
|
|
163
164
|
const commitment = computeSlotCommitment(slot);
|
|
164
|
-
const placementOf = (m:
|
|
165
|
+
const placementOf = (m: FuseSetBytesMember) => m.placement ?? placementForBytes(m.original);
|
|
165
166
|
const fused = members.map((m) => getPlacement(placementOf(m))!.build({ original: m.original, commitment }));
|
|
166
167
|
const rows = members.map((m, i) => ({ artifact: sha256(fused[i]!), origin: sha256(m.original), placement: placementOf(m) }));
|
|
167
168
|
const manifest = buildSetManifest(commitment, rows);
|
|
@@ -229,11 +230,11 @@ describe("fuseSet(): one slot, N files, the manifest as the artifact", () => {
|
|
|
229
230
|
assert.equal(r.proof.attribution?.title, "set/1");
|
|
230
231
|
});
|
|
231
232
|
|
|
232
|
-
test("3. members come back in the caller's order, each with its manifest row
|
|
233
|
+
test("3. members come back in the caller's order, each with its manifest row and its names; with verifyMembers each is SET_MEMBER_DIRECT against explicit bytes", async () => {
|
|
233
234
|
const members = two();
|
|
234
235
|
members[0]!.name = "photo.jpg";
|
|
235
236
|
const o = oracle(slot, members);
|
|
236
|
-
const r = await fuseSet(members, { transport: honest(key, slot).transport });
|
|
237
|
+
const r = await fuseSet(members, { transport: honest(key, slot).transport, verifyMembers: true });
|
|
237
238
|
assert.deepEqual(r.members.map((m) => m.index), [0, 1]);
|
|
238
239
|
for (const m of r.members) {
|
|
239
240
|
const row = o.rows[m.index]!;
|
|
@@ -241,13 +242,17 @@ describe("fuseSet(): one slot, N files, the manifest as the artifact", () => {
|
|
|
241
242
|
assert.equal(m.originDigestB64, bytesToBase64(row.origin));
|
|
242
243
|
assert.equal(m.artifactDigestB64, bytesToBase64(row.artifact));
|
|
243
244
|
assert.equal(m.manifestIndex, o.sorted.findIndex((s) => bytesToHex(s.artifact) === bytesToHex(row.artifact)));
|
|
244
|
-
assert.equal(m.manifestIndex, m.verification
|
|
245
|
+
assert.equal(m.manifestIndex, m.verification!.set!.member!.index);
|
|
245
246
|
assert.ok(!("fusedBytes" in m), "not kept by default");
|
|
246
|
-
assert.equal(m.verification
|
|
247
|
-
assert.equal(m.verification
|
|
248
|
-
assert.equal(m.verification
|
|
249
|
-
assert.equal(m.verification
|
|
247
|
+
assert.equal(m.verification!.category, "SET_MEMBER_DIRECT", m.verification!.reason ?? "");
|
|
248
|
+
assert.equal(m.verification!.set!.manifestSource, "argument");
|
|
249
|
+
assert.equal(m.verification!.set!.member!.fusedDigestB64, m.artifactDigestB64);
|
|
250
|
+
assert.equal(m.verification!.set!.memberCount, 2);
|
|
250
251
|
}
|
|
252
|
+
// Without verifyMembers the same rows come back bound by digest, and no verdict is invented.
|
|
253
|
+
const plain = await fuseSet(members, { transport: honest(key, slot).transport });
|
|
254
|
+
assert.deepEqual(plain.members.map((m) => [m.index, m.manifestIndex, m.placement, m.originDigestB64, m.artifactDigestB64]), r.members.map((m) => [m.index, m.manifestIndex, m.placement, m.originDigestB64, m.artifactDigestB64]));
|
|
255
|
+
for (const m of plain.members) assert.ok(!("verification" in m), "no verifier verdict without verifyMembers");
|
|
251
256
|
assert.deepEqual({ fusedName: r.members[0]!.fusedName, frameName: r.members[0]!.frameName }, fusedNamesFor("photo.jpg", "trailer/1"));
|
|
252
257
|
assert.equal(r.members[1]!.fusedName, null);
|
|
253
258
|
assert.equal(r.members[1]!.frameName, null);
|
|
@@ -331,7 +336,7 @@ describe("fuseSet(): a bad member burns the slot and commits nothing", () => {
|
|
|
331
336
|
test("9. bytes that carry the commitment but embed another member's origin are builder-failed naming the member and the origin", async () => {
|
|
332
337
|
const { calls, transport } = honest(key, slot);
|
|
333
338
|
// Two trailer/1 members; member 1's builder hands back member 0's fused bytes, which carry c but embed member 0's original.
|
|
334
|
-
const members:
|
|
339
|
+
const members: FuseSetBytesMember[] = [{ original, placement: "trailer/1" }, { original: note, placement: "trailer/1" }];
|
|
335
340
|
members[1]!.builder = ({ commitment }) => getPlacement("trailer/1")!.build({ original, commitment });
|
|
336
341
|
await assert.rejects(fuseSet(members, { transport }), (e: FuseError) => e.code === "builder-failed" && /member 1/.test(e.message) && /origin/.test(e.message) && e.member === 1);
|
|
337
342
|
assert.equal(commits(calls).length, 0);
|
|
@@ -340,21 +345,21 @@ describe("fuseSet(): a bad member burns the slot and commits nothing", () => {
|
|
|
340
345
|
|
|
341
346
|
test("9b. container/1: bytes that declare the member's origin digest but carry other bytes are builder-failed naming the member; no commit", async () => {
|
|
342
347
|
const { calls, transport } = honest(key, slot);
|
|
343
|
-
const members:
|
|
348
|
+
const members: FuseSetBytesMember[] = [{ original, placement: "trailer/1" }, { original: png, placement: "container/1" }];
|
|
344
349
|
// The payload names png's digest (so the declared origin matches) while the tar carries other bytes.
|
|
345
350
|
members[1]!.builder = ({ commitment, originDigest }) => getPlacement("container/1")!.build({ original: utf8("not the member's original\n"), originDigest: originDigest!, commitment });
|
|
346
351
|
await assert.rejects(fuseSet(members, { transport }), (e: FuseError) => e.code === "builder-failed" && /^member 1/.test(e.message) && /origin/.test(e.message) && e.member === 1);
|
|
347
352
|
assert.equal(commits(calls).length, 0, "nothing was committed");
|
|
348
353
|
assert.equal(allocates(calls).length, 1);
|
|
349
354
|
// The mirror: a payload declaring another digest over the member's own bytes is refused the same way.
|
|
350
|
-
const declared:
|
|
355
|
+
const declared: FuseSetBytesMember[] = [{ original, placement: "trailer/1" }, { original: png, placement: "container/1" }];
|
|
351
356
|
declared[1]!.builder = ({ commitment }) => getPlacement("container/1")!.build({ original: png, originDigest: sha256(unrelated), commitment });
|
|
352
357
|
const b = honest(key, slot);
|
|
353
358
|
await assert.rejects(fuseSet(declared, { transport: b.transport }), (e: FuseError) => e.code === "builder-failed" && e.member === 1);
|
|
354
359
|
assert.equal(commits(b.calls).length, 0);
|
|
355
360
|
// And the honest container/1 builder still passes both checks.
|
|
356
|
-
const ok = await fuseSet([{ original, placement: "trailer/1" }, { original: png, placement: "container/1" }], { transport: honest(key, slot).transport });
|
|
357
|
-
assert.equal(ok.members[1]!.verification
|
|
361
|
+
const ok = await fuseSet([{ original, placement: "trailer/1" }, { original: png, placement: "container/1" }], { transport: honest(key, slot).transport, verifyMembers: true });
|
|
362
|
+
assert.equal(ok.members[1]!.verification!.category, "SET_MEMBER_DIRECT");
|
|
358
363
|
});
|
|
359
364
|
});
|
|
360
365
|
|
|
@@ -370,12 +375,12 @@ describe("fuseSet(): bad input is refused before any request", () => {
|
|
|
370
375
|
(e: FuseError) => e.code === "bad-input" && /members 0 and 2/.test(e.message) && e.member === 2,
|
|
371
376
|
);
|
|
372
377
|
assert.equal(calls.length, 0, "no slot was burned");
|
|
373
|
-
const r = await fuseSet([{ original, placement: "trailer/1" }, { original, placement: "container/1" }], { transport });
|
|
378
|
+
const r = await fuseSet([{ original, placement: "trailer/1" }, { original, placement: "container/1" }], { transport, verifyMembers: true });
|
|
374
379
|
assert.equal(r.manifest.members.length, 2);
|
|
375
380
|
assert.deepEqual(r.members.map((m) => m.placement), ["trailer/1", "container/1"]);
|
|
376
381
|
assert.equal(r.members[0]!.originDigestB64, r.members[1]!.originDigestB64);
|
|
377
382
|
assert.notEqual(r.members[0]!.artifactDigestB64, r.members[1]!.artifactDigestB64);
|
|
378
|
-
for (const m of r.members) assert.equal(m.verification
|
|
383
|
+
for (const m of r.members) assert.equal(m.verification!.category, "SET_MEMBER_DIRECT");
|
|
379
384
|
});
|
|
380
385
|
|
|
381
386
|
test("11. an empty set and a set over the cap are refused; the cap runs before the duplicate check", async () => {
|
|
@@ -454,14 +459,14 @@ describe("fuseSet(): the boundary", () => {
|
|
|
454
459
|
() => { throw new Error("socket hang up"); },
|
|
455
460
|
() => ({ status: 200, json: { proofs: [{ proof: decoy }, { proof: real }] } }),
|
|
456
461
|
);
|
|
457
|
-
const r = await fuseSet(two(), { transport });
|
|
462
|
+
const r = await fuseSet(two(), { transport, verifyMembers: true });
|
|
458
463
|
assert.equal(r.recovered, true);
|
|
459
464
|
assert.deepEqual(r.proof, real);
|
|
460
465
|
assert.equal(allocates(calls).length, 1);
|
|
461
466
|
const lookup = calls.find((c) => c.path.startsWith("/api/proofs/"))!;
|
|
462
467
|
assert.ok(lookup.path.includes(toUrlSafe(o.digestB64)), lookup.path);
|
|
463
468
|
assert.equal(r.manifestEchoed, true);
|
|
464
|
-
for (const m of r.members) assert.equal(m.verification
|
|
469
|
+
for (const m of r.members) assert.equal(m.verification!.category, "SET_MEMBER_DIRECT");
|
|
465
470
|
});
|
|
466
471
|
|
|
467
472
|
test("16. 409 slot-unavailable: read back when the ledger has the proof, else reported as slot-unavailable and never retried into a new slot", async () => {
|
|
@@ -503,32 +508,40 @@ describe("fuseSet(): the boundary", () => {
|
|
|
503
508
|
// ---------------------------------------------------------------------------
|
|
504
509
|
|
|
505
510
|
describe("fuseSet(): the result and the checks before it is returned", () => {
|
|
506
|
-
test("19. keepFused returns each member's fused bytes; false and the default do not; the
|
|
511
|
+
test("19. keepFused returns each member's fused bytes; false and the default do not; the verifier's verdict only with verifyMembers", async () => {
|
|
507
512
|
const members = two();
|
|
508
513
|
const commitment = computeSlotCommitment(slot);
|
|
509
|
-
const kept = await fuseSet(members, { transport: honest(key, slot).transport, keepFused: true });
|
|
514
|
+
const kept = await fuseSet(members, { transport: honest(key, slot).transport, keepFused: true, verifyMembers: true });
|
|
510
515
|
for (const m of kept.members) {
|
|
511
516
|
assert.deepEqual(m.fusedBytes, getPlacement(m.placement)!.build({ original: members[m.index]!.original, commitment }));
|
|
512
|
-
assert.equal(m.verification
|
|
517
|
+
assert.equal(m.verification!.category, "SET_MEMBER_DIRECT");
|
|
518
|
+
}
|
|
519
|
+
const keptOnly = await fuseSet(members, { transport: honest(key, slot).transport, keepFused: true });
|
|
520
|
+
for (const m of keptOnly.members) {
|
|
521
|
+
assert.deepEqual(m.fusedBytes, getPlacement(m.placement)!.build({ original: members[m.index]!.original, commitment }));
|
|
522
|
+
assert.ok(!("verification" in m));
|
|
513
523
|
}
|
|
514
524
|
for (const opts of [{ keepFused: false }, {}]) {
|
|
515
525
|
const r = await fuseSet(members, { transport: honest(key, slot).transport, ...opts });
|
|
516
526
|
for (const m of r.members) {
|
|
517
527
|
assert.ok(!("fusedBytes" in m));
|
|
518
|
-
assert.
|
|
528
|
+
assert.ok(!("verification" in m));
|
|
519
529
|
}
|
|
520
530
|
}
|
|
521
531
|
});
|
|
522
532
|
|
|
523
|
-
test("20. a boundary that does not echo the manifest: success, manifestEchoed false, every member verified against the explicit bytes", async () => {
|
|
524
|
-
const r = await fuseSet(two(), { transport: honest(key, slot, { withMetadata: false }).transport });
|
|
533
|
+
test("20. a boundary that does not echo the manifest: success, manifestEchoed false, and with verifyMembers every member verified against the explicit bytes", async () => {
|
|
534
|
+
const r = await fuseSet(two(), { transport: honest(key, slot, { withMetadata: false }).transport, verifyMembers: true });
|
|
525
535
|
assert.equal(r.manifestEchoed, false);
|
|
526
536
|
assert.equal(r.proof.metadata, undefined);
|
|
527
537
|
assert.equal(r.verification.category, "FUSED_DIRECT");
|
|
528
538
|
for (const m of r.members) {
|
|
529
|
-
assert.equal(m.verification
|
|
530
|
-
assert.equal(m.verification
|
|
539
|
+
assert.equal(m.verification!.category, "SET_MEMBER_DIRECT", m.verification!.reason ?? "");
|
|
540
|
+
assert.equal(m.verification!.set!.manifestSource, "argument");
|
|
531
541
|
}
|
|
542
|
+
const plain = await fuseSet(two(), { transport: honest(key, slot, { withMetadata: false }).transport });
|
|
543
|
+
assert.equal(plain.manifestEchoed, false);
|
|
544
|
+
assert.equal(plain.members.length, 2);
|
|
532
545
|
});
|
|
533
546
|
|
|
534
547
|
test("21. an echoed manifest that differs from the committed one is verification-failed; no result", async () => {
|
|
@@ -589,7 +602,7 @@ describe("wiring", () => {
|
|
|
589
602
|
for (const name of ["SetManifest", "FuseMemberResult", "FuseVerifyResult", "FuseFrame", "PlacementId"]) assert.ok(typeExports.includes(name), `index.ts re-exports type ${name}`);
|
|
590
603
|
});
|
|
591
604
|
|
|
592
|
-
test("26. FUSE.md: Sets is its own subsection, the echo is stated
|
|
605
|
+
test("26. FUSE.md: Sets is its own subsection, the echo is stated per boundary, and the return-time binding and its costs are stated", () => {
|
|
593
606
|
const doc = readFileSync(fileURLToPath(new URL("../../docs/fuse/FUSE.md", import.meta.url)), "utf8");
|
|
594
607
|
const headings = [...doc.matchAll(/^(##+) (.+)$/gm)].map((m) => `${m[1]} ${m[2]}`);
|
|
595
608
|
const sets = headings.indexOf("### Sets");
|
|
@@ -599,9 +612,15 @@ describe("wiring", () => {
|
|
|
599
612
|
const setsText = doc.slice(doc.indexOf("### Sets"), doc.indexOf("### Harness"));
|
|
600
613
|
assert.ok(!setsText.includes("Bounded copy, verbatim"), "no bounded copy inside Sets");
|
|
601
614
|
assert.ok(!/the parent and the enclave do echo/.test(doc), "the parent path does not echo the manifest");
|
|
602
|
-
assert.match(setsText, /
|
|
603
|
-
assert.match(setsText, /
|
|
604
|
-
assert.match(setsText, /
|
|
615
|
+
assert.match(setsText, /Enclave v6 .*keeps metadata on a held-slot commit/s);
|
|
616
|
+
assert.match(setsText, /returns\s+`manifestEchoed:\s+true`/);
|
|
617
|
+
assert.match(setsText, /drops\s+metadata\s+returns\s+`manifestEchoed:\s+false`/);
|
|
618
|
+
assert.match(setsText, /bound to it by digest/);
|
|
619
|
+
assert.match(setsText, /`verifyMembers: true`/);
|
|
620
|
+
assert.match(setsText, /grows\s+with\s+the\s+square\s+of\s+the\s+member\s+count/);
|
|
621
|
+
assert.match(setsText, /native SHA-256/);
|
|
622
|
+
assert.match(setsText, /one fused copy/);
|
|
623
|
+
assert.match(setsText, /`onProgress`/);
|
|
605
624
|
assert.match(setsText, /after the commit, so the slot TTL is not\s+at risk/);
|
|
606
625
|
assert.ok(!doc.includes("\u2014"), "no em dashes");
|
|
607
626
|
});
|
|
@@ -636,3 +655,154 @@ describe("wiring", () => {
|
|
|
636
655
|
}
|
|
637
656
|
});
|
|
638
657
|
});
|
|
658
|
+
|
|
659
|
+
describe("fuseSet(): hashing and progress", () => {
|
|
660
|
+
test("28. digest(): the native hasher and the library agree at every size, and the library answers when the platform has no hasher or refuses the input", async () => {
|
|
661
|
+
for (const n of [0, 1, 55, 56, 63, 64, 65, 1000, 70_000, 1_000_003]) {
|
|
662
|
+
const b = new Uint8Array(n);
|
|
663
|
+
for (let i = 0; i < n; i++) b[i] = (i * 7 + n) & 0xff;
|
|
664
|
+
assert.equal(bytesToHex(await digest(b)), bytesToHex(sha256(b)), `size ${n}`);
|
|
665
|
+
}
|
|
666
|
+
const sample = utf8("a member hashed without WebCrypto\n");
|
|
667
|
+
const want = bytesToHex(sha256(sample));
|
|
668
|
+
const desc = Object.getOwnPropertyDescriptor(globalThis, "crypto")!;
|
|
669
|
+
try {
|
|
670
|
+
Object.defineProperty(globalThis, "crypto", { value: undefined, configurable: true, writable: true });
|
|
671
|
+
assert.equal(bytesToHex(await digest(sample)), want, "no platform hasher: the library");
|
|
672
|
+
Object.defineProperty(globalThis, "crypto", { value: { subtle: { digest: () => Promise.reject(new TypeError("refused")) } }, configurable: true, writable: true });
|
|
673
|
+
assert.equal(bytesToHex(await digest(sample)), want, "a refusing platform hasher: the library");
|
|
674
|
+
} finally {
|
|
675
|
+
Object.defineProperty(globalThis, "crypto", desc);
|
|
676
|
+
}
|
|
677
|
+
assert.equal(bytesToHex(await digest(sample)), want, "restored");
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test("29. onProgress: hash and fuse per member before and after the slot, commit 0 of 1 then 1 of 1, verify only with verifyMembers; a throwing hook changes nothing", async () => {
|
|
681
|
+
const members: FuseSetMember[] = [...two(), { original: note, placement: "container/1" }];
|
|
682
|
+
const seen: FuseSetProgress[] = [];
|
|
683
|
+
const r = await fuseSet(members, { transport: honest(key, slot).transport, onProgress: (p) => seen.push({ ...p }) });
|
|
684
|
+
assert.equal(r.members.length, 3);
|
|
685
|
+
const of = (phase: FuseSetProgress["phase"]) => seen.filter((p) => p.phase === phase).map((p) => `${p.done}/${p.total}`);
|
|
686
|
+
assert.deepEqual(of("hash"), ["1/3", "2/3", "3/3"]);
|
|
687
|
+
assert.deepEqual(of("fuse"), ["1/3", "2/3", "3/3"]);
|
|
688
|
+
assert.deepEqual(of("commit"), ["0/1", "1/1"]);
|
|
689
|
+
assert.deepEqual(of("verify"), []);
|
|
690
|
+
assert.deepEqual(seen.map((p) => p.phase), ["hash", "hash", "hash", "fuse", "fuse", "fuse", "commit", "commit"], "hash before the slot, fuse after it, then the commit");
|
|
691
|
+
const verified: FuseSetProgress[] = [];
|
|
692
|
+
await fuseSet(members, { transport: honest(key, slot).transport, verifyMembers: true, onProgress: (p) => verified.push({ ...p }) });
|
|
693
|
+
assert.deepEqual(verified.filter((p) => p.phase === "verify").map((p) => `${p.done}/${p.total}`), ["1/3", "2/3", "3/3"]);
|
|
694
|
+
assert.equal(verified.findIndex((p) => p.phase === "verify") > verified.findIndex((p) => p.phase === "commit" && p.done === 1), true, "verify after the commit");
|
|
695
|
+
let calls = 0;
|
|
696
|
+
const thrown = await fuseSet(members, { transport: honest(key, slot).transport, onProgress: () => { calls++; throw new Error("a hook that throws"); } });
|
|
697
|
+
assert.equal(thrown.members.length, 3);
|
|
698
|
+
assert.equal(calls, 8, "every report was attempted");
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
describe("fuseSet(): loaded and hashed members", () => {
|
|
703
|
+
/** The trailer/1 member finished from a hasher state, as a scanner does: hash the original once, keep the state, add the trailer later. */
|
|
704
|
+
const savedState = (bytes: Uint8Array) => { const h = sha256.create(); h.update(bytes); return h; };
|
|
705
|
+
const finish = (h: ReturnType<typeof sha256.create>, trailer: Uint8Array) => { const c = h.clone(); c.update(trailer); return c.digest(); };
|
|
706
|
+
|
|
707
|
+
test("30. trailerBytesFor is the placement's own suffix: original followed by it hashes to the trailer/1 build, and a saved state finished with it agrees", () => {
|
|
708
|
+
const commitment = computeSlotCommitment(slot);
|
|
709
|
+
const t = trailerBytesFor(commitment);
|
|
710
|
+
assert.equal(t.length, 48);
|
|
711
|
+
assert.deepEqual(t.subarray(0, 8), utf8("BGFUSE01"));
|
|
712
|
+
assert.deepEqual(t.subarray(8, 16), new Uint8Array(8));
|
|
713
|
+
assert.deepEqual(t.subarray(16), commitment);
|
|
714
|
+
for (const o of [original, png, note, new Uint8Array(0), new Uint8Array(55).fill(1), new Uint8Array(64).fill(2), new Uint8Array(1_000_003).fill(3)]) {
|
|
715
|
+
const built = getPlacement("trailer/1")!.build({ original: o, commitment });
|
|
716
|
+
assert.deepEqual(built.subarray(o.length), t, `suffix for ${o.length} bytes`);
|
|
717
|
+
assert.deepEqual(finish(savedState(o), t), sha256(built), `finished state for ${o.length} bytes`);
|
|
718
|
+
}
|
|
719
|
+
assert.throws(() => trailerBytesFor(new Uint8Array(31)), (e: FuseError) => e.code === "bad-input");
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test("31. a loaded member is read once, after the slot is held, checked against its named digest, and released unless kept", async () => {
|
|
723
|
+
const { calls, transport } = honest(key, slot);
|
|
724
|
+
const order: string[] = [];
|
|
725
|
+
const wrapped = { ...transport, fetch: async (url: string, init?: RequestInit) => { order.push(new URL(url).pathname); return (transport.fetch as (u: string, i?: RequestInit) => Promise<Response>)(url, init); } } as unknown as typeof transport;
|
|
726
|
+
let loads = 0;
|
|
727
|
+
const loaded: FuseSetLoadedMember = { load: async () => { loads++; order.push("load"); return original; }, originDigest: sha256(original), placement: "trailer/1", name: "photo.jpg" };
|
|
728
|
+
const r = await fuseSet([loaded, { original: png, placement: "container/1" }], { transport: wrapped });
|
|
729
|
+
assert.equal(loads, 1);
|
|
730
|
+
assert.ok(order.indexOf("load") > order.indexOf("/api/fuse/allocate"), "read after the slot is held");
|
|
731
|
+
assert.ok(order.indexOf("load") < order.indexOf("/api/fuse/commit"), "read before the commit");
|
|
732
|
+
const o = oracle(slot, [{ original, placement: "trailer/1" }, { original: png, placement: "container/1" }]);
|
|
733
|
+
assert.equal(r.members[0]!.artifactDigestB64, bytesToBase64(o.rows[0]!.artifact));
|
|
734
|
+
assert.equal(r.members[0]!.originDigestB64, bytesToBase64(sha256(original)));
|
|
735
|
+
assert.equal(r.members[0]!.fusedName, fusedNamesFor("photo.jpg", "trailer/1").fusedName);
|
|
736
|
+
assert.ok(!("fusedBytes" in r.members[0]!));
|
|
737
|
+
assert.equal(commits(calls).length, 1);
|
|
738
|
+
const kept = await fuseSet([{ ...loaded }], { transport: honest(key, slot).transport, keepFused: true });
|
|
739
|
+
assert.deepEqual(kept.members[0]!.fusedBytes, getPlacement("trailer/1")!.build({ original, commitment: computeSlotCommitment(slot) }));
|
|
740
|
+
// A wrong named digest burns the slot and commits nothing.
|
|
741
|
+
const bad = honest(key, slot);
|
|
742
|
+
await assert.rejects(fuseSet([{ load: () => original, originDigest: sha256(unrelated), placement: "trailer/1" }], { transport: bad.transport }), (e: FuseError) => e.code === "bad-input" && /^member 0: originDigest is not the SHA-256 of the loaded bytes/.test(e.message) && e.member === 0);
|
|
743
|
+
assert.equal(commits(bad.calls).length, 0);
|
|
744
|
+
assert.equal(allocates(bad.calls).length, 1);
|
|
745
|
+
// A loader that throws, or returns something other than bytes, is load-failed naming the member.
|
|
746
|
+
const thrown = honest(key, slot);
|
|
747
|
+
await assert.rejects(fuseSet([{ load: () => { throw new Error("gone"); }, originDigest: sha256(original), placement: "trailer/1" }], { transport: thrown.transport }), (e: FuseError) => e.code === "load-failed" && /^member 0: load threw: gone/.test(e.message) && e.member === 0);
|
|
748
|
+
assert.equal(commits(thrown.calls).length, 0);
|
|
749
|
+
await assert.rejects(fuseSet([{ original, placement: "trailer/1" }, { load: (() => "text") as never, originDigest: sha256(original), placement: "container/1" }], { transport: honest(key, slot).transport }), (e: FuseError) => e.code === "load-failed" && /^member 1: load must return a Uint8Array/.test(e.message) && e.member === 1);
|
|
750
|
+
// Without a placement or a digest nothing is requested.
|
|
751
|
+
const quiet = honest(key, slot);
|
|
752
|
+
await assert.rejects(fuseSet([{ load: () => original, originDigest: sha256(original) } as never], { transport: quiet.transport }), (e: FuseError) => e.code === "bad-input" && /^member 0: a loaded member names its placement/.test(e.message));
|
|
753
|
+
await assert.rejects(fuseSet([{ load: () => original, placement: "trailer/1" } as never], { transport: quiet.transport }), (e: FuseError) => e.code === "bad-input" && /^member 0: a loaded member names its originDigest/.test(e.message));
|
|
754
|
+
assert.equal(quiet.calls.length, 0);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test("32. a hashed member answers its fused digest for the held commitment and never shows its bytes; the row is what the real verifier finds", async () => {
|
|
758
|
+
const { calls, transport } = honest(key, slot);
|
|
759
|
+
const inputs: FusedDigestInput[] = [];
|
|
760
|
+
const state = savedState(original);
|
|
761
|
+
const hashed: FuseSetHashedMember = {
|
|
762
|
+
originDigest: sha256(original),
|
|
763
|
+
placement: "trailer/1",
|
|
764
|
+
name: "photo.jpg",
|
|
765
|
+
fusedDigest: (input) => { inputs.push(input); return finish(state, trailerBytesFor(input.commitment)); },
|
|
766
|
+
};
|
|
767
|
+
const r = await fuseSet([hashed, { original: png, placement: "container/1" }, { load: () => note, originDigest: sha256(note), placement: "container/1" }], { transport, keepFused: true });
|
|
768
|
+
assert.equal(inputs.length, 1);
|
|
769
|
+
const commitment = computeSlotCommitment(slot);
|
|
770
|
+
assert.deepEqual(inputs[0]!.commitment, commitment);
|
|
771
|
+
assert.equal(inputs[0]!.commitmentHex, bytesToHex(commitment));
|
|
772
|
+
assert.equal(inputs[0]!.slot.nonceB64, slot.nonceB64);
|
|
773
|
+
const built = getPlacement("trailer/1")!.build({ original, commitment });
|
|
774
|
+
assert.equal(r.members[0]!.artifactDigestB64, bytesToBase64(sha256(built)), "the row is the hash of the placement's build");
|
|
775
|
+
assert.ok(!("fusedBytes" in r.members[0]!), "nothing to keep for a hashed member");
|
|
776
|
+
assert.ok("fusedBytes" in r.members[1]! && "fusedBytes" in r.members[2]!, "bytes and loaded members are kept");
|
|
777
|
+
assert.equal(r.members.length, 3);
|
|
778
|
+
assert.equal(r.manifest.members.length, 3);
|
|
779
|
+
assert.equal(commits(calls).length, 1);
|
|
780
|
+
// The real verifier reads the member from the bytes a reader would build, and from its original.
|
|
781
|
+
const direct = await verifyFuseMember({ proof: r.proof, bytes: built, manifest: r.manifestBytes });
|
|
782
|
+
assert.equal(direct.category, "SET_MEMBER_DIRECT", direct.reason ?? "");
|
|
783
|
+
assert.equal(direct.set!.member!.index, r.members[0]!.manifestIndex);
|
|
784
|
+
const fromOrigin = await verifyFuseMember({ proof: r.proof, bytes: original, manifest: r.manifestBytes });
|
|
785
|
+
assert.equal(fromOrigin.category, "SET_MEMBER_FROM_ORIGIN", fromOrigin.reason ?? "");
|
|
786
|
+
// verifyMembers refuses a hashed member before any request; a throwing or short answer is builder-failed naming the member.
|
|
787
|
+
const quiet = honest(key, slot);
|
|
788
|
+
await assert.rejects(fuseSet([hashed], { transport: quiet.transport, verifyMembers: true }), (e: FuseError) => e.code === "bad-input" && /^member 0: a hashed member cannot be verified in full/.test(e.message) && e.member === 0);
|
|
789
|
+
assert.equal(quiet.calls.length, 0);
|
|
790
|
+
const throwing = honest(key, slot);
|
|
791
|
+
await assert.rejects(fuseSet([{ ...hashed, fusedDigest: () => { throw new Error("no state"); } }], { transport: throwing.transport }), (e: FuseError) => e.code === "builder-failed" && /^member 0: fusedDigest threw: no state/.test(e.message) && e.member === 0);
|
|
792
|
+
assert.equal(commits(throwing.calls).length, 0);
|
|
793
|
+
await assert.rejects(fuseSet([{ ...hashed, fusedDigest: () => new Uint8Array(31) }], { transport: honest(key, slot).transport }), (e: FuseError) => e.code === "builder-failed" && /^member 0: fusedDigest must return a 32-byte digest/.test(e.message));
|
|
794
|
+
// The same original as a hashed member and as a bytes member under one placement is the duplicate it always was.
|
|
795
|
+
await assert.rejects(fuseSet([hashed, { original, placement: "trailer/1" }], { transport: honest(key, slot).transport }), (e: FuseError) => e.code === "bad-input" && /^members 0 and 1 are the same original/.test(e.message));
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test("33. progress counts every shape: hash before the slot for all three, fuse after it, one commit", async () => {
|
|
799
|
+
const seen: FuseSetProgress[] = [];
|
|
800
|
+
const state = savedState(original);
|
|
801
|
+
await fuseSet([
|
|
802
|
+
{ originDigest: sha256(original), placement: "trailer/1", fusedDigest: ({ commitment }) => finish(state, trailerBytesFor(commitment)) },
|
|
803
|
+
{ load: () => note, originDigest: sha256(note), placement: "container/1" },
|
|
804
|
+
{ original: png, placement: "container/1" },
|
|
805
|
+
], { transport: honest(key, slot).transport, onProgress: (p) => seen.push({ ...p }) });
|
|
806
|
+
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
|
+
});
|
package/src/fuse-cli.ts
CHANGED
|
@@ -149,7 +149,8 @@ async function runSet(args: Args): Promise<number> {
|
|
|
149
149
|
seen.set(fusedName, i);
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
|
-
|
|
152
|
+
// The harness reads every member with the full verifier; the site binds by digest.
|
|
153
|
+
const r = await fuseSet(members, { keepFused: keep, verifyMembers: true, transport: transportFrom(args.flags) });
|
|
153
154
|
const outDir = outDirOf(args);
|
|
154
155
|
await mkdir(outDir, { recursive: true });
|
|
155
156
|
const written: string[] = [];
|
|
@@ -166,7 +167,7 @@ async function runSet(args: Args): Promise<number> {
|
|
|
166
167
|
written.push(p);
|
|
167
168
|
}
|
|
168
169
|
report(r.proof, r.verification.category, r.recovered, false);
|
|
169
|
-
for (const m of r.members) process.stdout.write(`member ${m.index} row ${m.manifestIndex} ${m.placement} ${m.verification
|
|
170
|
+
for (const m of r.members) process.stdout.write(`member ${m.index} row ${m.manifestIndex} ${m.placement} ${m.verification?.category ?? "listed"} ${m.fusedName ?? ""}\n`);
|
|
170
171
|
if (!r.manifestEchoed) process.stdout.write(`note the proof does not carry the manifest; keep set.manifest.json beside it\n`);
|
|
171
172
|
process.stdout.write(`\nwrote:\n${written.map((w) => " " + w).join("\n")}\n`);
|
|
172
173
|
return 0;
|