@mikeargento/bitgraph 1.4.0 → 1.5.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/src/fuse.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  computeSlotRecordHash,
47
47
  fuseAttribution,
48
48
  getPlacement,
49
+ parseSetManifest,
49
50
  readSetMetadata,
50
51
  SET_METADATA_KEY,
51
52
  verifyFuse,
@@ -65,6 +66,25 @@ import type {
65
66
  SlotAllocation,
66
67
  } from "@mikeargento/bitgraph-verify";
67
68
 
69
+ /**
70
+ * SHA-256 over bytes: the platform's native hasher when one is present
71
+ * (WebCrypto, in browsers and in Node), else the JavaScript library. The
72
+ * native path runs about ten times faster over large files and both give
73
+ * the same digest; a test pins that. A platform that refuses the input (a
74
+ * shared or detached buffer) falls back to the library.
75
+ */
76
+ export async function digest(bytes: Uint8Array): Promise<Uint8Array> {
77
+ const subtle = (globalThis as { crypto?: { subtle?: { digest?: (alg: string, data: Uint8Array) => Promise<ArrayBuffer> } } }).crypto?.subtle;
78
+ if (subtle !== undefined && typeof subtle.digest === "function") {
79
+ try {
80
+ return new Uint8Array(await subtle.digest("SHA-256", bytes));
81
+ } catch {
82
+ // fall through to the library
83
+ }
84
+ }
85
+ return sha256(bytes);
86
+ }
87
+
68
88
  export type { FuseFrame, PlacementId, SlotAllocation, BitGraphProof, SetManifest, FuseMemberResult, FuseVerifyResult } from "@mikeargento/bitgraph-verify";
69
89
 
70
90
  /** What the builder receives. The raw nonce is deliberately absent. */
@@ -381,7 +401,7 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
381
401
  if (options.originDigest !== undefined && options.originDigest.length !== 32) throw new FuseError("bad-input", "originDigest must be 32 bytes");
382
402
 
383
403
  const t: BoundTransport = { ...DEFAULTS, ...(options.transport ?? {}) };
384
- const originDigest = options.original !== undefined ? sha256(options.original) : options.originDigest;
404
+ const originDigest = options.original !== undefined ? await digest(options.original) : options.originDigest;
385
405
  const originDigestB64 = originDigest !== undefined ? bytesToBase64(originDigest) : null;
386
406
 
387
407
  // 1. nonce
@@ -399,7 +419,7 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
399
419
  requireCommitment(placement, fused, commitment);
400
420
 
401
421
  // 3. hash
402
- const artifactDigest = sha256(fused);
422
+ const artifactDigest = await digest(fused);
403
423
  const artifactDigestB64 = bytesToBase64(artifactDigest);
404
424
 
405
425
  // 4. fill
@@ -469,9 +489,32 @@ export interface FuseSetMember {
469
489
  builder?: FuseBuilder;
470
490
  }
471
491
 
492
+ export interface FuseSetProgress {
493
+ /**
494
+ * "hash": origin digests, one per member, before any request.
495
+ * "fuse": each member built and its fused digest taken, after the slot is held.
496
+ * "commit": 0 of 1 before the request, 1 of 1 when the proof is back.
497
+ * "verify": only with verifyMembers, one per member.
498
+ */
499
+ phase: "hash" | "fuse" | "commit" | "verify";
500
+ done: number;
501
+ total: number;
502
+ }
503
+
472
504
  export interface FuseSetOptions {
473
505
  /** Return each member's fused bytes. Default false: they are virtual, rebuilt from the original and the proof. */
474
506
  keepFused?: boolean;
507
+ /**
508
+ * Run the full verifier (verifyFuseMember) over every member's fused bytes
509
+ * after the commit and return each verdict under `verification`. Default
510
+ * false: every member is bound to the returned proof by digest, its row in
511
+ * the committed manifest, which is itself verified FUSED_DIRECT; that is
512
+ * linear and reads no bytes. The full pass re-hashes every member with the
513
+ * verifier's own hasher and grows with the square of the member count.
514
+ */
515
+ verifyMembers?: boolean;
516
+ /** Called as the set advances. A throw inside it is ignored: a progress hook never changes the outcome. */
517
+ onProgress?: (progress: FuseSetProgress) => void;
475
518
  /** Actor-bound commits: an agency envelope passed through untouched. */
476
519
  agency?: unknown;
477
520
  transport?: FuseTransport;
@@ -492,8 +535,8 @@ export interface FuseSetMemberResult {
492
535
  frameName: string | null;
493
536
  /** Present only when keepFused is true. */
494
537
  fusedBytes?: Uint8Array;
495
- /** The local verification of the returned proof against this member's fused bytes. Always SET_MEMBER_DIRECT on success, with set.manifestSource "argument". */
496
- verification: FuseMemberResult;
538
+ /** 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". */
539
+ verification?: FuseMemberResult;
497
540
  }
498
541
 
499
542
  export interface FuseSetResult {
@@ -528,6 +571,14 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
528
571
  interface Checked { placement: Placement; id: SetMemberPlacement; original: Uint8Array; originDigest: Uint8Array; name: string | null; builder: FuseBuilder }
529
572
  const checked: Checked[] = [];
530
573
  const seen = new Map<string, number>();
574
+ const report = (phase: FuseSetProgress["phase"], done: number, total: number) => {
575
+ if (options.onProgress === undefined) return;
576
+ try {
577
+ options.onProgress({ phase, done, total });
578
+ } catch {
579
+ // a progress hook never changes the outcome
580
+ }
581
+ };
531
582
  for (let i = 0; i < members.length; i++) {
532
583
  const m = members[i];
533
584
  // A null, undefined or missing element is refused like any other member without original bytes.
@@ -538,7 +589,7 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
538
589
  if (placement.form === "C") throw new FuseError("bad-input", `member ${i}: ${id} takes no original; a set holds trailer/1 and container/1 members only`, null, i);
539
590
  if (m.name !== undefined && typeof m.name !== "string") throw new FuseError("bad-input", `member ${i}: name must be a string`, null, i);
540
591
  if (m.builder !== undefined && typeof m.builder !== "function") throw new FuseError("bad-input", `member ${i}: builder must be a function`, null, i);
541
- const originDigest = sha256(m.original);
592
+ const originDigest = await digest(m.original);
542
593
  // The same original under the same placement fuses to the same bytes, which one manifest lists once.
543
594
  const key = `${id}:${bytesToHex(originDigest)}`;
544
595
  const j = seen.get(key);
@@ -547,6 +598,7 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
547
598
  }
548
599
  seen.set(key, i);
549
600
  checked.push({ placement, id, original: m.original, originDigest, name: m.name ?? null, builder: m.builder ?? builderFor(id, m.original) });
601
+ report("hash", i + 1, members.length);
550
602
  }
551
603
  const t: BoundTransport = { ...DEFAULTS, ...(options.transport ?? {}) };
552
604
 
@@ -557,7 +609,12 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
557
609
  // is held and its TTL is running; a throw here burns it but commits nothing.
558
610
  const commitment = computeSlotCommitment(slot);
559
611
  const commitmentHex = bytesToHex(commitment);
560
- const fusedBytes: Uint8Array[] = [];
612
+ const keep = options.keepFused === true;
613
+ const verifyMembers = options.verifyMembers === true;
614
+ // A member's fused bytes are virtual: each is built, hashed and released in
615
+ // turn, so memory holds the originals and one fused copy. They are held only
616
+ // for a caller who keeps them or asks the full verifier to read them.
617
+ const fusedBytes: (Uint8Array | null)[] = [];
561
618
  const rows: SetMember[] = [];
562
619
  for (let i = 0; i < checked.length; i++) {
563
620
  const c = checked[i]!;
@@ -572,16 +629,18 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
572
629
  // The row's origin must be the origin the bytes embed, else the member
573
630
  // would verify INVALID_ORIGIN_ATTRIBUTION after the slot is spent. Both
574
631
  // facts are checked when both are present: the digest the bytes declare
575
- // (container/1's payload) and the bytes they carry, so a builder cannot
576
- // pack other bytes under the member's digest and leave a member no
632
+ // (container/1's payload) and the bytes they carry, compared byte for
633
+ // byte with the member's original rather than hashed again, so a builder
634
+ // cannot pack other bytes under the member's digest and leave a member no
577
635
  // original rebuilds.
578
636
  const declared = located.originDigest;
579
- const carried = located.originalBytes !== undefined ? sha256(located.originalBytes) : undefined;
580
- if ((declared !== undefined && !bytesEqual(declared, c.originDigest)) || (carried !== undefined && !bytesEqual(carried, c.originDigest))) {
637
+ const carried = located.originalBytes;
638
+ if ((declared !== undefined && !bytesEqual(declared, c.originDigest)) || (carried !== undefined && !bytesEqual(carried, c.original))) {
581
639
  throw new FuseError("builder-failed", `member ${i}: the fused bytes embed an origin that is not the member's original; nothing was committed and the slot will expire`, null, i);
582
640
  }
583
- fusedBytes.push(fused);
584
- rows.push({ artifact: sha256(fused), origin: c.originDigest, placement: c.id });
641
+ rows.push({ artifact: await digest(fused), origin: c.originDigest, placement: c.id });
642
+ fusedBytes.push(keep || verifyMembers ? fused : null);
643
+ report("fuse", i + 1, checked.length);
585
644
  }
586
645
 
587
646
  // 3. hash: the canonical manifest is the artifact
@@ -591,7 +650,7 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
591
650
  } catch (err) {
592
651
  throw new FuseError("bad-input", `the set manifest could not be built: ${err instanceof Error ? err.message : String(err)}; nothing was committed and the slot will expire`);
593
652
  }
594
- const artifactDigestB64 = bytesToBase64(sha256(manifestBytes));
653
+ const artifactDigestB64 = bytesToBase64(await digest(manifestBytes));
595
654
  const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as SetManifest;
596
655
 
597
656
  // 4. fill: one commit, the parsed manifest riding along as unsigned metadata
@@ -604,45 +663,65 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
604
663
  metadata: { [SET_METADATA_KEY]: manifest },
605
664
  };
606
665
  if (options.agency !== undefined) body.agency = options.agency;
666
+ report("commit", 0, 1);
607
667
  const { proof, recovered } = await commitUnderSlot(t, body, artifactDigestB64, slot);
668
+ report("commit", 1, 1);
608
669
 
609
670
  // The manifest is verified by a reader before the proof is called a set proof.
610
671
  const verification = await verifyFuse({ proof, bytes: manifestBytes });
611
672
  if (verification.category !== "FUSED_DIRECT" || verification.placement !== "set/1") {
612
673
  throw new FuseError("verification-failed", `the returned proof does not verify as a set: ${verification.category}${verification.reason ? ` (${verification.reason})` : ""}`);
613
674
  }
614
- // The echo is unsigned and advisory. Absent is normal: no production
615
- // boundary returns it today (the site proxy does not forward metadata and
616
- // the enclave's commitDigest action drops it); differing means a boundary
617
- // rewrote the response.
675
+ // The echo is unsigned and advisory. Absent is normal for a boundary that
676
+ // drops metadata on a held-slot commit (enclaves before v6, and a proxy
677
+ // that does not forward it); differing means a boundary rewrote the
678
+ // response.
618
679
  const echoed = readSetMetadata(proof);
619
680
  if (echoed !== null && !bytesEqual(echoed, manifestBytes)) {
620
681
  throw new FuseError("verification-failed", `the returned proof echoes a set manifest under metadata["${SET_METADATA_KEY}"] that differs from the committed one`);
621
682
  }
622
683
  const manifestEchoed = echoed !== null;
623
- // Every member against the explicit manifest bytes, so no verdict depends on the echo.
624
- const keep = options.keepFused === true;
684
+ // Every member is bound to the returned proof by its row: the manifest the
685
+ // proof commits (verified FUSED_DIRECT above) is parsed strictly, and each
686
+ // member's computed fused digest, origin and placement must sit in it. No
687
+ // member's bytes are read again. With verifyMembers the full verifier runs
688
+ // over each member's fused bytes as well, against the explicit manifest
689
+ // bytes so no verdict depends on the echo, and its verdict is returned.
690
+ const parsed = parseSetManifest(manifestBytes);
691
+ if (parsed === null) throw new FuseError("verification-failed", "the committed manifest does not parse as a set manifest");
692
+ const rowIndex = new Map<string, number>();
693
+ parsed.members.forEach((row, k) => rowIndex.set(bytesToHex(row.artifact), k));
625
694
  const results: FuseSetMemberResult[] = [];
626
695
  for (let i = 0; i < checked.length; i++) {
627
696
  const c = checked[i]!;
628
- const fused = fusedBytes[i]!;
629
- const memberArtifactB64 = bytesToBase64(rows[i]!.artifact);
630
- const v = await verifyFuseMember({ proof, bytes: fused, manifest: manifestBytes });
631
- const member = v.set?.member ?? null;
632
- if (v.category !== "SET_MEMBER_DIRECT" || member === null || member.fusedDigestB64 !== memberArtifactB64) {
633
- throw new FuseError("verification-failed", `member ${i}: the returned proof does not verify this member: ${v.category}${v.reason ? ` (${v.reason})` : ""}`, null, i);
697
+ const row = rows[i]!;
698
+ const k = rowIndex.get(bytesToHex(row.artifact));
699
+ const listed = k !== undefined ? parsed.members[k] : undefined;
700
+ if (k === undefined || listed === undefined || !bytesEqual(listed.origin, row.origin) || listed.placement !== row.placement) {
701
+ 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);
702
+ }
703
+ const memberArtifactB64 = bytesToBase64(row.artifact);
704
+ let verification: FuseMemberResult | undefined;
705
+ if (verifyMembers) {
706
+ const v = await verifyFuseMember({ proof, bytes: fusedBytes[i]!, manifest: manifestBytes });
707
+ const member = v.set?.member ?? null;
708
+ if (v.category !== "SET_MEMBER_DIRECT" || member === null || member.fusedDigestB64 !== memberArtifactB64 || member.index !== k) {
709
+ throw new FuseError("verification-failed", `member ${i}: the returned proof does not verify this member: ${v.category}${v.reason ? ` (${v.reason})` : ""}`, null, i);
710
+ }
711
+ verification = v;
712
+ report("verify", i + 1, checked.length);
634
713
  }
635
714
  const names = c.name !== null ? fusedNamesFor(c.name, c.id) : null;
636
715
  results.push({
637
716
  index: i,
638
- manifestIndex: member.index,
717
+ manifestIndex: k,
639
718
  placement: c.id,
640
719
  originDigestB64: bytesToBase64(c.originDigest),
641
720
  artifactDigestB64: memberArtifactB64,
642
721
  fusedName: names?.fusedName ?? null,
643
722
  frameName: names?.frameName ?? null,
644
- ...(keep ? { fusedBytes: fused } : {}),
645
- verification: v,
723
+ ...(keep ? { fusedBytes: fusedBytes[i]! } : {}),
724
+ ...(verification !== undefined ? { verification } : {}),
646
725
  });
647
726
  }
648
727
  return {
package/src/index.ts CHANGED
@@ -42,7 +42,7 @@ export { Constructor } from "./constructor.js";
42
42
  // same slot. The resulting proof is ordinary bitgraph/1.
43
43
  export { fuse, fuseSet, MAX_SET_MEMBERS, 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, 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