@mikeargento/bitgraph-player 0.8.1 → 0.9.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 +3 -24
- package/dist/check.d.ts +2 -45
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +6 -92
- package/dist/check.js.map +1 -1
- package/dist/cli.js +11 -185
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +1 -1
- package/dist/verdict.d.ts +1 -1
- package/dist/verdict.js +1 -1
- package/dist-web/verify.html +5 -5
- package/package.json +2 -3
- package/src/check.ts +6 -143
- package/src/cli.ts +11 -189
- package/src/index.ts +1 -16
- package/src/verdict.ts +1 -1
- package/DOMAIN.md +0 -119
- package/dist/__tests__/domain.test.d.ts +0 -2
- package/dist/__tests__/domain.test.d.ts.map +0 -1
- package/dist/__tests__/domain.test.js +0 -265
- package/dist/__tests__/domain.test.js.map +0 -1
- package/dist/domain.d.ts +0 -64
- package/dist/domain.d.ts.map +0 -1
- package/dist/domain.js +0 -212
- package/dist/domain.js.map +0 -1
- package/dist/pin.d.ts +0 -55
- package/dist/pin.d.ts.map +0 -1
- package/dist/pin.js +0 -126
- package/dist/pin.js.map +0 -1
- package/src/__tests__/domain.test.ts +0 -321
- package/src/domain.ts +0 -245
- package/src/pin.ts +0 -155
|
@@ -1,321 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* BitGraph Domain: parseDomainFile, fingerprints, the pin store, the
|
|
5
|
-
* fetch, and check --from's "domain" line.
|
|
6
|
-
*
|
|
7
|
-
* The invariants under test:
|
|
8
|
-
* - The domain line is TRUE or UNDETERMINED, never FALSE: absence of
|
|
9
|
-
* domain evidence contradicts nothing (SPEC §9.3's open-world rule).
|
|
10
|
-
* - Fingerprints are always derived from key material, never read from
|
|
11
|
-
* the file; for es256 the fingerprint IS the actor keyId, proved
|
|
12
|
-
* against the real declared recording (fixtures/declared-12010).
|
|
13
|
-
* - A malformed file is refused at the pin, and a redirect cannot store
|
|
14
|
-
* one party's file under another party's name (the `domain` field
|
|
15
|
-
* must equal the domain the reader asked for).
|
|
16
|
-
* - A report without --from stays bitgraph-check/1 with no `from` key.
|
|
17
|
-
*
|
|
18
|
-
* Everything runs in memory or under a temp dir. The fetch is injected;
|
|
19
|
-
* no network, no ledger writes, ever.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { strict as assert } from "node:assert";
|
|
23
|
-
import { createHash, generateKeyPairSync, sign as cryptoSign } from "node:crypto";
|
|
24
|
-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
25
|
-
import { tmpdir } from "node:os";
|
|
26
|
-
import { join } from "node:path";
|
|
27
|
-
import { test } from "node:test";
|
|
28
|
-
import { ingestEntries } from "@mikeargento/bitgraph-audit";
|
|
29
|
-
import type { CheckRecording, CheckReport } from "../check.js";
|
|
30
|
-
import { checkIngest } from "../check.js";
|
|
31
|
-
import {
|
|
32
|
-
checkDomain,
|
|
33
|
-
diffDomainFiles,
|
|
34
|
-
DomainFileError,
|
|
35
|
-
domainKeyRefs,
|
|
36
|
-
isDomainName,
|
|
37
|
-
keyFingerprint,
|
|
38
|
-
parseDomainFile,
|
|
39
|
-
} from "../domain.js";
|
|
40
|
-
import { fetchDomainFile, forgetPin, listPins, readPin, writePin } from "../pin.js";
|
|
41
|
-
import type { FetchLike } from "../pin.js";
|
|
42
|
-
import { sigMessage } from "../sig.js";
|
|
43
|
-
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
// The real declared recording: actor key ee0c6517…, provider passkey
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
|
|
48
|
-
const proofBytes = readFileSync(new URL("../../src/__tests__/fixtures/declared-12010/proof.json", import.meta.url));
|
|
49
|
-
const proofJson = JSON.parse(proofBytes.toString("utf8")) as {
|
|
50
|
-
agency: { actor: { publicKeyB64: string; keyId: string } };
|
|
51
|
-
artifact: { digestB64: string };
|
|
52
|
-
};
|
|
53
|
-
const ACTOR_KEY_B64 = proofJson.agency.actor.publicKeyB64;
|
|
54
|
-
const ACTOR_KEY_ID = proofJson.agency.actor.keyId;
|
|
55
|
-
const DIGEST_HEX = Buffer.from(proofJson.artifact.digestB64, "base64").toString("hex");
|
|
56
|
-
|
|
57
|
-
function domainText(overrides?: Record<string, unknown>): string {
|
|
58
|
-
return JSON.stringify({
|
|
59
|
-
version: "bitgraph-domain/1",
|
|
60
|
-
domain: "acme.com",
|
|
61
|
-
party: "Acme Corp",
|
|
62
|
-
keys: { studio: { alg: "es256", publicKey: ACTOR_KEY_B64 } },
|
|
63
|
-
...overrides,
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function ingestProof(bytes: Uint8Array = proofBytes) {
|
|
68
|
-
return ingestEntries([{ path: "proof.json", open: () => Promise.resolve(new Uint8Array(bytes)) }]);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function onlyRecording(report: CheckReport): CheckRecording {
|
|
72
|
-
assert.equal(report.recordings.length, 1);
|
|
73
|
-
return report.recordings[0] as CheckRecording;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function domainLineOf(recording: CheckRecording) {
|
|
77
|
-
const line = recording.lines.find((l) => l.name === "domain");
|
|
78
|
-
assert.notEqual(line, undefined, "expected a domain line");
|
|
79
|
-
return line as NonNullable<typeof line>;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// ---------------------------------------------------------------------------
|
|
83
|
-
// Parsing and fingerprints
|
|
84
|
-
// ---------------------------------------------------------------------------
|
|
85
|
-
|
|
86
|
-
test("parseDomainFile accepts a valid file and derives the party/keys", () => {
|
|
87
|
-
const file = parseDomainFile(domainText(), "acme.com");
|
|
88
|
-
assert.equal(file.domain, "acme.com");
|
|
89
|
-
assert.equal(file.party, "Acme Corp");
|
|
90
|
-
assert.deepEqual(Object.keys(file.keys), ["studio"]);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("isDomainName: lowercase dotted hostnames only", () => {
|
|
94
|
-
assert.equal(isDomainName("acme.com"), true);
|
|
95
|
-
assert.equal(isDomainName("sub.acme-corp.co.uk"), true);
|
|
96
|
-
assert.equal(isDomainName("ACME.com"), false);
|
|
97
|
-
assert.equal(isDomainName("acme"), false);
|
|
98
|
-
assert.equal(isDomainName("acme.com/path"), false);
|
|
99
|
-
assert.equal(isDomainName("https://acme.com"), false);
|
|
100
|
-
assert.equal(isDomainName("acme.com:443"), false);
|
|
101
|
-
assert.equal(isDomainName(""), false);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test("parseDomainFile refuses malformed files with every issue named", () => {
|
|
105
|
-
const bad = (text: string, expected?: string): string[] => {
|
|
106
|
-
try {
|
|
107
|
-
parseDomainFile(text, expected);
|
|
108
|
-
} catch (err) {
|
|
109
|
-
assert.ok(err instanceof DomainFileError);
|
|
110
|
-
return [...err.issues];
|
|
111
|
-
}
|
|
112
|
-
assert.fail("expected DomainFileError");
|
|
113
|
-
};
|
|
114
|
-
assert.match(bad(domainText({ version: "bitgraph-domain/2" })).join(" "), /"version" must be exactly/);
|
|
115
|
-
assert.match(bad(domainText({ extra: 1 })).join(" "), /unknown field "extra"/);
|
|
116
|
-
assert.match(bad(domainText(), "other.example").join(" "), /refusing to store one party's file/);
|
|
117
|
-
assert.match(bad(domainText({ party: " " })).join(" "), /"party" is required/);
|
|
118
|
-
assert.match(bad(domainText({ keys: {} })).join(" "), /at least one key/);
|
|
119
|
-
assert.match(bad(domainText({ keys: { "1234": { alg: "es256", publicKey: ACTOR_KEY_B64 } } })).join(" "), /non-digit/);
|
|
120
|
-
assert.match(
|
|
121
|
-
bad(domainText({ keys: { studio: { alg: "es256", publicKey: ACTOR_KEY_B64, note: "x" } } })).join(" "),
|
|
122
|
-
/unknown field "note"/
|
|
123
|
-
);
|
|
124
|
-
assert.match(bad(domainText({ keys: { studio: { alg: "rsa", publicKey: ACTOR_KEY_B64 } } })).join(" "), /"alg" must be/);
|
|
125
|
-
assert.match(bad(domainText({ keys: { studio: { alg: "es256", publicKey: "!!!" } } })).join(" "), /does not decode/);
|
|
126
|
-
assert.match(
|
|
127
|
-
bad(domainText({ keys: { press: { alg: "ed25519", publicKey: Buffer.alloc(16).toString("base64") } } })).join(" "),
|
|
128
|
-
/does not decode/
|
|
129
|
-
);
|
|
130
|
-
assert.match(bad(domainText({ domain: "https://acme.com" })).join(" "), /lowercase hostname/);
|
|
131
|
-
// Oversize input is refused before parsing.
|
|
132
|
-
const big = new Uint8Array(70_000);
|
|
133
|
-
assert.throws(() => parseDomainFile(big), DomainFileError);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test("an es256 fingerprint IS the actor keyId (derived, never assigned)", () => {
|
|
137
|
-
assert.equal(keyFingerprint({ alg: "es256", publicKey: ACTOR_KEY_B64 }), ACTOR_KEY_ID);
|
|
138
|
-
const refs = domainKeyRefs(parseDomainFile(domainText()));
|
|
139
|
-
assert.deepEqual(refs.map((r) => [r.name, r.fingerprint]), [["studio", ACTOR_KEY_ID]]);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test("checkDomain resolves actor keyIds case-insensitively, es256 only", () => {
|
|
143
|
-
const from = checkDomain(parseDomainFile(domainText()));
|
|
144
|
-
assert.equal(from.actorKeyName(ACTOR_KEY_ID), "studio");
|
|
145
|
-
assert.equal(from.actorKeyName(ACTOR_KEY_ID.toUpperCase()), "studio");
|
|
146
|
-
assert.equal(from.actorKeyName("00".repeat(32)), undefined);
|
|
147
|
-
// An ed25519 key never actor-matches: actors are P-256.
|
|
148
|
-
const edPub = generateKeyPairSync("ed25519").publicKey.export({ type: "spki", format: "der" }) as Buffer;
|
|
149
|
-
const edOnly = parseDomainFile(
|
|
150
|
-
domainText({ keys: { press: { alg: "ed25519", publicKey: edPub.subarray(edPub.length - 32).toString("base64") } } })
|
|
151
|
-
);
|
|
152
|
-
const edFingerprint = keyFingerprint(edOnly.keys["press"] as { alg: "ed25519"; publicKey: string });
|
|
153
|
-
assert.equal(checkDomain(edOnly).actorKeyName(edFingerprint as string), undefined);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
// ---------------------------------------------------------------------------
|
|
157
|
-
// check --from: the domain line
|
|
158
|
-
// ---------------------------------------------------------------------------
|
|
159
|
-
|
|
160
|
-
test("check --from: the real declared recording reads TRUE under its published key", async () => {
|
|
161
|
-
const report = await checkIngest(await ingestProof(), { from: checkDomain(parseDomainFile(domainText())) });
|
|
162
|
-
assert.equal(report.check, "bitgraph-check/2");
|
|
163
|
-
assert.deepEqual(report.from, { domain: "acme.com", party: "Acme Corp" });
|
|
164
|
-
const line = domainLineOf(onlyRecording(report));
|
|
165
|
-
assert.equal(line.result, "TRUE");
|
|
166
|
-
assert.match(line.detail, /actor key "studio" · published by acme\.com \(Acme Corp\)/);
|
|
167
|
-
assert.ok(report.notChecked.some((n) => n.includes("pinned")));
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
test("check --from: a domain that never published the key reads UNDETERMINED, never FALSE", async () => {
|
|
171
|
-
const otherKey = generateKeyPairSync("ec", { namedCurve: "P-256" })
|
|
172
|
-
.publicKey.export({ type: "spki", format: "der" }) as Buffer;
|
|
173
|
-
const from = checkDomain(
|
|
174
|
-
parseDomainFile(domainText({ keys: { invoices: { alg: "es256", publicKey: otherKey.toString("base64") } } }))
|
|
175
|
-
);
|
|
176
|
-
const report = await checkIngest(await ingestProof(), { from });
|
|
177
|
-
const line = domainLineOf(onlyRecording(report));
|
|
178
|
-
assert.equal(line.result, "UNDETERMINED");
|
|
179
|
-
assert.match(line.detail, /is not among the 1 key\(s\) acme\.com publishes/);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
test("check --from: a recording that does not verify gets an UNDETERMINED domain line", async () => {
|
|
183
|
-
const stripped = JSON.parse(proofBytes.toString("utf8")) as Record<string, unknown>;
|
|
184
|
-
delete stripped["agency"];
|
|
185
|
-
const report = await checkIngest(await ingestProof(Buffer.from(JSON.stringify(stripped))), {
|
|
186
|
-
from: checkDomain(parseDomainFile(domainText())),
|
|
187
|
-
});
|
|
188
|
-
const recording = onlyRecording(report);
|
|
189
|
-
assert.equal(recording.lines.find((l) => l.name === "signature")?.result, "FALSE");
|
|
190
|
-
const line = domainLineOf(recording);
|
|
191
|
-
assert.equal(line.result, "UNDETERMINED");
|
|
192
|
-
assert.match(line.detail, /is not verified here/);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
test("check --from: a detached bitgraph-sig/1 under a published ed25519 key reads TRUE", async () => {
|
|
196
|
-
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
197
|
-
const spki = publicKey.export({ type: "spki", format: "der" }) as Buffer;
|
|
198
|
-
const rawB64 = spki.subarray(spki.length - 32).toString("base64");
|
|
199
|
-
const signature = cryptoSign(null, sigMessage(DIGEST_HEX), privateKey).toString("base64");
|
|
200
|
-
const sigBytes = Buffer.from(
|
|
201
|
-
JSON.stringify({
|
|
202
|
-
sig: "bitgraph-sig/1",
|
|
203
|
-
over: `sha256:${DIGEST_HEX}`,
|
|
204
|
-
alg: "ed25519",
|
|
205
|
-
publicKey: rawB64,
|
|
206
|
-
signature,
|
|
207
|
-
})
|
|
208
|
-
);
|
|
209
|
-
const from = checkDomain(parseDomainFile(domainText({ keys: { press: { alg: "ed25519", publicKey: rawB64 } } })));
|
|
210
|
-
const sigEvidence = new Map([[createHash("sha256").update(sigBytes).digest("hex"), new Uint8Array(sigBytes)]]);
|
|
211
|
-
const report = await checkIngest(await ingestProof(), { from, sigEvidence });
|
|
212
|
-
const line = domainLineOf(onlyRecording(report));
|
|
213
|
-
assert.equal(line.result, "TRUE");
|
|
214
|
-
assert.match(line.detail, /signature by "press" · published by acme\.com/);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
test("without --from the report stays bitgraph-check/1 with no from key and no domain line", async () => {
|
|
218
|
-
const report = await checkIngest(await ingestProof());
|
|
219
|
-
assert.equal(report.check, "bitgraph-check/1");
|
|
220
|
-
assert.equal("from" in report, false);
|
|
221
|
-
assert.equal(onlyRecording(report).lines.some((l) => l.name === "domain"), false);
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
// ---------------------------------------------------------------------------
|
|
225
|
-
// The pin store
|
|
226
|
-
// ---------------------------------------------------------------------------
|
|
227
|
-
|
|
228
|
-
test("pin store: write, read, list, forget; malformed pins are refused at read", () => {
|
|
229
|
-
const dir = mkdtempSync(join(tmpdir(), "bitgraph-pins-"));
|
|
230
|
-
try {
|
|
231
|
-
const path = writePin("acme.com", Buffer.from(domainText()), dir);
|
|
232
|
-
assert.ok(path.endsWith("acme.com"));
|
|
233
|
-
const pin = readPin("acme.com", dir);
|
|
234
|
-
assert.equal(pin?.file.party, "Acme Corp");
|
|
235
|
-
assert.equal(pin?.bytes.toString("utf8"), domainText());
|
|
236
|
-
|
|
237
|
-
// A stored file naming a different domain is refused: the store binds
|
|
238
|
-
// name to statement, so a bad write cannot impersonate.
|
|
239
|
-
writePin("evil.example", Buffer.from(domainText()), dir);
|
|
240
|
-
assert.throws(() => readPin("evil.example", dir), DomainFileError);
|
|
241
|
-
|
|
242
|
-
writePin("broken.example", Buffer.from("{"), dir);
|
|
243
|
-
assert.throws(() => readPin("broken.example", dir), DomainFileError);
|
|
244
|
-
|
|
245
|
-
const listed = listPins(dir);
|
|
246
|
-
assert.deepEqual(listed.map((p) => [p.domain, p.malformed]), [
|
|
247
|
-
["acme.com", false],
|
|
248
|
-
["broken.example", true],
|
|
249
|
-
["evil.example", true],
|
|
250
|
-
]);
|
|
251
|
-
assert.equal(listed[0]?.party, "Acme Corp");
|
|
252
|
-
assert.equal(listed[0]?.keyCount, 1);
|
|
253
|
-
|
|
254
|
-
assert.equal(forgetPin("acme.com", dir), true);
|
|
255
|
-
assert.equal(readPin("acme.com", dir), undefined);
|
|
256
|
-
assert.equal(forgetPin("acme.com", dir), false);
|
|
257
|
-
} finally {
|
|
258
|
-
rmSync(dir, { recursive: true, force: true });
|
|
259
|
-
}
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
// ---------------------------------------------------------------------------
|
|
263
|
-
// The fetch (injected; the only networked verb)
|
|
264
|
-
// ---------------------------------------------------------------------------
|
|
265
|
-
|
|
266
|
-
function fakeFetch(body: string | Buffer, status = 200): { impl: FetchLike; urls: string[] } {
|
|
267
|
-
const urls: string[] = [];
|
|
268
|
-
const impl: FetchLike = (url) => {
|
|
269
|
-
urls.push(url);
|
|
270
|
-
return Promise.resolve({
|
|
271
|
-
ok: status >= 200 && status < 300,
|
|
272
|
-
status,
|
|
273
|
-
headers: { get: () => null },
|
|
274
|
-
arrayBuffer: () => {
|
|
275
|
-
const buf = Buffer.from(body);
|
|
276
|
-
return Promise.resolve(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
|
|
277
|
-
},
|
|
278
|
-
});
|
|
279
|
-
};
|
|
280
|
-
return { impl, urls };
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
test("fetchDomainFile: fixed well-known path, verbatim bytes, strict domain binding", async () => {
|
|
284
|
-
const { impl, urls } = fakeFetch(domainText());
|
|
285
|
-
const fetched = await fetchDomainFile("acme.com", impl);
|
|
286
|
-
assert.deepEqual(urls, ["https://acme.com/.well-known/bitgraph"]);
|
|
287
|
-
assert.equal(fetched.file.party, "Acme Corp");
|
|
288
|
-
assert.equal(fetched.bytes.toString("utf8"), domainText());
|
|
289
|
-
|
|
290
|
-
// The served file names acme.com; pinning it as another.example must
|
|
291
|
-
// refuse: a redirect cannot repoint the name.
|
|
292
|
-
await assert.rejects(fetchDomainFile("another.example", fakeFetch(domainText()).impl), DomainFileError);
|
|
293
|
-
await assert.rejects(fetchDomainFile("acme.com", fakeFetch("nope", 404).impl), /HTTP 404/);
|
|
294
|
-
await assert.rejects(fetchDomainFile("acme.com", fakeFetch(Buffer.alloc(70_000)).impl), /the cap is/);
|
|
295
|
-
await assert.rejects(fetchDomainFile("https://acme.com", fakeFetch(domainText()).impl), /bare hostname/);
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
// ---------------------------------------------------------------------------
|
|
299
|
-
// Re-pin diffs
|
|
300
|
-
// ---------------------------------------------------------------------------
|
|
301
|
-
|
|
302
|
-
test("diffDomainFiles reports added, removed, changed, and a renamed party", () => {
|
|
303
|
-
const edPub = generateKeyPairSync("ed25519").publicKey.export({ type: "spki", format: "der" }) as Buffer;
|
|
304
|
-
const edB64 = edPub.subarray(edPub.length - 32).toString("base64");
|
|
305
|
-
const before = parseDomainFile(domainText());
|
|
306
|
-
const after = parseDomainFile(
|
|
307
|
-
domainText({
|
|
308
|
-
party: "Acme Corporation",
|
|
309
|
-
keys: {
|
|
310
|
-
studio: { alg: "ed25519", publicKey: edB64 },
|
|
311
|
-
press: { alg: "ed25519", publicKey: edB64 },
|
|
312
|
-
},
|
|
313
|
-
})
|
|
314
|
-
);
|
|
315
|
-
const diff = diffDomainFiles(before, after);
|
|
316
|
-
assert.deepEqual(diff.partyChanged, { before: "Acme Corp", after: "Acme Corporation" });
|
|
317
|
-
assert.deepEqual(diff.added.map((r) => r.name), ["press"]);
|
|
318
|
-
assert.deepEqual(diff.removed.map((r) => r.name), []);
|
|
319
|
-
assert.deepEqual(diff.changed.map((c) => c.name), ["studio"]);
|
|
320
|
-
assert.equal(diff.unchanged, 0);
|
|
321
|
-
});
|
package/src/domain.ts
DELETED
|
@@ -1,245 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* BitGraph Domain: a party's own domain publishing the keys that record
|
|
5
|
-
* for it. The file (`bitgraph-domain/1`) is served at
|
|
6
|
-
* `https://<domain>/.well-known/bitgraph` and is the party speaking for
|
|
7
|
-
* itself, never BitGraph speaking about the party: parsing enforces
|
|
8
|
-
* well-formedness only, and adopting the statement is the reader's act
|
|
9
|
-
* (see pin.ts). The format is DOMAIN.md; evaluation semantics (SPEC.md
|
|
10
|
-
* sections 1 through 9) are untouched by everything in this module.
|
|
11
|
-
*
|
|
12
|
-
* The file shares SPEC section 9.1's trusted-key grammar, so an entry
|
|
13
|
-
* pastes into a format 2 rule's `trustedKeys` unchanged, and a key's
|
|
14
|
-
* fingerprint is the lowercase hex SHA-256 of the decoded key bytes,
|
|
15
|
-
* which for es256 is exactly the `keyId` actor proofs carry. Fingerprints
|
|
16
|
-
* are always derived here and never read from the file, so a domain
|
|
17
|
-
* cannot claim a key it does not show.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { createHash } from "node:crypto";
|
|
21
|
-
import type { CheckDomain } from "./check.js";
|
|
22
|
-
import { decodeDigestBytes } from "./rule.js";
|
|
23
|
-
import type { TrustedKey } from "./sig.js";
|
|
24
|
-
import { decodeB64Strict, keyObjectFor, parseSigFile, verifySigFile } from "./sig.js";
|
|
25
|
-
|
|
26
|
-
export const DOMAIN_FILE_VERSION = "bitgraph-domain/1";
|
|
27
|
-
export const DOMAIN_WELL_KNOWN_PATH = "/.well-known/bitgraph";
|
|
28
|
-
export const DOMAIN_FILE_MAX_BYTES = 65_536;
|
|
29
|
-
|
|
30
|
-
export interface DomainFile {
|
|
31
|
-
version: "bitgraph-domain/1";
|
|
32
|
-
/** Lowercase hostname; the binding. Must equal the domain the reader asked for. */
|
|
33
|
-
domain: string;
|
|
34
|
-
/** The name the domain gives itself. Display only. */
|
|
35
|
-
party: string;
|
|
36
|
-
/** SPEC §9.1 trusted-key bodies, named. */
|
|
37
|
-
keys: Record<string, TrustedKey>;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export class DomainFileError extends Error {
|
|
41
|
-
readonly issues: readonly string[];
|
|
42
|
-
constructor(issues: string[]) {
|
|
43
|
-
super(issues[0] ?? "invalid domain file");
|
|
44
|
-
this.name = "DomainFileError";
|
|
45
|
-
this.issues = issues;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Lowercase registrable hostname: dot-separated LDH labels, at least two,
|
|
51
|
-
* no scheme, no port, no path. Also the safety property the pin store
|
|
52
|
-
* rests on: a name this grammar accepts contains no path separators.
|
|
53
|
-
*/
|
|
54
|
-
const LABEL = "[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?";
|
|
55
|
-
const DOMAIN_RE = new RegExp(`^${LABEL}(?:\\.${LABEL})+$`);
|
|
56
|
-
|
|
57
|
-
export function isDomainName(s: string): boolean {
|
|
58
|
-
return s.length > 0 && s.length <= 253 && DOMAIN_RE.test(s);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
62
|
-
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Parse and validate a bitgraph-domain/1 file. Strict: unknown fields are
|
|
67
|
-
* errors (additions are a new format version), and key material that does
|
|
68
|
-
* not decode is refused here rather than stored, so a pin can never hold
|
|
69
|
-
* keys that silently match nothing. Throws DomainFileError with every
|
|
70
|
-
* issue found.
|
|
71
|
-
*/
|
|
72
|
-
export function parseDomainFile(input: Uint8Array | string, expectedDomain?: string): DomainFile {
|
|
73
|
-
const issues: string[] = [];
|
|
74
|
-
const text = typeof input === "string" ? input : Buffer.from(input).toString("utf8");
|
|
75
|
-
if (typeof input !== "string" && input.length > DOMAIN_FILE_MAX_BYTES) {
|
|
76
|
-
throw new DomainFileError([`file is ${input.length} bytes; the cap is ${DOMAIN_FILE_MAX_BYTES}`]);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
let raw: unknown;
|
|
80
|
-
try {
|
|
81
|
-
raw = JSON.parse(text);
|
|
82
|
-
} catch (err) {
|
|
83
|
-
throw new DomainFileError([`not JSON: ${(err as Error).message}`]);
|
|
84
|
-
}
|
|
85
|
-
if (!isPlainObject(raw)) throw new DomainFileError(["not a JSON object"]);
|
|
86
|
-
|
|
87
|
-
for (const k of Object.keys(raw)) {
|
|
88
|
-
if (!["version", "domain", "party", "keys"].includes(k)) {
|
|
89
|
-
issues.push(`unknown field "${k}" (additions are a new format version)`);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (raw["version"] !== DOMAIN_FILE_VERSION) {
|
|
94
|
-
issues.push(`"version" must be exactly "${DOMAIN_FILE_VERSION}"`);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const domain = raw["domain"];
|
|
98
|
-
if (typeof domain !== "string" || !isDomainName(domain)) {
|
|
99
|
-
issues.push(`"domain" must be a lowercase hostname (no scheme, no port, no path)`);
|
|
100
|
-
} else if (expectedDomain !== undefined && domain !== expectedDomain) {
|
|
101
|
-
issues.push(
|
|
102
|
-
`the file names domain "${domain}" but was requested for "${expectedDomain}"; refusing to store one party's file under another party's name`
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const party = raw["party"];
|
|
107
|
-
if (typeof party !== "string" || party.trim().length === 0) {
|
|
108
|
-
issues.push(`"party" is required: the name the domain gives itself`);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const keys = Object.create(null) as Record<string, TrustedKey>;
|
|
112
|
-
const rawKeys = raw["keys"];
|
|
113
|
-
if (!isPlainObject(rawKeys) || Object.keys(rawKeys).length === 0) {
|
|
114
|
-
issues.push(`"keys" must be an object naming at least one key`);
|
|
115
|
-
} else {
|
|
116
|
-
for (const [name, entry] of Object.entries(rawKeys)) {
|
|
117
|
-
const where = `keys.${name}`;
|
|
118
|
-
if (!/^[A-Za-z0-9_.-]+$/.test(name) || /^[0-9]+$/.test(name)) {
|
|
119
|
-
issues.push(`${where}: key name must match [A-Za-z0-9_.-]+ with at least one non-digit`);
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
if (!isPlainObject(entry)) {
|
|
123
|
-
issues.push(`${where}: must be an object`);
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
for (const k of Object.keys(entry)) {
|
|
127
|
-
if (k !== "alg" && k !== "publicKey") issues.push(`${where}: unknown field "${k}"`);
|
|
128
|
-
}
|
|
129
|
-
const alg = entry["alg"];
|
|
130
|
-
const publicKey = entry["publicKey"];
|
|
131
|
-
if (alg !== "ed25519" && alg !== "es256") {
|
|
132
|
-
issues.push(`${where}: "alg" must be "ed25519" or "es256"`);
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
if (typeof publicKey !== "string" || publicKey.length === 0) {
|
|
136
|
-
issues.push(`${where}: "publicKey" is required and must be a non-empty string`);
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
const key: TrustedKey = { alg, publicKey };
|
|
140
|
-
if (keyObjectFor(key) === undefined) {
|
|
141
|
-
issues.push(
|
|
142
|
-
`${where}: key material does not decode as ${alg} (${alg === "es256" ? "SPKI DER for P-256" : "raw 32-byte key"}, standard base64)`
|
|
143
|
-
);
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
146
|
-
keys[name] = key;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (issues.length > 0) throw new DomainFileError(issues);
|
|
151
|
-
return { version: DOMAIN_FILE_VERSION, domain: domain as string, party: (party as string).trim(), keys };
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Lowercase hex SHA-256 of the decoded publicKey bytes. For es256 this is
|
|
156
|
-
* exactly the actor keyId (hex SHA-256 of the SPKI DER). Undefined when
|
|
157
|
-
* the material does not decode, which parseDomainFile refuses anyway.
|
|
158
|
-
*/
|
|
159
|
-
export function keyFingerprint(key: TrustedKey): string | undefined {
|
|
160
|
-
const material = decodeB64Strict(key.publicKey);
|
|
161
|
-
if (material === undefined) return undefined;
|
|
162
|
-
return createHash("sha256").update(material).digest("hex");
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export interface DomainKeyRef {
|
|
166
|
-
name: string;
|
|
167
|
-
key: TrustedKey;
|
|
168
|
-
fingerprint: string;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Every key with its derived fingerprint, name order. */
|
|
172
|
-
export function domainKeyRefs(file: DomainFile): DomainKeyRef[] {
|
|
173
|
-
const refs: DomainKeyRef[] = [];
|
|
174
|
-
for (const name of Object.keys(file.keys).sort()) {
|
|
175
|
-
const key = file.keys[name] as TrustedKey;
|
|
176
|
-
const fingerprint = keyFingerprint(key);
|
|
177
|
-
if (fingerprint !== undefined) refs.push({ name, key, fingerprint });
|
|
178
|
-
}
|
|
179
|
-
return refs;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export interface DomainDiff {
|
|
183
|
-
partyChanged?: { before: string; after: string };
|
|
184
|
-
added: DomainKeyRef[];
|
|
185
|
-
removed: DomainKeyRef[];
|
|
186
|
-
changed: { name: string; before: DomainKeyRef; after: DomainKeyRef }[];
|
|
187
|
-
unchanged: number;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** What a re-pin would change, for showing before asking. */
|
|
191
|
-
export function diffDomainFiles(before: DomainFile, after: DomainFile): DomainDiff {
|
|
192
|
-
const beforeRefs = new Map(domainKeyRefs(before).map((r) => [r.name, r]));
|
|
193
|
-
const afterRefs = new Map(domainKeyRefs(after).map((r) => [r.name, r]));
|
|
194
|
-
const diff: DomainDiff = { added: [], removed: [], changed: [], unchanged: 0 };
|
|
195
|
-
if (before.party !== after.party) diff.partyChanged = { before: before.party, after: after.party };
|
|
196
|
-
for (const [name, ref] of afterRefs) {
|
|
197
|
-
const prior = beforeRefs.get(name);
|
|
198
|
-
if (prior === undefined) diff.added.push(ref);
|
|
199
|
-
else if (prior.key.alg !== ref.key.alg || prior.key.publicKey !== ref.key.publicKey) {
|
|
200
|
-
diff.changed.push({ name, before: prior, after: ref });
|
|
201
|
-
} else diff.unchanged += 1;
|
|
202
|
-
}
|
|
203
|
-
for (const [name, ref] of beforeRefs) {
|
|
204
|
-
if (!afterRefs.has(name)) diff.removed.push(ref);
|
|
205
|
-
}
|
|
206
|
-
return diff;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* The check-report adapter: what `check --from` consults per recording.
|
|
211
|
-
* Defined as an interface in check.ts so the check module (which also
|
|
212
|
-
* builds verify.html) never imports the crypto this module uses; the CLI
|
|
213
|
-
* hands the report builder this object.
|
|
214
|
-
*/
|
|
215
|
-
export function checkDomain(file: DomainFile): CheckDomain {
|
|
216
|
-
const actorNames = new Map<string, string>();
|
|
217
|
-
for (const ref of domainKeyRefs(file)) {
|
|
218
|
-
if (ref.key.alg !== "es256") continue;
|
|
219
|
-
if (!actorNames.has(ref.fingerprint)) actorNames.set(ref.fingerprint, ref.name);
|
|
220
|
-
}
|
|
221
|
-
const sigKeys = domainKeyRefs(file)
|
|
222
|
-
.map((ref) => ({ name: ref.name, key: ref.key, keyObject: keyObjectFor(ref.key) }))
|
|
223
|
-
.filter((k) => k.keyObject !== undefined);
|
|
224
|
-
|
|
225
|
-
return {
|
|
226
|
-
domain: file.domain,
|
|
227
|
-
party: file.party,
|
|
228
|
-
keyCount: Object.keys(file.keys).length,
|
|
229
|
-
actorKeyName: (keyId: string): string | undefined => actorNames.get(keyId.toLowerCase()),
|
|
230
|
-
signatureKeyName: (targetSha256Hex: string, evidence: ReadonlyMap<string, Uint8Array>): string | undefined => {
|
|
231
|
-
// Deterministic: candidates in ascending content-hash order, keys in
|
|
232
|
-
// name order; the first verifying pair decides (SPEC §9.4 discipline).
|
|
233
|
-
for (const sha256Hex of [...evidence.keys()].sort()) {
|
|
234
|
-
const sig = parseSigFile(evidence.get(sha256Hex) as Uint8Array);
|
|
235
|
-
if (sig === undefined) continue;
|
|
236
|
-
for (const k of sigKeys) {
|
|
237
|
-
if (verifySigFile(sig, k.key, k.keyObject as NonNullable<typeof k.keyObject>, targetSha256Hex, decodeDigestBytes)) {
|
|
238
|
-
return k.name;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
return undefined;
|
|
243
|
-
},
|
|
244
|
-
};
|
|
245
|
-
}
|