@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/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.1";
34
+ export const PLAYER_VERSION = "0.4.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,