@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.
Files changed (61) hide show
  1. package/README.md +40 -0
  2. package/SPEC.md +95 -6
  3. package/dist/__tests__/check.test.d.ts +2 -0
  4. package/dist/__tests__/check.test.d.ts.map +1 -0
  5. package/dist/__tests__/check.test.js +358 -0
  6. package/dist/__tests__/check.test.js.map +1 -0
  7. package/dist/__tests__/sig-v2.test.d.ts +2 -0
  8. package/dist/__tests__/sig-v2.test.d.ts.map +1 -0
  9. package/dist/__tests__/sig-v2.test.js +266 -0
  10. package/dist/__tests__/sig-v2.test.js.map +1 -0
  11. package/dist/check.d.ts +156 -0
  12. package/dist/check.d.ts.map +1 -0
  13. package/dist/check.js +640 -0
  14. package/dist/check.js.map +1 -0
  15. package/dist/cli.js +84 -15
  16. package/dist/cli.js.map +1 -1
  17. package/dist/evaluate.d.ts +8 -21
  18. package/dist/evaluate.d.ts.map +1 -1
  19. package/dist/evaluate.js +93 -2
  20. package/dist/evaluate.js.map +1 -1
  21. package/dist/index.d.ts +8 -3
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/play.d.ts +12 -2
  26. package/dist/play.d.ts.map +1 -1
  27. package/dist/play.js +39 -4
  28. package/dist/play.js.map +1 -1
  29. package/dist/rule.d.ts +0 -13
  30. package/dist/rule.d.ts.map +1 -1
  31. package/dist/rule.js +84 -8
  32. package/dist/rule.js.map +1 -1
  33. package/dist/sig.d.ts +38 -0
  34. package/dist/sig.d.ts.map +1 -0
  35. package/dist/sig.js +154 -0
  36. package/dist/sig.js.map +1 -0
  37. package/dist/types.d.ts +21 -3
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/verdict.d.ts +1 -1
  40. package/dist/verdict.d.ts.map +1 -1
  41. package/dist/verdict.js +17 -2
  42. package/dist/verdict.js.map +1 -1
  43. package/dist-web/verify.html +159 -0
  44. package/package.json +8 -3
  45. package/src/__tests__/check.test.ts +417 -0
  46. package/src/__tests__/fixtures/export-random-043/ethereum-anchors/anchor-after-witness.json +6 -0
  47. package/src/__tests__/fixtures/export-random-043/ethereum-anchors/anchor-after.json +43 -0
  48. package/src/__tests__/fixtures/export-random-043/ethereum-anchors/anchor-before-witness.json +6 -0
  49. package/src/__tests__/fixtures/export-random-043/ethereum-anchors/anchor-before.json +47 -0
  50. package/src/__tests__/fixtures/export-random-043/proof.json +38 -0
  51. package/src/__tests__/fixtures/export-random-043/random-043.txt +1 -0
  52. package/src/__tests__/sig-v2.test.ts +315 -0
  53. package/src/check.ts +873 -0
  54. package/src/cli.ts +82 -16
  55. package/src/evaluate.ts +107 -2
  56. package/src/index.ts +9 -2
  57. package/src/play.ts +43 -5
  58. package/src/rule.ts +93 -9
  59. package/src/sig.ts +170 -0
  60. package/src/types.ts +21 -4
  61. 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
- ' "--" ends option parsing; a rule file literally named "init" is\n' +
42
- " evaluated with: bitgraph-play -- init <bundle>\n" +
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
- async function runInit(args: ParsedArgs): Promise<number> {
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 isInit = argv[0] === "init";
197
- if (isInit && existsSync("init")) {
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
- 'error: a file named "init" exists here, so this command is ambiguous.\n' +
203
- ' to evaluate it as a rule: bitgraph-play -- init <bundle> (or ./init)\n' +
204
- " to scaffold a rule: rename that file or run from another directory\n"
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(isInit ? argv.slice(1) : argv);
272
+ const parsed = parseArgs(subcommand !== undefined ? argv.slice(1) : argv);
209
273
  if (parsed === undefined) return usage();
210
- return isInit ? runInit(parsed) : runEvaluate(parsed);
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 = { rule, resolutions, audit, steps: [], usedTiers: new Set() };
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(rule: Rule, ruleSha256Hex: string, audit: AuditResult): PlayResult {
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
- return playAudit(rule, ruleSha256Hex, audit);
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 { CastEntry, CastPin, Claim, EvidenceTier, Rule } from "./types.js";
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(v: unknown, where: string, issues: string[], depth: number): Claim | undefined {
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(raw, ["rule", "id", "cast", "world", "requires", "claim", "then"], "rule", issues);
273
+ rejectUnknownKeys(
274
+ raw,
275
+ ["rule", "id", "cast", "world", "requires", "trustedKeys", "claim", "then"],
276
+ "rule",
277
+ issues
278
+ );
256
279
 
257
- if (raw["rule"] !== "bitgraph-player/1") {
258
- issues.push(`"rule" must be exactly "bitgraph-player/1"`);
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: "bitgraph-player/1",
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
  }