@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/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.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:
|
|
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,
|