@mikeargento/bitgraph-player 0.6.0 → 0.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/dist/__tests__/check.test.js +3 -2
- package/dist/__tests__/check.test.js.map +1 -1
- package/dist/__tests__/fixtures.d.ts.map +1 -1
- package/dist/__tests__/fixtures.js +3 -1
- package/dist/__tests__/fixtures.js.map +1 -1
- package/dist/__tests__/fuse-check.test.d.ts +2 -0
- package/dist/__tests__/fuse-check.test.d.ts.map +1 -0
- package/dist/__tests__/fuse-check.test.js +152 -0
- package/dist/__tests__/fuse-check.test.js.map +1 -0
- package/dist/check.d.ts +54 -3
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +194 -12
- package/dist/check.js.map +1 -1
- package/dist/cli.js +0 -0
- package/dist/verdict.d.ts +1 -1
- package/dist/verdict.js +1 -1
- package/dist-web/verify.html +10 -10
- package/package.json +5 -5
- package/src/__tests__/check.test.ts +3 -2
- package/src/__tests__/fixtures.ts +3 -1
- package/src/__tests__/fuse-check.test.ts +168 -0
- package/src/check.ts +245 -14
- package/src/verdict.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mikeargento/bitgraph-player",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Deterministic evaluation of causal rules over BitGraph proof bundles: a pure function from (rule, verified evidence) to a reproducible verdict.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -26,18 +26,18 @@
|
|
|
26
26
|
],
|
|
27
27
|
"scripts": {
|
|
28
28
|
"build": "tsc",
|
|
29
|
-
"prepack": "tsc",
|
|
29
|
+
"prepack": "tsc && npm run build:web",
|
|
30
30
|
"typecheck": "tsc --noEmit",
|
|
31
31
|
"test": "tsc && node --test \"dist/__tests__/*.test.js\"",
|
|
32
|
-
"build:web": "node scripts/build-verify-html.mjs --out
|
|
32
|
+
"build:web": "node scripts/build-verify-html.mjs --out ../../website/public/verify.html",
|
|
33
33
|
"typecheck:web": "tsc -p web/tsconfig.json"
|
|
34
34
|
},
|
|
35
35
|
"engines": {
|
|
36
36
|
"node": ">=20"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@mikeargento/bitgraph-audit": "^0.
|
|
40
|
-
"@mikeargento/bitgraph-verify": "^1.
|
|
39
|
+
"@mikeargento/bitgraph-audit": "^0.4.0",
|
|
40
|
+
"@mikeargento/bitgraph-verify": "^1.4.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@noble/curves": "^2.3.0",
|
|
@@ -116,10 +116,11 @@ describe("check: real export fixture (disk)", () => {
|
|
|
116
116
|
|
|
117
117
|
it("brackets the recording between the two verified block headers", () => {
|
|
118
118
|
const b = report.recordings[0]!.bounds;
|
|
119
|
-
assert.equal(b.status, "
|
|
119
|
+
assert.equal(b.status, "lower-bounded-with-following-anchor");
|
|
120
120
|
assert.equal(b.notBefore?.blockNumber, "25735831");
|
|
121
121
|
assert.equal(b.notAfter?.blockNumber, "25735832");
|
|
122
|
-
assert.match(b.detail, /after Ethereum block 25735831
|
|
122
|
+
assert.match(b.detail, /after Ethereum block 25735831 \(header verified in this bundle\); precedes an anchor that consumed block 25735832/);
|
|
123
|
+
assert.match(b.detail, /rather than an upper bound/);
|
|
123
124
|
});
|
|
124
125
|
|
|
125
126
|
it("orders anchors by causal position, not file name", () => {
|
|
@@ -94,6 +94,8 @@ export function makeBound(partial: Partial<SegmentBound>): SegmentBound {
|
|
|
94
94
|
weaker: partial.weaker ?? false,
|
|
95
95
|
basis: partial.basis ?? "block-hash-unpredictability",
|
|
96
96
|
claim: partial.claim ?? "fixture bound",
|
|
97
|
+
// Required since bitgraph-audit 0.3.0: a not-after bound is an assumption, never evidence.
|
|
98
|
+
boundClass: partial.boundClass ?? ((partial.kind ?? "not-before") === "not-before" ? "evidence" : "assumption"),
|
|
97
99
|
...(partial.blockNumber !== undefined ? { blockNumber: partial.blockNumber } : {}),
|
|
98
100
|
};
|
|
99
101
|
}
|
|
@@ -126,7 +128,7 @@ export function makeAudit(spec: FixtureAuditSpec): AuditResult {
|
|
|
126
128
|
const segments = (spec.segments ?? []).map((s) => ({
|
|
127
129
|
partition: { publicKeyB64: "key-A", chainId: "bitgraph:main" },
|
|
128
130
|
memberProofHashes: s.members.map(proofHashOf),
|
|
129
|
-
status: "
|
|
131
|
+
status: "lower-bounded-with-following-anchor",
|
|
130
132
|
lowerBounds: (s.lowerBounds ?? []).map((b) => makeBound({ ...b, kind: "not-before" })),
|
|
131
133
|
upperBounds: (s.upperBounds ?? []).map((b) => makeBound({ ...b, kind: "not-after" })),
|
|
132
134
|
}));
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `check` over fused recordings (profile bitgraph-fuse/1, working name).
|
|
5
|
+
* Fixtures are real proofs minted through the local enclave harness
|
|
6
|
+
* (real signatures, fake PCR0), read-only here; no ledger, no network.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it } from "node:test";
|
|
10
|
+
import * as assert from "node:assert/strict";
|
|
11
|
+
import { readFile, mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { ingestBundle, ingestEntries } from "@mikeargento/bitgraph-audit";
|
|
16
|
+
import type { AnchorRecord, BundleEntrySource, SegmentBound, TemporalSegment } from "@mikeargento/bitgraph-audit";
|
|
17
|
+
import { checkIngest, fusedFloor, renderCheckText, serializeCheckReport } from "../check.js";
|
|
18
|
+
|
|
19
|
+
const FIX = fileURLToPath(new URL("../../../../src/__tests__/fuse-fixtures/", import.meta.url));
|
|
20
|
+
const read = (name: string) => readFile(join(FIX, name)).then((b) => new Uint8Array(b));
|
|
21
|
+
|
|
22
|
+
async function entries(spec: Record<string, string>): Promise<BundleEntrySource[]> {
|
|
23
|
+
const out: BundleEntrySource[] = [];
|
|
24
|
+
for (const [path, fixture] of Object.entries(spec)) {
|
|
25
|
+
const bytes = await read(fixture);
|
|
26
|
+
out.push({ path, open: () => bytes });
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("fused recordings in check", () => {
|
|
32
|
+
it("fused bytes beside the proof: fused line TRUE, floor undetermined without anchors, statements carried", async () => {
|
|
33
|
+
const report = await checkIngest(await ingestEntries(await entries({ "proof.json": "trailer.proof.json", "photo.bin": "fused-trailer.bin" })));
|
|
34
|
+
assert.equal(report.recordings.length, 1);
|
|
35
|
+
const rec = report.recordings[0]!;
|
|
36
|
+
const fused = rec.lines.find((l) => l.name === "fused");
|
|
37
|
+
assert.ok(fused, "a fused line exists");
|
|
38
|
+
assert.equal(fused!.result, "TRUE", fused!.detail);
|
|
39
|
+
assert.equal(rec.lines.find((l) => l.name === "file")?.result, "TRUE");
|
|
40
|
+
assert.equal(rec.lines.find((l) => l.name === "signature")?.result, "TRUE");
|
|
41
|
+
assert.ok(rec.fused);
|
|
42
|
+
assert.equal(rec.fused!.category, "FUSED_DIRECT");
|
|
43
|
+
assert.equal(rec.fused!.evidence, "fused-bytes");
|
|
44
|
+
assert.equal(rec.fused!.placement, "trailer/1");
|
|
45
|
+
assert.equal(rec.fused!.floor, null);
|
|
46
|
+
assert.match(rec.fused!.floorDetail, /floor undetermined: no anchor precedes this slot in its epoch/);
|
|
47
|
+
assert.equal(rec.fused!.statements.length, 2);
|
|
48
|
+
assert.match(rec.fused!.statements[1]!, /at position \d+, and were committed no later than position \d+/);
|
|
49
|
+
assert.ok(rec.fused!.span && BigInt(rec.fused!.span.positions) >= 1n);
|
|
50
|
+
// The harness attests with a fake document, so the attestation line is FALSE
|
|
51
|
+
// here; that is the harness, not the fused check, and it is reported on its
|
|
52
|
+
// own line. The fused line above stands regardless.
|
|
53
|
+
assert.notEqual(rec.lines.find((l) => l.name === "attestation")?.result, "TRUE");
|
|
54
|
+
const text = renderCheckText(report);
|
|
55
|
+
assert.match(text, /fused floor floor undetermined/);
|
|
56
|
+
assert.match(text, /fused span slot \d+ to commit \d+/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("the original beside the proof: rebuilt, fused line TRUE, the original is noted as such and not as unmatched", async () => {
|
|
60
|
+
const report = await checkIngest(await ingestEntries(await entries({ "proof.json": "trailer.proof.json", "original.txt": "original.txt" })));
|
|
61
|
+
const rec = report.recordings[0]!;
|
|
62
|
+
const fused = rec.lines.find((l) => l.name === "fused")!;
|
|
63
|
+
assert.equal(fused.result, "TRUE", fused.detail);
|
|
64
|
+
assert.equal(rec.fused!.category, "FUSED_FROM_ORIGIN");
|
|
65
|
+
assert.equal(rec.fused!.evidence, "original");
|
|
66
|
+
// The committed bytes are not in hand, so the file line stays UNDETERMINED: the
|
|
67
|
+
// original receives the ceiling, the fused bytes the interval.
|
|
68
|
+
assert.equal(rec.lines.find((l) => l.name === "file")?.result, "UNDETERMINED");
|
|
69
|
+
assert.ok(report.notes.some((n) => /original of a fused recording: original\.txt/.test(n)), report.notes.join(" | "));
|
|
70
|
+
assert.ok(!report.notes.some((n) => /matches no recording here: original\.txt/.test(n)), "the original is not called unmatched");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("neither bytes present: fused line UNDETERMINED, never FALSE", async () => {
|
|
74
|
+
const report = await checkIngest(await ingestEntries(await entries({ "proof.json": "trailer.proof.json" })));
|
|
75
|
+
const rec = report.recordings[0]!;
|
|
76
|
+
const fused = rec.lines.find((l) => l.name === "fused")!;
|
|
77
|
+
assert.equal(fused.result, "UNDETERMINED");
|
|
78
|
+
assert.match(fused.detail, /neither the fused bytes nor the original/);
|
|
79
|
+
assert.equal(rec.fused!.category, "NO_EVIDENCE");
|
|
80
|
+
assert.equal(rec.fused!.evidence, null);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("a file committed under a different slot than its commitment names: fused line FALSE", async () => {
|
|
84
|
+
const report = await checkIngest(await ingestEntries(await entries({ "proof.json": "wrong-slot.proof.json", "photo.bin": "fused-wrong-slot.bin" })));
|
|
85
|
+
const rec = report.recordings[0]!;
|
|
86
|
+
assert.equal(rec.lines.find((l) => l.name === "fused")?.result, "FALSE");
|
|
87
|
+
assert.equal(rec.fused!.category, "INVALID_SLOT_COMMITMENT");
|
|
88
|
+
assert.equal(report.result, "FALSE");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("an unregistered placement: UNDETERMINED with the placement named", async () => {
|
|
92
|
+
const report = await checkIngest(await ingestEntries(await entries({ "proof.json": "unregistered.proof.json", "photo.bin": "fused-unregistered.bin" })));
|
|
93
|
+
const rec = report.recordings[0]!;
|
|
94
|
+
const fused = rec.lines.find((l) => l.name === "fused")!;
|
|
95
|
+
assert.equal(fused.result, "UNDETERMINED");
|
|
96
|
+
assert.match(fused.detail, /xmp\/9/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("an ordinary recording has no fused line and no fused block", async () => {
|
|
100
|
+
const report = await checkIngest(await ingestEntries(await entries({ "proof.json": "recorded.proof.json", "original.txt": "original.txt" })));
|
|
101
|
+
const rec = report.recordings[0]!;
|
|
102
|
+
assert.equal(rec.lines.find((l) => l.name === "fused"), undefined);
|
|
103
|
+
assert.equal(rec.fused, undefined);
|
|
104
|
+
assert.equal(rec.lines.find((l) => l.name === "file")?.result, "TRUE");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("container/1 from the PNG alone and produced/1 from its own bytes", async () => {
|
|
108
|
+
const a = await checkIngest(await ingestEntries(await entries({ "proof.json": "container.proof.json", "image.png": "image.png" })));
|
|
109
|
+
assert.equal(a.recordings[0]!.fused!.category, "FUSED_FROM_ORIGIN");
|
|
110
|
+
assert.equal(a.recordings[0]!.fused!.placement, "container/1");
|
|
111
|
+
const b = await checkIngest(await ingestEntries(await entries({ "proof.json": "produced-origin.proof.json", "record.json": "produced-origin.json" })));
|
|
112
|
+
assert.equal(b.recordings[0]!.fused!.category, "FUSED_DIRECT");
|
|
113
|
+
assert.equal(b.recordings[0]!.fused!.placement, "produced/1");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("disk and memory ingests of the same fused bundle give byte-identical reports", async () => {
|
|
117
|
+
const dir = await mkdtemp(join(tmpdir(), "bitgraph-fuse-check-"));
|
|
118
|
+
try {
|
|
119
|
+
await mkdir(dir, { recursive: true });
|
|
120
|
+
await writeFile(join(dir, "proof.json"), await read("trailer.proof.json"));
|
|
121
|
+
await writeFile(join(dir, "original.txt"), await read("original.txt"));
|
|
122
|
+
const fromDisk = await checkIngest(await ingestBundle(dir));
|
|
123
|
+
const fromMemory = await checkIngest(await ingestEntries(await entries({ "proof.json": "trailer.proof.json", "original.txt": "original.txt" })));
|
|
124
|
+
assert.equal(serializeCheckReport(fromDisk), serializeCheckReport(fromMemory));
|
|
125
|
+
assert.equal(fromDisk.recordings[0]!.fused!.category, "FUSED_FROM_ORIGIN");
|
|
126
|
+
} finally {
|
|
127
|
+
await rm(dir, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("fusedFloor", () => {
|
|
133
|
+
const bound = (anchorProofHash: string, blockNumber: string, timestamp: number): SegmentBound => ({
|
|
134
|
+
kind: "not-before", anchorProofHash, blockNumber, blockHash: "0x" + "ab".repeat(32), timestamp,
|
|
135
|
+
evidence: "counter-order", weaker: true, basis: "block-hash-unpredictability", boundClass: "evidence", claim: "",
|
|
136
|
+
});
|
|
137
|
+
const anchor = (proofHash: string, counter: string): AnchorRecord => ({ proofHash, chainId: "bitgraph:main", counter, metadataCorroboration: "absent" } as unknown as AnchorRecord);
|
|
138
|
+
const segment = (lower: SegmentBound[]): TemporalSegment => ({ lowerBounds: lower } as unknown as TemporalSegment);
|
|
139
|
+
|
|
140
|
+
it("picks the last verified anchor whose counter precedes the SLOT, not the commit", () => {
|
|
141
|
+
const anchors = new Map([["a1", anchor("a1", "10")], ["a2", anchor("a2", "40")], ["a3", anchor("a3", "45")]]);
|
|
142
|
+
const seg = segment([bound("a1", "100", 1000), bound("a2", "200", 2000), bound("a3", "300", 3000)]);
|
|
143
|
+
// slot 42, commit 50: a3 (counter 45) precedes the commit but not the slot.
|
|
144
|
+
const floor = fusedFloor(seg, anchors, "42");
|
|
145
|
+
assert.equal(floor?.anchorProofHash, "a2");
|
|
146
|
+
assert.equal(floor?.blockNumber, "200");
|
|
147
|
+
assert.equal(floor?.evidence, "counter-order");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("no anchor before the slot: null (UNDET), never a following anchor", () => {
|
|
151
|
+
const anchors = new Map([["a3", anchor("a3", "45")]]);
|
|
152
|
+
assert.equal(fusedFloor(segment([bound("a3", "300", 3000)]), anchors, "42"), null);
|
|
153
|
+
assert.equal(fusedFloor(undefined, anchors, "42"), null);
|
|
154
|
+
assert.equal(fusedFloor(segment([bound("a3", "300", 3000)]), anchors, undefined), null);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("a slot off the anchored chain has no floor", () => {
|
|
158
|
+
const anchors = new Map([["a1", anchor("a1", "10")]]);
|
|
159
|
+
assert.equal(fusedFloor(segment([bound("a1", "100", 1000)]), anchors, "42", "global"), null);
|
|
160
|
+
assert.equal(fusedFloor(segment([bound("a1", "100", 1000)]), anchors, "42", "bitgraph:main")?.anchorProofHash, "a1");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("only not-before bounds count; not-after bounds are never a ceiling", () => {
|
|
164
|
+
const anchors = new Map([["a9", anchor("a9", "5")]]);
|
|
165
|
+
const after: SegmentBound = { ...bound("a9", "900", 9000), kind: "not-after" };
|
|
166
|
+
assert.equal(fusedFloor(segment([after]), anchors, "42"), null);
|
|
167
|
+
});
|
|
168
|
+
});
|
package/src/check.ts
CHANGED
|
@@ -63,7 +63,9 @@ import type {
|
|
|
63
63
|
SegmentBound,
|
|
64
64
|
TemporalSegment,
|
|
65
65
|
} from "@mikeargento/bitgraph-audit";
|
|
66
|
-
import { auditIngest, AUDIT_VERSION, streamMatchedArtifacts } from "@mikeargento/bitgraph-audit";
|
|
66
|
+
import { auditIngest, AUDIT_VERSION, streamMatchedArtifacts, streamArtifactsByHash } from "@mikeargento/bitgraph-audit";
|
|
67
|
+
import { readFuseAttribution, verifyFuse, base64ToBytes, bytesToHex, resetEpochLinkState } from "@mikeargento/bitgraph-verify";
|
|
68
|
+
import type { FuseVerifyResult } from "@mikeargento/bitgraph-verify";
|
|
67
69
|
import type { ThreeValued } from "./types.js";
|
|
68
70
|
import { SIG_EVIDENCE_MAX_BYTES } from "./types.js";
|
|
69
71
|
import { kleeneAll } from "./logic.js";
|
|
@@ -108,7 +110,7 @@ export const KNOWN_ENCLAVE_MEASUREMENTS: ReadonlyArray<{ pcr0: string; label: st
|
|
|
108
110
|
|
|
109
111
|
/** One checked property, three-valued, with a plain-language reason. */
|
|
110
112
|
export interface CheckLine {
|
|
111
|
-
name: "file" | "signature" | "attestation" | "enclave" | "witness" | "contradiction" | "domain";
|
|
113
|
+
name: "file" | "signature" | "attestation" | "enclave" | "witness" | "contradiction" | "domain" | "fused";
|
|
112
114
|
result: ThreeValued;
|
|
113
115
|
detail: string;
|
|
114
116
|
}
|
|
@@ -125,12 +127,42 @@ export interface CheckBound {
|
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
export interface CheckBounds {
|
|
128
|
-
status: "
|
|
130
|
+
status: "lower-bounded-with-following-anchor" | "lower-bounded" | "upper-bounded" | "unanchored";
|
|
129
131
|
notBefore?: CheckBound;
|
|
130
132
|
notAfter?: CheckBound;
|
|
131
133
|
detail: string;
|
|
132
134
|
}
|
|
133
135
|
|
|
136
|
+
/**
|
|
137
|
+
* The wall-clock floor of a fused artifact: the last verified anchored
|
|
138
|
+
* block preceding its SLOT counter in the same epoch chain. Counter-order
|
|
139
|
+
* evidence by construction (no hash path reaches a slot record), reported
|
|
140
|
+
* with the same "genuine block" assumption as every other bound.
|
|
141
|
+
*/
|
|
142
|
+
export interface CheckFloor {
|
|
143
|
+
blockNumber?: string;
|
|
144
|
+
blockHash: string;
|
|
145
|
+
/** Unix seconds from the verified block header. */
|
|
146
|
+
timestamp: number;
|
|
147
|
+
anchorProofHash: string;
|
|
148
|
+
evidence: "counter-order";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** What the bundle establishes about a recording marked fused (profile bitgraph-fuse/1, working name). */
|
|
152
|
+
export interface CheckFused {
|
|
153
|
+
category: FuseVerifyResult["category"] | "NO_EVIDENCE";
|
|
154
|
+
placement: string | null;
|
|
155
|
+
originDigestB64: string | null;
|
|
156
|
+
/** Which bytes in the bundle the commitment check ran against. */
|
|
157
|
+
evidence: "fused-bytes" | "original" | null;
|
|
158
|
+
/** The bounded statements, carrying the floor clause when a floor exists. */
|
|
159
|
+
statements: string[];
|
|
160
|
+
floor: CheckFloor | null;
|
|
161
|
+
floorDetail: string;
|
|
162
|
+
/** The causal span [N, M]. */
|
|
163
|
+
span: { slotCounter: string; commitCounter: string; positions: string } | null;
|
|
164
|
+
}
|
|
165
|
+
|
|
134
166
|
/** A non-anchor recording in the bundle. */
|
|
135
167
|
export interface CheckRecording {
|
|
136
168
|
proofHash: string;
|
|
@@ -146,6 +178,8 @@ export interface CheckRecording {
|
|
|
146
178
|
/** Kleene all over `lines`. */
|
|
147
179
|
result: ThreeValued;
|
|
148
180
|
bounds: CheckBounds;
|
|
181
|
+
/** Present exactly when the proof's signed attribution marks it fused. */
|
|
182
|
+
fused?: CheckFused;
|
|
149
183
|
}
|
|
150
184
|
|
|
151
185
|
/** An Ethereum anchor recording in the bundle. */
|
|
@@ -227,6 +261,14 @@ export interface CheckOptions {
|
|
|
227
261
|
* embedders may supply additional candidates.
|
|
228
262
|
*/
|
|
229
263
|
sigEvidence?: ReadonlyMap<string, Uint8Array>;
|
|
264
|
+
/**
|
|
265
|
+
* Fuse evidence by proofHash: the verifyFuse result over the fused bytes
|
|
266
|
+
* (preferred) or the original, whichever the bundle holds. checkIngest
|
|
267
|
+
* collects this from the bundle; embedders may supply it.
|
|
268
|
+
*/
|
|
269
|
+
fuseEvidence?: ReadonlyMap<string, FuseVerifyResult>;
|
|
270
|
+
/** Content hashes (hex) of files that served as the original of a fused recording; not "unmatched". */
|
|
271
|
+
fuseOriginHexes?: ReadonlySet<string>;
|
|
230
272
|
}
|
|
231
273
|
|
|
232
274
|
// ---------------------------------------------------------------------------
|
|
@@ -270,9 +312,57 @@ export async function checkIngest(ingest: IngestResult, options?: CheckOptions):
|
|
|
270
312
|
opts = { ...opts, sigEvidence: collected };
|
|
271
313
|
}
|
|
272
314
|
const audit = await auditIngest(ingest, { startedAt: "" });
|
|
315
|
+
if (opts?.fuseEvidence === undefined) {
|
|
316
|
+
const collected = await collectFuseEvidence(ingest);
|
|
317
|
+
opts = { ...opts, fuseEvidence: collected.evidence, fuseOriginHexes: collected.originHexes };
|
|
318
|
+
}
|
|
273
319
|
return buildCheckReport(audit, opts);
|
|
274
320
|
}
|
|
275
321
|
|
|
322
|
+
/**
|
|
323
|
+
* For every proof marked fused, find the bytes the check can run against:
|
|
324
|
+
* the committed (fused) bytes if the bundle holds them, else the original
|
|
325
|
+
* named by the signed origin digest. Runs after the audit pass and resets
|
|
326
|
+
* the verify library's epoch-link state around its own verifications.
|
|
327
|
+
*/
|
|
328
|
+
async function collectFuseEvidence(
|
|
329
|
+
ingest: IngestResult
|
|
330
|
+
): Promise<{ evidence: Map<string, FuseVerifyResult>; originHexes: Set<string> }> {
|
|
331
|
+
const evidence = new Map<string, FuseVerifyResult>();
|
|
332
|
+
const originHexes = new Set<string>();
|
|
333
|
+
const wanted: Array<{ proof: ObservedProof; artifactHex: string | null; originHex: string | null }> = [];
|
|
334
|
+
const hexes = new Set<string>();
|
|
335
|
+
for (const proof of ingest.proofs) {
|
|
336
|
+
const marker = readFuseAttribution(proof.proof);
|
|
337
|
+
if (marker === null) continue;
|
|
338
|
+
const artifactBytes = base64ToBytes(proof.proof.artifact.digestB64);
|
|
339
|
+
const artifactHex = artifactBytes !== null ? bytesToHex(artifactBytes) : null;
|
|
340
|
+
const originHex = marker.originDigest !== undefined ? bytesToHex(marker.originDigest) : null;
|
|
341
|
+
wanted.push({ proof, artifactHex, originHex });
|
|
342
|
+
if (artifactHex !== null) hexes.add(artifactHex);
|
|
343
|
+
if (originHex !== null) hexes.add(originHex);
|
|
344
|
+
}
|
|
345
|
+
if (wanted.length === 0) return { evidence, originHexes };
|
|
346
|
+
const bytesByHex = new Map<string, Uint8Array>();
|
|
347
|
+
for await (const artifact of streamArtifactsByHash(ingest, hexes)) {
|
|
348
|
+
if (!bytesByHex.has(artifact.sha256Hex)) bytesByHex.set(artifact.sha256Hex, artifact.bytes);
|
|
349
|
+
}
|
|
350
|
+
resetEpochLinkState();
|
|
351
|
+
try {
|
|
352
|
+
for (const w of wanted) {
|
|
353
|
+
const direct = w.artifactHex !== null ? bytesByHex.get(w.artifactHex) : undefined;
|
|
354
|
+
const original = w.originHex !== null ? bytesByHex.get(w.originHex) : undefined;
|
|
355
|
+
if (original !== undefined && w.originHex !== null) originHexes.add(w.originHex);
|
|
356
|
+
const bytes = direct ?? original;
|
|
357
|
+
if (bytes === undefined) continue;
|
|
358
|
+
evidence.set(w.proof.proofHash, await verifyFuse({ proof: w.proof.proof, bytes }));
|
|
359
|
+
}
|
|
360
|
+
} finally {
|
|
361
|
+
resetEpochLinkState();
|
|
362
|
+
}
|
|
363
|
+
return { evidence, originHexes };
|
|
364
|
+
}
|
|
365
|
+
|
|
276
366
|
/** The pure report builder over an AuditResult. */
|
|
277
367
|
export function buildCheckReport(audit: AuditResult, options?: CheckOptions): CheckReport {
|
|
278
368
|
const webCrypto = options?.webCryptoAvailable ?? true;
|
|
@@ -316,7 +406,9 @@ export function buildCheckReport(audit: AuditResult, options?: CheckOptions): Ch
|
|
|
316
406
|
artifactPathByProof.get(proof.proofHash),
|
|
317
407
|
webCrypto,
|
|
318
408
|
options?.from,
|
|
319
|
-
options?.sigEvidence
|
|
409
|
+
options?.sigEvidence,
|
|
410
|
+
options?.fuseEvidence,
|
|
411
|
+
anchorByHash
|
|
320
412
|
)
|
|
321
413
|
);
|
|
322
414
|
}
|
|
@@ -325,7 +417,7 @@ export function buildCheckReport(audit: AuditResult, options?: CheckOptions): Ch
|
|
|
325
417
|
sortByPosition(recordings);
|
|
326
418
|
sortByPosition(anchors);
|
|
327
419
|
const contradictions = collectContradictions(audit);
|
|
328
|
-
const notes = collectNotes(audit, recordings, anchors);
|
|
420
|
+
const notes = collectNotes(audit, recordings, anchors, options?.fuseOriginHexes);
|
|
329
421
|
const notChecked = collectNotChecked(anchors.length > 0, options?.from?.domain);
|
|
330
422
|
|
|
331
423
|
const allLines: ThreeValued[] = [
|
|
@@ -383,7 +475,9 @@ function buildRecording(
|
|
|
383
475
|
filePath: string | undefined,
|
|
384
476
|
webCrypto: boolean,
|
|
385
477
|
from?: CheckDomain,
|
|
386
|
-
sigEvidence?: ReadonlyMap<string, Uint8Array
|
|
478
|
+
sigEvidence?: ReadonlyMap<string, Uint8Array>,
|
|
479
|
+
fuseEvidence?: ReadonlyMap<string, FuseVerifyResult>,
|
|
480
|
+
anchorByHash?: ReadonlyMap<string, AnchorRecord>
|
|
387
481
|
): CheckRecording {
|
|
388
482
|
const lines: CheckLine[] = [];
|
|
389
483
|
const v = proof.verification;
|
|
@@ -448,6 +542,19 @@ function buildRecording(
|
|
|
448
542
|
lines.push(domainLine(proof, verified, from, sigEvidence ?? EMPTY_EVIDENCE));
|
|
449
543
|
}
|
|
450
544
|
|
|
545
|
+
// fused: the proof's signed attribution marks a fused artifact (profile
|
|
546
|
+
// bitgraph-fuse/1). The commitment check runs over the fused bytes, or
|
|
547
|
+
// over the original by reconstruction; its floor is the last verified
|
|
548
|
+
// anchor preceding the SLOT, not the commit.
|
|
549
|
+
const marker = readFuseAttribution(proof.proof);
|
|
550
|
+
let fused: CheckFused | undefined;
|
|
551
|
+
if (marker !== null) {
|
|
552
|
+
const floor = fusedFloor(segment, anchorByHash, proof.slotCounter, proof.chainId);
|
|
553
|
+
const built = fusedLine(marker.placement, marker.originDigest !== undefined ? proof.proof.attribution?.message ?? null : null, fuseEvidence?.get(proof.proofHash), floor, proof);
|
|
554
|
+
lines.push(built.line);
|
|
555
|
+
fused = built.fused;
|
|
556
|
+
}
|
|
557
|
+
|
|
451
558
|
const result = kleeneAll(lines.map((l) => l.result));
|
|
452
559
|
|
|
453
560
|
return {
|
|
@@ -462,9 +569,116 @@ function buildRecording(
|
|
|
462
569
|
lines,
|
|
463
570
|
result,
|
|
464
571
|
bounds: boundsFor(segment),
|
|
572
|
+
...(fused !== undefined ? { fused } : {}),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* The fused floor: among the segment's verified not-before anchors, those
|
|
578
|
+
* whose commit counter precedes the SLOT counter, tightest last (largest
|
|
579
|
+
* block number, else latest timestamp). Null when no verified anchor
|
|
580
|
+
* precedes the slot in its epoch chain.
|
|
581
|
+
*/
|
|
582
|
+
export const ANCHORED_CHAIN = "bitgraph:main";
|
|
583
|
+
|
|
584
|
+
export function fusedFloor(
|
|
585
|
+
segment: TemporalSegment | undefined,
|
|
586
|
+
anchorByHash: ReadonlyMap<string, AnchorRecord> | undefined,
|
|
587
|
+
slotCounter: string | undefined,
|
|
588
|
+
chainId: string = ANCHORED_CHAIN
|
|
589
|
+
): CheckFloor | null {
|
|
590
|
+
// Anchors exist only on the anchored chain and the anchor index carries no
|
|
591
|
+
// chain segment, so a slot on any other chain compares to nothing.
|
|
592
|
+
if (chainId !== ANCHORED_CHAIN) return null;
|
|
593
|
+
if (segment === undefined || anchorByHash === undefined || slotCounter === undefined || !/^[0-9]+$/.test(slotCounter)) return null;
|
|
594
|
+
const slot = BigInt(slotCounter);
|
|
595
|
+
let best: SegmentBound | undefined;
|
|
596
|
+
for (const b of segment.lowerBounds) {
|
|
597
|
+
if (b.kind !== "not-before") continue;
|
|
598
|
+
const anchor = anchorByHash.get(b.anchorProofHash);
|
|
599
|
+
if (anchor === undefined || anchor.counter === undefined || !/^[0-9]+$/.test(anchor.counter)) continue;
|
|
600
|
+
if (BigInt(anchor.counter) >= slot) continue;
|
|
601
|
+
if (best === undefined) { best = b; continue; }
|
|
602
|
+
const bn = b.blockNumber !== undefined ? BigInt(b.blockNumber) : BigInt(b.timestamp);
|
|
603
|
+
const bb = best.blockNumber !== undefined ? BigInt(best.blockNumber) : BigInt(best.timestamp);
|
|
604
|
+
if (bn > bb) best = b;
|
|
605
|
+
}
|
|
606
|
+
if (best === undefined) return null;
|
|
607
|
+
return {
|
|
608
|
+
...(best.blockNumber !== undefined ? { blockNumber: best.blockNumber } : {}),
|
|
609
|
+
blockHash: best.blockHash,
|
|
610
|
+
timestamp: best.timestamp,
|
|
611
|
+
anchorProofHash: best.anchorProofHash,
|
|
612
|
+
evidence: "counter-order",
|
|
465
613
|
};
|
|
466
614
|
}
|
|
467
615
|
|
|
616
|
+
const FLOOR_UNDETERMINED = "floor undetermined: no anchor precedes this slot in its epoch";
|
|
617
|
+
|
|
618
|
+
function floorClause(floor: CheckFloor): string {
|
|
619
|
+
const block = floor.blockNumber !== undefined ? `#${floor.blockNumber}` : floor.blockHash;
|
|
620
|
+
return `anchored block ${block} (${new Date(floor.timestamp * 1000).toISOString()})`;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function fusedLine(
|
|
624
|
+
placement: string | null,
|
|
625
|
+
originMessage: string | null,
|
|
626
|
+
ev: FuseVerifyResult | undefined,
|
|
627
|
+
floor: CheckFloor | null,
|
|
628
|
+
proof: ObservedProof
|
|
629
|
+
): { line: CheckLine; fused: CheckFused } {
|
|
630
|
+
const slot = proof.slotCounter ?? "?";
|
|
631
|
+
const where = placement !== null ? ` (placement ${placement})` : " (placement undeclared)";
|
|
632
|
+
const floorDetail = floor !== null
|
|
633
|
+
? `assembled after ${floorClause(floor)}, the last verified anchor preceding slot ${slot} in this epoch`
|
|
634
|
+
: proof.chainId !== ANCHORED_CHAIN
|
|
635
|
+
? `floor undetermined: the slot is on chain "${proof.chainId}", not the anchored chain`
|
|
636
|
+
: FLOOR_UNDETERMINED;
|
|
637
|
+
const span = ev?.span !== undefined && ev.span !== null
|
|
638
|
+
? { slotCounter: ev.span.slotCounter, commitCounter: ev.span.commitCounter, positions: ev.span.positions }
|
|
639
|
+
: proof.slotCounter !== undefined && proof.counter !== undefined
|
|
640
|
+
? { slotCounter: proof.slotCounter, commitCounter: proof.counter, positions: (BigInt(proof.counter) - BigInt(proof.slotCounter)).toString() }
|
|
641
|
+
: null;
|
|
642
|
+
const statements = (ev?.statements ?? []).map((s) =>
|
|
643
|
+
floor !== null ? s.replace(`at position ${slot},`, `at position ${slot}, which followed ${floorClause(floor)},`) : s
|
|
644
|
+
);
|
|
645
|
+
const base = (category: CheckFused["category"], evidence: CheckFused["evidence"]): CheckFused => ({
|
|
646
|
+
category,
|
|
647
|
+
placement: ev?.placement ?? placement,
|
|
648
|
+
originDigestB64: ev?.originDigestB64 ?? originMessage,
|
|
649
|
+
evidence,
|
|
650
|
+
statements,
|
|
651
|
+
floor,
|
|
652
|
+
floorDetail,
|
|
653
|
+
span,
|
|
654
|
+
});
|
|
655
|
+
if (ev === undefined) {
|
|
656
|
+
return {
|
|
657
|
+
line: { name: "fused", result: "UNDETERMINED", detail: `marked fused${where}, but neither the fused bytes nor ${originMessage !== null ? "the original" : "an original"} is in this bundle, so the commitment to slot ${slot} cannot be checked` },
|
|
658
|
+
fused: base("NO_EVIDENCE", null),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
const evidence: CheckFused["evidence"] = ev.category === "FUSED_FROM_ORIGIN" || (ev.fileDigestB64 !== ev.artifactDigestB64 && ev.category !== "NO_MATCH") ? "original" : "fused-bytes";
|
|
662
|
+
switch (ev.category) {
|
|
663
|
+
case "FUSED_DIRECT":
|
|
664
|
+
return { line: { name: "fused", result: "TRUE", detail: `the fused bytes carry the commitment to slot ${slot}${where}` }, fused: base(ev.category, "fused-bytes") };
|
|
665
|
+
case "FUSED_FROM_ORIGIN":
|
|
666
|
+
return { line: { name: "fused", result: "TRUE", detail: `the original rebuilds the committed fused bytes${where}; they carry the commitment to slot ${slot}` }, fused: base(ev.category, "original") };
|
|
667
|
+
case "INVALID_SLOT_COMMITMENT":
|
|
668
|
+
return { line: { name: "fused", result: "FALSE", detail: `the fused bytes do not carry the commitment to this proof's slot ${slot}: ${ev.reason ?? ""}` }, fused: base(ev.category, evidence) };
|
|
669
|
+
case "INVALID_ORIGIN_ATTRIBUTION":
|
|
670
|
+
return { line: { name: "fused", result: "FALSE", detail: `the declared origin contradicts the origin inside the fused bytes: ${ev.reason ?? ""}` }, fused: base(ev.category, evidence) };
|
|
671
|
+
case "RECONSTRUCTION_MISMATCH":
|
|
672
|
+
return { line: { name: "fused", result: "FALSE", detail: `the file matching the declared origin does not rebuild the committed fused bytes: ${ev.reason ?? ""}` }, fused: base(ev.category, "original") };
|
|
673
|
+
case "UNDETERMINED_PLACEMENT":
|
|
674
|
+
return { line: { name: "fused", result: "UNDETERMINED", detail: `${ev.reason ?? "the placement is not registered"}; this verifier cannot check the commitment` }, fused: base(ev.category, evidence) };
|
|
675
|
+
case "INVALID_UNDERLYING_PROOF":
|
|
676
|
+
return { line: { name: "fused", result: "UNDETERMINED", detail: "not checked: the proof itself does not verify (see the signature line)" }, fused: base(ev.category, evidence) };
|
|
677
|
+
default:
|
|
678
|
+
return { line: { name: "fused", result: "UNDETERMINED", detail: `the commitment could not be checked: ${ev.reason ?? ev.category}` }, fused: base(ev.category, evidence) };
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
468
682
|
function attestationLine(
|
|
469
683
|
record: ProofAttestationRecord | undefined,
|
|
470
684
|
webCrypto: boolean
|
|
@@ -618,7 +832,7 @@ function boundsFor(segment: TemporalSegment | undefined): CheckBounds {
|
|
|
618
832
|
const notAfter = upper === undefined ? undefined : toBound(upper);
|
|
619
833
|
const status: CheckBounds["status"] =
|
|
620
834
|
notBefore !== undefined && notAfter !== undefined
|
|
621
|
-
? "
|
|
835
|
+
? "lower-bounded-with-following-anchor"
|
|
622
836
|
: notBefore !== undefined
|
|
623
837
|
? "lower-bounded"
|
|
624
838
|
: notAfter !== undefined
|
|
@@ -636,16 +850,22 @@ function boundsFor(segment: TemporalSegment | undefined): CheckBounds {
|
|
|
636
850
|
};
|
|
637
851
|
}
|
|
638
852
|
|
|
639
|
-
/**
|
|
853
|
+
/**
|
|
854
|
+
* "after Ethereum block A; precedes an anchor that consumed block B", shared by bounds.detail and the summary.
|
|
855
|
+
* The following anchor is deliberately NOT rendered as "before block B": a proof precedes the anchor's
|
|
856
|
+
* commit, and block B's timestamp bounds that commit from below, not the proof from above. Reading it as
|
|
857
|
+
* a ceiling assumes the anchor consumed a recently published block. An inbound anchor cannot carry a
|
|
858
|
+
* proof of an upper bound, so the phrase says what the evidence supports and names the assumption.
|
|
859
|
+
*/
|
|
640
860
|
function boundsPhrase(
|
|
641
861
|
status: CheckBounds["status"],
|
|
642
862
|
notBefore: CheckBound | undefined,
|
|
643
863
|
notAfter: CheckBound | undefined
|
|
644
864
|
): string {
|
|
645
865
|
switch (status) {
|
|
646
|
-
case "
|
|
866
|
+
case "lower-bounded-with-following-anchor":
|
|
647
867
|
return (
|
|
648
|
-
`after Ethereum block ${blockRef(notBefore as CheckBound)}
|
|
868
|
+
`after Ethereum block ${blockRef(notBefore as CheckBound)} (header verified in this bundle); precedes an anchor that consumed block ${blockRef(notAfter as CheckBound)} (header verified), an anchor-latency assumption rather than an upper bound on this recording` +
|
|
649
869
|
weakerSuffix((notBefore as CheckBound).weaker || (notAfter as CheckBound).weaker)
|
|
650
870
|
);
|
|
651
871
|
case "lower-bounded":
|
|
@@ -689,8 +909,8 @@ function toBound(b: SegmentBound): CheckBound {
|
|
|
689
909
|
/** The headline form of the bounds: one clause, no qualifiers (those live in bounds.detail). */
|
|
690
910
|
function shortBoundsPhrase(b: CheckBounds): string {
|
|
691
911
|
switch (b.status) {
|
|
692
|
-
case "
|
|
693
|
-
return `
|
|
912
|
+
case "lower-bounded-with-following-anchor":
|
|
913
|
+
return `after Ethereum block ${blockRef(b.notBefore as CheckBound)}, then an anchor at block ${blockRef(b.notAfter as CheckBound)}`;
|
|
694
914
|
case "lower-bounded":
|
|
695
915
|
return `after Ethereum block ${blockRef(b.notBefore as CheckBound)}`;
|
|
696
916
|
case "upper-bounded":
|
|
@@ -815,7 +1035,7 @@ function collectContradictions(audit: AuditResult): CheckLine[] {
|
|
|
815
1035
|
return out;
|
|
816
1036
|
}
|
|
817
1037
|
|
|
818
|
-
function collectNotes(audit: AuditResult, recordings: CheckRecording[], anchors: CheckAnchor[]): string[] {
|
|
1038
|
+
function collectNotes(audit: AuditResult, recordings: CheckRecording[], anchors: CheckAnchor[], fuseOriginHexes?: ReadonlySet<string>): string[] {
|
|
819
1039
|
const notes: string[] = [];
|
|
820
1040
|
|
|
821
1041
|
// Excerpt gaps, said once, plainly.
|
|
@@ -842,7 +1062,13 @@ function collectNotes(audit: AuditResult, recordings: CheckRecording[], anchors:
|
|
|
842
1062
|
|
|
843
1063
|
// Files that match no recording: the human hint for an edited file or a
|
|
844
1064
|
// wrong drop, without claiming which.
|
|
845
|
-
const
|
|
1065
|
+
const originals = audit.ingest.artifacts.filter((a) => a.matchedProofHashes.length === 0 && fuseOriginHexes?.has(a.sha256Hex) === true);
|
|
1066
|
+
if (originals.length > 0) {
|
|
1067
|
+
notes.push(
|
|
1068
|
+
`${originals.length} file${originals.length === 1 ? "" : "s"} in this bundle ${originals.length === 1 ? "is" : "are"} the original of a fused recording: ${originals.slice(0, 5).map((a) => a.paths[0] ?? "(unnamed)").join(", ")}${originals.length > 5 ? ", …" : ""}. The original itself receives only the ceiling (it existed no later than the commit); the fused bytes receive the interval`
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
const unmatched = audit.ingest.artifacts.filter((a) => a.matchedProofHashes.length === 0 && fuseOriginHexes?.has(a.sha256Hex) !== true);
|
|
846
1072
|
if (unmatched.length > 0 && recordings.length > 0) {
|
|
847
1073
|
const listed = unmatched
|
|
848
1074
|
.slice(0, 5)
|
|
@@ -963,6 +1189,11 @@ export function renderCheckText(report: CheckReport): string {
|
|
|
963
1189
|
}
|
|
964
1190
|
for (const line of rec.lines) out.push(` ${mark(line.result)} ${pad(line.name, 12)} ${line.detail}`);
|
|
965
1191
|
out.push(` bounds ${rec.bounds.detail}`);
|
|
1192
|
+
if (rec.fused !== undefined) {
|
|
1193
|
+
out.push(` fused floor ${rec.fused.floorDetail}`);
|
|
1194
|
+
if (rec.fused.span !== null) out.push(` fused span slot ${rec.fused.span.slotCounter} to commit ${rec.fused.span.commitCounter} (${rec.fused.span.positions} positions)`);
|
|
1195
|
+
for (const s of rec.fused.statements) out.push(` statement ${s}`);
|
|
1196
|
+
}
|
|
966
1197
|
out.push("");
|
|
967
1198
|
});
|
|
968
1199
|
report.anchors.forEach((a, i) => {
|
package/src/verdict.ts
CHANGED
|
@@ -31,7 +31,7 @@ import type { DeclaredEntry, Resolution, Rule, Verdict } from "./types.js";
|
|
|
31
31
|
* A unit test asserts this equals package.json's version, so the
|
|
32
32
|
* constant cannot drift silently across releases.
|
|
33
33
|
*/
|
|
34
|
-
export const PLAYER_VERSION = "0.
|
|
34
|
+
export const PLAYER_VERSION = "0.8.0";
|
|
35
35
|
|
|
36
36
|
/** The player package's own version. */
|
|
37
37
|
export function playerVersion(): string {
|