@mikeargento/bitgraph-player 0.2.0 → 0.3.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/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
  }
package/src/sig.ts ADDED
@@ -0,0 +1,170 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * bitgraph-sig/1: detached signature evidence over a digest.
5
+ *
6
+ * A signature file states that a key signed a domain-separated message
7
+ * derived from an artifact digest. Verification here is pure math over
8
+ * supplied bytes: no filesystem, no network, no clock. What a valid
9
+ * signature MEANS (that the key belongs to a named party) is never
10
+ * derived; the rule's trustedKeys block is a DECLARED binding and the
11
+ * verdict says so.
12
+ *
13
+ * The signed message is:
14
+ *
15
+ * "bitgraph-sig/1\n" + lowercase hex SHA-256 of the target bytes
16
+ *
17
+ * as UTF-8. Domain separation keeps a signature made in any other
18
+ * protocol from being replayed as a bitgraph-sig, and signing the digest
19
+ * spelling (not the raw 32 bytes) keeps the message printable and
20
+ * auditable by eye.
21
+ *
22
+ * Algorithms:
23
+ * "ed25519" publicKey is the raw 32-byte key, standard base64 — the
24
+ * same spelling bitgraph/1 proofs use for signer keys.
25
+ * signature is the raw 64-byte Ed25519 signature, base64.
26
+ * "es256" ECDSA P-256 over SHA-256. publicKey is SPKI DER, base64
27
+ * (the export format of every platform keystore, including
28
+ * Secure Enclave keys surfaced through WebCrypto).
29
+ * signature is DER-encoded ECDSA, base64.
30
+ *
31
+ * A signature claim can be TRUE or UNDETERMINED, never FALSE: "this key
32
+ * never signed these bytes" is a negative over an open world no bundle
33
+ * can close. A malformed or non-verifying signature file is not evidence
34
+ * of anything.
35
+ */
36
+
37
+ import { createPublicKey, verify as cryptoVerify } from "node:crypto";
38
+ import type { KeyObject } from "node:crypto";
39
+
40
+ export type SigAlg = "ed25519" | "es256";
41
+
42
+ export interface TrustedKey {
43
+ alg: SigAlg;
44
+ /** ed25519: raw 32-byte key, standard base64. es256: SPKI DER, base64. */
45
+ publicKey: string;
46
+ }
47
+
48
+ /** A parsed, well-formed bitgraph-sig/1 object (not yet verified). */
49
+ export interface SigFile {
50
+ sig: "bitgraph-sig/1";
51
+ /** Digest of the target bytes, in any accepted canonical spelling. */
52
+ over: string;
53
+ alg: SigAlg;
54
+ publicKey: string;
55
+ signature: string;
56
+ }
57
+
58
+ /** SPKI DER prefix for a raw Ed25519 public key (RFC 8410). */
59
+ const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
60
+
61
+ /** The domain-separated message a bitgraph-sig/1 signature covers. */
62
+ export function sigMessage(targetSha256Hex: string): Buffer {
63
+ return Buffer.from(`bitgraph-sig/1\n${targetSha256Hex.toLowerCase()}`, "utf8");
64
+ }
65
+
66
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
67
+ return typeof v === "object" && v !== null && !Array.isArray(v);
68
+ }
69
+
70
+ /**
71
+ * Parse candidate bytes as a bitgraph-sig/1 file. Returns undefined for
72
+ * anything malformed: a broken signature file is noise, never evidence,
73
+ * and never an error that stops evaluation.
74
+ */
75
+ export function parseSigFile(bytes: Uint8Array): SigFile | undefined {
76
+ let raw: unknown;
77
+ try {
78
+ raw = JSON.parse(Buffer.from(bytes).toString("utf8"));
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ if (!isPlainObject(raw)) return undefined;
83
+ if (raw["sig"] !== "bitgraph-sig/1") return undefined;
84
+ const over = raw["over"];
85
+ const alg = raw["alg"];
86
+ const publicKey = raw["publicKey"];
87
+ const signature = raw["signature"];
88
+ if (typeof over !== "string" || over.length === 0) return undefined;
89
+ if (alg !== "ed25519" && alg !== "es256") return undefined;
90
+ if (typeof publicKey !== "string" || publicKey.length === 0) return undefined;
91
+ if (typeof signature !== "string" || signature.length === 0) return undefined;
92
+ return { sig: "bitgraph-sig/1", over, alg, publicKey, signature };
93
+ }
94
+
95
+ /** Strict base64 decode: the spelling must round-trip byte-exactly. */
96
+ function decodeB64Strict(s: string): Buffer | undefined {
97
+ if (!/^[A-Za-z0-9+/]+=*$/.test(s)) return undefined;
98
+ const bytes = Buffer.from(s, "base64");
99
+ return bytes.toString("base64") === s ? bytes : undefined;
100
+ }
101
+
102
+ /**
103
+ * Build a KeyObject for a trusted key. Returns undefined when the key
104
+ * material is malformed — a rule declaring an undecodable key can never
105
+ * make a signedBy claim TRUE, and the verdict's reason says why.
106
+ */
107
+ export function keyObjectFor(key: TrustedKey): KeyObject | undefined {
108
+ const material = decodeB64Strict(key.publicKey);
109
+ if (material === undefined) return undefined;
110
+ try {
111
+ if (key.alg === "ed25519") {
112
+ if (material.length !== 32) return undefined;
113
+ return createPublicKey({
114
+ key: Buffer.concat([ED25519_SPKI_PREFIX, material]),
115
+ format: "der",
116
+ type: "spki",
117
+ });
118
+ }
119
+ const keyObject = createPublicKey({ key: material, format: "der", type: "spki" });
120
+ // es256 means ECDSA over P-256 specifically. SPKI decodes many key
121
+ // types; anything that is not an EC key on prime256v1 is not es256
122
+ // key material, and verification against it must never proceed.
123
+ if (
124
+ keyObject.asymmetricKeyType !== "ec" ||
125
+ keyObject.asymmetricKeyDetails?.namedCurve !== "prime256v1"
126
+ ) {
127
+ return undefined;
128
+ }
129
+ return keyObject;
130
+ } catch {
131
+ return undefined;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Verify one parsed signature file against a trusted key and a target
137
+ * digest (lowercase hex). TRUE means: the file's `over` names exactly
138
+ * these bytes, its key material equals the trusted key, and the
139
+ * signature verifies over the domain-separated message.
140
+ */
141
+ export function verifySigFile(
142
+ sig: SigFile,
143
+ key: TrustedKey,
144
+ keyObject: KeyObject,
145
+ targetSha256Hex: string,
146
+ decodeDigestBytes: (s: string) => Buffer | undefined
147
+ ): boolean {
148
+ if (sig.alg !== key.alg) return false;
149
+ if (sig.publicKey !== key.publicKey) return false;
150
+ // `over` accepts the same spellings as rule digests, including the
151
+ // "sha256:" prefix, which the byte-level decoder does not strip itself.
152
+ let over = sig.over.trim();
153
+ if (over.toLowerCase().startsWith("sha256:")) over = over.slice("sha256:".length);
154
+ const overBytes = decodeDigestBytes(over);
155
+ const targetBytes = decodeDigestBytes(targetSha256Hex);
156
+ if (overBytes === undefined || targetBytes === undefined) return false;
157
+ if (!overBytes.equals(targetBytes)) return false;
158
+ const signature = decodeB64Strict(sig.signature);
159
+ if (signature === undefined) return false;
160
+ if (sig.alg === "ed25519" && signature.length !== 64) return false;
161
+ const message = sigMessage(targetSha256Hex);
162
+ try {
163
+ if (sig.alg === "ed25519") {
164
+ return cryptoVerify(null, message, keyObject, signature);
165
+ }
166
+ return cryptoVerify("sha256", message, keyObject, signature);
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
package/src/types.ts CHANGED
@@ -133,10 +133,19 @@ export type Claim =
133
133
  | { between: [string, string, string] }
134
134
  | { all: Claim[] }
135
135
  | { any: Claim[] }
136
- | { not: Claim };
136
+ | { not: Claim }
137
+ /**
138
+ * Format 2 only: a valid bitgraph-sig/1 by the named trusted key over
139
+ * the role's digest is present in the supplied evidence. TRUE or
140
+ * UNDETERMINED, never FALSE — "this key never signed these bytes" is a
141
+ * negative over an open world no bundle can close.
142
+ */
143
+ | { signedBy: [string, string] };
144
+
145
+ export type RuleFormat = "bitgraph-player/1" | "bitgraph-player/2";
137
146
 
138
147
  export interface Rule {
139
- rule: "bitgraph-player/1";
148
+ rule: RuleFormat;
140
149
  id: string;
141
150
  cast: Record<string, CastEntry>;
142
151
  /** The only defined value. Absence is only ever asserted within the declared cast. */
@@ -146,6 +155,13 @@ export interface Rule {
146
155
  * the rule carries its own security policy.
147
156
  */
148
157
  requires: { ordering: EvidenceTier };
158
+ /**
159
+ * Format 2 only. Named keys the rule author trusts. The name-to-key
160
+ * binding is DECLARED — surfaced in the verdict, never derived. What
161
+ * becomes derived under format 2 is only the math: a signature by the
162
+ * named key material verifies over the role's digest.
163
+ */
164
+ trustedKeys?: Record<string, import("./sig.js").TrustedKey>;
149
165
  claim: Claim;
150
166
  /**
151
167
  * A label and nothing else. No field in this format is capable of
@@ -192,14 +208,15 @@ export interface DerivedStep {
192
208
 
193
209
  /** One assertion Player took on somebody's word. */
194
210
  export interface DeclaredEntry {
195
- assertion: "signedBy" | "means" | "closed-world" | "pinned-occurrence";
211
+ assertion: "signedBy" | "means" | "closed-world" | "pinned-occurrence" | "trusted-key";
196
212
  role?: string;
197
213
  verifiedHere: false;
198
214
  [k: string]: unknown;
199
215
  }
200
216
 
201
217
  export interface Verdict {
202
- verdict: "bitgraph-player-verdict/1";
218
+ /** Tracks the rule format: a /1 rule yields a /1 verdict, byte-identical to prior releases. */
219
+ verdict: "bitgraph-player-verdict/1" | "bitgraph-player-verdict/2";
203
220
  result: ThreeValued;
204
221
  rule: { id: string; sha256: string };
205
222
  then?: { label: string };
package/src/verdict.ts CHANGED
@@ -31,7 +31,7 @@ import type { DeclaredEntry, Resolution, Rule, Verdict } from "./types.js";
31
31
  * A unit test asserts this equals package.json's version, so the
32
32
  * constant cannot drift silently across releases.
33
33
  */
34
- export const PLAYER_VERSION = "0.2.0";
34
+ export const PLAYER_VERSION = "0.3.0";
35
35
 
36
36
  /** The player package's own version. */
37
37
  export function playerVersion(): string {
@@ -110,6 +110,21 @@ export function buildVerdict(
110
110
  });
111
111
  }
112
112
  }
113
+ // Format 2: the name-to-key bindings are declared trust. The signature
114
+ // MATH is derived (it appears in `derived` steps); that key K IS the
115
+ // named party is taken on the rule author's word, exactly like a cast
116
+ // digest's meaning.
117
+ if (rule.trustedKeys !== undefined) {
118
+ for (const [keyName, key] of Object.entries(rule.trustedKeys)) {
119
+ declared.push({
120
+ assertion: "trusted-key",
121
+ verifiedHere: false,
122
+ keyName,
123
+ alg: key.alg,
124
+ publicKey: key.publicKey,
125
+ });
126
+ }
127
+ }
113
128
  declared.push({
114
129
  assertion: "closed-world",
115
130
  verifiedHere: false,
@@ -120,7 +135,8 @@ export function buildVerdict(
120
135
  });
121
136
 
122
137
  const verdict: Verdict = {
123
- verdict: "bitgraph-player-verdict/1",
138
+ verdict:
139
+ rule.rule === "bitgraph-player/2" ? "bitgraph-player-verdict/2" : "bitgraph-player-verdict/1",
124
140
  result: evaluation.result,
125
141
  rule: { id: rule.id, sha256: ruleSha256Hex },
126
142
  cast,