@mikeargento/bitgraph-player 0.2.1 → 0.4.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/README.md +40 -0
- package/SPEC.md +95 -6
- package/dist/__tests__/check.test.d.ts +2 -0
- package/dist/__tests__/check.test.d.ts.map +1 -0
- package/dist/__tests__/check.test.js +358 -0
- package/dist/__tests__/check.test.js.map +1 -0
- package/dist/__tests__/sig-v2.test.d.ts +2 -0
- package/dist/__tests__/sig-v2.test.d.ts.map +1 -0
- package/dist/__tests__/sig-v2.test.js +266 -0
- package/dist/__tests__/sig-v2.test.js.map +1 -0
- package/dist/check.d.ts +156 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +640 -0
- package/dist/check.js.map +1 -0
- package/dist/cli.js +84 -15
- package/dist/cli.js.map +1 -1
- package/dist/evaluate.d.ts +8 -21
- package/dist/evaluate.d.ts.map +1 -1
- package/dist/evaluate.js +93 -2
- package/dist/evaluate.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/play.d.ts +12 -2
- package/dist/play.d.ts.map +1 -1
- package/dist/play.js +39 -4
- package/dist/play.js.map +1 -1
- package/dist/rule.d.ts +0 -13
- package/dist/rule.d.ts.map +1 -1
- package/dist/rule.js +84 -8
- package/dist/rule.js.map +1 -1
- package/dist/sig.d.ts +38 -0
- package/dist/sig.d.ts.map +1 -0
- package/dist/sig.js +154 -0
- package/dist/sig.js.map +1 -0
- package/dist/types.d.ts +21 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/verdict.d.ts +1 -1
- package/dist/verdict.d.ts.map +1 -1
- package/dist/verdict.js +17 -2
- package/dist/verdict.js.map +1 -1
- package/dist-web/verify.html +159 -0
- package/package.json +8 -3
- package/src/__tests__/check.test.ts +417 -0
- package/src/__tests__/fixtures/export-random-043/ethereum-anchors/anchor-after-witness.json +6 -0
- package/src/__tests__/fixtures/export-random-043/ethereum-anchors/anchor-after.json +43 -0
- package/src/__tests__/fixtures/export-random-043/ethereum-anchors/anchor-before-witness.json +6 -0
- package/src/__tests__/fixtures/export-random-043/ethereum-anchors/anchor-before.json +47 -0
- package/src/__tests__/fixtures/export-random-043/proof.json +38 -0
- package/src/__tests__/fixtures/export-random-043/random-043.txt +1 -0
- package/src/__tests__/sig-v2.test.ts +315 -0
- package/src/check.ts +873 -0
- package/src/cli.ts +82 -16
- package/src/evaluate.ts +107 -2
- package/src/index.ts +9 -2
- package/src/play.ts +43 -5
- package/src/rule.ts +93 -9
- package/src/sig.ts +170 -0
- package/src/types.ts +21 -4
- package/src/verdict.ts +18 -2
package/dist/check.js
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
+
import { auditIngest, AUDIT_VERSION } from "@mikeargento/bitgraph-audit";
|
|
3
|
+
import { kleeneAll } from "./logic.js";
|
|
4
|
+
import { PLAYER_VERSION } from "./verdict.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Declared knowledge: published enclave measurements
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
/**
|
|
9
|
+
* Published BitGraph enclave PCR0 measurements, per PINS.md, oldest first.
|
|
10
|
+
* Each remains correct for proofs minted during its own period; a proof
|
|
11
|
+
* carries its own measurement, so nothing here needs to be "current" for
|
|
12
|
+
* an old proof to check. Lowercase hex, exactly as attestations report.
|
|
13
|
+
*/
|
|
14
|
+
export const KNOWN_ENCLAVE_MEASUREMENTS = [
|
|
15
|
+
{
|
|
16
|
+
pcr0: "8530a6399399c4f23d89f5a1faa2e8bf2e09a5959f117070fca08148377f92c902c695fc926c17f67f35f110327dca92",
|
|
17
|
+
label: "genesis",
|
|
18
|
+
period: "2026-05-15 to 2026-06-27",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
pcr0: "bb9dd158703603ec222fe565495ceaa7edc08f665da5c1cddad91442ac2211731390267036d79deb720d13fb704f648a",
|
|
22
|
+
label: "enclave v2 (reproducible)",
|
|
23
|
+
period: "2026-06-27 to 2026-07-05",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
pcr0: "e2fccbae77ee40aac4830e84f195e05d69eb4547bbd961f4d3459feba10807140424aca42ad03810354982598c86b9cb",
|
|
27
|
+
label: "enclave v4 (reproducible)",
|
|
28
|
+
period: "2026-07-05 to 2026-07-29",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
pcr0: "6483cedffed74680ffb287507744a398b288c3fb943eb3f2e4fe889f8b60b3d575ad8942350360b69a1bd7bf713df27f",
|
|
32
|
+
label: "enclave v5 (reproducible)",
|
|
33
|
+
period: "2026-07-29 onward",
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Anomaly classification for excerpts
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
/**
|
|
40
|
+
* Codes that mean "the bundle is an excerpt", not "the chain is wrong":
|
|
41
|
+
* a recording whose predecessor is not supplied, positions between the
|
|
42
|
+
* supplied recordings that are not supplied, an epoch link whose other end
|
|
43
|
+
* is not supplied. Reported as notes. Everything else classifyAnomalies
|
|
44
|
+
* emits is a contradiction among the supplied proofs and surfaces as FALSE.
|
|
45
|
+
*/
|
|
46
|
+
const EXCERPT_NORMAL_CODES = new Set([
|
|
47
|
+
"unexplained-counter-positions",
|
|
48
|
+
"chain-break-missing",
|
|
49
|
+
"epochlink-terminal-missing",
|
|
50
|
+
"epochlink-dangling",
|
|
51
|
+
]);
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Public API
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
/**
|
|
56
|
+
* Check an already-ingested bundle: runs the audit's pure tail (no
|
|
57
|
+
* filesystem, no network) and builds the report. Both the CLI and the
|
|
58
|
+
* browser page call this, so they cannot drift.
|
|
59
|
+
*/
|
|
60
|
+
export async function checkIngest(ingest, options) {
|
|
61
|
+
const audit = await auditIngest(ingest, { startedAt: "" });
|
|
62
|
+
return buildCheckReport(audit, options);
|
|
63
|
+
}
|
|
64
|
+
/** The pure report builder over an AuditResult. */
|
|
65
|
+
export function buildCheckReport(audit, options) {
|
|
66
|
+
const webCrypto = options?.webCryptoAvailable ?? true;
|
|
67
|
+
const anchorByHash = new Map(audit.anchors.anchors.map((a) => [a.proofHash, a]));
|
|
68
|
+
const attestationByHash = new Map(audit.attestations.records.map((r) => [r.proofHash, r]));
|
|
69
|
+
const segmentByHash = new Map();
|
|
70
|
+
for (const segment of audit.temporal.segments) {
|
|
71
|
+
for (const hash of segment.memberProofHashes)
|
|
72
|
+
segmentByHash.set(hash, segment);
|
|
73
|
+
}
|
|
74
|
+
const witnessOutcomesByAnchor = new Map();
|
|
75
|
+
for (const outcome of audit.witnesses.outcomes) {
|
|
76
|
+
if (outcome.anchorProofHash === undefined)
|
|
77
|
+
continue;
|
|
78
|
+
const list = witnessOutcomesByAnchor.get(outcome.anchorProofHash) ?? [];
|
|
79
|
+
list.push(outcome);
|
|
80
|
+
witnessOutcomesByAnchor.set(outcome.anchorProofHash, list);
|
|
81
|
+
}
|
|
82
|
+
const artifactPathByProof = new Map();
|
|
83
|
+
for (const artifact of audit.ingest.artifacts) {
|
|
84
|
+
for (const hash of artifact.matchedProofHashes) {
|
|
85
|
+
if (!artifactPathByProof.has(hash) && artifact.paths[0] !== undefined) {
|
|
86
|
+
artifactPathByProof.set(hash, artifact.paths[0]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const recordings = [];
|
|
91
|
+
const anchors = [];
|
|
92
|
+
for (const proof of audit.ingest.proofs) {
|
|
93
|
+
const anchor = anchorByHash.get(proof.proofHash);
|
|
94
|
+
if (anchor !== undefined) {
|
|
95
|
+
anchors.push(buildAnchor(proof, anchor, witnessOutcomesByAnchor.get(proof.proofHash) ?? []));
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
recordings.push(buildRecording(proof, attestationByHash.get(proof.proofHash), segmentByHash.get(proof.proofHash), artifactPathByProof.get(proof.proofHash), webCrypto));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
sortByPosition(recordings);
|
|
102
|
+
sortByPosition(anchors);
|
|
103
|
+
const contradictions = collectContradictions(audit);
|
|
104
|
+
const notes = collectNotes(audit, recordings, anchors);
|
|
105
|
+
const notChecked = collectNotChecked(anchors.length > 0);
|
|
106
|
+
const allLines = [
|
|
107
|
+
...recordings.map((r) => r.result),
|
|
108
|
+
...anchors.map((a) => a.result),
|
|
109
|
+
...contradictions.map((c) => c.result),
|
|
110
|
+
];
|
|
111
|
+
const result = allLines.length === 0 ? "UNDETERMINED" : kleeneAll(allLines);
|
|
112
|
+
return {
|
|
113
|
+
check: "bitgraph-check/1",
|
|
114
|
+
result,
|
|
115
|
+
summary: summarize(result, recordings, anchors, contradictions),
|
|
116
|
+
recordings,
|
|
117
|
+
anchors,
|
|
118
|
+
contradictions,
|
|
119
|
+
notes,
|
|
120
|
+
notChecked,
|
|
121
|
+
evaluator: { name: "bitgraph-player", version: PLAYER_VERSION, audit: AUDIT_VERSION },
|
|
122
|
+
network: "none",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Causal display order: by epoch id, then commit counter as an integer.
|
|
127
|
+
* Entries without a parseable counter keep observation order after those
|
|
128
|
+
* with one. Deterministic, and it puts the before-anchor before the
|
|
129
|
+
* after-anchor regardless of file names.
|
|
130
|
+
*/
|
|
131
|
+
function sortByPosition(items) {
|
|
132
|
+
const key = (x) => [
|
|
133
|
+
x.epochId ?? "",
|
|
134
|
+
x.counter !== undefined && /^[0-9]+$/.test(x.counter) ? BigInt(x.counter) : undefined,
|
|
135
|
+
];
|
|
136
|
+
items.sort((a, b) => {
|
|
137
|
+
const [ae, ac] = key(a);
|
|
138
|
+
const [be, bc] = key(b);
|
|
139
|
+
if (ae !== be)
|
|
140
|
+
return ae < be ? -1 : 1;
|
|
141
|
+
if (ac === undefined || bc === undefined)
|
|
142
|
+
return ac === undefined ? (bc === undefined ? 0 : 1) : -1;
|
|
143
|
+
return ac < bc ? -1 : ac > bc ? 1 : 0;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Recordings
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
function buildRecording(proof, attestation, segment, filePath, webCrypto) {
|
|
150
|
+
const lines = [];
|
|
151
|
+
const v = proof.verification;
|
|
152
|
+
const digestB64 = proof.proof.artifact.digestB64;
|
|
153
|
+
// file: the bytes in hand hash to the recorded digest.
|
|
154
|
+
if (v?.tier === "full") {
|
|
155
|
+
lines.push({
|
|
156
|
+
name: "file",
|
|
157
|
+
result: v.status === "verified" || v.status === "failed" ? "TRUE" : "UNDETERMINED",
|
|
158
|
+
detail: v.status === "verified" || v.status === "failed"
|
|
159
|
+
? `${filePath ?? "the matched file"} hashes to the recorded digest ${digestB64}`
|
|
160
|
+
: `no file in this bundle hashes to the recorded digest ${digestB64}`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
lines.push({
|
|
165
|
+
name: "file",
|
|
166
|
+
result: "UNDETERMINED",
|
|
167
|
+
detail: `no file in this bundle hashes to the recorded digest ${digestB64}; the recording cannot be bound to bytes in hand`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
// signature: the proof body verifies (structure, slot binding, Ed25519).
|
|
171
|
+
// At full tier a matched artifact always hashes to the digest (matching
|
|
172
|
+
// is content-addressed), so a full-tier failure is a signature or
|
|
173
|
+
// structure failure, never a digest mismatch.
|
|
174
|
+
if (v === undefined) {
|
|
175
|
+
lines.push({ name: "signature", result: "UNDETERMINED", detail: "the proof was not verified" });
|
|
176
|
+
}
|
|
177
|
+
else if (v.status === "failed") {
|
|
178
|
+
lines.push({
|
|
179
|
+
name: "signature",
|
|
180
|
+
result: "FALSE",
|
|
181
|
+
detail: `the proof does not verify: ${v.reason ?? "unspecified failure"}`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
lines.push({
|
|
186
|
+
name: "signature",
|
|
187
|
+
result: "TRUE",
|
|
188
|
+
detail: `Ed25519 signature and slot binding verify under signer key ${shortB64(proof.publicKeyB64)}`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
// attestation: the AWS Nitro document validates, is bound to THIS proof
|
|
192
|
+
// (user_data), and attests the PCR0 the signed body declares.
|
|
193
|
+
const att = attestationLine(attestation, webCrypto);
|
|
194
|
+
lines.push(att.line);
|
|
195
|
+
// enclave: the attested PCR0 is a published BitGraph measurement.
|
|
196
|
+
lines.push(enclaveLine(att.attestedPcr0, att.line.result));
|
|
197
|
+
const result = kleeneAll(lines.map((l) => l.result));
|
|
198
|
+
return {
|
|
199
|
+
proofHash: proof.proofHash,
|
|
200
|
+
digestB64,
|
|
201
|
+
...(proof.epochId !== undefined ? { epochId: proof.epochId } : {}),
|
|
202
|
+
chainId: proof.chainId,
|
|
203
|
+
...(proof.counter !== undefined ? { counter: proof.counter } : {}),
|
|
204
|
+
...(proof.slotCounter !== undefined ? { slotCounter: proof.slotCounter } : {}),
|
|
205
|
+
...(proof.publicKeyB64 !== undefined ? { publicKeyB64: proof.publicKeyB64 } : {}),
|
|
206
|
+
...(filePath !== undefined && v?.tier === "full" ? { filePath } : {}),
|
|
207
|
+
lines,
|
|
208
|
+
result,
|
|
209
|
+
bounds: boundsFor(segment),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function attestationLine(record, webCrypto) {
|
|
213
|
+
if (record === undefined || !record.documentPresent) {
|
|
214
|
+
return {
|
|
215
|
+
line: {
|
|
216
|
+
name: "attestation",
|
|
217
|
+
result: "UNDETERMINED",
|
|
218
|
+
detail: "no attestation document in this proof",
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (!webCrypto) {
|
|
223
|
+
return {
|
|
224
|
+
line: {
|
|
225
|
+
name: "attestation",
|
|
226
|
+
result: "UNDETERMINED",
|
|
227
|
+
detail: "attestation not checked: this environment cannot verify ECDSA P-384 (no WebCrypto); open the verifier from a file or https page, or run bitgraph-play check",
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (!record.documentValidated) {
|
|
232
|
+
return {
|
|
233
|
+
line: {
|
|
234
|
+
name: "attestation",
|
|
235
|
+
result: "FALSE",
|
|
236
|
+
detail: `attestation document does not validate: ${record.validationFailure ?? "unspecified failure"}`,
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (record.userDataBoundToProof !== true) {
|
|
241
|
+
return {
|
|
242
|
+
line: {
|
|
243
|
+
name: "attestation",
|
|
244
|
+
result: "FALSE",
|
|
245
|
+
detail: "attestation document validates but its user_data is not this proof: the document belongs to some other proof",
|
|
246
|
+
},
|
|
247
|
+
attestedPcr0: record.attestedPcr0,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
if (record.pcr0MatchesDeclared !== true) {
|
|
251
|
+
return {
|
|
252
|
+
line: {
|
|
253
|
+
name: "attestation",
|
|
254
|
+
result: "FALSE",
|
|
255
|
+
detail: `attestation validates and binds this proof, but its PCR0 ${shortHex(record.attestedPcr0)} is not the measurement the signed body declares ${shortHex(record.declaredMeasurement)}`,
|
|
256
|
+
},
|
|
257
|
+
attestedPcr0: record.attestedPcr0,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
line: {
|
|
262
|
+
name: "attestation",
|
|
263
|
+
result: "TRUE",
|
|
264
|
+
detail: `AWS Nitro attestation validates to the AWS root, binds this exact proof (user_data), and attests PCR0 ${shortHex(record.attestedPcr0)}`,
|
|
265
|
+
},
|
|
266
|
+
attestedPcr0: record.attestedPcr0,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function enclaveLine(attestedPcr0, attestationResult) {
|
|
270
|
+
if (attestationResult !== "TRUE" || attestedPcr0 === undefined) {
|
|
271
|
+
return {
|
|
272
|
+
name: "enclave",
|
|
273
|
+
result: "UNDETERMINED",
|
|
274
|
+
detail: "enclave identity rests on a validated attestation, which this recording does not have here",
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const known = KNOWN_ENCLAVE_MEASUREMENTS.find((m) => m.pcr0 === attestedPcr0.toLowerCase());
|
|
278
|
+
if (known === undefined) {
|
|
279
|
+
return {
|
|
280
|
+
name: "enclave",
|
|
281
|
+
result: "UNDETERMINED",
|
|
282
|
+
detail: `PCR0 ${shortHex(attestedPcr0)} is not among the BitGraph enclave measurements this verifier knows (player ${PLAYER_VERSION}); compare it against the measurements published at bitgraph.ing/docs/self-host-tee`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
name: "enclave",
|
|
287
|
+
result: "TRUE",
|
|
288
|
+
detail: `PCR0 ${shortHex(attestedPcr0)} is the published BitGraph ${known.label} measurement (${known.period})`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
function boundsFor(segment) {
|
|
292
|
+
if (segment === undefined) {
|
|
293
|
+
return { status: "unanchored", detail: "no verified Ethereum anchor bounds this recording in this bundle" };
|
|
294
|
+
}
|
|
295
|
+
const lower = tightest(segment.lowerBounds, "not-before");
|
|
296
|
+
const upper = tightest(segment.upperBounds, "not-after");
|
|
297
|
+
const notBefore = lower === undefined ? undefined : toBound(lower);
|
|
298
|
+
const notAfter = upper === undefined ? undefined : toBound(upper);
|
|
299
|
+
const status = notBefore !== undefined && notAfter !== undefined
|
|
300
|
+
? "bracketed"
|
|
301
|
+
: notBefore !== undefined
|
|
302
|
+
? "lower-bounded"
|
|
303
|
+
: notAfter !== undefined
|
|
304
|
+
? "upper-bounded"
|
|
305
|
+
: "unanchored";
|
|
306
|
+
const detail = status === "unanchored"
|
|
307
|
+
? "no verified Ethereum anchor bounds this recording in this bundle"
|
|
308
|
+
: `recorded ${boundsPhrase(status, notBefore, notAfter)}`;
|
|
309
|
+
return {
|
|
310
|
+
status,
|
|
311
|
+
...(notBefore !== undefined ? { notBefore } : {}),
|
|
312
|
+
...(notAfter !== undefined ? { notAfter } : {}),
|
|
313
|
+
detail,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/** "after Ethereum block A and before block B (both headers verified)…", shared by bounds.detail and the summary. */
|
|
317
|
+
function boundsPhrase(status, notBefore, notAfter) {
|
|
318
|
+
switch (status) {
|
|
319
|
+
case "bracketed":
|
|
320
|
+
return (`after Ethereum block ${blockRef(notBefore)} and before block ${blockRef(notAfter)} (both headers verified in this bundle)` +
|
|
321
|
+
weakerSuffix(notBefore.weaker || notAfter.weaker));
|
|
322
|
+
case "lower-bounded":
|
|
323
|
+
return `after Ethereum block ${blockRef(notBefore)} (header verified in this bundle); no verified upper bound here` + weakerSuffix(notBefore.weaker);
|
|
324
|
+
case "upper-bounded":
|
|
325
|
+
return `before Ethereum block ${blockRef(notAfter)} (header verified in this bundle); no verified lower bound here` + weakerSuffix(notAfter.weaker);
|
|
326
|
+
default:
|
|
327
|
+
return "with no verified Ethereum bound in this bundle";
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The tightest bound of a kind: for not-before the LARGEST block number
|
|
332
|
+
* (latest verified anchor known to precede), for not-after the SMALLEST.
|
|
333
|
+
* Ties resolve by preferring chain-link evidence over counter-order.
|
|
334
|
+
*/
|
|
335
|
+
function tightest(bounds, kind) {
|
|
336
|
+
const ofKind = bounds.filter((b) => b.kind === kind);
|
|
337
|
+
if (ofKind.length === 0)
|
|
338
|
+
return undefined;
|
|
339
|
+
const sorted = [...ofKind].sort((a, b) => {
|
|
340
|
+
const an = a.blockNumber !== undefined ? BigInt(a.blockNumber) : BigInt(a.timestamp);
|
|
341
|
+
const bn = b.blockNumber !== undefined ? BigInt(b.blockNumber) : BigInt(b.timestamp);
|
|
342
|
+
if (an !== bn)
|
|
343
|
+
return kind === "not-before" ? (an > bn ? -1 : 1) : an < bn ? -1 : 1;
|
|
344
|
+
if (a.weaker !== b.weaker)
|
|
345
|
+
return a.weaker ? 1 : -1;
|
|
346
|
+
return 0;
|
|
347
|
+
});
|
|
348
|
+
return sorted[0];
|
|
349
|
+
}
|
|
350
|
+
function toBound(b) {
|
|
351
|
+
return {
|
|
352
|
+
...(b.blockNumber !== undefined ? { blockNumber: b.blockNumber } : {}),
|
|
353
|
+
blockHash: b.blockHash,
|
|
354
|
+
timestamp: b.timestamp,
|
|
355
|
+
anchorProofHash: b.anchorProofHash,
|
|
356
|
+
evidence: b.evidence,
|
|
357
|
+
weaker: b.weaker,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/** The headline form of the bounds: one clause, no qualifiers (those live in bounds.detail). */
|
|
361
|
+
function shortBoundsPhrase(b) {
|
|
362
|
+
switch (b.status) {
|
|
363
|
+
case "bracketed":
|
|
364
|
+
return `between Ethereum blocks ${blockRef(b.notBefore)} and ${blockRef(b.notAfter)}`;
|
|
365
|
+
case "lower-bounded":
|
|
366
|
+
return `after Ethereum block ${blockRef(b.notBefore)}`;
|
|
367
|
+
case "upper-bounded":
|
|
368
|
+
return `before Ethereum block ${blockRef(b.notAfter)}`;
|
|
369
|
+
default:
|
|
370
|
+
return "with no Ethereum bound in this bundle";
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function blockRef(b) {
|
|
374
|
+
return b.blockNumber !== undefined ? b.blockNumber : b.blockHash;
|
|
375
|
+
}
|
|
376
|
+
function weakerSuffix(weaker) {
|
|
377
|
+
return weaker
|
|
378
|
+
? "; ordered by counter position within the epoch, since the recordings between are not in this bundle"
|
|
379
|
+
: "";
|
|
380
|
+
}
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
// Anchors
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
function buildAnchor(proof, anchor, outcomes) {
|
|
385
|
+
const lines = [];
|
|
386
|
+
const v = proof.verification;
|
|
387
|
+
if (v === undefined) {
|
|
388
|
+
lines.push({ name: "signature", result: "UNDETERMINED", detail: "the anchor proof was not verified" });
|
|
389
|
+
}
|
|
390
|
+
else if (v.status === "failed") {
|
|
391
|
+
lines.push({
|
|
392
|
+
name: "signature",
|
|
393
|
+
result: "FALSE",
|
|
394
|
+
detail: `the anchor proof does not verify: ${v.reason ?? "unspecified failure"}`,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
lines.push({
|
|
399
|
+
name: "signature",
|
|
400
|
+
result: "TRUE",
|
|
401
|
+
detail: `anchor proof verifies under signer key ${shortB64(proof.publicKeyB64)}`,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
// witness: present and verified is TRUE; present and failing is FALSE;
|
|
405
|
+
// absent is not a line (absence is not a verdict) and shows in bounds.
|
|
406
|
+
let witnessPath;
|
|
407
|
+
if (outcomes.length > 0) {
|
|
408
|
+
const verified = outcomes.find((o) => o.verified);
|
|
409
|
+
if (verified !== undefined) {
|
|
410
|
+
witnessPath = verified.witnessPath;
|
|
411
|
+
lines.push({
|
|
412
|
+
name: "witness",
|
|
413
|
+
result: "TRUE",
|
|
414
|
+
detail: `block header ${verified.witnessPath} hashes (keccak-256) to the anchored block hash${anchor.blockNumber !== undefined ? ` of block ${anchor.blockNumber}` : ""}`,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
const first = outcomes[0];
|
|
419
|
+
witnessPath = first.witnessPath;
|
|
420
|
+
lines.push({
|
|
421
|
+
name: "witness",
|
|
422
|
+
result: "FALSE",
|
|
423
|
+
detail: `block header ${first.witnessPath} contradicts this anchor: ${first.detail ?? first.reason ?? "witness verification failed"}`,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
proofHash: proof.proofHash,
|
|
429
|
+
...(proof.epochId !== undefined ? { epochId: proof.epochId } : {}),
|
|
430
|
+
chainId: proof.chainId,
|
|
431
|
+
...(proof.counter !== undefined ? { counter: proof.counter } : {}),
|
|
432
|
+
...(anchor.blockNumber !== undefined ? { blockNumber: anchor.blockNumber } : {}),
|
|
433
|
+
...(anchor.blockHash !== undefined ? { blockHash: anchor.blockHash } : {}),
|
|
434
|
+
...(witnessPath !== undefined ? { witnessPath } : {}),
|
|
435
|
+
lines,
|
|
436
|
+
result: kleeneAll(lines.map((l) => l.result)),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// Contradictions, notes, not-checked
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
function collectContradictions(audit) {
|
|
443
|
+
const out = [];
|
|
444
|
+
const seen = new Set();
|
|
445
|
+
const push = (detail) => {
|
|
446
|
+
if (seen.has(detail))
|
|
447
|
+
return;
|
|
448
|
+
seen.add(detail);
|
|
449
|
+
out.push({ name: "contradiction", result: "FALSE", detail });
|
|
450
|
+
};
|
|
451
|
+
for (const anomaly of audit.anomalies.anomalies) {
|
|
452
|
+
if (EXCERPT_NORMAL_CODES.has(anomaly.code))
|
|
453
|
+
continue;
|
|
454
|
+
push(`${anomaly.code}: ${anomaly.message}`);
|
|
455
|
+
}
|
|
456
|
+
for (const divergence of audit.anomalies.divergences) {
|
|
457
|
+
push(`${divergence.kind}: ${divergence.explanation}`);
|
|
458
|
+
}
|
|
459
|
+
for (const anomaly of audit.authorities.anomalies) {
|
|
460
|
+
push(`${anomaly.code}: ${anomaly.message}`);
|
|
461
|
+
}
|
|
462
|
+
// Witness files that matched no anchor, or that are malformed, are
|
|
463
|
+
// supplied evidence that fails; witness outcomes bound to an anchor are
|
|
464
|
+
// already on that anchor's line.
|
|
465
|
+
for (const finding of audit.witnesses.findings) {
|
|
466
|
+
if (finding.code === "witness-unmatched" || finding.code === "witness-malformed" || finding.code === "witness-rlp-invalid") {
|
|
467
|
+
push(`${finding.code}: ${finding.message}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// An embedded proofHash that does not match the recomputed one: the
|
|
471
|
+
// stored proof file was altered after the ledger wrote it, or is not
|
|
472
|
+
// what it claims.
|
|
473
|
+
for (const finding of audit.ingest.findings) {
|
|
474
|
+
if (finding.code === "proofhash-mismatch") {
|
|
475
|
+
push(`${finding.code}: ${finding.message}${finding.path !== undefined ? ` (${finding.path})` : ""}`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return out;
|
|
479
|
+
}
|
|
480
|
+
function collectNotes(audit, recordings, anchors) {
|
|
481
|
+
const notes = [];
|
|
482
|
+
// Excerpt gaps, said once, plainly.
|
|
483
|
+
let missingPositions = 0n;
|
|
484
|
+
let predecessorsAbsent = 0;
|
|
485
|
+
for (const anomaly of audit.anomalies.anomalies) {
|
|
486
|
+
if (anomaly.code === "unexplained-counter-positions") {
|
|
487
|
+
const count = anomaly.details?.count;
|
|
488
|
+
if (count !== undefined && /^[0-9]+$/.test(count))
|
|
489
|
+
missingPositions += BigInt(count);
|
|
490
|
+
}
|
|
491
|
+
else if (anomaly.code === "chain-break-missing") {
|
|
492
|
+
predecessorsAbsent += anomaly.proofHashes.length;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (missingPositions > 0n) {
|
|
496
|
+
notes.push(`${missingPositions} causal position${missingPositions === 1n ? "" : "s"} between the earliest and latest recording here ${missingPositions === 1n ? "is" : "are"} not in this bundle: normal for an export, which is an excerpt of the chain; a full-epoch audit checks them`);
|
|
497
|
+
}
|
|
498
|
+
if (predecessorsAbsent > 0) {
|
|
499
|
+
notes.push(`${predecessorsAbsent} recording${predecessorsAbsent === 1 ? "" : "s"} link${predecessorsAbsent === 1 ? "s" : ""} to a predecessor that is not in this bundle: normal for an export`);
|
|
500
|
+
}
|
|
501
|
+
// Files that match no recording: the human hint for an edited file or a
|
|
502
|
+
// wrong drop, without claiming which.
|
|
503
|
+
const unmatched = audit.ingest.artifacts.filter((a) => a.matchedProofHashes.length === 0);
|
|
504
|
+
if (unmatched.length > 0 && recordings.length > 0) {
|
|
505
|
+
const listed = unmatched
|
|
506
|
+
.slice(0, 5)
|
|
507
|
+
.map((a) => `${a.paths[0] ?? "(unnamed)"} (sha256:${a.sha256Hex.slice(0, 16)}…)`)
|
|
508
|
+
.join(", ");
|
|
509
|
+
notes.push(`${unmatched.length} file${unmatched.length === 1 ? "" : "s"} in this bundle match${unmatched.length === 1 ? "es" : ""} no recording here: ${listed}${unmatched.length > 5 ? ", …" : ""}. If ${unmatched.length === 1 ? "it" : "one of them"} was meant to be a recorded file, its bytes differ from what was recorded`);
|
|
510
|
+
}
|
|
511
|
+
// Anchors with no witness: their bounds are not established here.
|
|
512
|
+
const anchorsWithoutWitness = anchors.filter((a) => a.witnessPath === undefined);
|
|
513
|
+
if (anchorsWithoutWitness.length > 0) {
|
|
514
|
+
notes.push(`${anchorsWithoutWitness.length} Ethereum anchor${anchorsWithoutWitness.length === 1 ? "" : "s"} in this bundle ha${anchorsWithoutWitness.length === 1 ? "s" : "ve"} no block-header witness file, so ${anchorsWithoutWitness.length === 1 ? "its" : "their"} block hash${anchorsWithoutWitness.length === 1 ? " is" : "es are"} not verified here and no bound is derived from ${anchorsWithoutWitness.length === 1 ? "it" : "them"}`);
|
|
515
|
+
}
|
|
516
|
+
for (const rec of audit.ingest.unsupportedVersions) {
|
|
517
|
+
notes.push(`${rec.path} is proof-shaped but its version "${rec.version}" is not bitgraph/1; it was not checked`);
|
|
518
|
+
}
|
|
519
|
+
const c = audit.ingest.counts;
|
|
520
|
+
if (c.exactDuplicates > 0 || c.semanticDuplicates > 0) {
|
|
521
|
+
notes.push(`${c.exactDuplicates + c.semanticDuplicates} duplicate proof file${c.exactDuplicates + c.semanticDuplicates === 1 ? "" : "s"} collapsed to one recording each`);
|
|
522
|
+
}
|
|
523
|
+
if (audit.ingest.manifest !== undefined) {
|
|
524
|
+
for (const finding of audit.ingest.findings) {
|
|
525
|
+
if (typeof finding.code === "string" && finding.code.startsWith("manifest-")) {
|
|
526
|
+
notes.push(`${finding.code}: ${finding.message}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (recordings.length === 0 && anchors.length === 0) {
|
|
531
|
+
notes.push("no BitGraph proofs were found in this bundle");
|
|
532
|
+
}
|
|
533
|
+
return notes;
|
|
534
|
+
}
|
|
535
|
+
function collectNotChecked(hasAnchors) {
|
|
536
|
+
const out = [];
|
|
537
|
+
if (hasAnchors) {
|
|
538
|
+
out.push("whether the anchored Ethereum blocks are canonical: their headers are recomputed here, but canonicality needs an Ethereum node or a block explorer");
|
|
539
|
+
}
|
|
540
|
+
out.push("whether the public ledger holds these exact recordings at these positions: this is an offline check; drop the file on bitgraph.ing to compare against the ledger");
|
|
541
|
+
out.push("whether an epoch key was later quarantined: that is published outside any bundle");
|
|
542
|
+
return out;
|
|
543
|
+
}
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
// Summary sentence
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
function summarize(result, recordings, anchors, contradictions) {
|
|
548
|
+
if (recordings.length === 0 && anchors.length === 0) {
|
|
549
|
+
return "UNDETERMINED: no BitGraph proofs were found in this bundle.";
|
|
550
|
+
}
|
|
551
|
+
const n = recordings.length;
|
|
552
|
+
const noun = `${n} recording${n === 1 ? "" : "s"}`;
|
|
553
|
+
if (result === "TRUE") {
|
|
554
|
+
const first = recordings[0];
|
|
555
|
+
if (n === 1 && first !== undefined) {
|
|
556
|
+
const where = first.counter !== undefined ? ` at position ${first.counter}${first.epochId !== undefined ? ` of epoch ${shortB64(first.epochId)}` : ""}` : "";
|
|
557
|
+
return `TRUE: this file was recorded${where}, ${shortBoundsPhrase(first.bounds)}.`;
|
|
558
|
+
}
|
|
559
|
+
return `TRUE: ${noun} verified: files match, signatures and attestations hold, enclave measurements published${anchors.length > 0 ? ", Ethereum bounds verified from block headers in the bundle" : ""}.`;
|
|
560
|
+
}
|
|
561
|
+
if (result === "FALSE") {
|
|
562
|
+
const failing = [
|
|
563
|
+
...recordings.flatMap((r) => r.lines.filter((l) => l.result === "FALSE").map((l) => `${l.name}: ${l.detail}`)),
|
|
564
|
+
...anchors.flatMap((a) => a.lines.filter((l) => l.result === "FALSE").map((l) => `${l.name}: ${l.detail}`)),
|
|
565
|
+
...contradictions.map((c) => c.detail),
|
|
566
|
+
];
|
|
567
|
+
return `FALSE: evidence in this bundle contradicts itself. ${failing[0] ?? ""}`.trim();
|
|
568
|
+
}
|
|
569
|
+
const open = [
|
|
570
|
+
...recordings.flatMap((r) => r.lines.filter((l) => l.result === "UNDETERMINED").map((l) => l.detail)),
|
|
571
|
+
...anchors.flatMap((a) => a.lines.filter((l) => l.result === "UNDETERMINED").map((l) => l.detail)),
|
|
572
|
+
];
|
|
573
|
+
return `UNDETERMINED: nothing here contradicts the ${noun}, but the evidence does not fully decide. ${open[0] ?? ""}`.trim();
|
|
574
|
+
}
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
// Text rendering (the CLI's stdout; the browser page renders the same
|
|
577
|
+
// report shape into DOM, reusing every detail string verbatim)
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
export function renderCheckText(report) {
|
|
580
|
+
const out = [];
|
|
581
|
+
const mark = (r) => (r === "TRUE" ? "TRUE " : r === "FALSE" ? "FALSE" : "UNDET");
|
|
582
|
+
out.push(report.summary);
|
|
583
|
+
out.push("");
|
|
584
|
+
report.recordings.forEach((rec, i) => {
|
|
585
|
+
out.push(`Recording ${i + 1}${rec.filePath !== undefined ? `: ${rec.filePath}` : ""} [${rec.result}]`);
|
|
586
|
+
out.push(` digest ${rec.digestB64}`);
|
|
587
|
+
if (rec.counter !== undefined) {
|
|
588
|
+
out.push(` position ${rec.counter}${rec.epochId !== undefined ? ` epoch ${rec.epochId}` : ""}`);
|
|
589
|
+
}
|
|
590
|
+
for (const line of rec.lines)
|
|
591
|
+
out.push(` ${mark(line.result)} ${pad(line.name, 12)} ${line.detail}`);
|
|
592
|
+
out.push(` bounds ${rec.bounds.detail}`);
|
|
593
|
+
out.push("");
|
|
594
|
+
});
|
|
595
|
+
report.anchors.forEach((a, i) => {
|
|
596
|
+
out.push(`Ethereum anchor ${i + 1}${a.blockNumber !== undefined ? `: block ${a.blockNumber}` : ""}${a.counter !== undefined ? ` at position ${a.counter}` : ""} [${a.result}]`);
|
|
597
|
+
for (const line of a.lines)
|
|
598
|
+
out.push(` ${mark(line.result)} ${pad(line.name, 12)} ${line.detail}`);
|
|
599
|
+
out.push("");
|
|
600
|
+
});
|
|
601
|
+
if (report.contradictions.length > 0) {
|
|
602
|
+
out.push("Contradictions");
|
|
603
|
+
for (const c of report.contradictions)
|
|
604
|
+
out.push(` FALSE ${c.detail}`);
|
|
605
|
+
out.push("");
|
|
606
|
+
}
|
|
607
|
+
if (report.notes.length > 0) {
|
|
608
|
+
out.push("Notes");
|
|
609
|
+
for (const n of report.notes)
|
|
610
|
+
out.push(` - ${n}`);
|
|
611
|
+
out.push("");
|
|
612
|
+
}
|
|
613
|
+
out.push("Not checked (no offline check can establish these)");
|
|
614
|
+
for (const n of report.notChecked)
|
|
615
|
+
out.push(` - ${n}`);
|
|
616
|
+
out.push("");
|
|
617
|
+
out.push(`Result: ${report.result} (bitgraph-player ${report.evaluator.version}, bitgraph-audit ${report.evaluator.audit}, network: none)`);
|
|
618
|
+
return out.join("\n") + "\n";
|
|
619
|
+
}
|
|
620
|
+
/** Deterministic JSON bytes for the report: two-space indent, one trailing newline. */
|
|
621
|
+
export function serializeCheckReport(report) {
|
|
622
|
+
return JSON.stringify(report, null, 2) + "\n";
|
|
623
|
+
}
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
// Small helpers
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
function shortB64(s) {
|
|
628
|
+
if (s === undefined)
|
|
629
|
+
return "(none)";
|
|
630
|
+
return s.length > 12 ? `${s.slice(0, 12)}…` : s;
|
|
631
|
+
}
|
|
632
|
+
function shortHex(s) {
|
|
633
|
+
if (s === undefined)
|
|
634
|
+
return "(none)";
|
|
635
|
+
return s.length > 16 ? `${s.slice(0, 16)}…` : s;
|
|
636
|
+
}
|
|
637
|
+
function pad(s, n) {
|
|
638
|
+
return s.length >= n ? s : s + " ".repeat(n - s.length);
|
|
639
|
+
}
|
|
640
|
+
//# sourceMappingURL=check.js.map
|