@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/README.md +1 -1
- package/SPEC.md +91 -5
- 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/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 +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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/package.json +1 -1
- package/src/__tests__/sig-v2.test.ts +315 -0
- package/src/evaluate.ts +107 -2
- package/src/index.ts +7 -3
- 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/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
|
}
|
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:
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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,
|