@mikeargento/bitgraph-player 0.5.2 → 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.
- package/DOMAIN.md +119 -0
- package/README.md +25 -5
- package/dist/__tests__/domain.test.d.ts +2 -0
- package/dist/__tests__/domain.test.d.ts.map +1 -0
- package/dist/__tests__/domain.test.js +265 -0
- package/dist/__tests__/domain.test.js.map +1 -0
- package/dist/check.d.ts +45 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +94 -7
- package/dist/check.js.map +1 -1
- package/dist/cli.js +194 -12
- package/dist/cli.js.map +1 -1
- package/dist/domain.d.ts +64 -0
- package/dist/domain.d.ts.map +1 -0
- package/dist/domain.js +212 -0
- package/dist/domain.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/pin.d.ts +55 -0
- package/dist/pin.d.ts.map +1 -0
- package/dist/pin.js +126 -0
- package/dist/pin.js.map +1 -0
- package/dist/play.d.ts +6 -1
- package/dist/play.d.ts.map +1 -1
- package/dist/play.js +6 -1
- package/dist/play.js.map +1 -1
- package/dist/sig.d.ts +2 -0
- package/dist/sig.d.ts.map +1 -1
- package/dist/sig.js +1 -1
- package/dist/sig.js.map +1 -1
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -1
- package/dist/verdict.d.ts +1 -1
- package/dist/verdict.js +1 -1
- package/package.json +2 -1
- package/src/__tests__/domain.test.ts +321 -0
- package/src/check.ts +149 -9
- package/src/cli.ts +199 -12
- package/src/domain.ts +245 -0
- package/src/index.ts +17 -1
- package/src/pin.ts +155 -0
- package/src/play.ts +6 -1
- package/src/sig.ts +1 -1
- package/src/types.ts +8 -0
- package/src/verdict.ts +1 -1
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
|
-
|
|
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,
|
|
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(
|
package/src/cli.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* bitgraph-play <rule.json> <bundle> [--out <file>] [--summary]
|
|
6
6
|
* bitgraph-play init <file>... [--out <rule.json>]
|
|
7
|
-
* bitgraph-play check <bundle-or-file>... [--json] [--out <file>]
|
|
7
|
+
* bitgraph-play check <bundle-or-file>... [--json] [--out <file>] [--from <domain> [--pins <dir>]]
|
|
8
|
+
* bitgraph-play pin [<domain>] [--forget <domain>] [--pins <dir>] [--yes]
|
|
8
9
|
*
|
|
9
10
|
* Evaluate: runs a bitgraph-player/1 rule against a proof bundle
|
|
10
11
|
* (directory, .tar, .tar.gz, or .tgz) and writes the verdict JSON to
|
|
@@ -22,6 +23,13 @@
|
|
|
22
23
|
* what bounds it. Human text on stdout by default; --json for the
|
|
23
24
|
* bitgraph-check/1 report. Offline. See check.ts for the vocabulary.
|
|
24
25
|
*
|
|
26
|
+
* Pin: fetches https://<domain>/.well-known/bitgraph once (the ONLY
|
|
27
|
+
* network access anywhere in this package, and only when invoked), shows
|
|
28
|
+
* the party and every key's fingerprint, and stores the bytes verbatim
|
|
29
|
+
* after confirmation. `check --from <domain>` then adds one three-valued
|
|
30
|
+
* "domain" line per recording, offline, from the stored pin: TRUE or
|
|
31
|
+
* UNDETERMINED, never FALSE. Format and semantics: DOMAIN.md.
|
|
32
|
+
*
|
|
25
33
|
* Exit codes: 0 TRUE, 1 FALSE, 2 UNDETERMINED, 3 error (init: 0 or 3).
|
|
26
34
|
* Diagnostics go to stderr; stdout carries the verdict (or skeleton, or
|
|
27
35
|
* check report) bytes only.
|
|
@@ -35,10 +43,14 @@ import { createHash } from "node:crypto";
|
|
|
35
43
|
import { createReadStream, existsSync, writeFileSync } from "node:fs";
|
|
36
44
|
import { readFile, stat } from "node:fs/promises";
|
|
37
45
|
import { basename } from "node:path";
|
|
46
|
+
import { createInterface } from "node:readline/promises";
|
|
38
47
|
import { pipeline } from "node:stream/promises";
|
|
39
48
|
import type { AuditResult, BundleEntrySource } from "@mikeargento/bitgraph-audit";
|
|
40
49
|
import { ingestBundle, ingestEntries } from "@mikeargento/bitgraph-audit";
|
|
50
|
+
import type { CheckOptions } from "./check.js";
|
|
41
51
|
import { checkIngest, renderCheckText, serializeCheckReport } from "./check.js";
|
|
52
|
+
import { checkDomain, diffDomainFiles, domainKeyRefs, DomainFileError, isDomainName } from "./domain.js";
|
|
53
|
+
import { defaultPinsDir, fetchDomainFile, forgetPin, listPins, readPin, writePin } from "./pin.js";
|
|
42
54
|
import { scaffoldRule } from "./init.js";
|
|
43
55
|
import type { ScaffoldEntry } from "./init.js";
|
|
44
56
|
import { play, PlayError } from "./play.js";
|
|
@@ -49,8 +61,12 @@ function usage(): number {
|
|
|
49
61
|
"usage: bitgraph-play <rule.json> <bundle> [--out <file>] [--summary]\n" +
|
|
50
62
|
" bitgraph-play init <file>... [--out <rule.json>]\n" +
|
|
51
63
|
" bitgraph-play check <bundle-or-file>... [--json] [--out <file>]\n" +
|
|
52
|
-
|
|
53
|
-
|
|
64
|
+
" [--from <domain> [--pins <dir>]]\n" +
|
|
65
|
+
" bitgraph-play pin [<domain>] [--forget <domain>] [--pins <dir>] [--yes]\n" +
|
|
66
|
+
' "--" ends option parsing; a rule file literally named "init",\n' +
|
|
67
|
+
' "check" or "pin" is evaluated with: bitgraph-play -- init <bundle>\n' +
|
|
68
|
+
" pin is the only command that touches the network; check --from\n" +
|
|
69
|
+
" reads the stored pin and runs offline\n" +
|
|
54
70
|
" exit codes: 0 TRUE, 1 FALSE, 2 UNDETERMINED, 3 error\n"
|
|
55
71
|
);
|
|
56
72
|
return 3;
|
|
@@ -91,10 +107,18 @@ interface ParsedArgs {
|
|
|
91
107
|
outFile?: string;
|
|
92
108
|
summary: boolean;
|
|
93
109
|
json: boolean;
|
|
110
|
+
from?: string;
|
|
111
|
+
pinsDir?: string;
|
|
112
|
+
forget?: string;
|
|
113
|
+
yes: boolean;
|
|
94
114
|
}
|
|
95
115
|
|
|
96
116
|
function parseArgs(args: string[]): ParsedArgs | undefined {
|
|
97
|
-
const parsed: ParsedArgs = { positional: [], summary: false, json: false };
|
|
117
|
+
const parsed: ParsedArgs = { positional: [], summary: false, json: false, yes: false };
|
|
118
|
+
const valueFor = (i: number): string | undefined => {
|
|
119
|
+
const next = args[i];
|
|
120
|
+
return next === undefined || next.startsWith("-") ? undefined : next;
|
|
121
|
+
};
|
|
98
122
|
for (let i = 0; i < args.length; i++) {
|
|
99
123
|
const arg = args[i] as string;
|
|
100
124
|
if (arg === "--") {
|
|
@@ -102,13 +126,27 @@ function parseArgs(args: string[]): ParsedArgs | undefined {
|
|
|
102
126
|
parsed.positional.push(...args.slice(i + 1));
|
|
103
127
|
break;
|
|
104
128
|
} else if (arg === "--out") {
|
|
105
|
-
const next =
|
|
106
|
-
if (next === undefined
|
|
129
|
+
const next = valueFor(++i);
|
|
130
|
+
if (next === undefined) return undefined;
|
|
107
131
|
parsed.outFile = next;
|
|
132
|
+
} else if (arg === "--from") {
|
|
133
|
+
const next = valueFor(++i);
|
|
134
|
+
if (next === undefined) return undefined;
|
|
135
|
+
parsed.from = next;
|
|
136
|
+
} else if (arg === "--pins") {
|
|
137
|
+
const next = valueFor(++i);
|
|
138
|
+
if (next === undefined) return undefined;
|
|
139
|
+
parsed.pinsDir = next;
|
|
140
|
+
} else if (arg === "--forget") {
|
|
141
|
+
const next = valueFor(++i);
|
|
142
|
+
if (next === undefined) return undefined;
|
|
143
|
+
parsed.forget = next;
|
|
108
144
|
} else if (arg === "--summary") {
|
|
109
145
|
parsed.summary = true;
|
|
110
146
|
} else if (arg === "--json") {
|
|
111
147
|
parsed.json = true;
|
|
148
|
+
} else if (arg === "--yes") {
|
|
149
|
+
parsed.yes = true;
|
|
112
150
|
} else if (arg.startsWith("-")) {
|
|
113
151
|
return undefined;
|
|
114
152
|
} else {
|
|
@@ -125,10 +163,43 @@ function parseArgs(args: string[]): ParsedArgs | undefined {
|
|
|
125
163
|
* `bitgraph-play check proof.json photo.jpg` works without a folder.
|
|
126
164
|
*/
|
|
127
165
|
async function runCheck(args: ParsedArgs): Promise<number> {
|
|
128
|
-
if (args.summary) return usage();
|
|
166
|
+
if (args.summary || args.yes || args.forget !== undefined) return usage();
|
|
167
|
+
if (args.pinsDir !== undefined && args.from === undefined) return usage();
|
|
129
168
|
const targets = args.positional;
|
|
130
169
|
if (targets.length === 0) return usage();
|
|
131
170
|
|
|
171
|
+
// Resolve the pin before touching the bundle: a missing pin should fail
|
|
172
|
+
// in milliseconds, with its remedy, not after a long ingest. check
|
|
173
|
+
// itself NEVER fetches; the pin was the one network step, already done.
|
|
174
|
+
let options: CheckOptions | undefined;
|
|
175
|
+
if (args.from !== undefined) {
|
|
176
|
+
const domain = args.from.toLowerCase();
|
|
177
|
+
if (!isDomainName(domain)) {
|
|
178
|
+
process.stderr.write(`error: not a domain name: ${args.from}\n`);
|
|
179
|
+
return 3;
|
|
180
|
+
}
|
|
181
|
+
const pinsDir = args.pinsDir ?? defaultPinsDir();
|
|
182
|
+
let pin;
|
|
183
|
+
try {
|
|
184
|
+
pin = readPin(domain, pinsDir);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
process.stderr.write(`error: the stored pin for ${domain} is malformed`);
|
|
187
|
+
if (err instanceof DomainFileError && err.issues[0] !== undefined) {
|
|
188
|
+
process.stderr.write(`: ${err.issues[0]}`);
|
|
189
|
+
}
|
|
190
|
+
process.stderr.write(`\n pin it again: bitgraph-play pin ${domain}\n`);
|
|
191
|
+
return 3;
|
|
192
|
+
}
|
|
193
|
+
if (pin === undefined) {
|
|
194
|
+
process.stderr.write(
|
|
195
|
+
`error: no pin for ${domain}\n` +
|
|
196
|
+
` pin it once (the only step that needs the network): bitgraph-play pin ${domain}\n`
|
|
197
|
+
);
|
|
198
|
+
return 3;
|
|
199
|
+
}
|
|
200
|
+
options = { from: checkDomain(pin.file) };
|
|
201
|
+
}
|
|
202
|
+
|
|
132
203
|
let ingest;
|
|
133
204
|
try {
|
|
134
205
|
const single = targets.length === 1 ? await stat(targets[0] as string) : undefined;
|
|
@@ -153,7 +224,7 @@ async function runCheck(args: ParsedArgs): Promise<number> {
|
|
|
153
224
|
return 3;
|
|
154
225
|
}
|
|
155
226
|
|
|
156
|
-
const report = await checkIngest(ingest);
|
|
227
|
+
const report = await checkIngest(ingest, options);
|
|
157
228
|
const bytes = args.json ? serializeCheckReport(report) : renderCheckText(report);
|
|
158
229
|
if (args.outFile !== undefined) {
|
|
159
230
|
writeFileSync(args.outFile, bytes);
|
|
@@ -169,7 +240,7 @@ function looksLikeArchive(path: string): boolean {
|
|
|
169
240
|
}
|
|
170
241
|
|
|
171
242
|
async function runInit(args: ParsedArgs): Promise<number> {
|
|
172
|
-
if (args.summary || args.json) return usage();
|
|
243
|
+
if (args.summary || args.json || args.from !== undefined || args.pinsDir !== undefined || args.yes || args.forget !== undefined) return usage();
|
|
173
244
|
const files = args.positional;
|
|
174
245
|
if (files.length === 0) return usage();
|
|
175
246
|
if (args.outFile !== undefined && existsSync(args.outFile)) {
|
|
@@ -225,7 +296,8 @@ async function runInit(args: ParsedArgs): Promise<number> {
|
|
|
225
296
|
}
|
|
226
297
|
|
|
227
298
|
async function runEvaluate(args: ParsedArgs): Promise<number> {
|
|
228
|
-
if (args.json || args.
|
|
299
|
+
if (args.json || args.from !== undefined || args.pinsDir !== undefined || args.yes || args.forget !== undefined) return usage();
|
|
300
|
+
if (args.positional.length !== 2) return usage();
|
|
229
301
|
const [rulePath, bundlePath] = args.positional as [string, string];
|
|
230
302
|
|
|
231
303
|
let result;
|
|
@@ -252,12 +324,126 @@ async function runEvaluate(args: ParsedArgs): Promise<number> {
|
|
|
252
324
|
return result.exitCode;
|
|
253
325
|
}
|
|
254
326
|
|
|
327
|
+
/**
|
|
328
|
+
* pin: the only command in this package that touches the network, and
|
|
329
|
+
* only when invoked. Conversational output goes to stderr (there are no
|
|
330
|
+
* report bytes); the pin listing, which IS the output, goes to stdout.
|
|
331
|
+
*/
|
|
332
|
+
async function runPin(args: ParsedArgs): Promise<number> {
|
|
333
|
+
if (args.summary || args.json || args.outFile !== undefined || args.from !== undefined) return usage();
|
|
334
|
+
const pinsDir = args.pinsDir ?? defaultPinsDir();
|
|
335
|
+
|
|
336
|
+
if (args.forget !== undefined) {
|
|
337
|
+
if (args.positional.length !== 0) return usage();
|
|
338
|
+
const domain = args.forget.toLowerCase();
|
|
339
|
+
if (!isDomainName(domain)) {
|
|
340
|
+
process.stderr.write(`error: not a domain name: ${args.forget}\n`);
|
|
341
|
+
return 3;
|
|
342
|
+
}
|
|
343
|
+
if (forgetPin(domain, pinsDir)) {
|
|
344
|
+
process.stderr.write(`forgot ${domain}\n`);
|
|
345
|
+
return 0;
|
|
346
|
+
}
|
|
347
|
+
process.stderr.write(`error: no pin for ${domain}\n`);
|
|
348
|
+
return 3;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (args.positional.length === 0) {
|
|
352
|
+
const pins = listPins(pinsDir);
|
|
353
|
+
if (pins.length === 0) {
|
|
354
|
+
process.stderr.write(
|
|
355
|
+
"no pins yet\n pin a domain (the only step that needs the network): bitgraph-play pin <domain>\n"
|
|
356
|
+
);
|
|
357
|
+
return 0;
|
|
358
|
+
}
|
|
359
|
+
for (const pin of pins) {
|
|
360
|
+
process.stdout.write(
|
|
361
|
+
pin.malformed
|
|
362
|
+
? `${pin.domain} (malformed pin; pin it again or --forget it)\n`
|
|
363
|
+
: `${pin.domain} ${pin.party as string} ${pin.keyCount as number} key(s) pinned ${pin.pinnedAt.toISOString().slice(0, 10)}\n`
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
return 0;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (args.positional.length !== 1) return usage();
|
|
370
|
+
const domain = (args.positional[0] as string).toLowerCase();
|
|
371
|
+
|
|
372
|
+
let fetched;
|
|
373
|
+
try {
|
|
374
|
+
fetched = await fetchDomainFile(domain);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
if (err instanceof DomainFileError) {
|
|
377
|
+
process.stderr.write(
|
|
378
|
+
`error: the file at https://${domain}/.well-known/bitgraph is not a valid bitgraph-domain/1 file:\n`
|
|
379
|
+
);
|
|
380
|
+
for (const issue of err.issues) process.stderr.write(` - ${issue}\n`);
|
|
381
|
+
} else {
|
|
382
|
+
process.stderr.write(`error: ${(err as Error).message}\n`);
|
|
383
|
+
}
|
|
384
|
+
return 3;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const refs = domainKeyRefs(fetched.file);
|
|
388
|
+
const nameWidth = refs.reduce((w, r) => Math.max(w, r.name.length), 4);
|
|
389
|
+
process.stderr.write(`\n${domain} · ${fetched.file.party}\n`);
|
|
390
|
+
for (const ref of refs) {
|
|
391
|
+
process.stderr.write(` ${ref.name.padEnd(nameWidth)} ${ref.key.alg.padEnd(7)} ${ref.fingerprint}\n`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let existing;
|
|
395
|
+
let existingMalformed = false;
|
|
396
|
+
try {
|
|
397
|
+
existing = readPin(domain, pinsDir);
|
|
398
|
+
} catch {
|
|
399
|
+
existingMalformed = true;
|
|
400
|
+
}
|
|
401
|
+
if (existingMalformed) {
|
|
402
|
+
process.stderr.write(`\nthe stored pin for ${domain} is malformed and will be replaced\n`);
|
|
403
|
+
} else if (existing !== undefined) {
|
|
404
|
+
const diff = diffDomainFiles(existing.file, fetched.file);
|
|
405
|
+
const changes: string[] = [];
|
|
406
|
+
if (diff.partyChanged !== undefined) {
|
|
407
|
+
changes.push(` party: "${diff.partyChanged.before}" is now "${diff.partyChanged.after}"`);
|
|
408
|
+
}
|
|
409
|
+
for (const ref of diff.added) changes.push(` + ${ref.name} ${ref.key.alg} ${ref.fingerprint}`);
|
|
410
|
+
for (const ref of diff.removed) changes.push(` - ${ref.name} ${ref.key.alg} ${ref.fingerprint}`);
|
|
411
|
+
for (const ch of diff.changed) changes.push(` ~ ${ch.name} now ${ch.after.key.alg} ${ch.after.fingerprint}`);
|
|
412
|
+
const pinnedOn = existing.pinnedAt.toISOString().slice(0, 10);
|
|
413
|
+
process.stderr.write(
|
|
414
|
+
changes.length === 0
|
|
415
|
+
? `\nunchanged since the stored pin (${pinnedOn})\n`
|
|
416
|
+
: `\nchanges since the stored pin (${pinnedOn}):\n${changes.join("\n")}\n`
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (!args.yes) {
|
|
421
|
+
if (!process.stdin.isTTY) {
|
|
422
|
+
process.stderr.write("\nerror: not a terminal; pass --yes to pin non-interactively\n");
|
|
423
|
+
return 3;
|
|
424
|
+
}
|
|
425
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
426
|
+
const answer = (await rl.question(`\npin ${refs.length} key(s) for ${domain}? [y/N] `)).trim().toLowerCase();
|
|
427
|
+
rl.close();
|
|
428
|
+
if (answer !== "y" && answer !== "yes") {
|
|
429
|
+
process.stderr.write("not pinned\n");
|
|
430
|
+
return 3;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const path = writePin(domain, fetched.bytes, pinsDir);
|
|
435
|
+
process.stderr.write(
|
|
436
|
+
`pinned: ${path}\nchecks now run offline: bitgraph-play check <export> --from ${domain}\n`
|
|
437
|
+
);
|
|
438
|
+
return 0;
|
|
439
|
+
}
|
|
440
|
+
|
|
255
441
|
async function main(): Promise<number> {
|
|
256
442
|
const argv = process.argv.slice(2);
|
|
257
443
|
// "--" as the first token forces evaluate mode: parseArgs treats
|
|
258
444
|
// everything after it as positional, so a rule file literally named
|
|
259
|
-
// "init" or "
|
|
260
|
-
const subcommand = argv[0] === "init" || argv[0] === "check" ? argv[0] : undefined;
|
|
445
|
+
// "init", "check" or "pin" is reachable as `bitgraph-play -- init <bundle>`.
|
|
446
|
+
const subcommand = argv[0] === "init" || argv[0] === "check" || argv[0] === "pin" ? argv[0] : undefined;
|
|
261
447
|
if (subcommand !== undefined && existsSync(subcommand)) {
|
|
262
448
|
// Both readings are plausible here; a silent pick would hand a
|
|
263
449
|
// 0.1.1 caller a skeleton with exit 0 where the published contract
|
|
@@ -273,6 +459,7 @@ async function main(): Promise<number> {
|
|
|
273
459
|
if (parsed === undefined) return usage();
|
|
274
460
|
if (subcommand === "init") return runInit(parsed);
|
|
275
461
|
if (subcommand === "check") return runCheck(parsed);
|
|
462
|
+
if (subcommand === "pin") return runPin(parsed);
|
|
276
463
|
return runEvaluate(parsed);
|
|
277
464
|
}
|
|
278
465
|
|