@letthembuild/run-receipts 0.0.0-stage → 0.2.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/sign.mjs ADDED
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ // Copyright 2026 GPULogy AI LLC
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ /**
5
+ * Sign a run receipt, and make the key to sign with.
6
+ *
7
+ * A signature says one thing: whoever holds this key put their name to this
8
+ * receipt, and it has not changed since. It does not say the commands were
9
+ * run. What a signature is worth is what the key is worth: who holds it, and
10
+ * how hard it is for anyone else to.
11
+ *
12
+ * No dependencies: Node 18 or later, and verify.mjs beside this file.
13
+ */
14
+ import { readFileSync, writeFileSync, realpathSync, existsSync } from "node:fs";
15
+ import { generateKeyPairSync, createPrivateKey, sign as signBytes } from "node:crypto";
16
+ import { fileURLToPath } from "node:url";
17
+ import { digestOf, signedBytes, verifyReceipt, publicKeyOf } from "./verify.mjs";
18
+
19
+ /** A new key pair: `key` is public and goes in receipts and trust lists; `secret` signs and goes nowhere. */
20
+ export function generateKey() {
21
+ const { privateKey } = generateKeyPairSync("ed25519");
22
+ const jwk = privateKey.export({ format: "jwk" });
23
+ return { key: `ed25519:${jwk.x}`, secret: jwk.d };
24
+ }
25
+
26
+ /** The signing key Node needs, from a { key, secret } pair. Throws when they are not a pair. */
27
+ function privateKeyOf(pair) {
28
+ const x = /^ed25519:([A-Za-z0-9_-]{43})$/.exec(String(pair?.key ?? ""))?.[1];
29
+ if (!x || !/^[A-Za-z0-9_-]{43}$/.test(String(pair?.secret ?? ""))) throw new Error("not a signing key");
30
+ if (!publicKeyOf(pair.key)) throw new Error("not a signing key");
31
+ try { return createPrivateKey({ key: { kty: "OKP", crv: "Ed25519", x, d: pair.secret }, format: "jwk" }); }
32
+ catch { throw new Error("not a signing key"); }
33
+ }
34
+
35
+ /**
36
+ * The receipt, signed: its digest retaken, and this key's signature added in
37
+ * place of any earlier one from the same key. Other keys' signatures are kept
38
+ * only while they still hold. Refuses a receipt that does not verify: a
39
+ * signature on a broken receipt would put a name to nothing.
40
+ */
41
+ export function signReceipt(receipt, pair) {
42
+ const key = privateKeyOf(pair);
43
+ const { signatures: before = [], ...rest } = receipt;
44
+ const unsigned = { ...rest, digest: digestOf(rest) };
45
+ const check = verifyReceipt(unsigned);
46
+ if (!check.valid) throw new Error(`the receipt does not verify, so it is not signed:\n${check.errors.join("\n")}`);
47
+ const sig = signBytes(null, signedBytes(unsigned.digest), key).toString("base64url");
48
+ const kept = before.filter((s) => s.key !== pair.key && verifyReceipt({ ...unsigned, signatures: [s] }).valid);
49
+ const signed = { ...unsigned, signatures: [...kept, { key: pair.key, sig }] };
50
+ const after = verifyReceipt(signed);
51
+ if (!after.valid) throw new Error("the key's two halves do not belong together");
52
+ return signed;
53
+ }
54
+
55
+ /* ── Command line ────────────────────────────────────────────────────── */
56
+
57
+ const USAGE = `Usage:
58
+ sign.mjs keygen <key-file> make a signing key; prints the public half
59
+ sign.mjs sign --key <key-file> <receipt> print the receipt, signed
60
+ sign.mjs sign --key <key-file> --write <receipt> sign it where it is
61
+
62
+ The key can also come from the environment, for a build server:
63
+ RUN_RECEIPT_SIGNING_KEY the contents of a key file`;
64
+
65
+ function readPair(file, env) {
66
+ const text = file ? readFileSync(file, "utf8") : env.RUN_RECEIPT_SIGNING_KEY;
67
+ if (!text) throw new Error("no signing key: pass --key <key-file> or set RUN_RECEIPT_SIGNING_KEY");
68
+ return JSON.parse(text);
69
+ }
70
+
71
+ export function main(argv, env = process.env) {
72
+ const [command, ...rest] = argv;
73
+ if (command === "keygen") {
74
+ if (rest.length !== 1 || rest[0].startsWith("--")) { console.error(USAGE); return 2; }
75
+ if (existsSync(rest[0])) { console.error(`${rest[0]} exists. A key is not overwritten: receipts signed with it would have nobody to answer for them.`); return 2; }
76
+ const pair = generateKey();
77
+ writeFileSync(rest[0], JSON.stringify(pair, null, 2) + "\n", { mode: 0o600, flag: "wx" });
78
+ console.log(pair.key);
79
+ console.error(`The signing key is in ${rest[0]}. Keep it out of your repository. Give readers the line above.`);
80
+ return 0;
81
+ }
82
+ if (command === "sign") {
83
+ const args = [...rest];
84
+ let keyFile = null;
85
+ const at = args.indexOf("--key");
86
+ if (at !== -1) { keyFile = args[at + 1] ?? null; args.splice(at, 2); if (!keyFile) { console.error(USAGE); return 2; } }
87
+ const write = args.includes("--write");
88
+ const files = args.filter((a) => !a.startsWith("--"));
89
+ if (files.length !== 1 || args.some((a) => a.startsWith("--") && a !== "--write")) { console.error(USAGE); return 2; }
90
+ let receipt, pair;
91
+ try { receipt = JSON.parse(readFileSync(files[0], "utf8")); }
92
+ catch (e) { console.error(`Cannot read ${files[0]}: ${e.message}`); return 2; }
93
+ try { pair = readPair(keyFile, env); }
94
+ catch (e) { console.error(`Cannot read the signing key: ${e.message}`); return 2; }
95
+ let signed;
96
+ try { signed = signReceipt(receipt, pair); }
97
+ catch (e) { console.error(e.message); return 1; }
98
+ const text = JSON.stringify(signed, null, 2) + "\n";
99
+ if (write) { writeFileSync(files[0], text); console.error(`Signed ${files[0]} as ${pair.key}`); }
100
+ else process.stdout.write(text);
101
+ return 0;
102
+ }
103
+ console.error(USAGE);
104
+ return 2;
105
+ }
106
+
107
+ function isMain() {
108
+ if (!process.argv[1]) return false;
109
+ try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); }
110
+ catch { return false; }
111
+ }
112
+
113
+ if (isMain()) process.exit(main(process.argv.slice(2)));
package/verify.mjs ADDED
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Run-receipt verifier, for format 0.1.
4
+ *
5
+ * Answers one question about a receipt file: does it hold together? First
6
+ * against the schema (shape, bounds), then against itself (the rules in
7
+ * SPEC.md: a grade that follows from the totals, a verdict that follows from
8
+ * where each reviewer ended, checks whose state matches their exit code).
9
+ *
10
+ * It does not prove the run happened. A receipt is the producer's account;
11
+ * this proves the account is well-formed, internally consistent and, when it
12
+ * carries a digest, unaltered since the digest was taken. When it is signed,
13
+ * it also says which keys signed it, and whether you listed them as trusted.
14
+ *
15
+ * No dependencies: Node 18 or later and this file, with the schema beside it.
16
+ */
17
+ import { readFileSync, realpathSync } from "node:fs";
18
+ import { createHash, createPublicKey, verify as verifyBytes } from "node:crypto";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ const SCHEMA_URL = new URL("./receipt.schema.json", import.meta.url);
22
+ let cachedSchema = null;
23
+ export function loadSchema() {
24
+ return (cachedSchema ??= JSON.parse(readFileSync(SCHEMA_URL, "utf8")));
25
+ }
26
+
27
+ /* ── Schema: the subset of JSON Schema that receipt.schema.json uses ───── */
28
+
29
+ const typeOf = (v) => (v === null ? "null" : Array.isArray(v) ? "array" : Number.isInteger(v) ? "integer" : typeof v);
30
+ const isType = (v, t) => typeOf(v) === t || (t === "number" && typeof v === "number");
31
+
32
+ /** Every way `value` fails `schema`, as "path: what is wrong". Empty when it passes. */
33
+ export function validateSchema(value, schema, root = schema, path = "$") {
34
+ if (schema.$ref) {
35
+ const target = schema.$ref.replace(/^#\//, "").split("/").reduce((o, k) => o?.[k], root);
36
+ if (!target) return [`${path}: schema reference ${schema.$ref} not found`];
37
+ return validateSchema(value, target, root, path);
38
+ }
39
+ if (schema.anyOf) {
40
+ return schema.anyOf.some((s) => validateSchema(value, s, root, path).length === 0) ? [] : [`${path}: matches none of the allowed forms`];
41
+ }
42
+ const out = [];
43
+ if ("const" in schema && value !== schema.const) return [`${path}: must be ${JSON.stringify(schema.const)}`];
44
+ if (schema.enum && !schema.enum.includes(value)) return [`${path}: must be one of ${schema.enum.join(", ")}`];
45
+ if (schema.type) {
46
+ const types = [].concat(schema.type);
47
+ if (!types.some((t) => isType(value, t))) return [`${path}: must be ${types.join(" or ")}, found ${typeOf(value)}`];
48
+ }
49
+ if (typeof value === "number") {
50
+ if (!Number.isFinite(value)) out.push(`${path}: must be a finite number`);
51
+ if (schema.minimum !== undefined && value < schema.minimum) out.push(`${path}: must be at least ${schema.minimum}`);
52
+ if (schema.maximum !== undefined && value > schema.maximum) out.push(`${path}: must be at most ${schema.maximum}`);
53
+ }
54
+ if (typeof value === "string") {
55
+ if (schema.minLength !== undefined && value.length < schema.minLength) out.push(`${path}: must be at least ${schema.minLength} characters`);
56
+ if (schema.maxLength !== undefined && value.length > schema.maxLength) out.push(`${path}: must be at most ${schema.maxLength} characters`);
57
+ if (schema.pattern && !new RegExp(schema.pattern).test(value)) out.push(`${path}: does not have the required form`);
58
+ }
59
+ if (Array.isArray(value)) {
60
+ if (schema.minItems !== undefined && value.length < schema.minItems) out.push(`${path}: needs at least ${schema.minItems} item(s)`);
61
+ if (schema.maxItems !== undefined && value.length > schema.maxItems) out.push(`${path}: allows at most ${schema.maxItems} items`);
62
+ if (schema.items) value.forEach((v, i) => out.push(...validateSchema(v, schema.items, root, `${path}[${i}]`)));
63
+ }
64
+ if (typeOf(value) === "object") {
65
+ const props = schema.properties ?? {};
66
+ for (const key of schema.required ?? []) if (!(key in value)) out.push(`${path}.${key}: is required`);
67
+ for (const [key, v] of Object.entries(value)) {
68
+ if (key in props) out.push(...validateSchema(v, props[key], root, `${path}.${key}`));
69
+ else if (schema.additionalProperties === false) out.push(`${path}.${key}: is not part of the format`);
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /* ── Digest ──────────────────────────────────────────────────────────── */
76
+
77
+ /** The receipt as bytes to hash: keys sorted, no whitespace, `digest` and `signatures` left out. */
78
+ export function canonical(value) {
79
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
80
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
81
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
82
+ }
83
+
84
+ export function digestOf(receipt) {
85
+ const { digest: _left_out, signatures: _nor_these, ...rest } = receipt;
86
+ return `sha256:${createHash("sha256").update(canonical(rest), "utf8").digest("hex")}`;
87
+ }
88
+
89
+ /* ── Signatures ──────────────────────────────────────────────────────── */
90
+
91
+ /** What a signature is made over: these words, then the receipt's digest. The words keep a signature made for a receipt from being one for anything else. */
92
+ export const SIGNATURE_CONTEXT = "run-receipt-signature-v1\n";
93
+ export const signedBytes = (digest) => Buffer.from(SIGNATURE_CONTEXT + digest, "utf8");
94
+
95
+ const KEY_FORM = /^ed25519:([A-Za-z0-9_-]{43})$/;
96
+
97
+ /** A public key as Node holds it, from its written form, or null when it is not one. */
98
+ export function publicKeyOf(key) {
99
+ const m = KEY_FORM.exec(String(key ?? ""));
100
+ if (!m) return null;
101
+ try { return createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: m[1] }, format: "jwk" }); }
102
+ catch { return null; }
103
+ }
104
+
105
+ /** True when `signature` is its key's signature over this receipt as it stands. */
106
+ export function signatureHolds(receipt, signature) {
107
+ const key = publicKeyOf(signature?.key);
108
+ if (!key || typeof signature.sig !== "string") return false;
109
+ try { return verifyBytes(null, signedBytes(digestOf(receipt)), key, Buffer.from(signature.sig, "base64url")); }
110
+ catch { return false; }
111
+ }
112
+
113
+ /**
114
+ * A list of keys somebody trusts, from text: either JSON,
115
+ * { "keys": [{ "key": "ed25519:…", "name": "…" }] }, or one key to a line with
116
+ * an optional name after it. Lines starting with # are comments. The names are
117
+ * the reader's own words for each key; a receipt never supplies them.
118
+ */
119
+ export function parseKeys(text) {
120
+ const src = String(text ?? "").trim();
121
+ const keys = [], problems = [];
122
+ const add = (key, name, where) => {
123
+ if (!publicKeyOf(key)) problems.push(`${where}: not a key`);
124
+ else if (!keys.some((k) => k.key === key)) keys.push({ key, name: String(name ?? "").trim().slice(0, 80) });
125
+ };
126
+ if (src.startsWith("{")) {
127
+ let doc;
128
+ try { doc = JSON.parse(src); } catch { return { keys, problems: ["the key list is not readable as JSON"] }; }
129
+ if (!Array.isArray(doc?.keys)) return { keys, problems: ["the key list has no keys"] };
130
+ doc.keys.forEach((k, i) => add(k?.key, k?.name, `keys[${i}]`));
131
+ } else {
132
+ src.split(/\r?\n/).forEach((raw, i) => {
133
+ const line = raw.trim();
134
+ if (!line || line.startsWith("#")) return;
135
+ const [key, ...name] = line.split(/\s+/);
136
+ add(key, name.join(" "), `line ${i + 1}`);
137
+ });
138
+ }
139
+ return { keys, problems };
140
+ }
141
+
142
+ /** Who signed a receipt: each key whose signature holds, with the reader's name for it when they listed it. */
143
+ export function signersOf(receipt, trustedKeys = []) {
144
+ return (receipt.signatures ?? []).filter((s) => signatureHolds(receipt, s)).map((s) => {
145
+ const known = trustedKeys.find((k) => k.key === s.key);
146
+ return { key: s.key, trusted: Boolean(known), name: known?.name ?? "" };
147
+ });
148
+ }
149
+
150
+ /* ── Consistency: the rules of SPEC.md, by number ────────────────────── */
151
+
152
+ /** The grade the totals earn (SPEC rule G). */
153
+ export function gradeOf(t) {
154
+ if (t.failingNow > 0 || t.contradicted > 0) return "failed";
155
+ if (t.ran > 0) return t.verifiers >= t.reviewers ? "verified" : "partial";
156
+ if (t.filesChanged === 0) return "reviewed";
157
+ if (t.reviewers > 0 && (t.notVisible ?? 0) >= t.reviewers) return "not-visible";
158
+ return "unverified";
159
+ }
160
+
161
+ /** Errors and warnings from a receipt that already passed the schema. */
162
+ export function checkConsistency(r) {
163
+ const errors = [], warnings = [];
164
+ const err = (rule, msg) => errors.push(`${rule}: ${msg}`);
165
+ const t = r.totals;
166
+
167
+ // S — seats.
168
+ const names = r.seats.map((s) => s.name);
169
+ if (new Set(names).size !== names.length) err("S1", "two seats share a name");
170
+ if (!r.seats.some((s) => s.role === "lead")) err("S2", "no seat is the lead");
171
+ const reviewers = r.seats.filter((s) => s.role === "reviewer");
172
+ for (const s of r.seats) {
173
+ if (s.timeouts > s.errors) err("S3", `${s.name}: more timeouts than errors`);
174
+ if (s.role === "reviewer" && !s.stance) err("S4", `${s.name}: a reviewer needs a stance`);
175
+ if (s.role === "lead" && s.stance) err("S4", `${s.name}: the lead has no stance`);
176
+ if (s.costBasis && s.costBasis !== "metered" && s.costUsd > 0) err("S5", `${s.name}: a ${s.costBasis} seat cannot carry a metered cost`);
177
+ }
178
+
179
+ // V — verdict.
180
+ const agreed = reviewers.filter((s) => s.stance === "agreed");
181
+ const answered = reviewers.filter((s) => s.stance !== "unavailable");
182
+ const out = reviewers.filter((s) => s.stance && s.stance !== "agreed");
183
+ if (reviewers.length === 0) {
184
+ if (r.verdict !== null) err("V1", "nobody reviewed, so there can be no verdict");
185
+ } else if (r.verdict === null) {
186
+ err("V1", "reviewers took part, so a verdict is required");
187
+ } else {
188
+ const v = r.verdict;
189
+ if (v.agreed !== agreed.length) err("V2", `verdict counts ${v.agreed} agreeing; the seats show ${agreed.length}`);
190
+ const key = (h) => `${h.name}\u0000${h.stance}`;
191
+ const want = out.map(key).sort().join("\n"), got = v.holdouts.map(key).sort().join("\n");
192
+ if (want !== got) err("V3", "holdouts are not exactly the reviewers that did not agree");
193
+ if (v.outcome === "agreed") {
194
+ const allWhoAnsweredAgreed = answered.length > 0 && answered.every((s) => s.stance === "agreed");
195
+ const someoneMissing = answered.length < reviewers.length;
196
+ if (!allWhoAnsweredAgreed) err("V4", "outcome is agreed while a reviewer that answered did not agree");
197
+ else if (someoneMissing && t.failingNow > 0) err("V4", "outcome is agreed without a reviewer while a check is still failing");
198
+ if (v.reviewers !== answered.length) err("V5", `an agreed verdict counts the reviewers that answered (${answered.length}), not ${v.reviewers}`);
199
+ } else {
200
+ const expected = agreed.length * 2 > reviewers.length ? "majority" : "none";
201
+ if (v.outcome !== expected) err("V4", `outcome should be ${expected}: ${agreed.length} of ${reviewers.length} agree`);
202
+ if (v.reviewers !== reviewers.length) err("V5", `verdict counts ${v.reviewers} reviewers; the seats show ${reviewers.length}`);
203
+ }
204
+ }
205
+
206
+ // T — totals.
207
+ if (t.reviewers !== answered.length) err("T1", `totals count ${t.reviewers} reviewers; ${answered.length} answered`);
208
+ if (t.verifiers > t.reviewers) err("T2", "more verifiers than reviewers");
209
+ if ((t.notVisible ?? 0) > t.reviewers) err("T2", "more not-visible seats than reviewers");
210
+ if (t.contradicted > t.reviewers) err("T2", "more contradicted seats than reviewers");
211
+ const hidden = reviewers.filter((s) => s.commandsVisible === false).length;
212
+ if ((t.notVisible ?? 0) > hidden) err("T2", `totals count ${t.notVisible} not-visible seats; ${hidden} reviewer(s) are marked so`);
213
+ if (t.passed + t.failed + (t.expected ?? 0) > t.ran) err("T3", "passed, failed and expected add up to more than ran");
214
+ if (t.failingNow > t.failed) err("T4", "more checks failing now than ever failed");
215
+ if ((t.resolved ?? 0) > t.failed - t.failingNow) err("T4", "more checks resolved than stopped failing");
216
+ if (t.ran > 0 && t.verifiers === 0) err("T5", "commands were run, but no reviewer is counted as running any");
217
+ if (t.ran === 0 && t.verifiers > 0) err("T5", "a reviewer is counted as running commands, but none were run");
218
+
219
+ // G — grade.
220
+ const grade = gradeOf(t);
221
+ if (r.grade !== grade) err("G1", `grade is ${r.grade}; the totals earn ${grade}`);
222
+
223
+ // C — checks.
224
+ r.checks.forEach((c, i) => {
225
+ const at = `checks[${i}]`;
226
+ for (const who of [c.by, ...(c.alsoBy ?? [])]) if (who !== null && !names.includes(who)) err("C1", `${at}: ${who} is not a seat in this run`);
227
+ if (c.exitCode === 0 && c.state !== "checked") err("C2", `${at}: exited 0, so its state is checked, not ${c.state}`);
228
+ if ((c.state === "blocked") !== (c.missingSetup !== null)) err("C3", `${at}: blocked and missing setup go together`);
229
+ if ((c.state === "unknown" || c.state === "untested") && c.exitCode !== null) err("C4", `${at}: a state of ${c.state} has no exit code`);
230
+ });
231
+ const ranHere = r.checks.filter((c) => c.state !== "untested").length;
232
+ if (ranHere > t.ran) warnings.push(`C5: ${ranHere} checks are listed as run, but the totals count ${t.ran}`);
233
+
234
+ // M — money and time.
235
+ const seatCost = r.seats.reduce((n, s) => n + s.costUsd, 0);
236
+ if (Math.abs(seatCost - r.costUsd) > 0.01) warnings.push(`M1: seats add up to $${seatCost.toFixed(4)}; the run says $${r.costUsd.toFixed(4)}`);
237
+ if (r.firstReplyMs !== undefined && r.firstReplyMs > r.durationMs) err("M2", "the first reply came after the run ended");
238
+ if (Number.isNaN(Date.parse(r.endedAt))) err("M3", "endedAt is not a real date");
239
+
240
+ // D — digest.
241
+ if (r.digest && r.digest !== digestOf(r)) err("D1", "digest does not match the receipt's contents");
242
+ if (!r.digest) warnings.push("D2: no digest, so a copy cannot be told from an edited one");
243
+
244
+ // K — signatures.
245
+ const signed = r.signatures ?? [];
246
+ if (new Set(signed.map((s) => s.key)).size !== signed.length) err("K2", "one key signed twice");
247
+ signed.forEach((s, i) => { if (!signatureHolds(r, s)) err("K1", `signatures[${i}]: is not that key's signature over this receipt`); });
248
+
249
+ return { errors, warnings };
250
+ }
251
+
252
+ /** Schema first; the consistency rules only run on a receipt with the right shape. */
253
+ /**
254
+ * `signers` lists the keys whose signatures hold, and is empty for a receipt
255
+ * that is not valid. Pass `trustedKeys` (as parseKeys returns them) to have
256
+ * each marked as one you listed. With `requireSignature`, a receipt no listed
257
+ * key has signed is not valid.
258
+ */
259
+ export function verifyReceipt(receipt, { trustedKeys = [], requireSignature = false } = {}) {
260
+ const schemaErrors = validateSchema(receipt, loadSchema());
261
+ if (schemaErrors.length) return { valid: false, errors: schemaErrors.map((e) => `schema: ${e}`), warnings: [], signers: [] };
262
+ const { errors, warnings } = checkConsistency(receipt);
263
+ const signers = errors.length ? [] : signersOf(receipt, trustedKeys);
264
+ if (requireSignature && !errors.length && !signers.some((s) => s.trusted)) {
265
+ errors.push(signers.length ? "K3: signed, but by no key on the trusted list" : "K3: not signed, and a signature from a trusted key is required");
266
+ }
267
+ return { valid: errors.length === 0, errors, warnings, signers: errors.length ? [] : signers };
268
+ }
269
+
270
+ /* ── Command line ────────────────────────────────────────────────────── */
271
+
272
+ const USAGE = `Usage:
273
+ verify.mjs <receipt.json> check a receipt; exit 0 valid, 1 invalid
274
+ verify.mjs --json <receipt.json> the same, as JSON
275
+ verify.mjs --digest <receipt.json> print the digest the receipt should carry
276
+
277
+ Signatures:
278
+ --keys <file> the keys you trust: JSON, or one "ed25519:… name" to a line
279
+ --require-signature not valid unless a key from --keys signed it`;
280
+
281
+ /** A key as people read it: enough of it to tell two apart. */
282
+ export const shortKey = (key) => `${String(key).slice(0, 16)}…`;
283
+
284
+ function main(argv) {
285
+ const args = [...argv];
286
+ let keysFile = null;
287
+ const at = args.indexOf("--keys");
288
+ if (at !== -1) { keysFile = args[at + 1] ?? null; args.splice(at, 2); if (!keysFile || keysFile.startsWith("--")) { console.error(USAGE); return 2; } }
289
+ const flags = args.filter((a) => a.startsWith("--"));
290
+ const files = args.filter((a) => !a.startsWith("--"));
291
+ if (files.length !== 1 || flags.some((f) => !["--json", "--digest", "--require-signature"].includes(f))) { console.error(USAGE); return 2; }
292
+ const requireSignature = flags.includes("--require-signature");
293
+ let trustedKeys = [];
294
+ if (keysFile) {
295
+ let parsed;
296
+ try { parsed = parseKeys(readFileSync(keysFile, "utf8")); }
297
+ catch (e) { console.error(`Cannot read ${keysFile}: ${e.message}`); return 2; }
298
+ if (parsed.problems.length) { console.error(`${keysFile}: ${parsed.problems.join("; ")}`); return 2; }
299
+ trustedKeys = parsed.keys;
300
+ }
301
+ if (requireSignature && !trustedKeys.length) { console.error("--require-signature needs --keys: a signature only means something from a key you trust."); return 2; }
302
+ let receipt;
303
+ try { receipt = JSON.parse(readFileSync(files[0], "utf8")); }
304
+ catch (e) { console.error(`Cannot read ${files[0]}: ${e.message}`); return 2; }
305
+ if (flags.includes("--digest")) {
306
+ if (typeOf(receipt) !== "object") { console.error("Not a receipt."); return 2; }
307
+ console.log(digestOf(receipt));
308
+ return 0;
309
+ }
310
+ const result = verifyReceipt(receipt, { trustedKeys, requireSignature });
311
+ if (flags.includes("--json")) console.log(JSON.stringify(result, null, 2));
312
+ else {
313
+ console.log(result.valid ? `VALID ${files[0]}` : `INVALID ${files[0]}`);
314
+ for (const e of result.errors) console.log(` error ${e}`);
315
+ for (const w of result.warnings) console.log(` warning ${w}`);
316
+ for (const s of result.signers) console.log(s.trusted ? ` signed ${s.name || "a trusted key"} (${shortKey(s.key)})` : ` signed ${shortKey(s.key)}, which ${keysFile ? "is not on your list" : "nothing here vouches for (pass --keys)"}`);
317
+ }
318
+ return result.valid ? 0 : 1;
319
+ }
320
+
321
+ /** Run as a program, and not imported: true through a symlink too, which is how an installed command arrives. */
322
+ function isMain() {
323
+ if (!process.argv[1]) return false;
324
+ try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); }
325
+ catch { return false; }
326
+ }
327
+
328
+ if (isMain()) process.exit(main(process.argv.slice(2)));