@mikeargento/bitgraph 1.1.1 → 1.2.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-roundtrip.test.d.ts +2 -0
- package/dist/__tests__/fuse-roundtrip.test.d.ts.map +1 -0
- package/dist/__tests__/fuse-roundtrip.test.js +172 -0
- package/dist/__tests__/fuse-roundtrip.test.js.map +1 -0
- package/dist/__tests__/fuse-sdk.test.d.ts +2 -0
- package/dist/__tests__/fuse-sdk.test.d.ts.map +1 -0
- package/dist/__tests__/fuse-sdk.test.js +117 -0
- package/dist/__tests__/fuse-sdk.test.js.map +1 -0
- package/dist/__tests__/fuse-vectors.test.d.ts +2 -0
- package/dist/__tests__/fuse-vectors.test.d.ts.map +1 -0
- package/dist/__tests__/fuse-vectors.test.js +175 -0
- package/dist/__tests__/fuse-vectors.test.js.map +1 -0
- package/dist/__tests__/fuse-verify.test.d.ts +2 -0
- package/dist/__tests__/fuse-verify.test.d.ts.map +1 -0
- package/dist/__tests__/fuse-verify.test.js +250 -0
- package/dist/__tests__/fuse-verify.test.js.map +1 -0
- package/dist/fuse-cli.d.ts +3 -0
- package/dist/fuse-cli.d.ts.map +1 -0
- package/dist/fuse-cli.js +210 -0
- package/dist/fuse-cli.js.map +1 -0
- package/dist/fuse.d.ts +75 -0
- package/dist/fuse.d.ts.map +1 -0
- package/dist/fuse.js +273 -0
- package/dist/fuse.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +12 -7
- package/src/__tests__/fuse-roundtrip.test.ts +174 -0
- package/src/__tests__/fuse-sdk.test.ts +142 -0
- package/src/__tests__/fuse-vectors.test.ts +218 -0
- package/src/__tests__/fuse-verify.test.ts +278 -0
- package/src/fuse-cli.ts +187 -0
- package/src/fuse.ts +356 -0
- package/src/index.ts +6 -0
- package/dist/canonical.d.ts +0 -54
- package/dist/canonical.d.ts.map +0 -1
- package/dist/canonical.js +0 -119
- package/dist/canonical.js.map +0 -1
- package/dist/proof-hash.d.ts +0 -9
- package/dist/proof-hash.d.ts.map +0 -1
- package/dist/proof-hash.js +0 -75
- package/dist/proof-hash.js.map +0 -1
- package/dist/types.d.ts +0 -705
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -3
- package/dist/types.js.map +0 -1
- package/dist/verifier.d.ts +0 -27
- package/dist/verifier.d.ts.map +0 -1
- package/dist/verifier.js +0 -844
- package/dist/verifier.js.map +0 -1
package/src/fuse-cli.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright (c) Mike Argento. All rights reserved. See LICENSE.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* bitgraph-fuse: the internal harness (spec 9.2), as a command rather than a
|
|
6
|
+
* page. Exercises Forms A, B and C end to end through fuse(), writes the
|
|
7
|
+
* Frame (and the fused bytes when they must be kept), and prints the bounded
|
|
8
|
+
* copy of spec 9.3. `check` runs verifyFuse over a Frame (or bare proof)
|
|
9
|
+
* and a file, so both verification paths can be exercised from a shell.
|
|
10
|
+
*
|
|
11
|
+
* Not a product surface. The site has no /fuse page: a page would make the
|
|
12
|
+
* website build depend on packages that are not published, which is a deploy
|
|
13
|
+
* hazard; this command needs nothing but the repository.
|
|
14
|
+
*
|
|
15
|
+
* The raw nonce is never printed. After commit it is public inside the proof.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
19
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
20
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
21
|
+
import { parseFrame, verifyFuse, bytesToBase64 } from "@mikeargento/bitgraph-verify";
|
|
22
|
+
import type { BitGraphProof, PlacementId } from "@mikeargento/bitgraph-verify";
|
|
23
|
+
import { fuse, builderFor, FuseError } from "./fuse.js";
|
|
24
|
+
import type { FuseTransport } from "./fuse.js";
|
|
25
|
+
|
|
26
|
+
const USAGE = `bitgraph-fuse: BitGraph producer harness (profile bitgraph-fuse/1, working name)
|
|
27
|
+
|
|
28
|
+
bitgraph-fuse fuse <file> --placement trailer/1|container/1 [options]
|
|
29
|
+
Form A or B over an existing file. The file is never modified.
|
|
30
|
+
bitgraph-fuse produce [--origin <file>] [options]
|
|
31
|
+
Form C: a canonical payload naming an optional source.
|
|
32
|
+
bitgraph-fuse check <frame-or-proof.json> <file> [--max-positions N]
|
|
33
|
+
Verify a Frame (or bare proof) against the fused bytes or the original.
|
|
34
|
+
|
|
35
|
+
Options for fuse and produce:
|
|
36
|
+
--out <dir> where to write the Frame and fused bytes (default: .)
|
|
37
|
+
--keep also write the fused bytes for byte-exact placements
|
|
38
|
+
--base-url <url> commit surface (default https://bitgraph.ing)
|
|
39
|
+
--allocate-path <p> default /api/fuse/allocate (parent-direct: /allocate-slot)
|
|
40
|
+
--commit-path <p> default /api/fuse/commit (parent-direct: /commit)
|
|
41
|
+
--api-key <key> Authorization: Bearer <key>
|
|
42
|
+
|
|
43
|
+
Exit codes: 0 fused/verified, 1 refused or contradicted, 2 undetermined, 64 usage.
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
/** The bounded copy of spec 9.3, verbatim. */
|
|
47
|
+
const COPY_ORIGINAL = "Original recorded\nThese exact original bytes existed no later than the commit.";
|
|
48
|
+
const COPY_FUSED = "Fused artifact created\nThese bytes were assembled after their slot allocation and committed at this position.";
|
|
49
|
+
|
|
50
|
+
interface Args { command: string; positional: string[]; flags: Map<string, string | true> }
|
|
51
|
+
|
|
52
|
+
function parseArgs(argv: string[]): Args {
|
|
53
|
+
const [command = "", ...rest] = argv;
|
|
54
|
+
const positional: string[] = [];
|
|
55
|
+
const flags = new Map<string, string | true>();
|
|
56
|
+
for (let i = 0; i < rest.length; i++) {
|
|
57
|
+
const a = rest[i]!;
|
|
58
|
+
if (a.startsWith("--")) {
|
|
59
|
+
const key = a.slice(2);
|
|
60
|
+
const next = rest[i + 1];
|
|
61
|
+
if (next !== undefined && !next.startsWith("--") && key !== "keep") { flags.set(key, next); i++; } else flags.set(key, true);
|
|
62
|
+
} else positional.push(a);
|
|
63
|
+
}
|
|
64
|
+
return { command, positional, flags };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sanitize(name: string): string {
|
|
68
|
+
return name.replace(/[\x00-\x1f\x7f/]/g, " ").trim() || "artifact";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function transportFrom(flags: Map<string, string | true>): FuseTransport {
|
|
72
|
+
const t: FuseTransport = {};
|
|
73
|
+
const s = (k: string) => { const v = flags.get(k); return typeof v === "string" ? v : undefined; };
|
|
74
|
+
const baseUrl = s("base-url"); if (baseUrl) t.baseUrl = baseUrl;
|
|
75
|
+
const allocatePath = s("allocate-path"); if (allocatePath) t.allocatePath = allocatePath;
|
|
76
|
+
const commitPath = s("commit-path"); if (commitPath) t.commitPath = commitPath;
|
|
77
|
+
const apiKey = s("api-key"); if (apiKey) t.apiKey = apiKey;
|
|
78
|
+
return t;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function writeOutputs(outDir: string, label: string, frame: unknown, fusedName: string | null, fused: Uint8Array | undefined): Promise<string[]> {
|
|
82
|
+
await mkdir(outDir, { recursive: true });
|
|
83
|
+
const written: string[] = [];
|
|
84
|
+
const framePath = join(outDir, `${label}.bitgraph-fuse.json`);
|
|
85
|
+
await writeFile(framePath, JSON.stringify(frame, null, 2) + "\n");
|
|
86
|
+
written.push(framePath);
|
|
87
|
+
if (fused !== undefined && fusedName !== null) {
|
|
88
|
+
const p = join(outDir, fusedName);
|
|
89
|
+
await writeFile(p, fused);
|
|
90
|
+
written.push(p);
|
|
91
|
+
}
|
|
92
|
+
return written;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function runFuse(args: Args): Promise<number> {
|
|
96
|
+
const file = args.positional[0];
|
|
97
|
+
const placement = args.flags.get("placement");
|
|
98
|
+
if (file === undefined || (placement !== "trailer/1" && placement !== "container/1")) { process.stderr.write(USAGE); return 64; }
|
|
99
|
+
const original = new Uint8Array(await readFile(resolve(file)));
|
|
100
|
+
const label = sanitize(basename(file));
|
|
101
|
+
const ext = placement === "trailer/1" ? extname(label) : ".tar";
|
|
102
|
+
const fusedName = `${label.slice(0, label.length - extname(label).length)}.fused${ext}`;
|
|
103
|
+
const keep = args.flags.get("keep") === true;
|
|
104
|
+
const r = await fuse(builderFor(placement as PlacementId, original), { placement: placement as PlacementId, original, fusedFile: fusedName, keepFused: keep, transport: transportFrom(args.flags) });
|
|
105
|
+
const outDir = resolve(typeof args.flags.get("out") === "string" ? (args.flags.get("out") as string) : ".");
|
|
106
|
+
const written = await writeOutputs(outDir, label, r.frame, keep ? fusedName : null, r.fusedBytes);
|
|
107
|
+
report(r.proof, r.verification.category, r.recovered, true);
|
|
108
|
+
process.stdout.write(`\nwrote:\n${written.map((w) => " " + w).join("\n")}\n`);
|
|
109
|
+
if (!keep) process.stdout.write(`\nThe fused bytes were not kept (${placement} is byte-exact: any verifier rebuilds them from the original and the proof). Pass --keep to write them.\n`);
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function runProduce(args: Args): Promise<number> {
|
|
114
|
+
const originPath = args.flags.get("origin");
|
|
115
|
+
const originDigest = typeof originPath === "string" ? sha256(new Uint8Array(await readFile(resolve(originPath)))) : undefined;
|
|
116
|
+
const r = await fuse(builderFor("produced/1"), { placement: "produced/1", ...(originDigest !== undefined ? { originDigest } : {}), fusedFile: "produced.json", transport: transportFrom(args.flags) });
|
|
117
|
+
const outDir = resolve(typeof args.flags.get("out") === "string" ? (args.flags.get("out") as string) : ".");
|
|
118
|
+
const written = await writeOutputs(outDir, "produced", r.frame, "produced.json", r.fusedBytes);
|
|
119
|
+
report(r.proof, r.verification.category, r.recovered, originDigest !== undefined);
|
|
120
|
+
process.stdout.write(`\nwrote:\n${written.map((w) => " " + w).join("\n")}\n`);
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function report(proof: BitGraphProof, category: string, recovered: boolean, hasOrigin: boolean): void {
|
|
125
|
+
const c = proof.commit;
|
|
126
|
+
if (hasOrigin) process.stdout.write(COPY_ORIGINAL + "\n\n");
|
|
127
|
+
process.stdout.write(COPY_FUSED + "\n\n");
|
|
128
|
+
process.stdout.write(`verification ${category}\n`);
|
|
129
|
+
process.stdout.write(`slot ${c.slotCounter ?? "?"}\n`);
|
|
130
|
+
process.stdout.write(`commit ${c.counter ?? "?"}\n`);
|
|
131
|
+
process.stdout.write(`epoch ${c.epochId ?? "?"}\n`);
|
|
132
|
+
process.stdout.write(`artifact ${proof.artifact.digestB64}\n`);
|
|
133
|
+
if (proof.attribution?.message) process.stdout.write(`origin ${proof.attribution.message}\n`);
|
|
134
|
+
if (recovered) process.stdout.write(`note the commit response was lost; the proof was read back by digest and matched on the held slot\n`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function runCheck(args: Args): Promise<number> {
|
|
138
|
+
const [frameArg, fileArg] = args.positional;
|
|
139
|
+
if (frameArg === undefined || fileArg === undefined) { process.stderr.write(USAGE); return 64; }
|
|
140
|
+
const text = await readFile(resolve(frameArg), "utf8");
|
|
141
|
+
const frame = parseFrame(text);
|
|
142
|
+
let proof: BitGraphProof;
|
|
143
|
+
if (frame !== null) proof = frame.proof;
|
|
144
|
+
else {
|
|
145
|
+
const parsed = JSON.parse(text) as { version?: unknown };
|
|
146
|
+
if (parsed?.version !== "bitgraph/1") { process.stderr.write("not a Frame and not a bitgraph/1 proof\n"); return 64; }
|
|
147
|
+
proof = parsed as unknown as BitGraphProof;
|
|
148
|
+
}
|
|
149
|
+
const bytes = new Uint8Array(await readFile(resolve(fileArg)));
|
|
150
|
+
const max = args.flags.get("max-positions");
|
|
151
|
+
const r = await verifyFuse({ proof, bytes, frame, ...(typeof max === "string" ? { maxPositions: BigInt(max) } : {}) });
|
|
152
|
+
process.stdout.write(`category ${r.category}\n`);
|
|
153
|
+
process.stdout.write(`proof ${r.proof.valid ? "valid" : `invalid: ${r.proof.reason ?? ""}`}\n`);
|
|
154
|
+
process.stdout.write(`file digest ${r.fileDigestB64}\n`);
|
|
155
|
+
process.stdout.write(`artifact ${r.artifactDigestB64}\n`);
|
|
156
|
+
if (r.originDigestB64) process.stdout.write(`origin ${r.originDigestB64}\n`);
|
|
157
|
+
if (r.placement) process.stdout.write(`placement ${r.placement}\n`);
|
|
158
|
+
if (r.span) process.stdout.write(`span slot ${r.span.slotCounter} to commit ${r.span.commitCounter} (${r.span.positions} positions)\n`);
|
|
159
|
+
if (r.policy.maxPositions !== null) process.stdout.write(`span policy ${r.policy.spanExceeded ? "EXCEEDED" : "within"} ${r.policy.maxPositions} positions\n`);
|
|
160
|
+
if (r.reason) process.stdout.write(`reason ${r.reason}\n`);
|
|
161
|
+
for (const s of r.statements) process.stdout.write(`\n${s}\n`);
|
|
162
|
+
process.stdout.write(`\nfloor computed by the Player from anchors in a bundle (bitgraph-play check); not available here\n`);
|
|
163
|
+
if (r.category === "RECORDED" || r.category === "FUSED_DIRECT" || r.category === "FUSED_FROM_ORIGIN") return r.policy.spanExceeded ? 1 : 0;
|
|
164
|
+
if (r.category === "UNDETERMINED_PLACEMENT" || r.category === "NO_MATCH") return 2;
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function main(): Promise<number> {
|
|
169
|
+
const args = parseArgs(process.argv.slice(2));
|
|
170
|
+
try {
|
|
171
|
+
switch (args.command) {
|
|
172
|
+
case "fuse": return await runFuse(args);
|
|
173
|
+
case "produce": return await runProduce(args);
|
|
174
|
+
case "check": return await runCheck(args);
|
|
175
|
+
default: process.stderr.write(USAGE); return 64;
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (err instanceof FuseError) {
|
|
179
|
+
process.stderr.write(`no fused proof was completed (${err.code}${err.status !== null ? `, ${err.status}` : ""}): ${err.message}\n`);
|
|
180
|
+
return err.code === "tee-restarting" ? 2 : 1;
|
|
181
|
+
}
|
|
182
|
+
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
main().then((code) => { process.exitCode = code; });
|
package/src/fuse.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// Copyright (c) Mike Argento. All rights reserved. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* fuse(builder, options): the producer interface of the bitgraph-fuse/1
|
|
5
|
+
* profile (working name; outwardly this is simply BitGraph).
|
|
6
|
+
*
|
|
7
|
+
* The four beats, in order, with nothing else in between:
|
|
8
|
+
* 1. nonce: allocate a slot; the enclave signs a record that contains no
|
|
9
|
+
* artifact data and hands back a nonce that is a bearer ticket
|
|
10
|
+
* until it is consumed.
|
|
11
|
+
* 2. fuse: hand the COMMITMENT to that record (never the raw nonce) to the
|
|
12
|
+
* builder, which writes it into the artifact it is producing and
|
|
13
|
+
* returns the finished bytes.
|
|
14
|
+
* 3. hash: SHA-256 of the fused bytes.
|
|
15
|
+
* 4. fill: commit that digest under the same slot, with the placement id
|
|
16
|
+
* and the origin digest in the signed attribution.
|
|
17
|
+
*
|
|
18
|
+
* What this module never does: write the nonce anywhere but process memory,
|
|
19
|
+
* put it in a message, or fall back to an ordinary recording when the fused
|
|
20
|
+
* commit fails. A failure is reported as a failure and the slot expires on
|
|
21
|
+
* its own.
|
|
22
|
+
*
|
|
23
|
+
* Transport: the site's proxy routes by default (POST /api/fuse/allocate and
|
|
24
|
+
* /api/fuse/commit, behind the anchor-first gate), configurable so a licensee
|
|
25
|
+
* can point it at a parent directly.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
29
|
+
import {
|
|
30
|
+
buildFrame,
|
|
31
|
+
bytesToBase64,
|
|
32
|
+
bytesToHex,
|
|
33
|
+
computeSlotCommitment,
|
|
34
|
+
computeSlotRecordHash,
|
|
35
|
+
fuseAttribution,
|
|
36
|
+
getPlacement,
|
|
37
|
+
verifyFuse,
|
|
38
|
+
base64ToBytes,
|
|
39
|
+
} from "@mikeargento/bitgraph-verify";
|
|
40
|
+
import type { BitGraphProof, FuseFrame, PlacementId, SlotAllocation, FuseVerifyResult } from "@mikeargento/bitgraph-verify";
|
|
41
|
+
|
|
42
|
+
export type { FuseFrame, PlacementId, SlotAllocation, BitGraphProof } from "@mikeargento/bitgraph-verify";
|
|
43
|
+
|
|
44
|
+
/** What the builder receives. The raw nonce is deliberately absent. */
|
|
45
|
+
export interface BuilderInput {
|
|
46
|
+
/** 32-byte commitment to the signed slot record. Write this into the artifact. */
|
|
47
|
+
commitment: Uint8Array;
|
|
48
|
+
commitmentHex: string;
|
|
49
|
+
/** The origin digest, when the fused artifact names a source. */
|
|
50
|
+
originDigest?: Uint8Array;
|
|
51
|
+
/** The signed slot record, for producers that want to embed its fields. Contains the nonce: do not copy it into the artifact. */
|
|
52
|
+
slot: SlotAllocation;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Produces the finished (fused) bytes from the commitment. */
|
|
56
|
+
export type FuseBuilder = (input: BuilderInput) => Uint8Array | Promise<Uint8Array>;
|
|
57
|
+
|
|
58
|
+
export interface FuseTransport {
|
|
59
|
+
/** Origin of the commit surface. Default "https://bitgraph.ing". */
|
|
60
|
+
baseUrl?: string;
|
|
61
|
+
/** Default "/api/fuse/allocate". A parent-direct licensee uses "/allocate-slot". */
|
|
62
|
+
allocatePath?: string;
|
|
63
|
+
/** Default "/api/fuse/commit". A parent-direct licensee uses "/commit". */
|
|
64
|
+
commitPath?: string;
|
|
65
|
+
/** Default "/api/proofs/": the by-digest lookup used to recover a lost commit response. */
|
|
66
|
+
lookupPath?: string;
|
|
67
|
+
apiKey?: string;
|
|
68
|
+
fetch?: typeof fetch;
|
|
69
|
+
/** Per-request timeout. Default 30 s. */
|
|
70
|
+
timeoutMs?: number;
|
|
71
|
+
/** Lost-response recovery: how many by-digest reads to attempt, and the wait between them. */
|
|
72
|
+
recoveryAttempts?: number;
|
|
73
|
+
recoveryDelayMs?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface FuseOptions {
|
|
77
|
+
/** A registered placement id: "trailer/1", "container/1", or "produced/1". */
|
|
78
|
+
placement: PlacementId;
|
|
79
|
+
/** Forms A and B: the original bytes. Never modified. Absent for Form C. */
|
|
80
|
+
original?: Uint8Array;
|
|
81
|
+
/** Form C only: a source the produced artifact references, if any. */
|
|
82
|
+
originDigest?: Uint8Array;
|
|
83
|
+
/** Filename recorded in the Frame manifest (advisory). */
|
|
84
|
+
fusedFile?: string | null;
|
|
85
|
+
/** Return the fused bytes in the result. Default: true for placements that are not byte-exact, false otherwise. */
|
|
86
|
+
keepFused?: boolean;
|
|
87
|
+
/** Actor-bound commits: an agency envelope passed through untouched. */
|
|
88
|
+
agency?: unknown;
|
|
89
|
+
transport?: FuseTransport;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface FuseResult {
|
|
93
|
+
frame: FuseFrame;
|
|
94
|
+
proof: BitGraphProof;
|
|
95
|
+
artifactDigestB64: string;
|
|
96
|
+
originDigestB64: string | null;
|
|
97
|
+
/** Present when keepFused is true (or defaulted to true). */
|
|
98
|
+
fusedBytes?: Uint8Array;
|
|
99
|
+
/** True when the commit response was lost and the proof was read back by digest. */
|
|
100
|
+
recovered: boolean;
|
|
101
|
+
/** The local verification of the returned proof against the fused bytes. Always FUSED_DIRECT on success. */
|
|
102
|
+
verification: FuseVerifyResult;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type FuseErrorCode =
|
|
106
|
+
| "bad-placement"
|
|
107
|
+
| "bad-input"
|
|
108
|
+
| "allocate-failed"
|
|
109
|
+
| "builder-failed"
|
|
110
|
+
| "commitment-missing"
|
|
111
|
+
| "commit-refused"
|
|
112
|
+
| "slot-unavailable"
|
|
113
|
+
| "tee-restarting"
|
|
114
|
+
| "network"
|
|
115
|
+
| "slot-mismatch"
|
|
116
|
+
| "verification-failed"
|
|
117
|
+
| "transport";
|
|
118
|
+
|
|
119
|
+
export class FuseError extends Error {
|
|
120
|
+
readonly code: FuseErrorCode;
|
|
121
|
+
readonly status: number | null;
|
|
122
|
+
constructor(code: FuseErrorCode, message: string, status: number | null = null) {
|
|
123
|
+
super(message);
|
|
124
|
+
this.name = "FuseError";
|
|
125
|
+
this.code = code;
|
|
126
|
+
this.status = status;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A builder for a registered placement over an existing original (Forms A and B), or for a bare Form C payload. */
|
|
131
|
+
export function builderFor(placement: PlacementId, original?: Uint8Array): FuseBuilder {
|
|
132
|
+
const p = getPlacement(placement);
|
|
133
|
+
if (p === undefined) throw new FuseError("bad-placement", `placement "${placement}" is not registered`);
|
|
134
|
+
return ({ commitment, originDigest }) => {
|
|
135
|
+
if (original !== undefined) return p.build({ original, originDigest: originDigest ?? sha256(original), commitment });
|
|
136
|
+
return p.build(originDigest !== undefined ? { originDigest, commitment } : { commitment });
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const DEFAULTS = {
|
|
141
|
+
baseUrl: "https://bitgraph.ing",
|
|
142
|
+
allocatePath: "/api/fuse/allocate",
|
|
143
|
+
commitPath: "/api/fuse/commit",
|
|
144
|
+
lookupPath: "/api/proofs/",
|
|
145
|
+
timeoutMs: 30_000,
|
|
146
|
+
recoveryAttempts: 5,
|
|
147
|
+
recoveryDelayMs: 1_500,
|
|
148
|
+
} as const;
|
|
149
|
+
|
|
150
|
+
const B64_32 = /^[A-Za-z0-9+/]{43}=$/;
|
|
151
|
+
const B64_64 = /^[A-Za-z0-9+/]{86}==$/;
|
|
152
|
+
|
|
153
|
+
function isSlotRecord(x: unknown): x is SlotAllocation {
|
|
154
|
+
if (x === null || typeof x !== "object" || Array.isArray(x)) return false;
|
|
155
|
+
const s = x as Record<string, unknown>;
|
|
156
|
+
return (
|
|
157
|
+
s.version === "bitgraph/slot/1" &&
|
|
158
|
+
typeof s.nonceB64 === "string" && B64_32.test(s.nonceB64) &&
|
|
159
|
+
typeof s.counter === "string" && /^(0|[1-9][0-9]*)$/.test(s.counter) &&
|
|
160
|
+
typeof s.epochId === "string" && typeof s.publicKeyB64 === "string" &&
|
|
161
|
+
typeof s.signatureB64 === "string" && B64_64.test(s.signatureB64)
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function toUrlSafe(b64: string): string {
|
|
166
|
+
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
170
|
+
|
|
171
|
+
async function request(
|
|
172
|
+
t: Required<Pick<FuseTransport, "baseUrl" | "timeoutMs">> & FuseTransport,
|
|
173
|
+
path: string,
|
|
174
|
+
init: { method: "GET" | "POST"; body?: unknown },
|
|
175
|
+
): Promise<{ status: number; json: unknown; headers: Headers }> {
|
|
176
|
+
const f = t.fetch ?? fetch;
|
|
177
|
+
const headers: Record<string, string> = {};
|
|
178
|
+
if (init.body !== undefined) headers["Content-Type"] = "application/json";
|
|
179
|
+
if (t.apiKey) headers["Authorization"] = `Bearer ${t.apiKey}`;
|
|
180
|
+
let res: Response;
|
|
181
|
+
try {
|
|
182
|
+
res = await f(`${t.baseUrl}${path}`, {
|
|
183
|
+
method: init.method,
|
|
184
|
+
headers,
|
|
185
|
+
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
|
|
186
|
+
signal: AbortSignal.timeout(t.timeoutMs),
|
|
187
|
+
});
|
|
188
|
+
} catch (err) {
|
|
189
|
+
throw new FuseError("network", `request failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
190
|
+
}
|
|
191
|
+
const text = await res.text();
|
|
192
|
+
let json: unknown = null;
|
|
193
|
+
try { json = text.length > 0 ? JSON.parse(text) : null; } catch { json = text; }
|
|
194
|
+
return { status: res.status, json, headers: res.headers };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function messageOf(json: unknown, fallback: string): string {
|
|
198
|
+
return json !== null && typeof json === "object" && typeof (json as { error?: unknown }).error === "string" ? (json as { error: string }).error : fallback;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function codeOf(json: unknown): string | null {
|
|
202
|
+
return json !== null && typeof json === "object" && typeof (json as { code?: unknown }).code === "string" ? (json as { code: string }).code : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Read the proof back by digest after a lost or refused commit: the ONE proof
|
|
207
|
+
* whose commit.slotHashB64 is the hash of the slot record we hold. Any other
|
|
208
|
+
* proof of the same digest is a different recording under a different slot.
|
|
209
|
+
* Never allocates.
|
|
210
|
+
*/
|
|
211
|
+
async function recover(
|
|
212
|
+
t: Required<Pick<FuseTransport, "baseUrl" | "timeoutMs" | "recoveryAttempts" | "recoveryDelayMs" | "lookupPath">> & FuseTransport,
|
|
213
|
+
artifactDigestB64: string,
|
|
214
|
+
slot: SlotAllocation,
|
|
215
|
+
): Promise<BitGraphProof | null> {
|
|
216
|
+
const expectedSlotHash = bytesToBase64(computeSlotRecordHash(slot));
|
|
217
|
+
for (let attempt = 0; attempt < t.recoveryAttempts; attempt++) {
|
|
218
|
+
if (attempt > 0) await sleep(t.recoveryDelayMs);
|
|
219
|
+
let r: { status: number; json: unknown };
|
|
220
|
+
try {
|
|
221
|
+
r = await request(t, `${t.lookupPath}${encodeURIComponent(toUrlSafe(artifactDigestB64))}`, { method: "GET" });
|
|
222
|
+
} catch {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (r.status !== 200) continue;
|
|
226
|
+
const proofs = (r.json as { proofs?: Array<{ proof?: BitGraphProof }> } | null)?.proofs;
|
|
227
|
+
if (!Array.isArray(proofs)) continue;
|
|
228
|
+
for (const entry of proofs) {
|
|
229
|
+
const p = entry?.proof;
|
|
230
|
+
if (p && p.commit?.slotHashB64 === expectedSlotHash && p.commit?.nonceB64 === slot.nonceB64) return p;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Allocate, fuse, hash, fill. Returns the Frame with the unchanged proof, or
|
|
238
|
+
* throws a FuseError; it never returns an ordinary recording in place of a
|
|
239
|
+
* fused one.
|
|
240
|
+
*/
|
|
241
|
+
export async function fuse(builder: FuseBuilder, options: FuseOptions): Promise<FuseResult> {
|
|
242
|
+
const placement = getPlacement(options.placement);
|
|
243
|
+
if (placement === undefined) throw new FuseError("bad-placement", `placement "${options.placement}" is not registered`);
|
|
244
|
+
if (placement.form !== "C" && options.original === undefined) throw new FuseError("bad-input", `${placement.id} needs the original bytes`);
|
|
245
|
+
if (placement.form === "C" && options.original !== undefined) throw new FuseError("bad-input", "produced/1 takes no original; pass originDigest to name a source");
|
|
246
|
+
if (options.originDigest !== undefined && options.originDigest.length !== 32) throw new FuseError("bad-input", "originDigest must be 32 bytes");
|
|
247
|
+
|
|
248
|
+
const t = { ...DEFAULTS, ...(options.transport ?? {}) };
|
|
249
|
+
const originDigest = options.original !== undefined ? sha256(options.original) : options.originDigest;
|
|
250
|
+
const originDigestB64 = originDigest !== undefined ? bytesToBase64(originDigest) : null;
|
|
251
|
+
|
|
252
|
+
// 1. nonce
|
|
253
|
+
const alloc = await request(t, t.allocatePath, { method: "POST", body: {} });
|
|
254
|
+
if (alloc.status === 503 && codeOf(alloc.json) === "tee-restarting") throw new FuseError("tee-restarting", messageOf(alloc.json, "the boundary is restarting"), 503);
|
|
255
|
+
if (alloc.status !== 200) throw new FuseError("allocate-failed", messageOf(alloc.json, `allocation failed (${alloc.status})`), alloc.status);
|
|
256
|
+
const slotId = (alloc.json as { slotId?: unknown } | null)?.slotId;
|
|
257
|
+
const slot = (alloc.json as { slot?: unknown } | null)?.slot;
|
|
258
|
+
if (!isSlotRecord(slot) || slotId !== slot.nonceB64) throw new FuseError("allocate-failed", "the allocation response is not a slot record", alloc.status);
|
|
259
|
+
if (slot.chainId !== "bitgraph:main") throw new FuseError("allocate-failed", "the slot is not on the anchored chain; a fused floor needs bitgraph:main");
|
|
260
|
+
|
|
261
|
+
// 2. fuse
|
|
262
|
+
const commitment = computeSlotCommitment(slot);
|
|
263
|
+
let fused: Uint8Array;
|
|
264
|
+
try {
|
|
265
|
+
fused = await builder({ commitment, commitmentHex: bytesToHex(commitment), ...(originDigest !== undefined ? { originDigest } : {}), slot });
|
|
266
|
+
} catch (err) {
|
|
267
|
+
throw new FuseError("builder-failed", `the builder threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
268
|
+
}
|
|
269
|
+
if (!(fused instanceof Uint8Array)) throw new FuseError("builder-failed", "the builder must return a Uint8Array");
|
|
270
|
+
// Fail closed: never commit bytes that do not carry the commitment.
|
|
271
|
+
const located = placement.locate(fused);
|
|
272
|
+
if (located === null || bytesToHex(located.commitment) !== bytesToHex(commitment)) {
|
|
273
|
+
throw new FuseError("commitment-missing", `the fused bytes do not carry the ${placement.id} commitment; nothing was committed and the slot will expire`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 3. hash
|
|
277
|
+
const artifactDigest = sha256(fused);
|
|
278
|
+
const artifactDigestB64 = bytesToBase64(artifactDigest);
|
|
279
|
+
|
|
280
|
+
// 4. fill
|
|
281
|
+
const attribution = fuseAttribution(placement.id, originDigest);
|
|
282
|
+
const body: Record<string, unknown> = {
|
|
283
|
+
digests: [{ digestB64: artifactDigestB64, hashAlg: "sha256" }],
|
|
284
|
+
slotId: slot.nonceB64,
|
|
285
|
+
slot,
|
|
286
|
+
chainId: "bitgraph:main",
|
|
287
|
+
attribution,
|
|
288
|
+
};
|
|
289
|
+
if (options.agency !== undefined) body.agency = options.agency;
|
|
290
|
+
|
|
291
|
+
let proof: BitGraphProof | null = null;
|
|
292
|
+
let recovered = false;
|
|
293
|
+
let commit: { status: number; json: unknown } | null = null;
|
|
294
|
+
try {
|
|
295
|
+
commit = await request(t, t.commitPath, { method: "POST", body });
|
|
296
|
+
} catch (err) {
|
|
297
|
+
// The request may have reached the boundary. Read back before giving up; never allocate again.
|
|
298
|
+
proof = await recover(t, artifactDigestB64, slot);
|
|
299
|
+
if (proof === null) throw err;
|
|
300
|
+
recovered = true;
|
|
301
|
+
}
|
|
302
|
+
if (proof === null && commit !== null) {
|
|
303
|
+
if (commit.status === 200) {
|
|
304
|
+
const j = commit.json as { proof?: BitGraphProof } | BitGraphProof[] | null;
|
|
305
|
+
proof = Array.isArray(j) ? (j[0] ?? null) : (j?.proof ?? null);
|
|
306
|
+
if (proof === null) throw new FuseError("commit-refused", "the commit response carried no proof", 200);
|
|
307
|
+
} else if (commit.status === 409 && codeOf(commit.json) === "slot-unavailable") {
|
|
308
|
+
proof = await recover(t, artifactDigestB64, slot);
|
|
309
|
+
if (proof === null) throw new FuseError("slot-unavailable", messageOf(commit.json, "the slot is no longer available"), 409);
|
|
310
|
+
recovered = true;
|
|
311
|
+
} else if (commit.status === 503 && codeOf(commit.json) === "tee-restarting") {
|
|
312
|
+
proof = await recover(t, artifactDigestB64, slot);
|
|
313
|
+
if (proof === null) throw new FuseError("tee-restarting", messageOf(commit.json, "the boundary is restarting"), 503);
|
|
314
|
+
recovered = true;
|
|
315
|
+
} else {
|
|
316
|
+
throw new FuseError("commit-refused", messageOf(commit.json, `commit refused (${commit.status})`), commit.status);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (proof === null) throw new FuseError("transport", "no proof");
|
|
320
|
+
|
|
321
|
+
// Never label as fused a proof under any other slot.
|
|
322
|
+
if (proof.slotAllocation?.nonceB64 !== slot.nonceB64 || proof.commit?.nonceB64 !== slot.nonceB64) {
|
|
323
|
+
throw new FuseError("slot-mismatch", "the boundary returned a proof under a different slot; nothing is labelled fused");
|
|
324
|
+
}
|
|
325
|
+
// A minted proof is verified by a reader before it is called a proof.
|
|
326
|
+
const verification = await verifyFuse({ proof, bytes: fused });
|
|
327
|
+
if (verification.category !== "FUSED_DIRECT") {
|
|
328
|
+
throw new FuseError("verification-failed", `the returned proof does not verify as fused: ${verification.category}${verification.reason ? ` (${verification.reason})` : ""}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const frame = buildFrame({
|
|
332
|
+
proof,
|
|
333
|
+
placement: placement.id,
|
|
334
|
+
artifactDigest,
|
|
335
|
+
...(originDigest !== undefined ? { originDigest } : {}),
|
|
336
|
+
fusedFile: options.fusedFile ?? null,
|
|
337
|
+
...(placement.form === "C" ? { fusePayload: fused } : {}),
|
|
338
|
+
});
|
|
339
|
+
const keep = options.keepFused ?? !placement.byteExact;
|
|
340
|
+
return {
|
|
341
|
+
frame,
|
|
342
|
+
proof,
|
|
343
|
+
artifactDigestB64,
|
|
344
|
+
originDigestB64,
|
|
345
|
+
...(keep ? { fusedBytes: fused } : {}),
|
|
346
|
+
recovered,
|
|
347
|
+
verification,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Decode a standard-base64 digest, for callers holding one as text. */
|
|
352
|
+
export function digestFromBase64(b64: string): Uint8Array {
|
|
353
|
+
const d = base64ToBytes(b64);
|
|
354
|
+
if (d === null || d.length !== 32) throw new FuseError("bad-input", "not a 32-byte base64 digest");
|
|
355
|
+
return d;
|
|
356
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,12 @@ export type { HostCapabilities } from "./host.js";
|
|
|
37
37
|
// Constructor (write path)
|
|
38
38
|
export { Constructor } from "./constructor.js";
|
|
39
39
|
|
|
40
|
+
// The producer profile over the primitive (working name Fuse): allocate a
|
|
41
|
+
// slot, write a commitment to it into the artifact, hash, commit under the
|
|
42
|
+
// same slot. The resulting proof is ordinary bitgraph/1.
|
|
43
|
+
export { fuse, builderFor, FuseError, digestFromBase64 } from "./fuse.js";
|
|
44
|
+
export type { FuseBuilder, BuilderInput, FuseOptions, FuseResult, FuseTransport, FuseErrorCode } from "./fuse.js";
|
|
45
|
+
|
|
40
46
|
// Policy parsing, hashing, and validation
|
|
41
47
|
export {
|
|
42
48
|
parsePolicy,
|
package/dist/canonical.d.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* bitgraph-core canonical serialization
|
|
3
|
-
*
|
|
4
|
-
* Produces a deterministic, UTF-8 encoded JSON byte sequence from an
|
|
5
|
-
* arbitrary JavaScript value. The output is used as the signing input
|
|
6
|
-
* for BitGraphProof signatures and must be reproduced identically by any
|
|
7
|
-
* verifier regardless of platform or runtime.
|
|
8
|
-
*
|
|
9
|
-
* Algorithm:
|
|
10
|
-
* 1. Recursively sort object keys lexicographically (Unicode code-point order)
|
|
11
|
-
* 2. Serialize with JSON.stringify (no whitespace)
|
|
12
|
-
* 3. Encode the resulting string as UTF-8
|
|
13
|
-
*
|
|
14
|
-
* Constraints satisfied:
|
|
15
|
-
* - Deterministic key ordering
|
|
16
|
-
* - No whitespace variance
|
|
17
|
-
* - Stable numeric formatting (JSON.stringify uses shortest representation)
|
|
18
|
-
* - UTF-8 output (TextEncoder default)
|
|
19
|
-
* - Rejects undefined, functions, and symbols, which have no JSON repr
|
|
20
|
-
*
|
|
21
|
-
* Limitations:
|
|
22
|
-
* - BigInt is not serializable via JSON.stringify; callers must convert
|
|
23
|
-
* to string before passing (consistent with the `counter` field type).
|
|
24
|
-
* - NaN and Infinity serialize as `null` in JSON; callers must validate
|
|
25
|
-
* numeric fields upstream.
|
|
26
|
-
* - Object prototype chains are not walked; only own enumerable keys.
|
|
27
|
-
*
|
|
28
|
-
* This module has zero dependencies on other bitgraph-core modules so that it
|
|
29
|
-
* can be used standalone for testing and external verification tooling.
|
|
30
|
-
*/
|
|
31
|
-
/**
|
|
32
|
-
* Serialize `obj` to canonical JSON and return UTF-8 encoded bytes.
|
|
33
|
-
*
|
|
34
|
-
* Throws if `obj` contains values that cannot survive a JSON round-trip
|
|
35
|
-
* without information loss (undefined top-level, functions, symbols, BigInt).
|
|
36
|
-
*/
|
|
37
|
-
export declare function canonicalize(obj: unknown): Uint8Array;
|
|
38
|
-
/**
|
|
39
|
-
* Serialize `obj` to a canonical JSON string.
|
|
40
|
-
* Exposed for debugging and test assertions.
|
|
41
|
-
*/
|
|
42
|
-
export declare function canonicalizeToString(obj: unknown): string;
|
|
43
|
-
/**
|
|
44
|
-
* Compare two Uint8Arrays in constant time.
|
|
45
|
-
*
|
|
46
|
-
* Returns true iff `a` and `b` have the same length and identical contents.
|
|
47
|
-
* Resistance against timing side-channels is important when comparing
|
|
48
|
-
* digests and signatures in the verifier.
|
|
49
|
-
*
|
|
50
|
-
* Note: JavaScript runtimes may still optimize this; a native binding would
|
|
51
|
-
* provide stronger guarantees. For v0.1 this is a reasonable best-effort.
|
|
52
|
-
*/
|
|
53
|
-
export declare function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean;
|
|
54
|
-
//# sourceMappingURL=canonical.d.ts.map
|
package/dist/canonical.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"canonical.d.ts","sourceRoot":"","sources":["../src/canonical.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAMH;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,UAAU,CAGrD;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAEzD;AA2DD;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,UAAU,GAAG,OAAO,CAUvE"}
|