@mikeargento/bitgraph-player 0.5.1 → 0.6.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 (50) hide show
  1. package/DOMAIN.md +119 -0
  2. package/README.md +25 -5
  3. package/dist/__tests__/domain.test.d.ts +2 -0
  4. package/dist/__tests__/domain.test.d.ts.map +1 -0
  5. package/dist/__tests__/domain.test.js +265 -0
  6. package/dist/__tests__/domain.test.js.map +1 -0
  7. package/dist/check.d.ts +45 -2
  8. package/dist/check.d.ts.map +1 -1
  9. package/dist/check.js +94 -7
  10. package/dist/check.js.map +1 -1
  11. package/dist/cli.js +194 -12
  12. package/dist/cli.js.map +1 -1
  13. package/dist/domain.d.ts +64 -0
  14. package/dist/domain.d.ts.map +1 -0
  15. package/dist/domain.js +212 -0
  16. package/dist/domain.js.map +1 -0
  17. package/dist/index.d.ts +5 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/pin.d.ts +55 -0
  22. package/dist/pin.d.ts.map +1 -0
  23. package/dist/pin.js +126 -0
  24. package/dist/pin.js.map +1 -0
  25. package/dist/play.d.ts +6 -1
  26. package/dist/play.d.ts.map +1 -1
  27. package/dist/play.js +6 -1
  28. package/dist/play.js.map +1 -1
  29. package/dist/sig.d.ts +2 -0
  30. package/dist/sig.d.ts.map +1 -1
  31. package/dist/sig.js +1 -1
  32. package/dist/sig.js.map +1 -1
  33. package/dist/types.d.ts +7 -0
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/types.js +7 -0
  36. package/dist/types.js.map +1 -1
  37. package/dist/verdict.d.ts +1 -1
  38. package/dist/verdict.js +1 -1
  39. package/dist-web/verify.html +3 -3
  40. package/package.json +4 -3
  41. package/src/__tests__/domain.test.ts +321 -0
  42. package/src/check.ts +149 -9
  43. package/src/cli.ts +199 -12
  44. package/src/domain.ts +245 -0
  45. package/src/index.ts +17 -1
  46. package/src/pin.ts +155 -0
  47. package/src/play.ts +6 -1
  48. package/src/sig.ts +1 -1
  49. package/src/types.ts +8 -0
  50. package/src/verdict.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikeargento/bitgraph-player",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Deterministic evaluation of causal rules over BitGraph proof bundles: a pure function from (rule, verified evidence) to a reproducible verdict.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,7 @@
20
20
  "dist-web",
21
21
  "src",
22
22
  "SPEC.md",
23
+ "DOMAIN.md",
23
24
  "README.md",
24
25
  "LICENSE"
25
26
  ],
@@ -35,8 +36,8 @@
35
36
  "node": ">=20"
36
37
  },
37
38
  "dependencies": {
38
- "@mikeargento/bitgraph-audit": "^0.2.1",
39
- "@mikeargento/bitgraph-verify": "^1.2.0"
39
+ "@mikeargento/bitgraph-audit": "^0.2.2",
40
+ "@mikeargento/bitgraph-verify": "^1.3.0"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@noble/curves": "^2.3.0",
@@ -0,0 +1,321 @@
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/check.ts CHANGED
@@ -63,8 +63,9 @@ import type {
63
63
  SegmentBound,
64
64
  TemporalSegment,
65
65
  } from "@mikeargento/bitgraph-audit";
66
- import { auditIngest, AUDIT_VERSION } from "@mikeargento/bitgraph-audit";
66
+ import { auditIngest, AUDIT_VERSION, streamMatchedArtifacts } from "@mikeargento/bitgraph-audit";
67
67
  import type { ThreeValued } from "./types.js";
68
+ import { SIG_EVIDENCE_MAX_BYTES } from "./types.js";
68
69
  import { kleeneAll } from "./logic.js";
69
70
  import { PLAYER_VERSION } from "./verdict.js";
70
71
 
@@ -107,7 +108,7 @@ export const KNOWN_ENCLAVE_MEASUREMENTS: ReadonlyArray<{ pcr0: string; label: st
107
108
 
108
109
  /** One checked property, three-valued, with a plain-language reason. */
109
110
  export interface CheckLine {
110
- name: "file" | "signature" | "attestation" | "enclave" | "witness" | "contradiction";
111
+ name: "file" | "signature" | "attestation" | "enclave" | "witness" | "contradiction" | "domain";
111
112
  result: ThreeValued;
112
113
  detail: string;
113
114
  }
@@ -162,7 +163,10 @@ export interface CheckAnchor {
162
163
  }
163
164
 
164
165
  export interface CheckReport {
165
- check: "bitgraph-check/1";
166
+ /** /2 exactly when the report was built against a pinned domain (`from`). */
167
+ check: "bitgraph-check/1" | "bitgraph-check/2";
168
+ /** The pinned domain this report was checked against, when one was. */
169
+ from?: { domain: string; party: string };
166
170
  result: ThreeValued;
167
171
  /** One-sentence plain-language conclusion, deterministic. */
168
172
  summary: string;
@@ -178,6 +182,31 @@ export interface CheckReport {
178
182
  network: "none";
179
183
  }
180
184
 
185
+ /**
186
+ * A pinned BitGraph Domain, as the report builder consults it: one line
187
+ * per recording, TRUE or UNDETERMINED, never FALSE (absence of domain
188
+ * evidence contradicts nothing; the open-world rule of SPEC §9.3).
189
+ *
190
+ * An interface here rather than an import from domain.ts, deliberately:
191
+ * this module is also built into the browser verifier, and the crypto the
192
+ * adapter needs (key decoding, signature verification) stays behind it.
193
+ * domain.ts's `checkDomain(file)` is the implementation; embedders may
194
+ * supply their own.
195
+ */
196
+ export interface CheckDomain {
197
+ domain: string;
198
+ party: string;
199
+ keyCount: number;
200
+ /** Key name for an actor keyId (es256 fingerprint match), if published. */
201
+ actorKeyName(keyId: string): string | undefined;
202
+ /**
203
+ * Key name of the first pinned key a candidate bitgraph-sig/1 file
204
+ * verifies under, over this digest (lowercase hex). Deterministic:
205
+ * candidates ascending by content hash, keys in name order.
206
+ */
207
+ signatureKeyName(targetSha256Hex: string, evidence: ReadonlyMap<string, Uint8Array>): string | undefined;
208
+ }
209
+
181
210
  export interface CheckOptions {
182
211
  /**
183
212
  * False when the environment cannot run the attestation's ECDSA P-384
@@ -185,6 +214,19 @@ export interface CheckOptions {
185
214
  * with that reason instead of a false FALSE. Defaults to true.
186
215
  */
187
216
  webCryptoAvailable?: boolean;
217
+ /**
218
+ * Check against a pinned domain: adds one "domain" line per recording
219
+ * and stamps the report bitgraph-check/2. The check itself stays
220
+ * offline; the pin was the one fetch, and it already happened.
221
+ */
222
+ from?: CheckDomain;
223
+ /**
224
+ * Candidate signature bytes by content sha256 hex, for the domain
225
+ * line's detached-signature path (SPEC §9.4 discipline). checkIngest
226
+ * collects this from the bundle's matched artifacts when absent;
227
+ * embedders may supply additional candidates.
228
+ */
229
+ sigEvidence?: ReadonlyMap<string, Uint8Array>;
188
230
  }
189
231
 
190
232
  // ---------------------------------------------------------------------------
@@ -215,8 +257,20 @@ const EXCERPT_NORMAL_CODES: ReadonlySet<string> = new Set([
215
257
  * browser page call this, so they cannot drift.
216
258
  */
217
259
  export async function checkIngest(ingest: IngestResult, options?: CheckOptions): Promise<CheckReport> {
260
+ let opts = options;
261
+ // Domain checking's detached-signature path wants the same candidate set
262
+ // the evaluator uses (SPEC §9.4: matched artifacts, size-capped). Collect
263
+ // it here, where the ingest is in hand, unless the embedder supplied one.
264
+ if (opts?.from !== undefined && opts.sigEvidence === undefined) {
265
+ const collected = new Map<string, Uint8Array>();
266
+ for await (const matched of streamMatchedArtifacts(ingest)) {
267
+ if (matched.bytes.length > SIG_EVIDENCE_MAX_BYTES) continue;
268
+ if (!collected.has(matched.sha256Hex)) collected.set(matched.sha256Hex, matched.bytes);
269
+ }
270
+ opts = { ...opts, sigEvidence: collected };
271
+ }
218
272
  const audit = await auditIngest(ingest, { startedAt: "" });
219
- return buildCheckReport(audit, options);
273
+ return buildCheckReport(audit, opts);
220
274
  }
221
275
 
222
276
  /** The pure report builder over an AuditResult. */
@@ -260,7 +314,9 @@ export function buildCheckReport(audit: AuditResult, options?: CheckOptions): Ch
260
314
  attestationByHash.get(proof.proofHash),
261
315
  segmentByHash.get(proof.proofHash),
262
316
  artifactPathByProof.get(proof.proofHash),
263
- webCrypto
317
+ webCrypto,
318
+ options?.from,
319
+ options?.sigEvidence
264
320
  )
265
321
  );
266
322
  }
@@ -270,7 +326,7 @@ export function buildCheckReport(audit: AuditResult, options?: CheckOptions): Ch
270
326
  sortByPosition(anchors);
271
327
  const contradictions = collectContradictions(audit);
272
328
  const notes = collectNotes(audit, recordings, anchors);
273
- const notChecked = collectNotChecked(anchors.length > 0);
329
+ const notChecked = collectNotChecked(anchors.length > 0, options?.from?.domain);
274
330
 
275
331
  const allLines: ThreeValued[] = [
276
332
  ...recordings.map((r) => r.result),
@@ -280,7 +336,10 @@ export function buildCheckReport(audit: AuditResult, options?: CheckOptions): Ch
280
336
  const result: ThreeValued = allLines.length === 0 ? "UNDETERMINED" : kleeneAll(allLines);
281
337
 
282
338
  return {
283
- check: "bitgraph-check/1",
339
+ check: options?.from !== undefined ? "bitgraph-check/2" : "bitgraph-check/1",
340
+ ...(options?.from !== undefined
341
+ ? { from: { domain: options.from.domain, party: options.from.party } }
342
+ : {}),
284
343
  result,
285
344
  summary: summarize(result, recordings, anchors, contradictions),
286
345
  recordings,
@@ -322,7 +381,9 @@ function buildRecording(
322
381
  attestation: ProofAttestationRecord | undefined,
323
382
  segment: TemporalSegment | undefined,
324
383
  filePath: string | undefined,
325
- webCrypto: boolean
384
+ webCrypto: boolean,
385
+ from?: CheckDomain,
386
+ sigEvidence?: ReadonlyMap<string, Uint8Array>
326
387
  ): CheckRecording {
327
388
  const lines: CheckLine[] = [];
328
389
  const v = proof.verification;
@@ -374,6 +435,19 @@ function buildRecording(
374
435
  // enclave: the attested PCR0 is a published BitGraph measurement.
375
436
  lines.push(enclaveLine(att.attestedPcr0, att.line.result));
376
437
 
438
+ // domain: a key the pinned domain published stands behind this
439
+ // recording. TRUE or UNDETERMINED, never FALSE: domain evidence can
440
+ // exist outside any bundle, so its absence contradicts nothing
441
+ // (SPEC §9.3's open-world rule); tampering already reads FALSE on the
442
+ // lines above, and this line does not restate them.
443
+ if (from !== undefined) {
444
+ // Verified exactly when the signature line above reads TRUE: the
445
+ // integrity-tier "artifact-unavailable" status is a PASS (proof-only
446
+ // bundles verify; whether bytes are in hand is the file line's job).
447
+ const verified = v !== undefined && v.status !== "failed";
448
+ lines.push(domainLine(proof, verified, from, sigEvidence ?? EMPTY_EVIDENCE));
449
+ }
450
+
377
451
  const result = kleeneAll(lines.map((l) => l.result));
378
452
 
379
453
  return {
@@ -453,6 +527,64 @@ function attestationLine(
453
527
  };
454
528
  }
455
529
 
530
+ const EMPTY_EVIDENCE: ReadonlyMap<string, Uint8Array> = new Map();
531
+
532
+ function domainLine(
533
+ proof: ObservedProof,
534
+ verified: boolean,
535
+ from: CheckDomain,
536
+ sigEvidence: ReadonlyMap<string, Uint8Array>
537
+ ): CheckLine {
538
+ if (!verified) {
539
+ return {
540
+ name: "domain",
541
+ result: "UNDETERMINED",
542
+ detail: `the recording is not verified here, so nothing binds it to ${from.domain}`,
543
+ };
544
+ }
545
+ const actorKeyId = proof.proof.agency?.actor?.keyId;
546
+ if (actorKeyId !== undefined) {
547
+ const name = from.actorKeyName(actorKeyId);
548
+ if (name !== undefined) {
549
+ return {
550
+ name: "domain",
551
+ result: "TRUE",
552
+ detail: `actor key "${name}" · published by ${from.domain} (${from.party})`,
553
+ };
554
+ }
555
+ }
556
+ const targetBytes = decodeDigestB64(proof.proof.artifact.digestB64);
557
+ if (targetBytes !== undefined && sigEvidence.size > 0) {
558
+ const name = from.signatureKeyName(targetBytes.toString("hex"), sigEvidence);
559
+ if (name !== undefined) {
560
+ return {
561
+ name: "domain",
562
+ result: "TRUE",
563
+ detail: `signature by "${name}" · published by ${from.domain} (${from.party})`,
564
+ };
565
+ }
566
+ }
567
+ if (actorKeyId !== undefined) {
568
+ return {
569
+ name: "domain",
570
+ result: "UNDETERMINED",
571
+ detail: `actor key ${actorKeyId.slice(0, 12)}… is not among the ${from.keyCount} key(s) ${from.domain} publishes`,
572
+ };
573
+ }
574
+ return {
575
+ name: "domain",
576
+ result: "UNDETERMINED",
577
+ detail: `no evidence binds this recording to ${from.domain}`,
578
+ };
579
+ }
580
+
581
+ /** Standard-base64 digest to bytes; undefined when it does not round-trip. */
582
+ function decodeDigestB64(digestB64: string): Buffer | undefined {
583
+ if (!/^[A-Za-z0-9+/]+=*$/.test(digestB64)) return undefined;
584
+ const bytes = Buffer.from(digestB64, "base64");
585
+ return bytes.toString("base64") === digestB64 ? bytes : undefined;
586
+ }
587
+
456
588
  function enclaveLine(attestedPcr0: string | undefined, attestationResult: ThreeValued): CheckLine {
457
589
  if (attestationResult !== "TRUE" || attestedPcr0 === undefined) {
458
590
  return {
@@ -751,13 +883,18 @@ function collectNotes(audit: AuditResult, recordings: CheckRecording[], anchors:
751
883
  return notes;
752
884
  }
753
885
 
754
- function collectNotChecked(hasAnchors: boolean): string[] {
886
+ function collectNotChecked(hasAnchors: boolean, fromDomain?: string): string[] {
755
887
  const out: string[] = [];
756
888
  if (hasAnchors) {
757
889
  out.push(
758
890
  "whether the anchored Ethereum blocks are canonical: their headers are recomputed here, but canonicality needs an Ethereum node or a block explorer"
759
891
  );
760
892
  }
893
+ if (fromDomain !== undefined) {
894
+ out.push(
895
+ `whether ${fromDomain}'s published key file has changed since it was pinned: a pin is read as stored; pin the domain again to refresh it`
896
+ );
897
+ }
761
898
  out.push(
762
899
  "whether the public ledger holds these exact recordings at these positions: this is an offline check; drop the file on bitgraph.ing to compare against the ledger"
763
900
  );
@@ -812,6 +949,9 @@ export function renderCheckText(report: CheckReport): string {
812
949
  const out: string[] = [];
813
950
  const mark = (r: ThreeValued): string => (r === "TRUE" ? "TRUE " : r === "FALSE" ? "FALSE" : "UNDET");
814
951
  out.push(report.summary);
952
+ if (report.from !== undefined) {
953
+ out.push(`checked against ${report.from.domain} (${report.from.party}), from the stored pin`);
954
+ }
815
955
  out.push("");
816
956
  report.recordings.forEach((rec, i) => {
817
957
  out.push(