@mikeargento/bitgraph-verify 1.6.0 → 1.7.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
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import { sha256 } from "@noble/hashes/sha256";
29
+ import { merkleLeafHash, merkleRootFromPath, MerkleTree } from "./fuse-merkle.js";
29
30
  import { canonicalize } from "./canonical.js";
30
31
  import type { Attribution, BitGraphProof, SlotAllocation } from "./types.js";
31
32
 
@@ -325,7 +326,7 @@ function parseTar(bytes: Uint8Array): TarEntry[] | null {
325
326
  // Placement registry
326
327
  // ---------------------------------------------------------------------------
327
328
 
328
- export type PlacementId = "trailer/1" | "container/1" | "container/2" | "produced/1" | "set/1";
329
+ export type PlacementId = "trailer/1" | "container/1" | "container/2" | "produced/1" | "set/1" | "set/2";
329
330
 
330
331
  export interface Located {
331
332
  /** The commitment found in the fused bytes. */
@@ -567,7 +568,7 @@ export function buildSetManifest(commitment: Uint8Array, members: readonly SetMe
567
568
  if (m.artifact.length !== 32) throw new TypeError("member artifact digest must be 32 bytes");
568
569
  if (m.origin.length !== 32) throw new TypeError("member origin digest must be 32 bytes");
569
570
  if (!PLACEMENT_ID_PATTERN.test(m.placement)) throw new TypeError(`member placement "${m.placement}" is not a placement id`);
570
- if (m.placement === SET_PLACEMENT_ID) throw new TypeError("a set cannot list a set as a member");
571
+ if (m.placement === SET_PLACEMENT_ID || m.placement === SET2_PLACEMENT_ID) throw new TypeError("a set cannot list a set as a member");
571
572
  const artifact = bytesToHex(m.artifact);
572
573
  if (seen.has(artifact)) throw new TypeError(`duplicate member artifact digest ${artifact}`);
573
574
  seen.add(artifact);
@@ -631,7 +632,7 @@ export function parseSetManifest(bytes: Uint8Array): { commitment: Uint8Array; m
631
632
  const origin = readDigestField(row["origin"]);
632
633
  const placement = row["placement"];
633
634
  if (artifact === null || origin === null || typeof placement !== "string") return null;
634
- if (!PLACEMENT_ID_PATTERN.test(placement) || placement === SET_PLACEMENT_ID) return null;
635
+ if (!PLACEMENT_ID_PATTERN.test(placement) || placement === SET_PLACEMENT_ID || placement === SET2_PLACEMENT_ID) return null;
635
636
  members.push({ artifact, origin, placement });
636
637
  }
637
638
  let rebuilt: Uint8Array;
@@ -675,15 +676,225 @@ const set1: Placement = {
675
676
  },
676
677
  };
677
678
 
679
+ // ---------------------------------------------------------------------------
680
+ // Merkle set (placement set/2): N files under ONE slot, any N
681
+ // ---------------------------------------------------------------------------
682
+ //
683
+ // set/1 commits the whole member list, which is what caps it: the list rides
684
+ // in the commit body and in every copy of the proof. set/2 commits the ROOT
685
+ // of a Merkle tree over the same rows, so the committed artifact is a few
686
+ // hundred bytes whatever N is, and a member proves its place with a path of
687
+ // ceil(log2 N) siblings. The floor is unchanged: every member's fused bytes
688
+ // still carry the slot's commitment through its own placement. Membership
689
+ // and floor stay inseparable in verifyFuseMember; what changes is where the
690
+ // list lives (with the producer and the reader that serves it) and what a
691
+ // member carries (its row, its index and its path, see SetMemberProof).
692
+ //
693
+ // Leaves are the canonical row bytes (the same {artifact, origin, placement}
694
+ // row set/1 lists), hashed with the RFC 6962 leaf prefix, in the same strict
695
+ // order as a set/1 manifest: ascending by artifact digest, no duplicates.
696
+ // So one root stands for exactly one member list, and a reader holding the
697
+ // list can rebuild the tree; a reader holding one member needs its path.
698
+
699
+ export const SET2_PLACEMENT_ID = "set/2" as const;
700
+
701
+ /** The proof.metadata key under which a member's own evidence (row, index, count, path) may ride, UNSIGNED, beside the root document. */
702
+ export const SET_MEMBER_METADATA_KEY = `${FUSE_PROFILE}/member` as const;
703
+
704
+ /** The most members one set/2 tree lists. A limit stated plainly, not a design constant: the tree and the paths are fine far beyond it. */
705
+ export const MAX_SET2_MEMBERS = 1_000_000;
706
+
707
+ /** The root document as JSON: the type of the value under proof.metadata[SET_METADATA_KEY] for a set/2 proof. */
708
+ export interface SetRoot {
709
+ count: number;
710
+ placement: typeof SET2_PLACEMENT_ID;
711
+ root: { algorithm: "sha256"; digest: string };
712
+ slotCommitment: { algorithm: "sha256"; digest: string };
713
+ type: typeof FUSE_PROFILE;
714
+ }
715
+
716
+ /** One member's evidence as JSON: its row, its leaf index, the tree size, and the sibling path from the leaf up. */
717
+ export interface SetMemberProof {
718
+ count: number;
719
+ index: number;
720
+ member: SetManifest["members"][number];
721
+ path: string[];
722
+ placement: typeof SET2_PLACEMENT_ID;
723
+ type: typeof FUSE_PROFILE;
724
+ }
725
+
726
+ /** The canonical bytes of one row, exactly as a set/1 manifest lists it; the leaf a set/2 tree hashes. */
727
+ export function canonicalSetRow(m: SetMember): Uint8Array {
728
+ if (m.artifact.length !== 32) throw new TypeError("member artifact digest must be 32 bytes");
729
+ if (m.origin.length !== 32) throw new TypeError("member origin digest must be 32 bytes");
730
+ if (!PLACEMENT_ID_PATTERN.test(m.placement) || m.placement === SET_PLACEMENT_ID || m.placement === SET2_PLACEMENT_ID) throw new TypeError(`member placement "${m.placement}" is not a member placement id`);
731
+ return canonicalize({
732
+ artifact: { algorithm: "sha256", digest: bytesToHex(m.artifact) },
733
+ origin: { algorithm: "sha256", digest: bytesToHex(m.origin) },
734
+ placement: m.placement,
735
+ } as unknown as BitGraphProof);
736
+ }
737
+
738
+ /** The leaf hash of one row: SHA-256(0x00, canonical row bytes). */
739
+ export function setLeaf(m: SetMember): Uint8Array {
740
+ return merkleLeafHash(canonicalSetRow(m));
741
+ }
742
+
743
+ /**
744
+ * Order members as a set/2 tree lists them: strictly ascending by artifact
745
+ * digest, no duplicate artifact. Throws on a duplicate, an empty list, or a
746
+ * malformed row, like buildSetManifest.
747
+ */
748
+ export function sortSetMembers(members: readonly SetMember[]): SetMember[] {
749
+ if (members.length === 0) throw new TypeError("a set lists at least one member");
750
+ const sorted = [...members].sort((a, b) => {
751
+ const x = bytesToHex(a.artifact);
752
+ const y = bytesToHex(b.artifact);
753
+ return x < y ? -1 : x > y ? 1 : 0;
754
+ });
755
+ for (let i = 0; i < sorted.length; i++) {
756
+ canonicalSetRow(sorted[i]!);
757
+ if (i > 0 && bytesEqual(sorted[i]!.artifact, sorted[i - 1]!.artifact)) throw new TypeError(`duplicate member artifact digest ${bytesToHex(sorted[i]!.artifact)}`);
758
+ }
759
+ return sorted;
760
+ }
761
+
762
+ /** The tree over a member list, in tree order (sortSetMembers): the sorted rows, their leaf hashes, the root, and every member's path on demand. */
763
+ export function buildSetTree(members: readonly SetMember[]): { sorted: SetMember[]; leaves: Uint8Array[]; root: Uint8Array; tree: MerkleTree } {
764
+ const sorted = sortSetMembers(members);
765
+ const leaves = sorted.map(setLeaf);
766
+ const tree = new MerkleTree(leaves);
767
+ return { sorted, leaves, root: tree.root, tree };
768
+ }
769
+
770
+ /** The inclusion path of the member at `index` in tree order. */
771
+ export function setMemberPath(tree: MerkleTree, index: number): Uint8Array[] {
772
+ return tree.path(index);
773
+ }
774
+
775
+ /** Build the canonical set/2 root document bytes: the committed artifact. */
776
+ export function buildSetRoot(commitment: Uint8Array, count: number, root: Uint8Array): Uint8Array {
777
+ if (commitment.length !== 32) throw new TypeError("commitment must be 32 bytes");
778
+ if (root.length !== 32) throw new TypeError("root must be 32 bytes");
779
+ if (!Number.isInteger(count) || count < 1 || count > MAX_SET2_MEMBERS) throw new TypeError(`count must be an integer from 1 to ${MAX_SET2_MEMBERS}`);
780
+ const doc: SetRoot = {
781
+ count,
782
+ placement: SET2_PLACEMENT_ID,
783
+ root: { algorithm: "sha256", digest: bytesToHex(root) },
784
+ slotCommitment: { algorithm: "sha256", digest: bytesToHex(commitment) },
785
+ type: FUSE_PROFILE,
786
+ };
787
+ return canonicalize(doc as unknown as BitGraphProof);
788
+ }
789
+
790
+ /** Strict parse of set/2 root document bytes: exactly the five keys, the literals, 32-byte digests, a count in range, byte-equal to its own rebuild. */
791
+ export function parseSetRoot(bytes: Uint8Array): { commitment: Uint8Array; count: number; root: Uint8Array } | null {
792
+ let text: string;
793
+ try {
794
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
795
+ } catch {
796
+ return null;
797
+ }
798
+ let parsed: unknown;
799
+ try {
800
+ parsed = JSON.parse(text);
801
+ } catch {
802
+ return null;
803
+ }
804
+ if (!isPlainObject(parsed)) return null;
805
+ if (Object.keys(parsed).sort().join(",") !== "count,placement,root,slotCommitment,type") return null;
806
+ if (parsed["type"] !== FUSE_PROFILE || parsed["placement"] !== SET2_PLACEMENT_ID) return null;
807
+ const count = parsed["count"];
808
+ if (typeof count !== "number" || !Number.isInteger(count) || count < 1 || count > MAX_SET2_MEMBERS) return null;
809
+ const commitment = readDigestField(parsed["slotCommitment"]);
810
+ const root = readDigestField(parsed["root"]);
811
+ if (commitment === null || root === null) return null;
812
+ let rebuilt: Uint8Array;
813
+ try {
814
+ rebuilt = buildSetRoot(commitment, count, root);
815
+ } catch {
816
+ return null;
817
+ }
818
+ if (!bytesEqual(rebuilt, bytes)) return null;
819
+ return { commitment, count, root };
820
+ }
821
+
822
+ /** Build a member's evidence object (JSON shape) from tree order. */
823
+ export function buildSetMemberProof(member: SetMember, index: number, count: number, path: readonly Uint8Array[]): SetMemberProof {
824
+ if (!Number.isInteger(index) || !Number.isInteger(count) || count < 1 || index < 0 || index >= count) throw new RangeError("member index out of range");
825
+ canonicalSetRow(member);
826
+ return {
827
+ count,
828
+ index,
829
+ member: {
830
+ artifact: { algorithm: "sha256", digest: bytesToHex(member.artifact) },
831
+ origin: { algorithm: "sha256", digest: bytesToHex(member.origin) },
832
+ placement: member.placement,
833
+ },
834
+ path: path.map((p) => {
835
+ if (p.length !== 32) throw new TypeError("a path node is 32 bytes");
836
+ return bytesToHex(p);
837
+ }),
838
+ placement: SET2_PLACEMENT_ID,
839
+ type: FUSE_PROFILE,
840
+ };
841
+ }
842
+
843
+ /** Strict read of a member's evidence from a parsed JSON value: shape, literals, 32-byte hex digests and path nodes, index within count. Null on any deviation. UNBOUND: nothing here touches a root. */
844
+ export function parseSetMemberProof(value: unknown): { member: SetMember; index: number; count: number; path: Uint8Array[] } | null {
845
+ if (!isPlainObject(value)) return null;
846
+ if (Object.keys(value).sort().join(",") !== "count,index,member,path,placement,type") return null;
847
+ if (value["type"] !== FUSE_PROFILE || value["placement"] !== SET2_PLACEMENT_ID) return null;
848
+ const count = value["count"];
849
+ const index = value["index"];
850
+ if (typeof count !== "number" || !Number.isInteger(count) || count < 1 || count > MAX_SET2_MEMBERS) return null;
851
+ if (typeof index !== "number" || !Number.isInteger(index) || index < 0 || index >= count) return null;
852
+ const row = value["member"];
853
+ if (!isPlainObject(row) || Object.keys(row).sort().join(",") !== "artifact,origin,placement") return null;
854
+ const artifact = readDigestField(row["artifact"]);
855
+ const origin = readDigestField(row["origin"]);
856
+ const placement = row["placement"];
857
+ if (artifact === null || origin === null || typeof placement !== "string") return null;
858
+ if (!PLACEMENT_ID_PATTERN.test(placement) || placement === SET_PLACEMENT_ID || placement === SET2_PLACEMENT_ID) return null;
859
+ const list = value["path"];
860
+ if (!Array.isArray(list)) return null;
861
+ const path: Uint8Array[] = [];
862
+ for (const node of list as unknown[]) {
863
+ if (typeof node !== "string" || !/^[0-9a-f]{64}$/.test(node)) return null;
864
+ const bytes = hexToBytes(node);
865
+ if (bytes === null) return null;
866
+ path.push(bytes);
867
+ }
868
+ return { member: { artifact, origin, placement }, index, count, path };
869
+ }
870
+
871
+ /** Recompute a root from a member's leaf and path; null when the path does not fit. */
872
+ export function setRootFromMember(member: SetMember, index: number, count: number, path: readonly Uint8Array[]): Uint8Array | null {
873
+ return merkleRootFromPath(setLeaf(member), index, count, path);
874
+ }
875
+
876
+ const set2: Placement = {
877
+ id: SET2_PLACEMENT_ID,
878
+ form: "C",
879
+ byteExact: false,
880
+ build() {
881
+ throw new TypeError("set/2 is built with buildSetRoot(commitment, count, root)");
882
+ },
883
+ locate(fused) {
884
+ const doc = parseSetRoot(fused);
885
+ return doc === null ? null : { commitment: doc.commitment };
886
+ },
887
+ };
888
+
678
889
  /**
679
- * Resolve a placement by id. set/1 resolves here but is NOT in PLACEMENTS:
890
+ * Resolve a placement by id. set/1 and set/2 resolve here but are NOT in PLACEMENTS:
680
891
  * the undeclared scan is for bytes whose placement was not declared, whereas
681
892
  * a set manifest is identified by hashing to the signed artifact digest and
682
893
  * by its signed title, so the scan order of every existing fixture is
683
894
  * literally unchanged.
684
895
  */
685
896
  export function getPlacement(id: string): Placement | undefined {
686
- return [...PLACEMENTS, set1].find((p) => p.id === id);
897
+ return [...PLACEMENTS, set1, set2].find((p) => p.id === id);
687
898
  }
688
899
 
689
900
  // ---------------------------------------------------------------------------
@@ -698,7 +909,7 @@ export function getPlacement(id: string): Placement | undefined {
698
909
  */
699
910
  export function fuseAttribution(placement: PlacementId, originDigest?: Uint8Array): Attribution {
700
911
  if (originDigest !== undefined && originDigest.length !== 32) throw new TypeError("originDigest must be 32 bytes");
701
- if (placement === SET_PLACEMENT_ID && originDigest !== undefined) throw new TypeError("set/1 has no single origin; a set marker carries no origin digest");
912
+ if ((placement === SET_PLACEMENT_ID || placement === SET2_PLACEMENT_ID) && originDigest !== undefined) throw new TypeError(`${placement} has no single origin; a set marker carries no origin digest`);
702
913
  return {
703
914
  name: FUSE_ATTRIBUTION_NAME,
704
915
  title: placement,
package/src/index.ts CHANGED
@@ -69,3 +69,6 @@ export { verifyFuse, assembledAfterCommit } from "./fuse-verify.js";
69
69
  export type { FuseCategory, FuseSpan, FuseVerifyResult, FuseVerifyOptions } from "./fuse-verify.js";
70
70
  export { verifyFuseMember } from "./fuse-member.js";
71
71
  export type { FuseMemberCategory, FuseSetEvidence, FuseMemberOptions, FuseMemberResult } from "./fuse-member.js";
72
+ export { SET2_PLACEMENT_ID, SET_MEMBER_METADATA_KEY, MAX_SET2_MEMBERS, canonicalSetRow, setLeaf, sortSetMembers, buildSetTree, setMemberPath, buildSetRoot, parseSetRoot, buildSetMemberProof, parseSetMemberProof, setRootFromMember } from "./fuse.js";
73
+ export type { SetRoot, SetMemberProof } from "./fuse.js";
74
+ export { merkleLeafHash, merkleNodeHash, merkleRoot, merklePath, merkleRootFromPath, MerkleTree } from "./fuse-merkle.js";