@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/src/fuse.ts
CHANGED
|
@@ -46,8 +46,11 @@ import {
|
|
|
46
46
|
computeSlotRecordHash,
|
|
47
47
|
fuseAttribution,
|
|
48
48
|
getPlacement,
|
|
49
|
+
parseSetManifest,
|
|
49
50
|
readSetMetadata,
|
|
50
51
|
SET_METADATA_KEY,
|
|
52
|
+
TRAILER_LENGTH,
|
|
53
|
+
TRAILER_MAGIC,
|
|
51
54
|
verifyFuse,
|
|
52
55
|
verifyFuseMember,
|
|
53
56
|
base64ToBytes,
|
|
@@ -65,6 +68,25 @@ import type {
|
|
|
65
68
|
SlotAllocation,
|
|
66
69
|
} from "@mikeargento/bitgraph-verify";
|
|
67
70
|
|
|
71
|
+
/**
|
|
72
|
+
* SHA-256 over bytes: the platform's native hasher when one is present
|
|
73
|
+
* (WebCrypto, in browsers and in Node), else the JavaScript library. The
|
|
74
|
+
* native path runs about ten times faster over large files and both give
|
|
75
|
+
* the same digest; a test pins that. A platform that refuses the input (a
|
|
76
|
+
* shared or detached buffer) falls back to the library.
|
|
77
|
+
*/
|
|
78
|
+
export async function digest(bytes: Uint8Array): Promise<Uint8Array> {
|
|
79
|
+
const subtle = (globalThis as { crypto?: { subtle?: { digest?: (alg: string, data: Uint8Array) => Promise<ArrayBuffer> } } }).crypto?.subtle;
|
|
80
|
+
if (subtle !== undefined && typeof subtle.digest === "function") {
|
|
81
|
+
try {
|
|
82
|
+
return new Uint8Array(await subtle.digest("SHA-256", bytes));
|
|
83
|
+
} catch {
|
|
84
|
+
// fall through to the library
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return sha256(bytes);
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
export type { FuseFrame, PlacementId, SlotAllocation, BitGraphProof, SetManifest, FuseMemberResult, FuseVerifyResult } from "@mikeargento/bitgraph-verify";
|
|
69
91
|
|
|
70
92
|
/** What the builder receives. The raw nonce is deliberately absent. */
|
|
@@ -133,6 +155,7 @@ export type FuseErrorCode =
|
|
|
133
155
|
| "bad-input"
|
|
134
156
|
| "allocate-failed"
|
|
135
157
|
| "builder-failed"
|
|
158
|
+
| "load-failed"
|
|
136
159
|
| "commitment-missing"
|
|
137
160
|
| "commit-refused"
|
|
138
161
|
| "slot-unavailable"
|
|
@@ -381,7 +404,7 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
|
|
|
381
404
|
if (options.originDigest !== undefined && options.originDigest.length !== 32) throw new FuseError("bad-input", "originDigest must be 32 bytes");
|
|
382
405
|
|
|
383
406
|
const t: BoundTransport = { ...DEFAULTS, ...(options.transport ?? {}) };
|
|
384
|
-
const originDigest = options.original !== undefined ?
|
|
407
|
+
const originDigest = options.original !== undefined ? await digest(options.original) : options.originDigest;
|
|
385
408
|
const originDigestB64 = originDigest !== undefined ? bytesToBase64(originDigest) : null;
|
|
386
409
|
|
|
387
410
|
// 1. nonce
|
|
@@ -399,7 +422,7 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
|
|
|
399
422
|
requireCommitment(placement, fused, commitment);
|
|
400
423
|
|
|
401
424
|
// 3. hash
|
|
402
|
-
const artifactDigest =
|
|
425
|
+
const artifactDigest = await digest(fused);
|
|
403
426
|
const artifactDigestB64 = bytesToBase64(artifactDigest);
|
|
404
427
|
|
|
405
428
|
// 4. fill
|
|
@@ -458,7 +481,18 @@ export const MAX_SET_MEMBERS = 2000;
|
|
|
458
481
|
/** The placements a set member takes: Forms A and B, one original per member. */
|
|
459
482
|
export type SetMemberPlacement = "trailer/1" | "container/1";
|
|
460
483
|
|
|
461
|
-
|
|
484
|
+
/** What a hashed member's fused digest is computed for: the held slot and its commitment. */
|
|
485
|
+
export interface FusedDigestInput {
|
|
486
|
+
commitment: Uint8Array;
|
|
487
|
+
commitmentHex: string;
|
|
488
|
+
slot: SlotAllocation;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* A member given as bytes. The core hashes the original, builds the fused
|
|
493
|
+
* bytes under the slot's commitment, checks them, and hashes them.
|
|
494
|
+
*/
|
|
495
|
+
export interface FuseSetBytesMember {
|
|
462
496
|
/** The original bytes. Never modified. */
|
|
463
497
|
original: Uint8Array;
|
|
464
498
|
/** Default: placementForBytes(original). */
|
|
@@ -469,9 +503,85 @@ export interface FuseSetMember {
|
|
|
469
503
|
builder?: FuseBuilder;
|
|
470
504
|
}
|
|
471
505
|
|
|
506
|
+
/**
|
|
507
|
+
* A member whose bytes are read only when it is that member's turn, after
|
|
508
|
+
* the slot is held, and released once hashed: one member's bytes in memory
|
|
509
|
+
* at a time, however large the set. Nothing can be read before allocation
|
|
510
|
+
* without reading twice, so the caller names the placement and the origin
|
|
511
|
+
* digest up front; the digest is checked against the loaded bytes, and the
|
|
512
|
+
* byte guards run as for a bytes member.
|
|
513
|
+
*/
|
|
514
|
+
export interface FuseSetLoadedMember {
|
|
515
|
+
load: () => Promise<Uint8Array> | Uint8Array;
|
|
516
|
+
originDigest: Uint8Array;
|
|
517
|
+
placement: SetMemberPlacement;
|
|
518
|
+
/** Advisory; feeds fusedNamesFor. */
|
|
519
|
+
name?: string;
|
|
520
|
+
/** Default: builderFor(placement, bytes). The locate and origin guards run regardless. */
|
|
521
|
+
builder?: FuseBuilder;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* A member the caller hashes itself: it answers the fused digest for the
|
|
526
|
+
* held slot's commitment. For trailer/1 that is a hash state saved after
|
|
527
|
+
* the original and finished with trailerBytesFor(commitment), so the bytes
|
|
528
|
+
* are read once, when they are scanned, and never again. The core never
|
|
529
|
+
* sees this member's bytes: no byte guard runs, keepFused returns nothing
|
|
530
|
+
* for it, and verifyMembers refuses it before any request. Its row is bound
|
|
531
|
+
* to the committed manifest by digest like every other.
|
|
532
|
+
*/
|
|
533
|
+
export interface FuseSetHashedMember {
|
|
534
|
+
originDigest: Uint8Array;
|
|
535
|
+
placement: SetMemberPlacement;
|
|
536
|
+
fusedDigest: (input: FusedDigestInput) => Promise<Uint8Array> | Uint8Array;
|
|
537
|
+
/** Advisory; feeds fusedNamesFor. */
|
|
538
|
+
name?: string;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export type FuseSetMember = FuseSetBytesMember | FuseSetLoadedMember | FuseSetHashedMember;
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* The 48 bytes trailer/1 appends after the original: the magic, eight
|
|
545
|
+
* reserved zero bytes, the commitment. A hasher whose state was saved after
|
|
546
|
+
* the original finishes with these and holds the member's fused digest
|
|
547
|
+
* without reading the original again. A test pins them against the
|
|
548
|
+
* placement's own build.
|
|
549
|
+
*/
|
|
550
|
+
export function trailerBytesFor(commitment: Uint8Array): Uint8Array {
|
|
551
|
+
if (!(commitment instanceof Uint8Array) || commitment.length !== 32) throw new FuseError("bad-input", "a slot commitment is 32 bytes");
|
|
552
|
+
const out = new Uint8Array(TRAILER_LENGTH);
|
|
553
|
+
out.set(new TextEncoder().encode(TRAILER_MAGIC), 0);
|
|
554
|
+
out.set(commitment, TRAILER_LENGTH - 32);
|
|
555
|
+
return out;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export interface FuseSetProgress {
|
|
559
|
+
/**
|
|
560
|
+
* "hash": each member checked before any request (a bytes member's origin digest is taken here).
|
|
561
|
+
* "fuse": each member's fused digest taken, after the slot is held.
|
|
562
|
+
* "commit": 0 of 1 before the request, 1 of 1 when the proof is back.
|
|
563
|
+
* "verify": only with verifyMembers, one per member.
|
|
564
|
+
*/
|
|
565
|
+
phase: "hash" | "fuse" | "commit" | "verify";
|
|
566
|
+
done: number;
|
|
567
|
+
total: number;
|
|
568
|
+
}
|
|
569
|
+
|
|
472
570
|
export interface FuseSetOptions {
|
|
473
|
-
/** Return each member's fused bytes. Default false: they are virtual, rebuilt from the original and the proof. */
|
|
571
|
+
/** 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. */
|
|
474
572
|
keepFused?: boolean;
|
|
573
|
+
/**
|
|
574
|
+
* Run the full verifier (verifyFuseMember) over every member's fused bytes
|
|
575
|
+
* after the commit and return each verdict under `verification`. Default
|
|
576
|
+
* false: every member is bound to the returned proof by digest, its row in
|
|
577
|
+
* the committed manifest, which is itself verified FUSED_DIRECT; that is
|
|
578
|
+
* linear and reads no bytes. The full pass re-hashes every member with the
|
|
579
|
+
* verifier's own hasher and grows with the square of the member count. A
|
|
580
|
+
* set with a hashed member refuses it before any request.
|
|
581
|
+
*/
|
|
582
|
+
verifyMembers?: boolean;
|
|
583
|
+
/** Called as the set advances. A throw inside it is ignored: a progress hook never changes the outcome. */
|
|
584
|
+
onProgress?: (progress: FuseSetProgress) => void;
|
|
475
585
|
/** Actor-bound commits: an agency envelope passed through untouched. */
|
|
476
586
|
agency?: unknown;
|
|
477
587
|
transport?: FuseTransport;
|
|
@@ -490,10 +600,10 @@ export interface FuseSetMemberResult {
|
|
|
490
600
|
fusedName: string | null;
|
|
491
601
|
/** Advisory; no Frame is written for a set member this phase. */
|
|
492
602
|
frameName: string | null;
|
|
493
|
-
/** Present only when keepFused is true. */
|
|
603
|
+
/** Present only when keepFused is true and the member's bytes passed through the core (never for a hashed member). */
|
|
494
604
|
fusedBytes?: Uint8Array;
|
|
495
|
-
/**
|
|
496
|
-
verification
|
|
605
|
+
/** 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". */
|
|
606
|
+
verification?: FuseMemberResult;
|
|
497
607
|
}
|
|
498
608
|
|
|
499
609
|
export interface FuseSetResult {
|
|
@@ -519,26 +629,55 @@ export interface FuseSetResult {
|
|
|
519
629
|
* Allocate once, fuse every member with the one commitment, hash the set
|
|
520
630
|
* manifest, fill the slot with it. Returns the proof with the manifest bytes
|
|
521
631
|
* beside it, or throws a FuseError; it never commits a partial set and never
|
|
522
|
-
* allocates a second slot.
|
|
632
|
+
* allocates a second slot. Members may be given as bytes, as a loader read
|
|
633
|
+
* one at a time after the slot is held, or as a digest the caller finishes
|
|
634
|
+
* itself; one set may mix them.
|
|
523
635
|
*/
|
|
524
636
|
export async function fuseSet(members: readonly FuseSetMember[], options: FuseSetOptions = {}): Promise<FuseSetResult> {
|
|
525
637
|
// 0. validate, before any request. A refusal here burns nothing.
|
|
526
638
|
if (!Array.isArray(members) || members.length === 0) throw new FuseError("bad-input", "a set lists at least one member");
|
|
527
639
|
if (members.length > MAX_SET_MEMBERS) throw new FuseError("bad-input", `a set lists at most ${MAX_SET_MEMBERS} members (got ${members.length})`);
|
|
528
|
-
|
|
640
|
+
const keep = options.keepFused === true;
|
|
641
|
+
const verifyMembers = options.verifyMembers === true;
|
|
642
|
+
type Kind = "bytes" | "loaded" | "hashed";
|
|
643
|
+
interface Checked {
|
|
644
|
+
kind: Kind;
|
|
645
|
+
placement: Placement;
|
|
646
|
+
id: SetMemberPlacement;
|
|
647
|
+
originDigest: Uint8Array;
|
|
648
|
+
name: string | null;
|
|
649
|
+
original: Uint8Array | null;
|
|
650
|
+
load: (() => Promise<Uint8Array> | Uint8Array) | null;
|
|
651
|
+
builder: FuseBuilder | null;
|
|
652
|
+
fusedDigest: ((input: FusedDigestInput) => Promise<Uint8Array> | Uint8Array) | null;
|
|
653
|
+
}
|
|
529
654
|
const checked: Checked[] = [];
|
|
530
655
|
const seen = new Map<string, number>();
|
|
656
|
+
const report = (phase: FuseSetProgress["phase"], done: number, total: number) => {
|
|
657
|
+
if (options.onProgress === undefined) return;
|
|
658
|
+
try {
|
|
659
|
+
options.onProgress({ phase, done, total });
|
|
660
|
+
} catch {
|
|
661
|
+
// a progress hook never changes the outcome
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
const bad = (i: number, message: string) => new FuseError("bad-input", `member ${i}: ${message}`, null, i);
|
|
531
665
|
for (let i = 0; i < members.length; i++) {
|
|
532
|
-
const m = members[i];
|
|
533
|
-
// A null, undefined or missing element is refused like any other member without
|
|
534
|
-
if (m === null || typeof m !== "object"
|
|
535
|
-
const
|
|
666
|
+
const m = members[i] as Partial<FuseSetBytesMember & FuseSetLoadedMember & FuseSetHashedMember> | null | undefined;
|
|
667
|
+
// A null, undefined or missing element is refused like any other member without bytes, a loader or a digest.
|
|
668
|
+
if (m === null || typeof m !== "object") throw bad(i, "original must be a Uint8Array, or load or fusedDigest a function");
|
|
669
|
+
const kind: Kind | null = m.original instanceof Uint8Array ? "bytes" : typeof m.load === "function" ? "loaded" : typeof m.fusedDigest === "function" ? "hashed" : null;
|
|
670
|
+
if (kind === null) throw bad(i, "original must be a Uint8Array, or load or fusedDigest a function");
|
|
671
|
+
if (kind !== "bytes" && m.placement === undefined) throw bad(i, `a ${kind} member names its placement`);
|
|
672
|
+
const id = m.placement ?? placementForBytes(m.original as Uint8Array);
|
|
536
673
|
const placement = getPlacement(id);
|
|
537
674
|
if (placement === undefined) throw new FuseError("bad-placement", `member ${i}: placement "${id}" is not registered`, null, i);
|
|
538
|
-
if (placement.form === "C") throw
|
|
539
|
-
if (m.name !== undefined && typeof m.name !== "string") throw
|
|
540
|
-
if (m.builder !== undefined && typeof m.builder !== "function") throw
|
|
541
|
-
|
|
675
|
+
if (placement.form === "C") throw bad(i, `${id} takes no original; a set holds trailer/1 and container/1 members only`);
|
|
676
|
+
if (m.name !== undefined && typeof m.name !== "string") throw bad(i, "name must be a string");
|
|
677
|
+
if (m.builder !== undefined && typeof m.builder !== "function") throw bad(i, "builder must be a function");
|
|
678
|
+
if (kind !== "bytes" && !(m.originDigest instanceof Uint8Array && m.originDigest.length === 32)) throw bad(i, `a ${kind} member names its originDigest, 32 bytes`);
|
|
679
|
+
if (kind === "hashed" && verifyMembers) throw bad(i, "a hashed member cannot be verified in full; pass its bytes or drop verifyMembers");
|
|
680
|
+
const originDigest = kind === "bytes" ? await digest(m.original as Uint8Array) : (m.originDigest as Uint8Array);
|
|
542
681
|
// The same original under the same placement fuses to the same bytes, which one manifest lists once.
|
|
543
682
|
const key = `${id}:${bytesToHex(originDigest)}`;
|
|
544
683
|
const j = seen.get(key);
|
|
@@ -546,42 +685,91 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
|
|
|
546
685
|
throw new FuseError("bad-input", `members ${j} and ${i} are the same original under the same placement (${id}) and would fuse to the same bytes; a set lists each fused artifact once`, null, i);
|
|
547
686
|
}
|
|
548
687
|
seen.set(key, i);
|
|
549
|
-
checked.push({
|
|
688
|
+
checked.push({
|
|
689
|
+
kind,
|
|
690
|
+
placement,
|
|
691
|
+
id,
|
|
692
|
+
originDigest,
|
|
693
|
+
name: m.name ?? null,
|
|
694
|
+
original: kind === "bytes" ? (m.original as Uint8Array) : null,
|
|
695
|
+
load: kind === "loaded" ? (m.load as Checked["load"]) : null,
|
|
696
|
+
builder: kind !== "hashed" && m.builder !== undefined ? (m.builder as FuseBuilder) : null,
|
|
697
|
+
fusedDigest: kind === "hashed" ? (m.fusedDigest as Checked["fusedDigest"]) : null,
|
|
698
|
+
});
|
|
699
|
+
report("hash", i + 1, members.length);
|
|
550
700
|
}
|
|
551
701
|
const t: BoundTransport = { ...DEFAULTS, ...(options.transport ?? {}) };
|
|
552
702
|
|
|
553
703
|
// 1. nonce: one slot for the whole set
|
|
554
704
|
const slot = await allocateSlot(t);
|
|
555
705
|
|
|
556
|
-
// 2. fuse: the commitment once, every member's
|
|
557
|
-
// is held and its TTL is running; a throw here burns it but commits
|
|
706
|
+
// 2. fuse: the commitment once, every member's digest under it. The slot
|
|
707
|
+
// is held and its TTL is running; a throw here burns it but commits
|
|
708
|
+
// nothing. A member's fused bytes are virtual: each is built, hashed
|
|
709
|
+
// and released in turn, so memory holds one member's bytes at a time.
|
|
710
|
+
// They are held only for a caller who keeps them or asks the full
|
|
711
|
+
// verifier to read them.
|
|
558
712
|
const commitment = computeSlotCommitment(slot);
|
|
559
713
|
const commitmentHex = bytesToHex(commitment);
|
|
560
|
-
const fusedBytes: Uint8Array[] = [];
|
|
714
|
+
const fusedBytes: (Uint8Array | null)[] = [];
|
|
561
715
|
const rows: SetMember[] = [];
|
|
716
|
+
const expiring = "nothing was committed and the slot will expire";
|
|
562
717
|
for (let i = 0; i < checked.length; i++) {
|
|
563
718
|
const c = checked[i]!;
|
|
564
|
-
let
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
719
|
+
let artifact: Uint8Array;
|
|
720
|
+
let held: Uint8Array | null = null;
|
|
721
|
+
if (c.kind === "hashed") {
|
|
722
|
+
let d: unknown;
|
|
723
|
+
try {
|
|
724
|
+
d = await c.fusedDigest!({ commitment, commitmentHex, slot });
|
|
725
|
+
} catch (err) {
|
|
726
|
+
throw new FuseError("builder-failed", `member ${i}: fusedDigest threw: ${err instanceof Error ? err.message : String(err)}; ${expiring}`, null, i);
|
|
727
|
+
}
|
|
728
|
+
if (!(d instanceof Uint8Array) || d.length !== 32) throw new FuseError("builder-failed", `member ${i}: fusedDigest must return a 32-byte digest; ${expiring}`, null, i);
|
|
729
|
+
artifact = d;
|
|
730
|
+
} else {
|
|
731
|
+
let original: Uint8Array;
|
|
732
|
+
if (c.kind === "loaded") {
|
|
733
|
+
let loaded: unknown;
|
|
734
|
+
try {
|
|
735
|
+
loaded = await c.load!();
|
|
736
|
+
} catch (err) {
|
|
737
|
+
throw new FuseError("load-failed", `member ${i}: load threw: ${err instanceof Error ? err.message : String(err)}; ${expiring}`, null, i);
|
|
738
|
+
}
|
|
739
|
+
if (!(loaded instanceof Uint8Array)) throw new FuseError("load-failed", `member ${i}: load must return a Uint8Array; ${expiring}`, null, i);
|
|
740
|
+
original = loaded;
|
|
741
|
+
// The digest the caller named is the row's origin; it must be these bytes' own.
|
|
742
|
+
if (!bytesEqual(await digest(original), c.originDigest)) throw new FuseError("bad-input", `member ${i}: originDigest is not the SHA-256 of the loaded bytes; ${expiring}`, null, i);
|
|
743
|
+
} else {
|
|
744
|
+
original = c.original!;
|
|
745
|
+
}
|
|
746
|
+
const builder = c.builder ?? builderFor(c.id, original);
|
|
747
|
+
let fused: Uint8Array;
|
|
748
|
+
try {
|
|
749
|
+
fused = await builder({ commitment, commitmentHex, originDigest: c.originDigest, slot });
|
|
750
|
+
} catch (err) {
|
|
751
|
+
throw new FuseError("builder-failed", `member ${i}: the builder threw: ${err instanceof Error ? err.message : String(err)}`, null, i);
|
|
752
|
+
}
|
|
753
|
+
if (!(fused instanceof Uint8Array)) throw new FuseError("builder-failed", `member ${i}: the builder must return a Uint8Array`, null, i);
|
|
754
|
+
const located = requireCommitment(c.placement, fused, commitment, i);
|
|
755
|
+
// The row's origin must be the origin the bytes embed, else the member
|
|
756
|
+
// would verify INVALID_ORIGIN_ATTRIBUTION after the slot is spent. Both
|
|
757
|
+
// facts are checked when both are present: the digest the bytes declare
|
|
758
|
+
// (container/1's payload) and the bytes they carry, compared byte for
|
|
759
|
+
// byte with the member's original rather than hashed again, so a builder
|
|
760
|
+
// cannot pack other bytes under the member's digest and leave a member no
|
|
761
|
+
// original rebuilds.
|
|
762
|
+
const declared = located.originDigest;
|
|
763
|
+
const carried = located.originalBytes;
|
|
764
|
+
if ((declared !== undefined && !bytesEqual(declared, c.originDigest)) || (carried !== undefined && !bytesEqual(carried, original))) {
|
|
765
|
+
throw new FuseError("builder-failed", `member ${i}: the fused bytes embed an origin that is not the member's original; ${expiring}`, null, i);
|
|
766
|
+
}
|
|
767
|
+
artifact = await digest(fused);
|
|
768
|
+
if (keep || verifyMembers) held = fused;
|
|
582
769
|
}
|
|
583
|
-
|
|
584
|
-
|
|
770
|
+
rows.push({ artifact, origin: c.originDigest, placement: c.id });
|
|
771
|
+
fusedBytes.push(held);
|
|
772
|
+
report("fuse", i + 1, checked.length);
|
|
585
773
|
}
|
|
586
774
|
|
|
587
775
|
// 3. hash: the canonical manifest is the artifact
|
|
@@ -589,9 +777,9 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
|
|
|
589
777
|
try {
|
|
590
778
|
manifestBytes = buildSetManifest(commitment, rows);
|
|
591
779
|
} catch (err) {
|
|
592
|
-
throw new FuseError("bad-input", `the set manifest could not be built: ${err instanceof Error ? err.message : String(err)};
|
|
780
|
+
throw new FuseError("bad-input", `the set manifest could not be built: ${err instanceof Error ? err.message : String(err)}; ${expiring}`);
|
|
593
781
|
}
|
|
594
|
-
const artifactDigestB64 = bytesToBase64(
|
|
782
|
+
const artifactDigestB64 = bytesToBase64(await digest(manifestBytes));
|
|
595
783
|
const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as SetManifest;
|
|
596
784
|
|
|
597
785
|
// 4. fill: one commit, the parsed manifest riding along as unsigned metadata
|
|
@@ -604,45 +792,66 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
|
|
|
604
792
|
metadata: { [SET_METADATA_KEY]: manifest },
|
|
605
793
|
};
|
|
606
794
|
if (options.agency !== undefined) body.agency = options.agency;
|
|
795
|
+
report("commit", 0, 1);
|
|
607
796
|
const { proof, recovered } = await commitUnderSlot(t, body, artifactDigestB64, slot);
|
|
797
|
+
report("commit", 1, 1);
|
|
608
798
|
|
|
609
799
|
// The manifest is verified by a reader before the proof is called a set proof.
|
|
610
800
|
const verification = await verifyFuse({ proof, bytes: manifestBytes });
|
|
611
801
|
if (verification.category !== "FUSED_DIRECT" || verification.placement !== "set/1") {
|
|
612
802
|
throw new FuseError("verification-failed", `the returned proof does not verify as a set: ${verification.category}${verification.reason ? ` (${verification.reason})` : ""}`);
|
|
613
803
|
}
|
|
614
|
-
// The echo is unsigned and advisory. Absent is normal
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
//
|
|
804
|
+
// The echo is unsigned and advisory. Absent is normal for a boundary that
|
|
805
|
+
// drops metadata on a held-slot commit (enclaves before v6, and a proxy
|
|
806
|
+
// that does not forward it); differing means a boundary rewrote the
|
|
807
|
+
// response.
|
|
618
808
|
const echoed = readSetMetadata(proof);
|
|
619
809
|
if (echoed !== null && !bytesEqual(echoed, manifestBytes)) {
|
|
620
810
|
throw new FuseError("verification-failed", `the returned proof echoes a set manifest under metadata["${SET_METADATA_KEY}"] that differs from the committed one`);
|
|
621
811
|
}
|
|
622
812
|
const manifestEchoed = echoed !== null;
|
|
623
|
-
// Every member
|
|
624
|
-
|
|
813
|
+
// Every member is bound to the returned proof by its row: the manifest the
|
|
814
|
+
// proof commits (verified FUSED_DIRECT above) is parsed strictly, and each
|
|
815
|
+
// member's computed fused digest, origin and placement must sit in it. No
|
|
816
|
+
// member's bytes are read again. With verifyMembers the full verifier runs
|
|
817
|
+
// over each member's fused bytes as well, against the explicit manifest
|
|
818
|
+
// bytes so no verdict depends on the echo, and its verdict is returned.
|
|
819
|
+
const parsed = parseSetManifest(manifestBytes);
|
|
820
|
+
if (parsed === null) throw new FuseError("verification-failed", "the committed manifest does not parse as a set manifest");
|
|
821
|
+
const rowIndex = new Map<string, number>();
|
|
822
|
+
parsed.members.forEach((row, k) => rowIndex.set(bytesToHex(row.artifact), k));
|
|
625
823
|
const results: FuseSetMemberResult[] = [];
|
|
626
824
|
for (let i = 0; i < checked.length; i++) {
|
|
627
825
|
const c = checked[i]!;
|
|
628
|
-
const
|
|
629
|
-
const
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
826
|
+
const row = rows[i]!;
|
|
827
|
+
const k = rowIndex.get(bytesToHex(row.artifact));
|
|
828
|
+
const listed = k !== undefined ? parsed.members[k] : undefined;
|
|
829
|
+
if (k === undefined || listed === undefined || !bytesEqual(listed.origin, row.origin) || listed.placement !== row.placement) {
|
|
830
|
+
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);
|
|
831
|
+
}
|
|
832
|
+
const memberArtifactB64 = bytesToBase64(row.artifact);
|
|
833
|
+
let verification: FuseMemberResult | undefined;
|
|
834
|
+
if (verifyMembers) {
|
|
835
|
+
const v = await verifyFuseMember({ proof, bytes: fusedBytes[i]!, manifest: manifestBytes });
|
|
836
|
+
const member = v.set?.member ?? null;
|
|
837
|
+
if (v.category !== "SET_MEMBER_DIRECT" || member === null || member.fusedDigestB64 !== memberArtifactB64 || member.index !== k) {
|
|
838
|
+
throw new FuseError("verification-failed", `member ${i}: the returned proof does not verify this member: ${v.category}${v.reason ? ` (${v.reason})` : ""}`, null, i);
|
|
839
|
+
}
|
|
840
|
+
verification = v;
|
|
841
|
+
report("verify", i + 1, checked.length);
|
|
634
842
|
}
|
|
635
843
|
const names = c.name !== null ? fusedNamesFor(c.name, c.id) : null;
|
|
844
|
+
const held = fusedBytes[i];
|
|
636
845
|
results.push({
|
|
637
846
|
index: i,
|
|
638
|
-
manifestIndex:
|
|
847
|
+
manifestIndex: k,
|
|
639
848
|
placement: c.id,
|
|
640
849
|
originDigestB64: bytesToBase64(c.originDigest),
|
|
641
850
|
artifactDigestB64: memberArtifactB64,
|
|
642
851
|
fusedName: names?.fusedName ?? null,
|
|
643
852
|
frameName: names?.frameName ?? null,
|
|
644
|
-
...(keep ? { fusedBytes:
|
|
645
|
-
verification:
|
|
853
|
+
...(keep && held !== null ? { fusedBytes: held } : {}),
|
|
854
|
+
...(verification !== undefined ? { verification } : {}),
|
|
646
855
|
});
|
|
647
856
|
}
|
|
648
857
|
return {
|
package/src/index.ts
CHANGED
|
@@ -40,9 +40,9 @@ export { Constructor } from "./constructor.js";
|
|
|
40
40
|
// The producer profile over the primitive (working name Fuse): allocate a
|
|
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
|
-
export { fuse, fuseSet, MAX_SET_MEMBERS, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
|
|
43
|
+
export { fuse, fuseSet, MAX_SET_MEMBERS, trailerBytesFor, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
|
|
44
44
|
export type { FuseBuilder, BuilderInput, FuseOptions, FuseResult, FuseTransport, FuseErrorCode } from "./fuse.js";
|
|
45
|
-
export type { FuseSetMember, SetMemberPlacement, FuseSetOptions, FuseSetMemberResult, FuseSetResult } from "./fuse.js";
|
|
45
|
+
export type { FuseSetMember, FuseSetBytesMember, FuseSetLoadedMember, FuseSetHashedMember, FusedDigestInput, SetMemberPlacement, FuseSetOptions, FuseSetProgress, FuseSetMemberResult, FuseSetResult } from "./fuse.js";
|
|
46
46
|
// The verify-package types those results are made of, so the core entry names everything it returns.
|
|
47
47
|
export type { FuseFrame, PlacementId, SetManifest, FuseMemberResult, FuseVerifyResult } from "@mikeargento/bitgraph-verify";
|
|
48
48
|
|