@mikeargento/bitgraph 1.10.0 → 1.11.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/export.d.ts +88 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +298 -0
- package/dist/export.js.map +1 -0
- package/dist/fuse.d.ts +136 -2
- package/dist/fuse.d.ts.map +1 -1
- package/dist/fuse.js +349 -2
- package/dist/fuse.js.map +1 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/recovery-write.d.ts +58 -0
- package/dist/recovery-write.d.ts.map +1 -0
- package/dist/recovery-write.js +265 -0
- package/dist/recovery-write.js.map +1 -0
- package/dist/recovery.d.ts +490 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +1124 -0
- package/dist/recovery.js.map +1 -0
- package/package.json +3 -3
- package/src/export.ts +365 -0
- package/src/fuse.ts +470 -3
- package/src/index.ts +28 -3
- package/src/recovery-write.ts +334 -0
- package/src/recovery.ts +1261 -0
package/src/fuse.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
// Copyright (c) Argento Computing Inc. All rights reserved. See LICENSE.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
+
* fuseTree(members, options): the producer of every NEW BitGraph since
|
|
5
|
+
* 2026-10-03 (tree/1). One position, one Merkle tree of 1 to N files; a
|
|
6
|
+
* single file is a tree of one. Each file is a 65-byte leaf (placement code,
|
|
7
|
+
* committed digest, origin digest); the committed artifact is the 84-byte
|
|
8
|
+
* root document; the signed attribution is bitgraph-fuse/2 with title
|
|
9
|
+
* "tree/1" and the spec's hash as its message. tree/1 needs the floor the
|
|
10
|
+
* allocation returns (enclave v9 and later): without it nothing is made.
|
|
11
|
+
* fuse() and fuseSet() below are superseded by it and kept so code that
|
|
12
|
+
* imports them keeps working; what they made stays readable.
|
|
13
|
+
*
|
|
4
14
|
* fuse(builder, options): the producer interface of the bitgraph-fuse/1
|
|
5
15
|
* profile (working name; outwardly this is simply BitGraph).
|
|
6
16
|
*
|
|
@@ -63,8 +73,21 @@ import {
|
|
|
63
73
|
verifyFuse,
|
|
64
74
|
verifyFuseMember,
|
|
65
75
|
base64ToBytes,
|
|
76
|
+
buildTree,
|
|
77
|
+
buildTreeMemberEvidence,
|
|
78
|
+
buildTreeRootDocument,
|
|
79
|
+
currentTreeSpecHash,
|
|
80
|
+
leafCodeOf,
|
|
81
|
+
LEAF_AS_IS,
|
|
82
|
+
MAX_TREE_LEAVES,
|
|
83
|
+
readTreeMetadata,
|
|
84
|
+
TREE_METADATA_KEY,
|
|
85
|
+
treeAttribution,
|
|
86
|
+
treeRootFromMember,
|
|
87
|
+
verifyTreeMember,
|
|
88
|
+
MAX_CONTAINER_ENTRY_BYTES,
|
|
66
89
|
} from "@mikeargento/bitgraph-verify";
|
|
67
|
-
import type { BitGraphProof, FuseFrame, FuseMemberResult, FuseVerifyResult, Located, Placement, PlacementId, SetManifest, SetMember, SetMemberProof, SetRoot, SlotAllocation } from "@mikeargento/bitgraph-verify";
|
|
90
|
+
import type { BitGraphProof, FuseFrame, FuseMemberResult, FuseVerifyResult, Located, MerkleTree, Placement, PlacementId, SetManifest, SetMember, SetMemberProof, SetRoot, SlotAllocation, TreeLeaf, TreeMemberEvidence, TreeVerifyResult } from "@mikeargento/bitgraph-verify";
|
|
68
91
|
|
|
69
92
|
/**
|
|
70
93
|
* SHA-256 over bytes: the platform's native hasher when one is present
|
|
@@ -172,7 +195,9 @@ export type FuseErrorCode =
|
|
|
172
195
|
| "network"
|
|
173
196
|
| "slot-mismatch"
|
|
174
197
|
| "verification-failed"
|
|
175
|
-
| "transport"
|
|
198
|
+
| "transport"
|
|
199
|
+
/** tree/1 only: the allocation returned no floor anchor (a boundary before enclave v9), so no tree/1 commitment can be made. */
|
|
200
|
+
| "floor-missing";
|
|
176
201
|
|
|
177
202
|
export class FuseError extends Error {
|
|
178
203
|
readonly code: FuseErrorCode;
|
|
@@ -368,7 +393,12 @@ function requireCommitment(placement: Placement, fused: Uint8Array, commitment:
|
|
|
368
393
|
const located = placement.locate(fused);
|
|
369
394
|
if (located === null || bytesToHex(located.commitment) !== bytesToHex(commitment)) {
|
|
370
395
|
const label = member !== null ? `member ${member}: ` : "";
|
|
371
|
-
|
|
396
|
+
// A container is located only when its archive, the original inside it
|
|
397
|
+
// (hashed against the origin it declares) and the commitment all hold.
|
|
398
|
+
const what = placement.form === "B"
|
|
399
|
+
? `are not a valid ${placement.id} carrying this position's commitment: the archive, the original inside it or the commitment does not hold`
|
|
400
|
+
: `do not carry the ${placement.id} commitment`;
|
|
401
|
+
throw new FuseError("commitment-missing", `${label}the fused bytes ${what}; nothing was committed and the slot will expire`, null, member);
|
|
372
402
|
}
|
|
373
403
|
return located;
|
|
374
404
|
}
|
|
@@ -420,6 +450,9 @@ async function commitUnderSlot(t: BoundTransport, body: Record<string, unknown>,
|
|
|
420
450
|
* Allocate, fuse, hash, fill. Returns the Frame with the unchanged proof, or
|
|
421
451
|
* throws a FuseError; it never returns an ordinary recording in place of a
|
|
422
452
|
* fused one.
|
|
453
|
+
*
|
|
454
|
+
* Superseded by fuseTree (tree/1, 2026-10-03): a new BitGraph of one file is
|
|
455
|
+
* a tree of one. Kept so code that imports it keeps working.
|
|
423
456
|
*/
|
|
424
457
|
export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<FuseResult> {
|
|
425
458
|
const placement = getPlacement(options.placement);
|
|
@@ -681,6 +714,9 @@ export interface FuseSetResult {
|
|
|
681
714
|
* allocates a second slot. Members may be given as bytes, as a loader read
|
|
682
715
|
* one at a time after the slot is held, or as a digest the caller finishes
|
|
683
716
|
* itself; one set may mix them.
|
|
717
|
+
*
|
|
718
|
+
* Superseded by fuseTree (tree/1, 2026-10-03): N files are one tree under one
|
|
719
|
+
* position. Kept so code that imports it keeps working.
|
|
684
720
|
*/
|
|
685
721
|
export async function fuseSet(members: readonly FuseSetMember[], options: FuseSetOptions = {}): Promise<FuseSetResult> {
|
|
686
722
|
// 0. validate, before any request. A refusal here burns nothing.
|
|
@@ -954,6 +990,437 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
|
|
|
954
990
|
};
|
|
955
991
|
}
|
|
956
992
|
|
|
993
|
+
// ---------------------------------------------------------------------------
|
|
994
|
+
// tree/1: every BitGraph is a Merkle tree under one position (2026-10-03)
|
|
995
|
+
// ---------------------------------------------------------------------------
|
|
996
|
+
|
|
997
|
+
/** How a file goes into a tree: as is (0x00), or placed into committed bytes that carry the commitment. */
|
|
998
|
+
export type TreeMemberPlacement = "as-is" | SetMemberPlacement;
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* The placement a file takes in a tree, from its size and its first bytes
|
|
1002
|
+
* (every magic number placementForBytes reads sits in the first 16): trailer/1
|
|
1003
|
+
* for the formats that ignore trailing bytes, container/2 for everything else,
|
|
1004
|
+
* except that a file too large for a ustar entry (MAX_CONTAINER_ENTRY_BYTES,
|
|
1005
|
+
* 8 GiB - 1, SPEC 7.3) takes trailer/1 whatever its bytes: the committed
|
|
1006
|
+
* bytes are virtual, so a trailer costs the file nothing. Never as is: that
|
|
1007
|
+
* is the user's choice, and no producer decides it by size (SPEC 7.5, 8.1).
|
|
1008
|
+
*/
|
|
1009
|
+
export function treePlacementFor(size: number, head: Uint8Array): "trailer/1" | "container/2" {
|
|
1010
|
+
return size > MAX_CONTAINER_ENTRY_BYTES ? "trailer/1" : placementForBytes(head);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** A tree member given as bytes: hashed, placed (unless as is), checked and hashed again by the core. */
|
|
1014
|
+
export interface FuseTreeBytesMember {
|
|
1015
|
+
original: Uint8Array;
|
|
1016
|
+
/** Default: treePlacementFor(original.length, original). As is is asked for by name, never by size. */
|
|
1017
|
+
placement?: TreeMemberPlacement;
|
|
1018
|
+
/** Unsigned and informational: the owner's export lists it beside the leaf. */
|
|
1019
|
+
name?: string;
|
|
1020
|
+
/** Placed members only. Default: the placement's own build. The locate and origin guards run regardless. */
|
|
1021
|
+
builder?: FuseBuilder;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/** A placed member read only when it is its turn, after the slot is held, and checked against its named digest. */
|
|
1025
|
+
export interface FuseTreeLoadedMember {
|
|
1026
|
+
load: () => Promise<Uint8Array> | Uint8Array;
|
|
1027
|
+
originDigest: Uint8Array;
|
|
1028
|
+
placement: SetMemberPlacement;
|
|
1029
|
+
name?: string;
|
|
1030
|
+
builder?: FuseBuilder;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* A placed member whose committed digest the caller finishes itself for the
|
|
1035
|
+
* held commitment (a scanner's saved hash state finished with the placement's
|
|
1036
|
+
* suffix). The core never sees its bytes.
|
|
1037
|
+
*/
|
|
1038
|
+
export interface FuseTreeHashedMember {
|
|
1039
|
+
originDigest: Uint8Array;
|
|
1040
|
+
placement: SetMemberPlacement;
|
|
1041
|
+
fusedDigest: (input: FusedDigestInput) => Promise<Uint8Array> | Uint8Array;
|
|
1042
|
+
name?: string;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/** A file that goes in as is, given by its digest alone: nothing is read, placed or loaded. */
|
|
1046
|
+
export interface FuseTreeAsIsMember {
|
|
1047
|
+
originDigest: Uint8Array;
|
|
1048
|
+
placement: "as-is";
|
|
1049
|
+
name?: string;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
export type FuseTreeMember = FuseTreeBytesMember | FuseTreeLoadedMember | FuseTreeHashedMember | FuseTreeAsIsMember;
|
|
1053
|
+
|
|
1054
|
+
/** Progress, in fuseSet's phases: hash (before any request), fuse (each leaf, after the slot is held), tree, commit, verify (only with verifyMembers). */
|
|
1055
|
+
export type FuseTreeProgress = FuseSetProgress;
|
|
1056
|
+
|
|
1057
|
+
export interface FuseTreeOptions {
|
|
1058
|
+
/** Return each member's committed bytes (for as is, the original itself) when they passed through the core. Default false: they are virtual, rebuilt from the original and the proof. */
|
|
1059
|
+
keepCommitted?: boolean;
|
|
1060
|
+
/**
|
|
1061
|
+
* Run verifyTreeMember over every member's committed bytes after the
|
|
1062
|
+
* commit and return each verdict. Default false: every member's leaf is
|
|
1063
|
+
* bound to the returned proof by its path to the verified root, which
|
|
1064
|
+
* reads no bytes. A hashed member, or an as-is member given by its digest,
|
|
1065
|
+
* has no bytes here and is refused before any request.
|
|
1066
|
+
*/
|
|
1067
|
+
verifyMembers?: boolean;
|
|
1068
|
+
/** Called as the tree advances. A throw inside it is ignored: a progress hook never changes the outcome. */
|
|
1069
|
+
onProgress?: (progress: FuseTreeProgress) => void;
|
|
1070
|
+
/** Actor-bound commits: an agency envelope passed through untouched. */
|
|
1071
|
+
agency?: unknown;
|
|
1072
|
+
transport?: FuseTransport;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
export interface FuseTreeMemberResult {
|
|
1076
|
+
/** The caller's index into members. */
|
|
1077
|
+
index: number;
|
|
1078
|
+
/** The member's leaf in the sorted tree: its evidence's index. */
|
|
1079
|
+
leafIndex: number;
|
|
1080
|
+
placement: TreeMemberPlacement;
|
|
1081
|
+
/** The leaf's placement code: 0x00 as is, 0x01 trailer/1, 0x02 container/1, 0x03 container/2. */
|
|
1082
|
+
code: number;
|
|
1083
|
+
originDigestB64: string;
|
|
1084
|
+
/** SHA-256 of the committed bytes: the leaf's artifact. For as is, the file's own digest. */
|
|
1085
|
+
artifactDigestB64: string;
|
|
1086
|
+
name: string | null;
|
|
1087
|
+
/** Present only with keepCommitted, for a member whose bytes passed through the core (never a hashed member, nor an as-is member given by its digest). */
|
|
1088
|
+
committedBytes?: Uint8Array;
|
|
1089
|
+
/** Present only with verifyMembers: the verifier's verdict on the committed bytes, TREE_MEMBER_DIRECT (TREE_MEMBER_AS_IS for as is) on success. */
|
|
1090
|
+
verification?: TreeVerifyResult;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
export interface FuseTreeResult {
|
|
1094
|
+
proof: BitGraphProof;
|
|
1095
|
+
/** The committed artifact: the 84-byte root document. Its SHA-256 is the signed digest; every export carries it. */
|
|
1096
|
+
rootDocument: Uint8Array;
|
|
1097
|
+
/** SHA-256 of rootDocument, standard base64; equals proof.artifact.digestB64. */
|
|
1098
|
+
artifactDigestB64: string;
|
|
1099
|
+
count: number;
|
|
1100
|
+
/** The tree's root, lowercase hex. */
|
|
1101
|
+
rootHex: string;
|
|
1102
|
+
/** commitment/2, which every placed member's committed bytes carry. committedBytesFor(member.code, original, commitment) rebuilds them. */
|
|
1103
|
+
commitment: Uint8Array;
|
|
1104
|
+
/** The floor block the commitment binds, as the proof signs it (commit.slotAnchor). */
|
|
1105
|
+
floor: AnchorMark;
|
|
1106
|
+
/** The spec hash the signed attribution pins, standard base64. */
|
|
1107
|
+
specHashB64: string;
|
|
1108
|
+
/** Every leaf, in tree order (strictly ascending artifact digest). */
|
|
1109
|
+
leaves: TreeLeaf[];
|
|
1110
|
+
/** The tree over those leaves: tree.path(k) is leaf k's path. */
|
|
1111
|
+
tree: MerkleTree;
|
|
1112
|
+
/** In the caller's order. */
|
|
1113
|
+
members: FuseTreeMemberResult[];
|
|
1114
|
+
/** A member's evidence (TreeMemberEvidence JSON) by the caller's index, built on demand so a large tree holds no path it is not asked for. */
|
|
1115
|
+
memberEvidence: (index: number) => TreeMemberEvidence;
|
|
1116
|
+
/** True when the commit response was lost and the proof was read back by the root document's digest. */
|
|
1117
|
+
recovered: boolean;
|
|
1118
|
+
/** True only when the proof's metadata carries this root document. Absent is normal for a boundary that drops metadata; exports carry the document either way. */
|
|
1119
|
+
rootDocumentEchoed: boolean;
|
|
1120
|
+
/** verifyTreeMember over the proof and the root document: TREE_ROOT_VALID on success. */
|
|
1121
|
+
verification: TreeVerifyResult;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function compareDigests(a: Uint8Array, b: Uint8Array): number {
|
|
1125
|
+
for (let i = 0; i < a.length && i < b.length; i++) if (a[i] !== b[i]) return a[i]! - b[i]!;
|
|
1126
|
+
return a.length - b.length;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/** The index of the leaf with this artifact digest in a sorted list, or -1. */
|
|
1130
|
+
function leafIndexOf(sorted: readonly TreeLeaf[], artifact: Uint8Array): number {
|
|
1131
|
+
let lo = 0;
|
|
1132
|
+
let hi = sorted.length - 1;
|
|
1133
|
+
while (lo <= hi) {
|
|
1134
|
+
const mid = (lo + hi) >>> 1;
|
|
1135
|
+
const c = compareDigests(sorted[mid]!.artifact, artifact);
|
|
1136
|
+
if (c === 0) return mid;
|
|
1137
|
+
if (c < 0) lo = mid + 1;
|
|
1138
|
+
else hi = mid - 1;
|
|
1139
|
+
}
|
|
1140
|
+
return -1;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Make ONE tree/1 BitGraph of 1 to N files: allocate one slot, take its floor,
|
|
1145
|
+
* make every member's leaf under commitment/2, build the tree and its root
|
|
1146
|
+
* document, commit the document's digest under the same slot with the spec
|
|
1147
|
+
* pinned in the signed attribution, and verify what comes back before
|
|
1148
|
+
* returning it. Throws a FuseError otherwise; it never commits a partial
|
|
1149
|
+
* tree, never allocates a second slot, and never makes a tree without the
|
|
1150
|
+
* floor. Members may be bytes, a loader read after the slot is held, a digest
|
|
1151
|
+
* the caller finishes from a saved hash state, or (for as is) a digest alone;
|
|
1152
|
+
* one tree may mix them.
|
|
1153
|
+
*/
|
|
1154
|
+
export async function fuseTree(members: readonly FuseTreeMember[], options: FuseTreeOptions = {}): Promise<FuseTreeResult> {
|
|
1155
|
+
// 0. validate, before any request. A refusal here burns nothing.
|
|
1156
|
+
if (!Array.isArray(members) || members.length === 0) throw new FuseError("bad-input", "a tree lists at least one member");
|
|
1157
|
+
if (members.length > MAX_TREE_LEAVES) throw new FuseError("bad-input", `a tree lists at most ${MAX_TREE_LEAVES} members (got ${members.length})`);
|
|
1158
|
+
const keep = options.keepCommitted === true;
|
|
1159
|
+
const verifyMembers = options.verifyMembers === true;
|
|
1160
|
+
let specHash: Uint8Array;
|
|
1161
|
+
try {
|
|
1162
|
+
specHash = currentTreeSpecHash();
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
throw new FuseError("bad-input", `no tree/1 spec hash to pin: ${err instanceof Error ? err.message : String(err)}`);
|
|
1165
|
+
}
|
|
1166
|
+
type Kind = "bytes" | "loaded" | "hashed" | "as-is";
|
|
1167
|
+
interface Checked {
|
|
1168
|
+
kind: Kind;
|
|
1169
|
+
id: TreeMemberPlacement;
|
|
1170
|
+
code: number;
|
|
1171
|
+
/** The registered placement; null for as is. */
|
|
1172
|
+
placement: Placement | null;
|
|
1173
|
+
originDigest: Uint8Array;
|
|
1174
|
+
name: string | null;
|
|
1175
|
+
original: Uint8Array | null;
|
|
1176
|
+
load: (() => Promise<Uint8Array> | Uint8Array) | null;
|
|
1177
|
+
builder: FuseBuilder | null;
|
|
1178
|
+
fusedDigest: ((input: FusedDigestInput) => Promise<Uint8Array> | Uint8Array) | null;
|
|
1179
|
+
}
|
|
1180
|
+
const checked: Checked[] = [];
|
|
1181
|
+
const seen = new Map<string, number>();
|
|
1182
|
+
const report = (phase: FuseTreeProgress["phase"], done: number, total: number) => {
|
|
1183
|
+
if (options.onProgress === undefined) return;
|
|
1184
|
+
try {
|
|
1185
|
+
options.onProgress({ phase, done, total });
|
|
1186
|
+
} catch {
|
|
1187
|
+
// a progress hook never changes the outcome
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
const bad = (i: number, message: string) => new FuseError("bad-input", `member ${i}: ${message}`, null, i);
|
|
1191
|
+
const shapes = 'original must be a Uint8Array, or load or fusedDigest a function, or placement "as-is" with an originDigest';
|
|
1192
|
+
interface Loose { original?: unknown; load?: unknown; fusedDigest?: unknown; placement?: unknown; originDigest?: unknown; name?: unknown; builder?: unknown }
|
|
1193
|
+
for (let i = 0; i < members.length; i++) {
|
|
1194
|
+
// A null, undefined or missing element is refused like any other member without bytes, a loader or a digest.
|
|
1195
|
+
const m = members[i] as Loose | null | undefined;
|
|
1196
|
+
if (m === null || m === undefined || typeof m !== "object") throw bad(i, shapes);
|
|
1197
|
+
const kind: Kind | null = m.original instanceof Uint8Array ? "bytes" : typeof m.load === "function" ? "loaded" : typeof m.fusedDigest === "function" ? "hashed" : m.placement === "as-is" ? "as-is" : null;
|
|
1198
|
+
if (kind === null) throw bad(i, shapes);
|
|
1199
|
+
if (kind !== "bytes" && m.placement === undefined) throw bad(i, `a ${kind} member names its placement`);
|
|
1200
|
+
if (m.placement !== undefined && typeof m.placement !== "string") throw bad(i, "placement must be a string");
|
|
1201
|
+
const original = kind === "bytes" ? (m.original as Uint8Array) : null;
|
|
1202
|
+
const id = m.placement !== undefined ? (m.placement as string) : treePlacementFor(original!.length, original!);
|
|
1203
|
+
const code = leafCodeOf(id);
|
|
1204
|
+
if (code === null) {
|
|
1205
|
+
if (getPlacement(id) === undefined) throw new FuseError("bad-placement", `member ${i}: placement "${id}" is not registered`, null, i);
|
|
1206
|
+
throw bad(i, `${id} is not a tree placement; a tree holds as-is, trailer/1, container/1 and container/2 members`);
|
|
1207
|
+
}
|
|
1208
|
+
if ((kind === "loaded" || kind === "hashed") && code === LEAF_AS_IS) throw bad(i, `an as-is member is given by its bytes or its originDigest alone; a ${kind} member is placed`);
|
|
1209
|
+
if (m.name !== undefined && typeof m.name !== "string") throw bad(i, "name must be a string");
|
|
1210
|
+
if (m.builder !== undefined && typeof m.builder !== "function") throw bad(i, "builder must be a function");
|
|
1211
|
+
if (code === LEAF_AS_IS && m.builder !== undefined) throw bad(i, "an as-is member takes no builder: nothing is placed in it");
|
|
1212
|
+
if (kind !== "bytes" && !(m.originDigest instanceof Uint8Array && m.originDigest.length === 32)) throw bad(i, `${kind === "as-is" ? "an as-is" : `a ${kind}`} member names its originDigest, 32 bytes`);
|
|
1213
|
+
if (verifyMembers && (kind === "hashed" || kind === "as-is")) throw bad(i, `${kind === "as-is" ? "an as-is member given by its digest" : "a hashed member"} cannot be verified in full; pass its bytes or drop verifyMembers`);
|
|
1214
|
+
const originDigest = original !== null ? await digest(original) : (m.originDigest as Uint8Array);
|
|
1215
|
+
// The same original under the same placement makes the same leaf, which a tree lists once.
|
|
1216
|
+
const key = `${code}:${bytesToHex(originDigest)}`;
|
|
1217
|
+
const j = seen.get(key);
|
|
1218
|
+
if (j !== undefined) {
|
|
1219
|
+
throw new FuseError("bad-input", `members ${j} and ${i} are the same original under the same placement (${id}) and would make the same leaf; a tree lists each leaf once`, null, i);
|
|
1220
|
+
}
|
|
1221
|
+
seen.set(key, i);
|
|
1222
|
+
checked.push({
|
|
1223
|
+
kind,
|
|
1224
|
+
id: id as TreeMemberPlacement,
|
|
1225
|
+
code,
|
|
1226
|
+
placement: code === LEAF_AS_IS ? null : getPlacement(id)!,
|
|
1227
|
+
originDigest,
|
|
1228
|
+
name: typeof m.name === "string" ? m.name : null,
|
|
1229
|
+
original,
|
|
1230
|
+
load: kind === "loaded" ? (m.load as Checked["load"]) : null,
|
|
1231
|
+
builder: (kind === "bytes" || kind === "loaded") && m.builder !== undefined ? (m.builder as FuseBuilder) : null,
|
|
1232
|
+
fusedDigest: kind === "hashed" ? (m.fusedDigest as Checked["fusedDigest"]) : null,
|
|
1233
|
+
});
|
|
1234
|
+
report("hash", i + 1, members.length);
|
|
1235
|
+
}
|
|
1236
|
+
const t: BoundTransport = { ...DEFAULTS, ...(options.transport ?? {}) };
|
|
1237
|
+
|
|
1238
|
+
// 1. nonce: one slot for the whole tree, and the floor it binds.
|
|
1239
|
+
const { slot, anchor } = await allocateSlot(t);
|
|
1240
|
+
if (anchor === null) {
|
|
1241
|
+
throw new FuseError("floor-missing", "the allocation returned no floor anchor, so no tree/1 commitment can be made (tree/1 binds the floor block: bitgraph-fuse/2, enclave v9 and later); nothing was committed and the position will expire");
|
|
1242
|
+
}
|
|
1243
|
+
const { commitment, version } = producerCommitment(slot, anchor);
|
|
1244
|
+
if (version !== 2) throw new FuseError("floor-missing", "the floor anchor could not be bound into the commitment; nothing was committed and the position will expire");
|
|
1245
|
+
const commitmentHex = bytesToHex(commitment);
|
|
1246
|
+
const expiring = "nothing was committed and the slot will expire";
|
|
1247
|
+
|
|
1248
|
+
// 2. leaves: every member's under the one commitment. Committed bytes are
|
|
1249
|
+
// virtual: each is built, checked, hashed and released in turn, held only
|
|
1250
|
+
// for a caller who keeps them or asks the full verifier to read them.
|
|
1251
|
+
const leaves: TreeLeaf[] = [];
|
|
1252
|
+
const held: (Uint8Array | null)[] = [];
|
|
1253
|
+
for (let i = 0; i < checked.length; i++) {
|
|
1254
|
+
const c = checked[i]!;
|
|
1255
|
+
let artifact: Uint8Array;
|
|
1256
|
+
let bytes: Uint8Array | null = null;
|
|
1257
|
+
if (c.code === LEAF_AS_IS) {
|
|
1258
|
+
// As is: the file is its own committed bytes and its digest its artifact.
|
|
1259
|
+
artifact = c.originDigest;
|
|
1260
|
+
if (c.original !== null && (keep || verifyMembers)) bytes = c.original;
|
|
1261
|
+
} else if (c.kind === "hashed") {
|
|
1262
|
+
let d: unknown;
|
|
1263
|
+
try {
|
|
1264
|
+
d = await c.fusedDigest!({ commitment, commitmentHex, fuseVersion: version, floor: anchor, slot });
|
|
1265
|
+
} catch (err) {
|
|
1266
|
+
throw new FuseError("builder-failed", `member ${i}: fusedDigest threw: ${err instanceof Error ? err.message : String(err)}; ${expiring}`, null, i);
|
|
1267
|
+
}
|
|
1268
|
+
if (!(d instanceof Uint8Array) || d.length !== 32) throw new FuseError("builder-failed", `member ${i}: fusedDigest must return a 32-byte digest; ${expiring}`, null, i);
|
|
1269
|
+
artifact = d;
|
|
1270
|
+
} else {
|
|
1271
|
+
let original: Uint8Array;
|
|
1272
|
+
if (c.kind === "loaded") {
|
|
1273
|
+
let loaded: unknown;
|
|
1274
|
+
try {
|
|
1275
|
+
loaded = await c.load!();
|
|
1276
|
+
} catch (err) {
|
|
1277
|
+
throw new FuseError("load-failed", `member ${i}: load threw: ${err instanceof Error ? err.message : String(err)}; ${expiring}`, null, i);
|
|
1278
|
+
}
|
|
1279
|
+
if (!(loaded instanceof Uint8Array)) throw new FuseError("load-failed", `member ${i}: load must return a Uint8Array; ${expiring}`, null, i);
|
|
1280
|
+
original = loaded;
|
|
1281
|
+
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);
|
|
1282
|
+
} else {
|
|
1283
|
+
original = c.original!;
|
|
1284
|
+
}
|
|
1285
|
+
const builder = c.builder ?? builderFor(c.id as PlacementId, original);
|
|
1286
|
+
let committed: Uint8Array;
|
|
1287
|
+
try {
|
|
1288
|
+
committed = await builder({ commitment, commitmentHex, fuseVersion: version, floor: anchor, originDigest: c.originDigest, slot });
|
|
1289
|
+
} catch (err) {
|
|
1290
|
+
throw new FuseError("builder-failed", `member ${i}: the builder threw: ${err instanceof Error ? err.message : String(err)}; ${expiring}`, null, i);
|
|
1291
|
+
}
|
|
1292
|
+
if (!(committed instanceof Uint8Array)) throw new FuseError("builder-failed", `member ${i}: the builder must return a Uint8Array; ${expiring}`, null, i);
|
|
1293
|
+
const located = requireCommitment(c.placement!, committed, commitment, i);
|
|
1294
|
+
// The leaf's origin must be the origin the bytes embed (declared, and
|
|
1295
|
+
// carried byte for byte), else the member would verify INVALID_ORIGIN
|
|
1296
|
+
// after the slot is spent; the same two checks fuseSet runs.
|
|
1297
|
+
const declared = located.originDigest;
|
|
1298
|
+
const carried = located.originalBytes;
|
|
1299
|
+
if ((declared !== undefined && !bytesEqual(declared, c.originDigest)) || (carried !== undefined && !bytesEqual(carried, original))) {
|
|
1300
|
+
throw new FuseError("builder-failed", `member ${i}: the committed bytes embed an origin that is not the member's original; ${expiring}`, null, i);
|
|
1301
|
+
}
|
|
1302
|
+
artifact = await digest(committed);
|
|
1303
|
+
if (keep || verifyMembers) bytes = committed;
|
|
1304
|
+
}
|
|
1305
|
+
leaves.push({ placement: c.code, artifact, origin: c.originDigest });
|
|
1306
|
+
held.push(bytes);
|
|
1307
|
+
report("fuse", i + 1, checked.length);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// 3. hash: the tree, and the root document that is the committed artifact.
|
|
1311
|
+
report("tree", 0, 1);
|
|
1312
|
+
let built: ReturnType<typeof buildTree>;
|
|
1313
|
+
try {
|
|
1314
|
+
built = buildTree(leaves);
|
|
1315
|
+
} catch (err) {
|
|
1316
|
+
throw new FuseError("bad-input", `the tree could not be built: ${err instanceof Error ? err.message : String(err)}; ${expiring}`);
|
|
1317
|
+
}
|
|
1318
|
+
const count = built.sorted.length;
|
|
1319
|
+
const rootDocument = buildTreeRootDocument(commitment, count, built.root);
|
|
1320
|
+
report("tree", 1, 1);
|
|
1321
|
+
const artifactDigestB64 = bytesToBase64(await digest(rootDocument));
|
|
1322
|
+
const specHashB64 = bytesToBase64(specHash);
|
|
1323
|
+
|
|
1324
|
+
// 4. fill: one commit; the root document rides as unsigned metadata, bound by its hash.
|
|
1325
|
+
const body: Record<string, unknown> = {
|
|
1326
|
+
digests: [{ digestB64: artifactDigestB64, hashAlg: "sha256" }],
|
|
1327
|
+
slotId: slot.nonceB64,
|
|
1328
|
+
slot,
|
|
1329
|
+
chainId: "bitgraph:main",
|
|
1330
|
+
attribution: treeAttribution(specHash),
|
|
1331
|
+
metadata: { [TREE_METADATA_KEY]: bytesToHex(rootDocument) },
|
|
1332
|
+
// fuse/2: the boundary checks the bound floor against its ledger before spending the slot.
|
|
1333
|
+
anchor,
|
|
1334
|
+
};
|
|
1335
|
+
if (options.agency !== undefined) body.agency = options.agency;
|
|
1336
|
+
report("commit", 0, 1);
|
|
1337
|
+
const { proof, recovered } = await commitUnderSlot(t, body, artifactDigestB64, slot);
|
|
1338
|
+
report("commit", 1, 1);
|
|
1339
|
+
|
|
1340
|
+
// A reader verifies the proof before it is called a tree: the signature,
|
|
1341
|
+
// fuse/2 and a known spec, the commitment recomputed from the proof's own
|
|
1342
|
+
// slot record and signed floor, and this root document under the signed
|
|
1343
|
+
// digest. The explicit document is used, so no verdict rests on the echo.
|
|
1344
|
+
const verification = await verifyTreeMember({ proof, rootDocument });
|
|
1345
|
+
if (verification.category !== "TREE_ROOT_VALID") {
|
|
1346
|
+
throw new FuseError("verification-failed", `the returned proof does not verify as this tree: ${verification.category} (${verification.reason})`);
|
|
1347
|
+
}
|
|
1348
|
+
if (proof.attribution?.message !== specHashB64) {
|
|
1349
|
+
throw new FuseError("verification-failed", "the returned proof pins a different spec than the one sent");
|
|
1350
|
+
}
|
|
1351
|
+
// The echo is unsigned and advisory: absent is normal, different is a rewrite.
|
|
1352
|
+
let rootDocumentEchoed = false;
|
|
1353
|
+
if (proof.metadata?.[TREE_METADATA_KEY] !== undefined) {
|
|
1354
|
+
const echoed = readTreeMetadata(proof);
|
|
1355
|
+
if (echoed === null || !bytesEqual(echoed, rootDocument)) {
|
|
1356
|
+
throw new FuseError("verification-failed", `the returned proof echoes a root document under metadata["${TREE_METADATA_KEY}"] that differs from the committed one`);
|
|
1357
|
+
}
|
|
1358
|
+
rootDocumentEchoed = true;
|
|
1359
|
+
}
|
|
1360
|
+
const floor = proof.commit.slotAnchor!;
|
|
1361
|
+
|
|
1362
|
+
// Every member is bound to the verified root by its own path (the
|
|
1363
|
+
// verifier's check, run here once per member); no member's bytes are read
|
|
1364
|
+
// again. With verifyMembers the full verifier reads the committed bytes too.
|
|
1365
|
+
const results: FuseTreeMemberResult[] = [];
|
|
1366
|
+
for (let i = 0; i < checked.length; i++) {
|
|
1367
|
+
const c = checked[i]!;
|
|
1368
|
+
const leaf = leaves[i]!;
|
|
1369
|
+
const k = leafIndexOf(built.sorted, leaf.artifact);
|
|
1370
|
+
const listed = k >= 0 ? built.sorted[k] : undefined;
|
|
1371
|
+
if (listed === undefined || listed.placement !== leaf.placement || !bytesEqual(listed.origin, leaf.origin)) {
|
|
1372
|
+
throw new FuseError("verification-failed", `member ${i}: the committed tree does not list this member's leaf`, null, i);
|
|
1373
|
+
}
|
|
1374
|
+
const path = built.tree.path(k);
|
|
1375
|
+
const reached = treeRootFromMember(listed, k, count, path);
|
|
1376
|
+
if (reached === null || !bytesEqual(reached, built.root)) throw new FuseError("verification-failed", `member ${i}: its path does not recompute the committed root`, null, i);
|
|
1377
|
+
let memberVerification: TreeVerifyResult | undefined;
|
|
1378
|
+
if (verifyMembers) {
|
|
1379
|
+
const want = c.code === LEAF_AS_IS ? "TREE_MEMBER_AS_IS" : "TREE_MEMBER_DIRECT";
|
|
1380
|
+
const v = await verifyTreeMember({ proof, rootDocument, member: buildTreeMemberEvidence(listed, k, count, path), bytes: held[i]!, proofAlreadyVerified: true });
|
|
1381
|
+
if (v.category !== want || v.member?.index !== k) {
|
|
1382
|
+
throw new FuseError("verification-failed", `member ${i}: the returned proof does not verify this member: ${v.category} (${v.reason})`, null, i);
|
|
1383
|
+
}
|
|
1384
|
+
memberVerification = v;
|
|
1385
|
+
report("verify", i + 1, checked.length);
|
|
1386
|
+
}
|
|
1387
|
+
const kept = held[i];
|
|
1388
|
+
results.push({
|
|
1389
|
+
index: i,
|
|
1390
|
+
leafIndex: k,
|
|
1391
|
+
placement: c.id,
|
|
1392
|
+
code: c.code,
|
|
1393
|
+
originDigestB64: bytesToBase64(c.originDigest),
|
|
1394
|
+
artifactDigestB64: bytesToBase64(leaf.artifact),
|
|
1395
|
+
name: c.name,
|
|
1396
|
+
...(keep && kept !== null && kept !== undefined ? { committedBytes: kept } : {}),
|
|
1397
|
+
...(memberVerification !== undefined ? { verification: memberVerification } : {}),
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
const memberEvidence = (index: number): TreeMemberEvidence => {
|
|
1401
|
+
const r = results[index];
|
|
1402
|
+
if (r === undefined) throw new RangeError(`no member ${index}`);
|
|
1403
|
+
return buildTreeMemberEvidence(built.sorted[r.leafIndex]!, r.leafIndex, count, built.tree.path(r.leafIndex));
|
|
1404
|
+
};
|
|
1405
|
+
return {
|
|
1406
|
+
proof,
|
|
1407
|
+
rootDocument,
|
|
1408
|
+
artifactDigestB64,
|
|
1409
|
+
count,
|
|
1410
|
+
rootHex: bytesToHex(built.root),
|
|
1411
|
+
commitment,
|
|
1412
|
+
floor: { counter: floor.counter, blockNumber: floor.blockNumber, blockHash: floor.blockHash },
|
|
1413
|
+
specHashB64,
|
|
1414
|
+
leaves: built.sorted,
|
|
1415
|
+
tree: built.tree,
|
|
1416
|
+
members: results,
|
|
1417
|
+
memberEvidence,
|
|
1418
|
+
recovered,
|
|
1419
|
+
rootDocumentEchoed,
|
|
1420
|
+
verification,
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
|
|
957
1424
|
/** Decode a standard-base64 digest, for callers holding one as text. */
|
|
958
1425
|
export function digestFromBase64(b64: string): Uint8Array {
|
|
959
1426
|
const d = base64ToBytes(b64);
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Copyright (c) Argento Computing Inc. All rights reserved. See LICENSE.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* bitgraph-core
|
|
4
|
+
* bitgraph-core: BitGraph
|
|
5
5
|
*
|
|
6
6
|
* Portable cryptographic proof at finalization.
|
|
7
7
|
* Hardware TEE enforcement via AWS Nitro Enclaves.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* compatibility. Verification of BitGraph proofs is permissionless.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
// Read side
|
|
14
|
+
// Read side: re-exported from the permissive verifier package
|
|
15
15
|
export type {
|
|
16
16
|
BitGraphProof,
|
|
17
17
|
BitGraphPolicy,
|
|
@@ -38,9 +38,20 @@ export type { HostCapabilities } from "./host.js";
|
|
|
38
38
|
// Constructor (write path)
|
|
39
39
|
export { Constructor } from "./constructor.js";
|
|
40
40
|
|
|
41
|
+
// tree/1 (2026-10-03): every new BitGraph is one Merkle tree of 1 to N files
|
|
42
|
+
// under one position; a single file is a tree of one. fuseTree makes it, and
|
|
43
|
+
// the export builders write bitgraph-export/1 for one member or the owner.
|
|
44
|
+
export { fuseTree, treePlacementFor } from "./fuse.js";
|
|
45
|
+
export type { FuseTreeMember, FuseTreeBytesMember, FuseTreeLoadedMember, FuseTreeHashedMember, FuseTreeAsIsMember, TreeMemberPlacement, FuseTreeOptions, FuseTreeProgress, FuseTreeMemberResult, FuseTreeResult, AnchorMark } from "./fuse.js";
|
|
46
|
+
export { buildMemberExport, buildOwnerExport, namesByLeaf, floorFromHeader, fetchFloorHeader, completeExport } from "./export.js";
|
|
47
|
+
export type { ExportFloor, TreeExportSource, ExportParts, ExportFetchOptions, CompleteExportOptions, CompletedExport } from "./export.js";
|
|
48
|
+
export { committedBytesFor, verifyTreeMember, verifyExport, parseExport, EXPORT_FORMAT } from "@mikeargento/bitgraph-verify";
|
|
49
|
+
export type { BitGraphExport, TreeLeaf, TreeMemberEvidence, TreeVerifyResult, ExportClaim, ExportVerifyResult } from "@mikeargento/bitgraph-verify";
|
|
50
|
+
|
|
41
51
|
// The producer profile over the primitive (working name Fuse): allocate a
|
|
42
52
|
// slot, write a commitment to it into the artifact, hash, commit under the
|
|
43
|
-
// same slot. The resulting proof is ordinary bitgraph/1.
|
|
53
|
+
// same slot. The resulting proof is ordinary bitgraph/1. fuse and fuseSet are
|
|
54
|
+
// superseded by fuseTree and kept so code that imports them keeps working.
|
|
44
55
|
export { fuse, fuseSet, MAX_SET_MEMBERS, trailerBytesFor, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
|
|
45
56
|
export { MAX_SET2_MEMBERS, SET2_PLACEMENT_ID, SET_MEMBER_METADATA_KEY } from "@mikeargento/bitgraph-verify";
|
|
46
57
|
export type { FuseBuilder, BuilderInput, FuseOptions, FuseResult, FuseTransport, FuseErrorCode } from "./fuse.js";
|
|
@@ -60,3 +71,17 @@ export type {
|
|
|
60
71
|
PolicyRules,
|
|
61
72
|
ActionValidationResult,
|
|
62
73
|
} from "./policy.js";
|
|
74
|
+
|
|
75
|
+
// Recovery from the file (2026-10-03): the sealed entries a tree's files are
|
|
76
|
+
// found again by, and the writer the CLI, SDK and MCP use after making a tree.
|
|
77
|
+
export { writeRecoveryEntries } from "./recovery-write.js";
|
|
78
|
+
export type { RecoveryWriteInput, RecoveryWriteOptions, RecoveryWriteResult, RecoveryWriteState } from "./recovery-write.js";
|
|
79
|
+
export {
|
|
80
|
+
recoveryAddress, recoveryEntryId, recoverySaltedEntryId, recoveryEntryIdOf, newRecoverySalt, recoveryKeyBytes, recoveryObjectKey,
|
|
81
|
+
sealRecoveryEnvelope, openRecoveryEnvelope, encodeRecoveryPlaintext, parseRecoveryPlaintext, recoveryTreeFrom, sealRecoveryMember, recoveryObjectKeyFor,
|
|
82
|
+
recoveryPlaintextFor, recoveryDigestOf, recoverySidesOf, existingEntryHoldsMember, findOwnRecoveryEntry, proofTrusted,
|
|
83
|
+
recoverFromDigest, recoverFromDigests, fetchRecoveredProof,
|
|
84
|
+
RECOVERY_SIDE_BITS, recoveryMemberStatus, recoverySideState, recoverySideResolved, recoverySaltKey, recoverySaltsFor,
|
|
85
|
+
RECOVERY_FORMAT, RECOVERY_PREFIX, SALT_BYTES, MAX_LOOKUP_ADDRESSES, MAX_RECOVERED_PER_DIGEST, LOOKUP_TIMEOUT_MS, OBJECT_KEY_LENGTH,
|
|
86
|
+
} from "./recovery.js";
|
|
87
|
+
export type { RecoveredEntry, RecoveryPlaintext, RecoveryLookupAnswer, RecoverySide, RecoveryMemberStatus, RecoverySideState, RecoveryTrust } from "./recovery.js";
|