@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/src/cli.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* bitgraph-play <rule.json> <bundle> [--out <file>] [--summary]
|
|
6
6
|
* bitgraph-play init <file>... [--out <rule.json>]
|
|
7
|
+
* bitgraph-play check <bundle-or-file>... [--json] [--out <file>]
|
|
7
8
|
*
|
|
8
9
|
* Evaluate: runs a bitgraph-player/1 rule against a proof bundle
|
|
9
10
|
* (directory, .tar, .tar.gz, or .tgz) and writes the verdict JSON to
|
|
@@ -15,9 +16,15 @@
|
|
|
15
16
|
* a placeholder the author must replace — the trust floor has no
|
|
16
17
|
* default, and the scaffolder must not choose it either.
|
|
17
18
|
*
|
|
19
|
+
* Check: reads a bundle (a directory or archive, or a list of files such
|
|
20
|
+
* as a proof.json and the file it records) and reports, in the three
|
|
21
|
+
* values, what the bundle establishes about each recording it holds and
|
|
22
|
+
* what bounds it. Human text on stdout by default; --json for the
|
|
23
|
+
* bitgraph-check/1 report. Offline. See check.ts for the vocabulary.
|
|
24
|
+
*
|
|
18
25
|
* Exit codes: 0 TRUE, 1 FALSE, 2 UNDETERMINED, 3 error (init: 0 or 3).
|
|
19
|
-
* Diagnostics go to stderr; stdout carries the verdict (or skeleton
|
|
20
|
-
* bytes only.
|
|
26
|
+
* Diagnostics go to stderr; stdout carries the verdict (or skeleton, or
|
|
27
|
+
* check report) bytes only.
|
|
21
28
|
*
|
|
22
29
|
* The process exits by setting process.exitCode and returning, never by
|
|
23
30
|
* process.exit(): exiting early would truncate output still draining to
|
|
@@ -26,9 +33,12 @@
|
|
|
26
33
|
|
|
27
34
|
import { createHash } from "node:crypto";
|
|
28
35
|
import { createReadStream, existsSync, writeFileSync } from "node:fs";
|
|
36
|
+
import { readFile, stat } from "node:fs/promises";
|
|
29
37
|
import { basename } from "node:path";
|
|
30
38
|
import { pipeline } from "node:stream/promises";
|
|
31
|
-
import type { AuditResult } from "@mikeargento/bitgraph-audit";
|
|
39
|
+
import type { AuditResult, BundleEntrySource } from "@mikeargento/bitgraph-audit";
|
|
40
|
+
import { ingestBundle, ingestEntries } from "@mikeargento/bitgraph-audit";
|
|
41
|
+
import { checkIngest, renderCheckText, serializeCheckReport } from "./check.js";
|
|
32
42
|
import { scaffoldRule } from "./init.js";
|
|
33
43
|
import type { ScaffoldEntry } from "./init.js";
|
|
34
44
|
import { play, PlayError } from "./play.js";
|
|
@@ -38,8 +48,9 @@ function usage(): number {
|
|
|
38
48
|
process.stderr.write(
|
|
39
49
|
"usage: bitgraph-play <rule.json> <bundle> [--out <file>] [--summary]\n" +
|
|
40
50
|
" bitgraph-play init <file>... [--out <rule.json>]\n" +
|
|
41
|
-
|
|
42
|
-
"
|
|
51
|
+
" bitgraph-play check <bundle-or-file>... [--json] [--out <file>]\n" +
|
|
52
|
+
' "--" ends option parsing; a rule file literally named "init" or\n' +
|
|
53
|
+
' "check" is evaluated with: bitgraph-play -- init <bundle>\n' +
|
|
43
54
|
" exit codes: 0 TRUE, 1 FALSE, 2 UNDETERMINED, 3 error\n"
|
|
44
55
|
);
|
|
45
56
|
return 3;
|
|
@@ -79,10 +90,11 @@ interface ParsedArgs {
|
|
|
79
90
|
positional: string[];
|
|
80
91
|
outFile?: string;
|
|
81
92
|
summary: boolean;
|
|
93
|
+
json: boolean;
|
|
82
94
|
}
|
|
83
95
|
|
|
84
96
|
function parseArgs(args: string[]): ParsedArgs | undefined {
|
|
85
|
-
const parsed: ParsedArgs = { positional: [], summary: false };
|
|
97
|
+
const parsed: ParsedArgs = { positional: [], summary: false, json: false };
|
|
86
98
|
for (let i = 0; i < args.length; i++) {
|
|
87
99
|
const arg = args[i] as string;
|
|
88
100
|
if (arg === "--") {
|
|
@@ -95,6 +107,8 @@ function parseArgs(args: string[]): ParsedArgs | undefined {
|
|
|
95
107
|
parsed.outFile = next;
|
|
96
108
|
} else if (arg === "--summary") {
|
|
97
109
|
parsed.summary = true;
|
|
110
|
+
} else if (arg === "--json") {
|
|
111
|
+
parsed.json = true;
|
|
98
112
|
} else if (arg.startsWith("-")) {
|
|
99
113
|
return undefined;
|
|
100
114
|
} else {
|
|
@@ -104,8 +118,58 @@ function parseArgs(args: string[]): ParsedArgs | undefined {
|
|
|
104
118
|
return parsed;
|
|
105
119
|
}
|
|
106
120
|
|
|
107
|
-
|
|
121
|
+
/**
|
|
122
|
+
* check: one directory or archive path is ingested as a bundle; any other
|
|
123
|
+
* argument list (one or more plain files, or several paths) is ingested
|
|
124
|
+
* in memory as a flat set of entries named by their basenames, so
|
|
125
|
+
* `bitgraph-play check proof.json photo.jpg` works without a folder.
|
|
126
|
+
*/
|
|
127
|
+
async function runCheck(args: ParsedArgs): Promise<number> {
|
|
108
128
|
if (args.summary) return usage();
|
|
129
|
+
const targets = args.positional;
|
|
130
|
+
if (targets.length === 0) return usage();
|
|
131
|
+
|
|
132
|
+
let ingest;
|
|
133
|
+
try {
|
|
134
|
+
const single = targets.length === 1 ? await stat(targets[0] as string) : undefined;
|
|
135
|
+
if (single !== undefined && (single.isDirectory() || looksLikeArchive(targets[0] as string))) {
|
|
136
|
+
ingest = await ingestBundle(targets[0] as string);
|
|
137
|
+
} else {
|
|
138
|
+
const entries: BundleEntrySource[] = [];
|
|
139
|
+
for (const target of targets) {
|
|
140
|
+
const info = await stat(target);
|
|
141
|
+
if (info.isDirectory()) {
|
|
142
|
+
// A directory among several arguments: ingest it as its own
|
|
143
|
+
// bundle would be ambiguous, so refuse plainly.
|
|
144
|
+
process.stderr.write(`error: ${target} is a directory; pass one bundle path, or a list of files\n`);
|
|
145
|
+
return 3;
|
|
146
|
+
}
|
|
147
|
+
entries.push({ path: basename(target), open: () => readFile(target).then((b) => new Uint8Array(b)) });
|
|
148
|
+
}
|
|
149
|
+
ingest = await ingestEntries(entries);
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
process.stderr.write(`error: cannot read bundle: ${(err as Error).message}\n`);
|
|
153
|
+
return 3;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const report = await checkIngest(ingest);
|
|
157
|
+
const bytes = args.json ? serializeCheckReport(report) : renderCheckText(report);
|
|
158
|
+
if (args.outFile !== undefined) {
|
|
159
|
+
writeFileSync(args.outFile, bytes);
|
|
160
|
+
} else {
|
|
161
|
+
process.stdout.write(bytes);
|
|
162
|
+
}
|
|
163
|
+
return report.result === "TRUE" ? 0 : report.result === "FALSE" ? 1 : 2;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function looksLikeArchive(path: string): boolean {
|
|
167
|
+
const lower = path.toLowerCase();
|
|
168
|
+
return lower.endsWith(".tar") || lower.endsWith(".tar.gz") || lower.endsWith(".tgz");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function runInit(args: ParsedArgs): Promise<number> {
|
|
172
|
+
if (args.summary || args.json) return usage();
|
|
109
173
|
const files = args.positional;
|
|
110
174
|
if (files.length === 0) return usage();
|
|
111
175
|
if (args.outFile !== undefined && existsSync(args.outFile)) {
|
|
@@ -161,7 +225,7 @@ async function runInit(args: ParsedArgs): Promise<number> {
|
|
|
161
225
|
}
|
|
162
226
|
|
|
163
227
|
async function runEvaluate(args: ParsedArgs): Promise<number> {
|
|
164
|
-
if (args.positional.length !== 2) return usage();
|
|
228
|
+
if (args.json || args.positional.length !== 2) return usage();
|
|
165
229
|
const [rulePath, bundlePath] = args.positional as [string, string];
|
|
166
230
|
|
|
167
231
|
let result;
|
|
@@ -192,22 +256,24 @@ async function main(): Promise<number> {
|
|
|
192
256
|
const argv = process.argv.slice(2);
|
|
193
257
|
// "--" as the first token forces evaluate mode: parseArgs treats
|
|
194
258
|
// everything after it as positional, so a rule file literally named
|
|
195
|
-
// "init" is reachable as `bitgraph-play -- init <bundle>`.
|
|
196
|
-
const
|
|
197
|
-
if (
|
|
259
|
+
// "init" or "check" is reachable as `bitgraph-play -- init <bundle>`.
|
|
260
|
+
const subcommand = argv[0] === "init" || argv[0] === "check" ? argv[0] : undefined;
|
|
261
|
+
if (subcommand !== undefined && existsSync(subcommand)) {
|
|
198
262
|
// Both readings are plausible here; a silent pick would hand a
|
|
199
263
|
// 0.1.1 caller a skeleton with exit 0 where the published contract
|
|
200
264
|
// returned a verdict. Refuse loudly instead.
|
|
201
265
|
process.stderr.write(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
266
|
+
`error: a file named "${subcommand}" exists here, so this command is ambiguous.\n` +
|
|
267
|
+
` to evaluate it as a rule: bitgraph-play -- ${subcommand} <bundle> (or ./${subcommand})\n` +
|
|
268
|
+
` to run the ${subcommand} subcommand: rename that file or run from another directory\n`
|
|
205
269
|
);
|
|
206
270
|
return 3;
|
|
207
271
|
}
|
|
208
|
-
const parsed = parseArgs(
|
|
272
|
+
const parsed = parseArgs(subcommand !== undefined ? argv.slice(1) : argv);
|
|
209
273
|
if (parsed === undefined) return usage();
|
|
210
|
-
|
|
274
|
+
if (subcommand === "init") return runInit(parsed);
|
|
275
|
+
if (subcommand === "check") return runCheck(parsed);
|
|
276
|
+
return runEvaluate(parsed);
|
|
211
277
|
}
|
|
212
278
|
|
|
213
279
|
main().then(
|
package/src/evaluate.ts
CHANGED
|
@@ -21,9 +21,12 @@
|
|
|
21
21
|
* that evidence in either direction.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import type { KeyObject } from "node:crypto";
|
|
24
25
|
import type { AuditResult, ObservedProof } from "@mikeargento/bitgraph-audit";
|
|
25
26
|
import { kleeneAll, kleeneAny, kleeneNot } from "./logic.js";
|
|
26
27
|
import { compare } from "./order.js";
|
|
28
|
+
import { decodeDigestBytes, normalizeDigest } from "./rule.js";
|
|
29
|
+
import { keyObjectFor, parseSigFile, verifySigFile } from "./sig.js";
|
|
27
30
|
import { basisTier, meetsFloor } from "./types.js";
|
|
28
31
|
import type {
|
|
29
32
|
Claim,
|
|
@@ -41,12 +44,22 @@ export interface Evaluation {
|
|
|
41
44
|
weakestEvidence?: EvidenceTier;
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Signature evidence for format-2 signedBy claims: candidate file bytes
|
|
49
|
+
* keyed by lowercase hex SHA-256. Collected by the caller (play() streams
|
|
50
|
+
* size-capped matched artifacts from the bundle); evaluate() itself stays
|
|
51
|
+
* a pure function over its arguments.
|
|
52
|
+
*/
|
|
53
|
+
export type SigEvidence = ReadonlyMap<string, Uint8Array>;
|
|
54
|
+
|
|
44
55
|
interface Ctx {
|
|
45
56
|
rule: Rule;
|
|
46
57
|
resolutions: Map<string, Resolution>;
|
|
47
58
|
audit: AuditResult;
|
|
48
59
|
steps: DerivedStep[];
|
|
49
60
|
usedTiers: Set<EvidenceTier>;
|
|
61
|
+
sigEvidence: SigEvidence;
|
|
62
|
+
keyObjects: Map<string, KeyObject | undefined>;
|
|
50
63
|
}
|
|
51
64
|
|
|
52
65
|
function positionOf(proof: ObservedProof): Record<string, unknown> {
|
|
@@ -198,8 +211,91 @@ function evalPrecedes(ctx: Ctx, claim: string, x: string, y: string): ThreeValue
|
|
|
198
211
|
});
|
|
199
212
|
}
|
|
200
213
|
|
|
214
|
+
/**
|
|
215
|
+
* signedBy(role, keyName): TRUE when a valid bitgraph-sig/1 by the named
|
|
216
|
+
* trusted key over the role's digest is present in the supplied evidence;
|
|
217
|
+
* UNDETERMINED otherwise, NEVER FALSE. Absence of a signature from a
|
|
218
|
+
* bundle proves nothing about the world, and `not(signedBy(...))` is
|
|
219
|
+
* therefore permanently UNDETERMINED by the Kleene table.
|
|
220
|
+
*
|
|
221
|
+
* The claim is about BITS, not occurrences: it evaluates against the
|
|
222
|
+
* declared role digest whether or not the role resolved to a recording.
|
|
223
|
+
* Only an undeclared role is UNDETERMINED for want of a digest.
|
|
224
|
+
*/
|
|
225
|
+
function evalSignedBy(ctx: Ctx, role: string, keyName: string): ThreeValued {
|
|
226
|
+
const claim = `signedBy(${role}, ${keyName})`;
|
|
227
|
+
const entry = ctx.rule.cast[role];
|
|
228
|
+
if (entry === undefined) {
|
|
229
|
+
return record(ctx, {
|
|
230
|
+
claim,
|
|
231
|
+
result: "UNDETERMINED",
|
|
232
|
+
because: { reason: `role "${role}" is not declared in the cast` },
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
// parseRule guarantees the key name resolves; guard for direct API use.
|
|
236
|
+
const key = ctx.rule.trustedKeys?.[keyName];
|
|
237
|
+
if (key === undefined) {
|
|
238
|
+
return record(ctx, {
|
|
239
|
+
claim,
|
|
240
|
+
result: "UNDETERMINED",
|
|
241
|
+
because: { reason: `trusted key "${keyName}" is not declared` },
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (!ctx.keyObjects.has(keyName)) ctx.keyObjects.set(keyName, keyObjectFor(key));
|
|
245
|
+
const keyObject = ctx.keyObjects.get(keyName);
|
|
246
|
+
if (keyObject === undefined) {
|
|
247
|
+
return record(ctx, {
|
|
248
|
+
claim,
|
|
249
|
+
result: "UNDETERMINED",
|
|
250
|
+
because: {
|
|
251
|
+
reason: `trusted key "${keyName}" is not decodable as ${key.alg} key material; no signature can verify against it`,
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const digestB64 = normalizeDigest(entry.digest) as string;
|
|
257
|
+
const targetHex = (decodeDigestBytes(digestB64) as Buffer).toString("hex");
|
|
258
|
+
|
|
259
|
+
// Deterministic scan: candidates in ascending content-hash order.
|
|
260
|
+
const candidates = [...ctx.sigEvidence.keys()].sort();
|
|
261
|
+
let parsedCount = 0;
|
|
262
|
+
for (const sha256Hex of candidates) {
|
|
263
|
+
const bytes = ctx.sigEvidence.get(sha256Hex) as Uint8Array;
|
|
264
|
+
const sig = parseSigFile(bytes);
|
|
265
|
+
if (sig === undefined) continue;
|
|
266
|
+
parsedCount += 1;
|
|
267
|
+
if (verifySigFile(sig, key, keyObject, targetHex, decodeDigestBytes)) {
|
|
268
|
+
return record(ctx, {
|
|
269
|
+
claim,
|
|
270
|
+
result: "TRUE",
|
|
271
|
+
because: {
|
|
272
|
+
signatureSha256Hex: sha256Hex,
|
|
273
|
+
alg: key.alg,
|
|
274
|
+
keyName,
|
|
275
|
+
over: `sha256:${targetHex}`,
|
|
276
|
+
note: "signature math verified here; that this key belongs to the named party is declared, never derived",
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return record(ctx, {
|
|
282
|
+
claim,
|
|
283
|
+
result: "UNDETERMINED",
|
|
284
|
+
because: {
|
|
285
|
+
reason:
|
|
286
|
+
`no valid bitgraph-sig/1 by trusted key "${keyName}" over the role digest is present in the ` +
|
|
287
|
+
`supplied evidence (${ctx.sigEvidence.size} candidate file(s), ${parsedCount} parsed as signatures); ` +
|
|
288
|
+
`absence of a signature from a bundle proves nothing`,
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
201
293
|
function evalClaim(ctx: Ctx, claim: Claim): ThreeValued {
|
|
202
294
|
if ("exists" in claim) return evalExists(ctx, claim.exists);
|
|
295
|
+
if ("signedBy" in claim) {
|
|
296
|
+
const [role, keyName] = claim.signedBy;
|
|
297
|
+
return evalSignedBy(ctx, role, keyName);
|
|
298
|
+
}
|
|
203
299
|
if ("before" in claim) {
|
|
204
300
|
const [x, y] = claim.before;
|
|
205
301
|
return evalPrecedes(ctx, `before(${x}, ${y})`, x, y);
|
|
@@ -224,9 +320,18 @@ function evalClaim(ctx: Ctx, claim: Claim): ThreeValued {
|
|
|
224
320
|
export function evaluate(
|
|
225
321
|
rule: Rule,
|
|
226
322
|
resolutions: Map<string, Resolution>,
|
|
227
|
-
audit: AuditResult
|
|
323
|
+
audit: AuditResult,
|
|
324
|
+
sigEvidence: SigEvidence = new Map()
|
|
228
325
|
): Evaluation {
|
|
229
|
-
const ctx: Ctx = {
|
|
326
|
+
const ctx: Ctx = {
|
|
327
|
+
rule,
|
|
328
|
+
resolutions,
|
|
329
|
+
audit,
|
|
330
|
+
steps: [],
|
|
331
|
+
usedTiers: new Set(),
|
|
332
|
+
sigEvidence,
|
|
333
|
+
keyObjects: new Map(),
|
|
334
|
+
};
|
|
230
335
|
const result = evalClaim(ctx, rule.claim);
|
|
231
336
|
const evaluation: Evaluation = { result, steps: ctx.steps };
|
|
232
337
|
if (ctx.usedTiers.has("assumption-dependent")) {
|
package/src/index.ts
CHANGED
|
@@ -24,14 +24,18 @@ export type {
|
|
|
24
24
|
OrderResult,
|
|
25
25
|
Resolution,
|
|
26
26
|
Rule,
|
|
27
|
+
RuleFormat,
|
|
27
28
|
ThreeValued,
|
|
28
29
|
Verdict,
|
|
29
30
|
} from "./types.js";
|
|
30
31
|
export { basisTier, meetsFloor } from "./types.js";
|
|
31
32
|
|
|
32
|
-
export { parseRule, normalizeDigest, RuleError } from "./rule.js";
|
|
33
|
-
export { play, playAudit, PlayError } from "./play.js";
|
|
33
|
+
export { parseRule, normalizeDigest, decodeDigestBytes, RuleError } from "./rule.js";
|
|
34
|
+
export { play, playAudit, claimUsesSignatures, SIG_EVIDENCE_MAX_BYTES, PlayError } from "./play.js";
|
|
34
35
|
export type { PlayResult, PlayStage } from "./play.js";
|
|
36
|
+
export { parseSigFile, verifySigFile, keyObjectFor, sigMessage } from "./sig.js";
|
|
37
|
+
export type { SigAlg, SigFile, TrustedKey } from "./sig.js";
|
|
38
|
+
export type { SigEvidence } from "./evaluate.js";
|
|
35
39
|
export { scaffoldRule, roleNameForFile, ORDERING_PLACEHOLDER } from "./init.js";
|
|
36
40
|
export type { ScaffoldEntry } from "./init.js";
|
|
37
41
|
export { resolveCast, resolveRole } from "./cast.js";
|
|
@@ -40,3 +44,6 @@ export { kleeneAll, kleeneAny, kleeneNot } from "./logic.js";
|
|
|
40
44
|
export { evaluate } from "./evaluate.js";
|
|
41
45
|
export type { Evaluation } from "./evaluate.js";
|
|
42
46
|
export { buildVerdict, serializeVerdict, playerVersion, PLAYER_VERSION } from "./verdict.js";
|
|
47
|
+
|
|
48
|
+
export { checkIngest, buildCheckReport, renderCheckText, serializeCheckReport, KNOWN_ENCLAVE_MEASUREMENTS } from "./check.js";
|
|
49
|
+
export type { CheckReport, CheckRecording, CheckAnchor, CheckLine, CheckBounds, CheckBound, CheckOptions } from "./check.js";
|
package/src/play.ts
CHANGED
|
@@ -16,12 +16,13 @@
|
|
|
16
16
|
|
|
17
17
|
import { createHash } from "node:crypto";
|
|
18
18
|
import { readFileSync } from "node:fs";
|
|
19
|
-
import { runAudit } from "@mikeargento/bitgraph-audit";
|
|
19
|
+
import { runAudit, streamMatchedArtifacts } from "@mikeargento/bitgraph-audit";
|
|
20
20
|
import type { AuditResult } from "@mikeargento/bitgraph-audit";
|
|
21
21
|
import { resolveCast } from "./cast.js";
|
|
22
22
|
import { evaluate } from "./evaluate.js";
|
|
23
|
+
import type { SigEvidence } from "./evaluate.js";
|
|
23
24
|
import { parseRule } from "./rule.js";
|
|
24
|
-
import type { Rule, Verdict } from "./types.js";
|
|
25
|
+
import type { Claim, Rule, Verdict } from "./types.js";
|
|
25
26
|
import { buildVerdict, serializeVerdict } from "./verdict.js";
|
|
26
27
|
|
|
27
28
|
export type PlayStage = "rule-read" | "audit";
|
|
@@ -45,10 +46,32 @@ export interface PlayResult {
|
|
|
45
46
|
audit: AuditResult;
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/** True when the claim tree contains a signedBy claim anywhere. */
|
|
50
|
+
export function claimUsesSignatures(claim: Claim): boolean {
|
|
51
|
+
if ("signedBy" in claim) return true;
|
|
52
|
+
if ("all" in claim) return claim.all.some(claimUsesSignatures);
|
|
53
|
+
if ("any" in claim) return claim.any.some(claimUsesSignatures);
|
|
54
|
+
if ("not" in claim) return claimUsesSignatures(claim.not);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Ceiling on candidate signature-file bytes retained for signedBy
|
|
60
|
+
* evaluation. Signature files are a few hundred bytes; the cap only
|
|
61
|
+
* exists so a bundle full of large artifacts never inflates memory.
|
|
62
|
+
* A file above the cap is simply not signature evidence.
|
|
63
|
+
*/
|
|
64
|
+
export const SIG_EVIDENCE_MAX_BYTES = 1_048_576;
|
|
65
|
+
|
|
48
66
|
/** The pure tail of the pipeline: no filesystem, no network. */
|
|
49
|
-
export function playAudit(
|
|
67
|
+
export function playAudit(
|
|
68
|
+
rule: Rule,
|
|
69
|
+
ruleSha256Hex: string,
|
|
70
|
+
audit: AuditResult,
|
|
71
|
+
sigEvidence: SigEvidence = new Map()
|
|
72
|
+
): PlayResult {
|
|
50
73
|
const resolutions = resolveCast(rule.cast, audit);
|
|
51
|
-
const evaluation = evaluate(rule, resolutions, audit);
|
|
74
|
+
const evaluation = evaluate(rule, resolutions, audit, sigEvidence);
|
|
52
75
|
const verdict = buildVerdict(rule, ruleSha256Hex, resolutions, evaluation, audit);
|
|
53
76
|
const exitCode = evaluation.result === "TRUE" ? 0 : evaluation.result === "FALSE" ? 1 : 2;
|
|
54
77
|
return { verdict, bytes: serializeVerdict(verdict), exitCode, audit };
|
|
@@ -74,5 +97,20 @@ export async function play(rulePath: string, bundlePath: string): Promise<PlayRe
|
|
|
74
97
|
} catch (err) {
|
|
75
98
|
throw new PlayError("audit", `bundle audit failed: ${(err as Error).message}`, err);
|
|
76
99
|
}
|
|
77
|
-
|
|
100
|
+
|
|
101
|
+
// Format 2 with signature claims: candidate evidence is every RECORDED
|
|
102
|
+
// artifact in the bundle (matched to a proof), size-capped. A signature
|
|
103
|
+
// that was never recorded can still be supplied to playAudit directly
|
|
104
|
+
// by an embedder; the reference CLI evaluates recorded evidence, which
|
|
105
|
+
// is what a Titles thread produces by construction.
|
|
106
|
+
let sigEvidence: SigEvidence | undefined;
|
|
107
|
+
if (rule.rule === "bitgraph-player/2" && claimUsesSignatures(rule.claim)) {
|
|
108
|
+
const collected = new Map<string, Uint8Array>();
|
|
109
|
+
for await (const matched of streamMatchedArtifacts(audit.ingest)) {
|
|
110
|
+
if (matched.bytes.length > SIG_EVIDENCE_MAX_BYTES) continue;
|
|
111
|
+
if (!collected.has(matched.sha256Hex)) collected.set(matched.sha256Hex, matched.bytes);
|
|
112
|
+
}
|
|
113
|
+
sigEvidence = collected;
|
|
114
|
+
}
|
|
115
|
+
return playAudit(rule, ruleSha256Hex, audit, sigEvidence);
|
|
78
116
|
}
|
package/src/rule.ts
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* was undeclared.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import type {
|
|
17
|
+
import type { TrustedKey } from "./sig.js";
|
|
18
|
+
import type { CastEntry, CastPin, Claim, EvidenceTier, Rule, RuleFormat } from "./types.js";
|
|
18
19
|
|
|
19
20
|
export class RuleError extends Error {
|
|
20
21
|
readonly issues: readonly string[];
|
|
@@ -171,7 +172,13 @@ function parseCastEntry(role: string, v: unknown, issues: string[]): CastEntry |
|
|
|
171
172
|
return entry;
|
|
172
173
|
}
|
|
173
174
|
|
|
174
|
-
function parseClaim(
|
|
175
|
+
function parseClaim(
|
|
176
|
+
v: unknown,
|
|
177
|
+
where: string,
|
|
178
|
+
issues: string[],
|
|
179
|
+
depth: number,
|
|
180
|
+
format: RuleFormat
|
|
181
|
+
): Claim | undefined {
|
|
175
182
|
if (depth > 32) {
|
|
176
183
|
issues.push(`${where}: claim nesting exceeds the maximum depth of 32`);
|
|
177
184
|
return undefined;
|
|
@@ -220,7 +227,7 @@ function parseClaim(v: unknown, where: string, issues: string[], depth: number):
|
|
|
220
227
|
const parsed: Claim[] = [];
|
|
221
228
|
let ok = true;
|
|
222
229
|
body.forEach((child, i) => {
|
|
223
|
-
const c = parseClaim(child, `${where}.${op}[${i}]`, issues, depth + 1);
|
|
230
|
+
const c = parseClaim(child, `${where}.${op}[${i}]`, issues, depth + 1, format);
|
|
224
231
|
if (c === undefined) ok = false;
|
|
225
232
|
else parsed.push(c);
|
|
226
233
|
});
|
|
@@ -228,10 +235,21 @@ function parseClaim(v: unknown, where: string, issues: string[], depth: number):
|
|
|
228
235
|
return op === "all" ? { all: parsed } : { any: parsed };
|
|
229
236
|
}
|
|
230
237
|
case "not": {
|
|
231
|
-
const inner = parseClaim(body, `${where}.not`, issues, depth + 1);
|
|
238
|
+
const inner = parseClaim(body, `${where}.not`, issues, depth + 1, format);
|
|
232
239
|
if (inner === undefined) return undefined;
|
|
233
240
|
return { not: inner };
|
|
234
241
|
}
|
|
242
|
+
case "signedBy": {
|
|
243
|
+
if (format !== "bitgraph-player/2") {
|
|
244
|
+
issues.push(`${where}.signedBy: requires rule format "bitgraph-player/2"`);
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
if (!Array.isArray(body) || body.length !== 2 || body.some((r) => typeof r !== "string")) {
|
|
248
|
+
issues.push(`${where}.signedBy: must be [roleName, trustedKeyName]`);
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
return { signedBy: body as [string, string] };
|
|
252
|
+
}
|
|
235
253
|
default:
|
|
236
254
|
issues.push(`${where}: unknown operator "${op}"`);
|
|
237
255
|
return undefined;
|
|
@@ -252,10 +270,18 @@ export function parseRule(jsonText: string): Rule {
|
|
|
252
270
|
const issues: string[] = [];
|
|
253
271
|
if (!isPlainObject(raw)) throw new RuleError(["rule file must be a JSON object"]);
|
|
254
272
|
|
|
255
|
-
rejectUnknownKeys(
|
|
273
|
+
rejectUnknownKeys(
|
|
274
|
+
raw,
|
|
275
|
+
["rule", "id", "cast", "world", "requires", "trustedKeys", "claim", "then"],
|
|
276
|
+
"rule",
|
|
277
|
+
issues
|
|
278
|
+
);
|
|
256
279
|
|
|
257
|
-
|
|
258
|
-
|
|
280
|
+
let format: RuleFormat = "bitgraph-player/1";
|
|
281
|
+
if (raw["rule"] === "bitgraph-player/1" || raw["rule"] === "bitgraph-player/2") {
|
|
282
|
+
format = raw["rule"];
|
|
283
|
+
} else {
|
|
284
|
+
issues.push(`"rule" must be "bitgraph-player/1" or "bitgraph-player/2"`);
|
|
259
285
|
}
|
|
260
286
|
if (typeof raw["id"] !== "string" || raw["id"].length === 0) {
|
|
261
287
|
issues.push(`"id" is required and must be a non-empty string`);
|
|
@@ -302,11 +328,68 @@ export function parseRule(jsonText: string): Rule {
|
|
|
302
328
|
}
|
|
303
329
|
}
|
|
304
330
|
|
|
331
|
+
// trustedKeys: format 2 only. The name-to-key binding is declared, so
|
|
332
|
+
// parsing only enforces well-formedness, never meaning.
|
|
333
|
+
let trustedKeys: Record<string, TrustedKey> | undefined;
|
|
334
|
+
if ("trustedKeys" in raw) {
|
|
335
|
+
if (format !== "bitgraph-player/2") {
|
|
336
|
+
issues.push(`"trustedKeys" requires rule format "bitgraph-player/2"`);
|
|
337
|
+
} else if (!isPlainObject(raw["trustedKeys"]) || Object.keys(raw["trustedKeys"]).length === 0) {
|
|
338
|
+
issues.push(`"trustedKeys" must be an object naming at least one key`);
|
|
339
|
+
} else {
|
|
340
|
+
trustedKeys = Object.create(null) as Record<string, TrustedKey>;
|
|
341
|
+
for (const [name, entry] of Object.entries(raw["trustedKeys"])) {
|
|
342
|
+
const where = `trustedKeys.${name}`;
|
|
343
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(name) || /^[0-9]+$/.test(name)) {
|
|
344
|
+
issues.push(
|
|
345
|
+
`trustedKeys: key name "${name}" must match [A-Za-z0-9_.-]+ with at least one non-digit`
|
|
346
|
+
);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (!isPlainObject(entry)) {
|
|
350
|
+
issues.push(`${where}: must be an object`);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
rejectUnknownKeys(entry, ["alg", "publicKey"], where, issues);
|
|
354
|
+
const alg = entry["alg"];
|
|
355
|
+
const publicKey = entry["publicKey"];
|
|
356
|
+
if (alg !== "ed25519" && alg !== "es256") {
|
|
357
|
+
issues.push(`${where}: "alg" must be "ed25519" or "es256"`);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (typeof publicKey !== "string" || publicKey.length === 0) {
|
|
361
|
+
issues.push(`${where}: "publicKey" is required and must be a non-empty string`);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
trustedKeys[name] = { alg, publicKey };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
305
369
|
let claim: Claim | undefined;
|
|
306
370
|
if (!("claim" in raw)) {
|
|
307
371
|
issues.push(`"claim" is required`);
|
|
308
372
|
} else {
|
|
309
|
-
claim = parseClaim(raw["claim"], "claim", issues, 0);
|
|
373
|
+
claim = parseClaim(raw["claim"], "claim", issues, 0, format);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Every signedBy claim must reference a declared trusted key: the
|
|
377
|
+
// reference is statically checkable, and an unresolvable key name is a
|
|
378
|
+
// rule the author believes is being enforced and is not.
|
|
379
|
+
if (claim !== undefined) {
|
|
380
|
+
const referenced: string[] = [];
|
|
381
|
+
const walk = (c: Claim): void => {
|
|
382
|
+
if ("signedBy" in c) referenced.push(c.signedBy[1]);
|
|
383
|
+
else if ("all" in c) c.all.forEach(walk);
|
|
384
|
+
else if ("any" in c) c.any.forEach(walk);
|
|
385
|
+
else if ("not" in c) walk(c.not);
|
|
386
|
+
};
|
|
387
|
+
walk(claim);
|
|
388
|
+
for (const name of referenced) {
|
|
389
|
+
if (trustedKeys === undefined || !(name in trustedKeys)) {
|
|
390
|
+
issues.push(`claim: signedBy references trusted key "${name}" which trustedKeys does not declare`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
310
393
|
}
|
|
311
394
|
|
|
312
395
|
let then: { label: string } | undefined;
|
|
@@ -328,13 +411,14 @@ export function parseRule(jsonText: string): Rule {
|
|
|
328
411
|
if (issues.length > 0) throw new RuleError(issues);
|
|
329
412
|
|
|
330
413
|
const rule: Rule = {
|
|
331
|
-
rule:
|
|
414
|
+
rule: format,
|
|
332
415
|
id: raw["id"] as string,
|
|
333
416
|
cast,
|
|
334
417
|
world: "closed",
|
|
335
418
|
requires: { ordering: ordering as EvidenceTier },
|
|
336
419
|
claim: claim as Claim,
|
|
337
420
|
};
|
|
421
|
+
if (trustedKeys !== undefined) rule.trustedKeys = trustedKeys;
|
|
338
422
|
if (then !== undefined) rule.then = then;
|
|
339
423
|
return rule;
|
|
340
424
|
}
|