@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/src/fuse.ts CHANGED
@@ -39,6 +39,14 @@ import { sha256 } from "@noble/hashes/sha256";
39
39
  import {
40
40
  buildFrame,
41
41
  buildSetManifest,
42
+ buildSetMemberProof,
43
+ buildSetRoot,
44
+ buildSetTree,
45
+ parseSetRoot,
46
+ SET2_PLACEMENT_ID,
47
+ SET_PLACEMENT_ID as SET_PLACEMENT_ID_LOCAL,
48
+ setRootFromMember,
49
+ MAX_SET2_MEMBERS,
42
50
  bytesEqual,
43
51
  bytesToBase64,
44
52
  bytesToHex,
@@ -55,18 +63,7 @@ import {
55
63
  verifyFuseMember,
56
64
  base64ToBytes,
57
65
  } from "@mikeargento/bitgraph-verify";
58
- import type {
59
- BitGraphProof,
60
- FuseFrame,
61
- FuseMemberResult,
62
- FuseVerifyResult,
63
- Located,
64
- Placement,
65
- PlacementId,
66
- SetManifest,
67
- SetMember,
68
- SlotAllocation,
69
- } from "@mikeargento/bitgraph-verify";
66
+ import type { BitGraphProof, FuseFrame, FuseMemberResult, FuseVerifyResult, Located, Placement, PlacementId, SetManifest, SetMember, SetMemberProof, SetRoot, SlotAllocation } from "@mikeargento/bitgraph-verify";
70
67
 
71
68
  /**
72
69
  * SHA-256 over bytes: the platform's native hasher when one is present
@@ -186,12 +183,14 @@ export class FuseError extends Error {
186
183
  * safe only where decoders stop at an end marker or read by declared sizes:
187
184
  * JPEG (EOI), PNG (IEND), GIF (0x3B), TIFF and the TIFF-based raws such as
188
185
  * DNG, CR2, NEF, ARW (offset tables), BMP and RIFF containers such as WebP,
189
- * WAV, AVI (declared sizes). Everything else goes into `container/1`, a tar
190
- * that carries the original untouched: PDF, ZIP-based documents, ISO base
186
+ * WAV, AVI (declared sizes). Everything else goes into `container/2`, a tar
187
+ * that carries the original untouched and FIRST, so a scanner can hash it
188
+ * once and finish the fused digest later: PDF, ZIP-based documents, ISO base
191
189
  * media video and images, Matroska, MP3, structured and plain text, and any
192
- * format not recognised here.
190
+ * format not recognised here. Artifacts made under `container/1` (the
191
+ * manifest first) stay readable; nothing new is made under it.
193
192
  */
194
- export function placementForBytes(bytes: Uint8Array): "trailer/1" | "container/1" {
193
+ export function placementForBytes(bytes: Uint8Array): "trailer/1" | "container/2" {
195
194
  const at = (sig: number[], offset = 0): boolean => bytes.length >= offset + sig.length && sig.every((v, i) => bytes[offset + i] === v);
196
195
  const trailerSafe =
197
196
  at([0xff, 0xd8, 0xff]) || // JPEG
@@ -200,7 +199,7 @@ export function placementForBytes(bytes: Uint8Array): "trailer/1" | "container/1
200
199
  at([0x49, 0x49, 0x2a, 0x00]) || at([0x4d, 0x4d, 0x00, 0x2a]) || // TIFF, DNG, CR2, NEF, ARW
201
200
  (at([0x42, 0x4d]) && bytes.length >= 14) || // BMP
202
201
  (at([0x52, 0x49, 0x46, 0x46]) && bytes.length >= 12); // RIFF: WebP, WAV, AVI
203
- return trailerSafe ? "trailer/1" : "container/1";
202
+ return trailerSafe ? "trailer/1" : "container/2";
204
203
  }
205
204
 
206
205
  /** Names for a fused artifact and its Frame, from the original's name. */
@@ -209,7 +208,7 @@ export function fusedNamesFor(originalName: string, placement: PlacementId): { f
209
208
  const stem = dot > 0 ? originalName.slice(0, dot) : originalName;
210
209
  const ext = dot > 0 ? originalName.slice(dot) : "";
211
210
  return {
212
- fusedName: placement === "trailer/1" ? `${stem}.fused${ext}` : placement === "container/1" ? `${stem}.fused.tar` : `${stem}.produced.json`,
211
+ fusedName: placement === "trailer/1" ? `${stem}.fused${ext}` : placement.startsWith("container/") ? `${stem}.fused.tar` : `${stem}.produced.json`,
213
212
  frameName: `${originalName}.bitgraph-fuse.json`,
214
213
  };
215
214
  }
@@ -479,7 +478,7 @@ export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<
479
478
  export const MAX_SET_MEMBERS = 2000;
480
479
 
481
480
  /** The placements a set member takes: Forms A and B, one original per member. */
482
- export type SetMemberPlacement = "trailer/1" | "container/1";
481
+ export type SetMemberPlacement = "trailer/1" | "container/1" | "container/2";
483
482
 
484
483
  /** What a hashed member's fused digest is computed for: the held slot and its commitment. */
485
484
  export interface FusedDigestInput {
@@ -559,15 +558,25 @@ export interface FuseSetProgress {
559
558
  /**
560
559
  * "hash": each member checked before any request (a bytes member's origin digest is taken here).
561
560
  * "fuse": each member's fused digest taken, after the slot is held.
561
+ * "tree": set/2 only, 0 of 1 before the tree is built, 1 of 1 after.
562
562
  * "commit": 0 of 1 before the request, 1 of 1 when the proof is back.
563
563
  * "verify": only with verifyMembers, one per member.
564
564
  */
565
- phase: "hash" | "fuse" | "commit" | "verify";
565
+ phase: "hash" | "fuse" | "tree" | "commit" | "verify";
566
566
  done: number;
567
567
  total: number;
568
568
  }
569
569
 
570
570
  export interface FuseSetOptions {
571
+ /**
572
+ * "set/1" (default): the committed artifact is the canonical manifest of
573
+ * every member, which rides in the commit and in the proof; at most
574
+ * MAX_SET_MEMBERS. "set/2": the committed artifact is the root of a Merkle
575
+ * tree over the same rows, a few hundred bytes whatever N is; each member
576
+ * gets its leaf index and path (see FuseSetMemberResult.path), which a
577
+ * reader needs beside the proof; at most MAX_SET2_MEMBERS.
578
+ */
579
+ set?: "set/1" | "set/2";
571
580
  /** Return each member's fused bytes. Default false: they are virtual, rebuilt from the original and the proof. A hashed member has none to return. */
572
581
  keepFused?: boolean;
573
582
  /**
@@ -604,14 +613,22 @@ export interface FuseSetMemberResult {
604
613
  fusedBytes?: Uint8Array;
605
614
  /** Present only with verifyMembers: the verifier's own verdict against this member's fused bytes. Always SET_MEMBER_DIRECT on success, with set.manifestSource "argument". */
606
615
  verification?: FuseMemberResult;
616
+ /** set/2 only: the member's inclusion path, siblings from the leaf up; with manifestIndex and the set's count it is the member's evidence (memberProof). */
617
+ path?: Uint8Array[];
618
+ /** set/2 only: the member's evidence as its JSON object, ready to ride beside the proof or under proof.metadata[SET_MEMBER_METADATA_KEY]. */
619
+ memberProof?: SetMemberProof;
607
620
  }
608
621
 
609
622
  export interface FuseSetResult {
623
+ /** Which set kind was made. */
624
+ set: "set/1" | "set/2";
610
625
  proof: BitGraphProof;
611
- /** The committed artifact. Keep it beside the proof. */
626
+ /** The committed artifact: the set/1 manifest, or the set/2 root document. Keep it beside the proof. */
612
627
  manifestBytes: Uint8Array;
613
- /** JSON.parse of manifestBytes; the exact object sent under metadata. */
614
- manifest: SetManifest;
628
+ /** JSON.parse of manifestBytes; the exact object sent under metadata. For set/2 a SetRoot. */
629
+ manifest: SetManifest | SetRoot;
630
+ /** set/2 only: the tree root, standard base64. */
631
+ treeRootB64?: string;
615
632
  /** SHA-256 of manifestBytes; equals proof.artifact.digestB64. */
616
633
  artifactDigestB64: string;
617
634
  slotCommitmentB64: string;
@@ -636,7 +653,10 @@ export interface FuseSetResult {
636
653
  export async function fuseSet(members: readonly FuseSetMember[], options: FuseSetOptions = {}): Promise<FuseSetResult> {
637
654
  // 0. validate, before any request. A refusal here burns nothing.
638
655
  if (!Array.isArray(members) || members.length === 0) throw new FuseError("bad-input", "a set lists at least one member");
639
- if (members.length > MAX_SET_MEMBERS) throw new FuseError("bad-input", `a set lists at most ${MAX_SET_MEMBERS} members (got ${members.length})`);
656
+ const setKind = options.set ?? "set/1";
657
+ if (setKind !== "set/1" && setKind !== "set/2") throw new FuseError("bad-input", `set must be "set/1" or "set/2" (got ${String(setKind)})`);
658
+ const cap = setKind === "set/1" ? MAX_SET_MEMBERS : MAX_SET2_MEMBERS;
659
+ if (members.length > cap) throw new FuseError("bad-input", `a ${setKind} set lists at most ${cap} members (got ${members.length})`);
640
660
  const keep = options.keepFused === true;
641
661
  const verifyMembers = options.verifyMembers === true;
642
662
  type Kind = "bytes" | "loaded" | "hashed";
@@ -672,7 +692,7 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
672
692
  const id = m.placement ?? placementForBytes(m.original as Uint8Array);
673
693
  const placement = getPlacement(id);
674
694
  if (placement === undefined) throw new FuseError("bad-placement", `member ${i}: placement "${id}" is not registered`, null, i);
675
- if (placement.form === "C") throw bad(i, `${id} takes no original; a set holds trailer/1 and container/1 members only`);
695
+ if (placement.form === "C") throw bad(i, `${id} takes no original; a set holds trailer/1, container/1 and container/2 members only`);
676
696
  if (m.name !== undefined && typeof m.name !== "string") throw bad(i, "name must be a string");
677
697
  if (m.builder !== undefined && typeof m.builder !== "function") throw bad(i, "builder must be a function");
678
698
  if (kind !== "bytes" && !(m.originDigest instanceof Uint8Array && m.originDigest.length === 32)) throw bad(i, `a ${kind} member names its originDigest, 32 bytes`);
@@ -772,23 +792,32 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
772
792
  report("fuse", i + 1, checked.length);
773
793
  }
774
794
 
775
- // 3. hash: the canonical manifest is the artifact
795
+ // 3. hash: the committed artifact. set/1: the canonical manifest of every
796
+ // row. set/2: the root document over the Merkle tree of the same rows.
776
797
  let manifestBytes: Uint8Array;
798
+ let tree: ReturnType<typeof buildSetTree> | null = null;
777
799
  try {
778
- manifestBytes = buildSetManifest(commitment, rows);
800
+ if (setKind === "set/1") {
801
+ manifestBytes = buildSetManifest(commitment, rows);
802
+ } else {
803
+ report("tree", 0, 1);
804
+ tree = buildSetTree(rows);
805
+ manifestBytes = buildSetRoot(commitment, tree.sorted.length, tree.root);
806
+ report("tree", 1, 1);
807
+ }
779
808
  } catch (err) {
780
- throw new FuseError("bad-input", `the set manifest could not be built: ${err instanceof Error ? err.message : String(err)}; ${expiring}`);
809
+ throw new FuseError("bad-input", `the set ${setKind === "set/1" ? "manifest" : "root document"} could not be built: ${err instanceof Error ? err.message : String(err)}; ${expiring}`);
781
810
  }
782
811
  const artifactDigestB64 = bytesToBase64(await digest(manifestBytes));
783
- const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as SetManifest;
812
+ const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as SetManifest | SetRoot;
784
813
 
785
- // 4. fill: one commit, the parsed manifest riding along as unsigned metadata
814
+ // 4. fill: one commit, the parsed artifact riding along as unsigned metadata
786
815
  const body: Record<string, unknown> = {
787
816
  digests: [{ digestB64: artifactDigestB64, hashAlg: "sha256" }],
788
817
  slotId: slot.nonceB64,
789
818
  slot,
790
819
  chainId: "bitgraph:main",
791
- attribution: fuseAttribution("set/1"),
820
+ attribution: fuseAttribution(setKind === "set/1" ? SET_PLACEMENT_ID_LOCAL : SET2_PLACEMENT_ID),
792
821
  metadata: { [SET_METADATA_KEY]: manifest },
793
822
  };
794
823
  if (options.agency !== undefined) body.agency = options.agency;
@@ -796,10 +825,10 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
796
825
  const { proof, recovered } = await commitUnderSlot(t, body, artifactDigestB64, slot);
797
826
  report("commit", 1, 1);
798
827
 
799
- // The manifest is verified by a reader before the proof is called a set proof.
828
+ // The committed artifact is verified by a reader before the proof is called a set proof.
800
829
  const verification = await verifyFuse({ proof, bytes: manifestBytes });
801
- if (verification.category !== "FUSED_DIRECT" || verification.placement !== "set/1") {
802
- throw new FuseError("verification-failed", `the returned proof does not verify as a set: ${verification.category}${verification.reason ? ` (${verification.reason})` : ""}`);
830
+ if (verification.category !== "FUSED_DIRECT" || verification.placement !== setKind) {
831
+ throw new FuseError("verification-failed", `the returned proof does not verify as a ${setKind} set: ${verification.category}${verification.reason ? ` (${verification.reason})` : ""}`);
803
832
  }
804
833
  // The echo is unsigned and advisory. Absent is normal for a boundary that
805
834
  // drops metadata on a held-slot commit (enclaves before v6, and a proxy
@@ -816,18 +845,39 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
816
845
  // member's bytes are read again. With verifyMembers the full verifier runs
817
846
  // over each member's fused bytes as well, against the explicit manifest
818
847
  // 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");
848
+ // set/1: the strictly parsed manifest lists each computed row. set/2: the
849
+ // strictly parsed root document states the count and the root the tree
850
+ // over these rows computed, and each member's path recomputes that root
851
+ // (the verifier's own check, run here once per member).
852
+ let listedRows: readonly SetMember[];
853
+ if (setKind === "set/1") {
854
+ const parsed = parseSetManifest(manifestBytes);
855
+ if (parsed === null) throw new FuseError("verification-failed", "the committed manifest does not parse as a set manifest");
856
+ listedRows = parsed.members;
857
+ } else {
858
+ const doc = parseSetRoot(manifestBytes);
859
+ if (doc === null || tree === null) throw new FuseError("verification-failed", "the committed root document does not parse as a set root");
860
+ if (doc.count !== tree.sorted.length || !bytesEqual(doc.root, tree.root)) throw new FuseError("verification-failed", "the committed root document does not state this tree's count and root");
861
+ listedRows = tree.sorted;
862
+ }
821
863
  const rowIndex = new Map<string, number>();
822
- parsed.members.forEach((row, k) => rowIndex.set(bytesToHex(row.artifact), k));
864
+ listedRows.forEach((row, k) => rowIndex.set(bytesToHex(row.artifact), k));
823
865
  const results: FuseSetMemberResult[] = [];
824
866
  for (let i = 0; i < checked.length; i++) {
825
867
  const c = checked[i]!;
826
868
  const row = rows[i]!;
827
869
  const k = rowIndex.get(bytesToHex(row.artifact));
828
- const listed = k !== undefined ? parsed.members[k] : undefined;
870
+ const listed = k !== undefined ? listedRows[k] : undefined;
829
871
  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);
872
+ throw new FuseError("verification-failed", `member ${i}: the committed ${setKind === "set/1" ? "manifest" : "tree"} does not list this member's fused digest with its origin and placement`, null, i);
873
+ }
874
+ let path: Uint8Array[] | undefined;
875
+ let memberProof: SetMemberProof | undefined;
876
+ if (tree !== null) {
877
+ path = tree.tree.path(k);
878
+ const reached = setRootFromMember(row, k, tree.sorted.length, path);
879
+ if (reached === null || !bytesEqual(reached, tree.root)) throw new FuseError("verification-failed", `member ${i}: its path does not recompute the committed root`, null, i);
880
+ memberProof = buildSetMemberProof(row, k, tree.sorted.length, path);
831
881
  }
832
882
  const memberArtifactB64 = bytesToBase64(row.artifact);
833
883
  let verification: FuseMemberResult | undefined;
@@ -852,12 +902,16 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
852
902
  frameName: names?.frameName ?? null,
853
903
  ...(keep && held !== null ? { fusedBytes: held } : {}),
854
904
  ...(verification !== undefined ? { verification } : {}),
905
+ ...(path !== undefined ? { path } : {}),
906
+ ...(memberProof !== undefined ? { memberProof } : {}),
855
907
  });
856
908
  }
857
909
  return {
910
+ set: setKind,
858
911
  proof,
859
912
  manifestBytes,
860
913
  manifest,
914
+ ...(tree !== null ? { treeRootB64: bytesToBase64(tree.root) } : {}),
861
915
  artifactDigestB64,
862
916
  slotCommitmentB64: bytesToBase64(commitment),
863
917
  members: results,
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ export { Constructor } from "./constructor.js";
41
41
  // slot, write a commitment to it into the artifact, hash, commit under the
42
42
  // same slot. The resulting proof is ordinary bitgraph/1.
43
43
  export { fuse, fuseSet, MAX_SET_MEMBERS, trailerBytesFor, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
44
+ export { MAX_SET2_MEMBERS, SET2_PLACEMENT_ID, SET_MEMBER_METADATA_KEY } from "@mikeargento/bitgraph-verify";
44
45
  export type { FuseBuilder, BuilderInput, FuseOptions, FuseResult, FuseTransport, FuseErrorCode } from "./fuse.js";
45
46
  export type { FuseSetMember, FuseSetBytesMember, FuseSetLoadedMember, FuseSetHashedMember, FusedDigestInput, SetMemberPlacement, FuseSetOptions, FuseSetProgress, FuseSetMemberResult, FuseSetResult } from "./fuse.js";
46
47
  // The verify-package types those results are made of, so the core entry names everything it returns.