Repository navigation
Expand file tree
/
Copy pathcli.js
More file actions
executable file
·416 lines (394 loc) · 21.4 KB
/
Copy pathcli.js
File metadata and controls
executable file
·416 lines (394 loc) · 21.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
#!/usr/bin/env node
'use strict';
/*
* CodeRifts chain-receipt verifier — THE COMMAND.
*
* `./verify.js` is the pure library; everything that makes this a program — the shebang, argument
* parsing, the network key fetch, stdout/exit codes — lives here. Four repositories vendor
* `verify.js` as their `src/verify.js` and none of them can run a CLI; carrying one inside the
* library meant every one of them shipped a command they could not invoke, and a network path
* their offline verifiers must never take.
*
* Usage:
* coderifts-receipt-verifier <receipt> [--key pub.pem | --keys <url|file>] [--kid <kid>] [--fetch <url>] [--refresh-keys]
* coderifts-receipt-verifier --chain receipts.txt [--key pub.pem | --keys <url|file>] [--kid <kid>] [--fetch <url>] [--refresh-keys]
* (from a clone: `node cli.js …`)
*
* ── 1355-default: VENDORED KEYS ARE THE DEFAULT; THE NETWORK IS OPT-IN ──────────────────────
*
* With no --key/--keys/--fetch/--refresh-keys, keys are loaded from the pinned snapshot at
* keys/coderifts-keys.json (offline). A CA pins roots locally; this command does the same.
* Live discovery is opt-in: --refresh-keys (well-known URL) or --keys <url> / --fetch <url>.
*
* ── 1282-A': THE LEGACY SINGLE-KEY BODY CANNOT SUPPORT A CURRENT VERDICT ────────────────────
*
* --fetch / --refresh-keys may answer with either shape:
*
* registry { keys: [{ kid, public_key_pem, status, valid_from, retired_at, revoked_at }] }
* legacy { kid, public_key_pem } (/api/v1/attestation/public-key)
*
* The legacy body carries NO status field. The library reads a status-less entry as `status: null`
* and `null` is a KNOWN status, so the healthy path is taken and the answer is VERIFIED_CURRENT.
* That answer is not wrong about the signature — it is wrong about what it could see: a key the
* operator has since REVOKED produces exactly the same output, because the document that would
* have said so was never asked for.
*
* So on the legacy path this command:
* - prints a KEY_STATUS_UNAVAILABLE warning to stderr, naming the document it read; and
* - refuses to report a CURRENT verdict, re-classing it as UNKNOWN_KEY_STATUS — the existing
* fail-closed-on-a-status-we-cannot-read status, not a new word.
*
* WHAT IS NOT CHANGED: the signature verdict itself. A MALFORMED / INVALID_SIGNATURE /
* UNKNOWN_KEY answer is already non-current and is passed through untouched — downgrading a
* refusal to a different refusal would lose the reason. Registry bodies, --key and --keys are
* unaffected: they can see key status, so they keep their verdicts.
*/
const fs = require('fs');
const path = require('path');
const {
verifyReceipt,
verifyChain,
loadKeyring,
keyFromPem,
keyringFromDocument,
pickActiveFromKeyring,
DEFAULT_FETCH_URL,
} = require('./verify');
/** Pinned well-known snapshot shipped with the package. Default verify is offline against this file. */
const VENDORED_KEYS_PATH = path.join(__dirname, 'keys', 'coderifts-keys.json');
/**
* Fetch a key document. Accepts BOTH shapes:
* - registry: { keys: [{ kid, public_key_pem, status, retired_at, ...}] }
* - legacy single-key: { kid, public_key_pem } (/api/v1/attestation/public-key)
* Registry returns { publicKey, kid, keyring } (keyring carries retired keys).
* Legacy returns { publicKey, kid } — same as before, so --keys users + grant
* verifiers that read publicKey/kid stay byte-compatible.
*/
async function fetchKeyInfo(url) {
const res = await fetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`fetch ${url} -> HTTP ${res.status}`);
const info = await res.json();
const keyring = keyringFromDocument(info, url);
if (keyring) {
const picked = pickActiveFromKeyring(keyring);
if (!picked) throw new Error(`no usable keys at ${url}`);
return { publicKey: picked.entry.publicKey, kid: picked.kid, keyring };
}
if (!info || !info.public_key_pem) throw new Error(`no public_key_pem at ${url}`);
// `legacy: true` is the whole 1282-A' signal: this document cannot say whether the key is
// still trusted, and the caller must not report a current verdict from it.
return { publicKey: keyFromPem(info.public_key_pem), kid: info.kid || null, legacy: true, source: url };
}
/**
* 1282-A' — a verdict from a document that cannot report key status is not CURRENT.
*
* Applied ONLY on the legacy single-key path, and only to a verdict that would otherwise be
* current. Every other status is already a refusal carrying its own reason, and rewriting a
* refusal into a different refusal would throw that reason away.
*/
function downgradeLegacyVerdict(result, source) {
if (!result || result.valid !== true) return result;
return {
...result,
valid: false,
status: 'UNKNOWN_KEY_STATUS',
reason: 'key_status_unavailable',
key_status_unavailable: {
source,
why: 'the key document is the legacy single-key body and carries no status field, so a '
+ 'revoked or retired key is indistinguishable from an active one',
remedy: 'point --keys (or --fetch) at a key registry that publishes keys[].status',
},
};
}
/**
* 1961/7.2 — DISCOVERY FAILED IS NOT "THIS KEY IS NOT OURS".
*
* ⚠ MEASURED BEFORE WRITING THIS, and the gap is worth stating plainly: when a MANDATORY network
* discovery threw (`--refresh-keys`, `--fetch <url>`, or `--keys <url>`), this CLI called
* `fail()` — stderr text and exit 2, the same shape as a mistyped flag. A caller parsing stdout
* got no status at all; a caller reading only the exit code could not tell an outage from a
* usage error. And where a stale or empty keyring did reach the verifier, the receipt came back
* `UNKNOWN_KEY` — which reads as "signed by a key we do not publish", i.e. forgery-shaped, when
* the truth was "we could not ask".
*
* ⚠ NOTHING IS LOOSENED. Both answers were, and remain, `valid: false`. What changes is WHERE an
* operator is sent: to the signer, or to their own network. `REGISTRY_UNREACHABLE` is already
* normative in RECEIPT_FORMAT.md §7.1 — this is the code catching up with the format, not a new
* state being invented.
*
* ⚠ AND IT APPLIES ONLY TO THE NETWORK PATH. A missing `--key` file, an unreadable local registry
* file or a corrupt vendored snapshot stay `fail()`: those are operator errors, and dressing them
* as a registry outage would send the same operator to the wrong place in the other direction.
*/
function registryUnreachableVerdict(source, err) {
return {
valid: false,
status: 'REGISTRY_UNREACHABLE',
reason: 'registry_unreachable',
registry_unreachable: {
source,
why: `discovery was requested and could not be completed: ${err && err.message}`,
remedy: 'retry when the registry is reachable, or verify offline against a pinned keyring '
+ '(--keys <file>, or the vendored snapshot with no flags). An offline verdict is a '
+ 'verdict about the keys you pinned, not about the registry as it stands now.',
},
};
}
/** Did the operator ask for the network? A file path or the vendored default did not. */
function discoveryWasMandatory(opts) {
if (opts.refreshKeys || opts.fetchUrl) return true;
return typeof opts.keysSource === 'string' && /^https?:\/\//i.test(opts.keysSource);
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const opts = { receipt: null, chainFile: null, keyFile: null, keysSource: null, kid: null, fetchUrl: null, refreshKeys: false, envelopeFile: null, audience: null, environment: null, fromCommit: null, repo: null, contracts: [], json: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--chain') opts.chainFile = argv[++i];
else if (a === '--key') opts.keyFile = argv[++i];
else if (a === '--keys') opts.keysSource = argv[++i];
else if (a === '--kid') opts.kid = argv[++i];
else if (a === '--fetch') opts.fetchUrl = argv[++i];
else if (a === '--refresh-keys') opts.refreshKeys = true;
// 1961 TAG 1 — read the receipt off a commit instead of the command line.
else if (a === '--from-commit') opts.fromCommit = argv[++i];
else if (a === '--repo') opts.repo = argv[++i];
// P58 (2026-10-05): the contract file(s) whose content the receipt's artifact_digest must match.
else if (a === '--contract') opts.contracts.push(argv[++i]);
/*
* 1961 TAG 9 — `--json`: MEASURED FIRST, AND IT IS NOT WHAT IT SOUNDS LIKE.
*
* ⚠ stdout was ALREADY pure JSON on every verdict path — `--json` would be a no-op if it
* meant "print JSON". What it actually buys is a CLEAN PAIR OF STREAMS: this CLI writes
* human notes to stderr (the legacy-key warning, and `receipt for <sha> via <carrier>` from
* --from-commit), and a caller capturing `2>&1` — which people do — gets those notes mixed
* into what they are about to `JSON.parse`. `--json` silences stderr notes so the two streams
* can be merged safely.
*
* ⚠ IT NEVER SILENCES AN ERROR. `fail()` still writes to stderr and still exits 2: a flag
* asking for machine-readable output must not turn a usage error into silence, which would
* be a script that looks like it worked.
*/
else if (a === '--json') opts.json = true;
else if (a === '--envelope') opts.envelopeFile = argv[++i];
else if (a === '--audience') opts.audience = argv[++i];
else if (a === '--environment') opts.environment = argv[++i];
else if (a === '-h' || a === '--help') opts.help = true;
else if (a.startsWith('--')) throw new Error(`unknown flag: ${a}`);
else if (opts.receipt === null) opts.receipt = a;
else throw new Error(`unexpected argument: ${a}`);
}
if (opts.keyFile && opts.keysSource) throw new Error('--key and --keys are mutually exclusive');
// ⚠ A receipt from a commit AND a receipt on the command line is an ambiguity, not a
// convenience: silently preferring one would verify a token the operator did not think they
// were asking about.
if (opts.fromCommit && opts.receipt) throw new Error('--from-commit and a positional receipt are mutually exclusive');
if (opts.fromCommit && opts.chainFile) throw new Error('--from-commit and --chain are mutually exclusive');
if (opts.contracts.length && !opts.fromCommit) throw new Error('--contract needs --from-commit (it reads the file at that commit)');
if (opts.refreshKeys && (opts.keyFile || opts.keysSource)) {
throw new Error('--refresh-keys is mutually exclusive with --key and --keys');
}
return opts;
}
// T19 (2026-10-06): the text names the npm bin (`npx @coderifts/receipt-verifier …`); from a clone the
// same command is `node cli.js …`.
const USAGE =
'usage: coderifts-receipt-verifier <receipt> [--key pub.pem | --keys <url|file>] [--kid <kid>] [--fetch <url>] [--refresh-keys]\n' +
' coderifts-receipt-verifier --chain receipts.txt [--key pub.pem | --keys <url|file>] [--kid <kid>] [--fetch <url>] [--refresh-keys]\n' +
' coderifts-receipt-verifier --from-commit <sha> [--repo <path>] [--contract <path>]... [--key pub.pem | --keys <url|file>] [--kid <kid>]\n' +
' reads the CodeRifts-Receipt trailer or .coderifts/receipts/<sha>.json\n' +
' (docs/receipt-commit-binding.md)\n' +
' --json suppress human notes on stderr so 2>&1 stays parseable. Errors still go to\n' +
' stderr and still exit 2 — this never silences a failure.\n';
function fail(msg) {
process.stderr.write(`${msg}\n${USAGE}`);
process.exit(2);
}
async function main() {
let opts;
try {
opts = parseArgs(process.argv.slice(2));
} catch (e) {
return fail(e.message);
}
if (opts.help) {
process.stdout.write(USAGE);
process.exit(2);
}
// Empty string from `$(curl -s … | grep …)` on GitHub 403/rate-limit is a common silent path
// when the homepage one-liner is used without HTTP status checks — fail honestly.
// 1961 TAG 1 — resolve the receipt from a commit before anything else needs it.
//
// ⚠ ANY FAILURE HERE IS A USAGE ERROR (exit 2), NOT A VERDICT. "no receipt is attached to this
// commit" is not a statement about a receipt — there is no receipt to have an opinion about.
// Emitting `valid: false` would be a verdict on a token that was never presented.
let fromCommitEnvelope = null;
let fromCommitFound = null;
if (opts.fromCommit) {
try {
const { receiptForCommit } = require('./receipt-from-commit.js');
const found = receiptForCommit(opts.fromCommit, { cwd: opts.repo || process.cwd() });
opts.receipt = found.token;
fromCommitEnvelope = found.envelope;
fromCommitFound = found;
if (!opts.json) process.stderr.write(`receipt for ${found.sha} via ${found.carrier}\n`);
} catch (e) {
return fail(e.message);
}
}
if (!opts.chainFile && (!opts.receipt || !String(opts.receipt).trim())) {
return fail(
'no receipt provided — if you fetched via unauthenticated GitHub comments, a 403 rate limit '
+ 'yields an empty capture; try again in a minute, or paste the receipt token directly',
);
}
// Resolve the verification key(s) + expected kid.
let ctx;
// Non-null once the fetch resolved the LEGACY single-key body (1282-A').
let legacyKeySource = null;
try {
if (opts.keysSource) {
// Registry mode: resolve each receipt's key by its kid. --kid stays an
// optional additional guard (null => accept any kid present in the registry).
// A URL here is the opt-in network path; a file is offline.
const keyring = await loadKeyring(opts.keysSource);
ctx = { keyring, expectedKid: opts.kid };
} else if (opts.keyFile) {
const pem = fs.readFileSync(opts.keyFile, 'utf8');
ctx = { publicKey: keyFromPem(pem), expectedKid: opts.kid };
} else if (opts.refreshKeys || opts.fetchUrl) {
// Opt-in live discovery. --refresh-keys hits the well-known URL; --fetch overrides it.
const info = await fetchKeyInfo(opts.fetchUrl || DEFAULT_FETCH_URL);
if (info.keyring) {
// Registry shape: resolve each receipt by kid (retired window included).
ctx = { keyring: info.keyring, expectedKid: opts.kid };
} else {
// Legacy single-key body. An explicit --kid overrides the discovered kid.
ctx = { publicKey: info.publicKey, expectedKid: opts.kid || info.kid };
legacyKeySource = info.source || opts.fetchUrl || DEFAULT_FETCH_URL;
}
} else {
// Default: vendored snapshot, no network. A CA pins roots locally.
const keyring = await loadKeyring(VENDORED_KEYS_PATH);
ctx = { keyring, expectedKid: opts.kid };
}
} catch (e) {
// ⚠ A FAILED MANDATORY DISCOVERY IS A VERDICT, NOT A USAGE ERROR. Structured status on stdout
// and the verdict exit code, so a machine can tell an outage from a mistyped flag.
if (discoveryWasMandatory(opts)) {
const source = opts.fetchUrl || opts.keysSource || DEFAULT_FETCH_URL;
process.stdout.write(JSON.stringify(registryUnreachableVerdict(source, e)) + '\n');
process.exit(1);
}
return fail(`could not load public key: ${e.message}`);
}
// Optional envelope + dormant-check inputs for the taxonomy.
let envelope = null;
if (opts.envelopeFile) {
try {
envelope = JSON.parse(fs.readFileSync(opts.envelopeFile, 'utf8'));
} catch (e) {
return fail(`could not read --envelope: ${e.message}`);
}
}
// ⚠ An explicit --envelope WINS over a sidecar's. The operator naming a file is a stronger
// statement than a pointer found in the repository, and silently overriding it would make
// `--envelope` unreliable exactly where it matters.
if (!envelope && fromCommitEnvelope) envelope = fromCommitEnvelope;
const verifyOpts = { envelope, expectedAudience: opts.audience, expectedEnvironment: opts.environment };
let result;
if (opts.chainFile) {
const raw = fs.readFileSync(opts.chainFile, 'utf8');
const tokens = raw.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
if (tokens.length === 0) return fail('chain file is empty');
// The PUBLIC library entry points — the *Inner variants are private to verify.js and are
// not part of what the four vendoring repos consume.
result = verifyChain(tokens, ctx, verifyOpts);
} else {
result = verifyReceipt(opts.receipt, ctx, verifyOpts);
// 1.1.1 / P66 (2026-10-12): a receipt used as an authorization — with an envelope (--envelope or a
// sidecar) or read off a commit — must be v4: v1–v3 bind no envelope and carry no expiry. MEASURED
// (audit probe p1b): a legitimately signed v1–v3 token beside a hand-written envelope was
// VERIFIED_CURRENT + BOUND. A bare token with no envelope is still a signature check only.
// 1.1.4: with an envelope (--envelope, or a sidecar's, which arrives in verifyOpts.envelope) the core
// verifyReceipt now applies this itself, so the branch below only still acts for --from-commit WITHOUT
// an envelope (a trailer, or a sidecar that carries none).
const v = result && result.payload && result.payload.v;
if (!envelope && fromCommitFound && result && result.valid && result.payload && v !== 4) {
result = { ...result, valid: false, signature_status: result.status, status: 'RECEIPT_VERSION_UNSUPPORTED',
reason: `receipt version v${v} binds no envelope and carries no expiry; only a v4 receipt is accepted with an envelope or --from-commit` };
}
}
if (legacyKeySource) {
// Non-silent: the operator learns WHY the verdict cannot be current, on stderr, so stdout
// stays a clean JSON document for a pipe.
if (!opts.json) process.stderr.write(
`warning: KEY_STATUS_UNAVAILABLE — ${legacyKeySource} returned the legacy single-key body, `
+ 'which carries no keys[].status. A revoked key is indistinguishable from an active one '
+ 'here, so no CURRENT verdict is reported. Use a key registry (--keys <url|file>) for a '
+ 'verdict that can see key status.\n',
);
result = downgradeLegacyVerdict(result, legacyKeySource);
}
// P58 (2026-10-05) — a receipt read off a commit must be ABOUT that commit (commit-binding.js).
// Only after a valid signature: a forged or broken receipt keeps its own verdict. The envelope
// checked is the one the signature covered (an explicit --envelope, else the sidecar's).
if (fromCommitFound && result.valid) {
let binding;
try {
const { checkCommitBinding } = require('./commit-binding.js');
binding = checkCommitBinding({
sha: fromCommitFound.sha,
carrier: fromCommitFound.carrier,
envelope,
cwd: opts.repo || process.cwd(),
contracts: opts.contracts,
});
} catch (e) {
return fail(e.message);
}
if (binding.note && !opts.json) process.stderr.write(`${binding.note}\n`);
if (binding.status === 'FAILED') {
result = { ...result, valid: false, signature_status: result.status, status: binding.code, reason: binding.reason,
commit_binding: { status: binding.status, head: binding.head, content: binding.content } };
} else if (binding.status === 'BOUND') {
result = { ...result, commit_binding: { status: binding.status, head: binding.head, content: binding.content } };
}
}
// 1.1.4 (2026-10-18): the receipt verdict names its limits, as the bundle (verify-bundle.js:140),
// attestation (verify-atomic-attestation.js:198) and DSSE (to-dsse.js:158) verdicts already do.
// Appended, so nothing else in the verdict moves; a --chain verdict is a different object and keeps its shape.
if (!opts.chainFile) result = { ...result, does_not_prove: [...RECEIPT_DOES_NOT_PROVE] };
process.stdout.write(JSON.stringify(result) + '\n');
process.exit(result.valid ? 0 : 1);
}
/**
* What a verified receipt token does NOT establish — the sentences already published, verbatim:
* [0] README.md:231-233 ("What it does NOT prove", the first two sentences);
* [1..3] the receipt scope the app publishes (coderifts-app src/does-not-prove.js `receipt`, the
* vendored src/generated/does-not-prove.json), itself the SDK's wording
* (sdk src/verify-receipt-local.ts:87) and mirrored on the website's /portable-verification/.
* No new claim: a change here is a change to a published sentence.
*/
const RECEIPT_DOES_NOT_PROVE = Object.freeze([
'it does not prove the agent then stopped. It is evidence about a decision we issued, never about what '
+ 'the caller did next.',
'that the receipt AUTHORIZES the action you are about to take — a valid signature is '
+ 'authenticity, not authorization. Pass an intended context to the server verify, or use '
+ 'authorize() for the execution binding',
'that the signing key is still trusted — REVOCATION and the issuer\'s clock are not visible '
+ 'to any local verifier, including this one. A key compromised a minute ago still verifies here',
'that the bytes came from the run you think they did — this checks ONE token, and "these '
+ 'tokens are one run" is a property of a set (see cr.evidence.root.v1)',
]);
// CLI entry — this file IS the command; verify.js never runs one.
if (require.main === module) {
main().catch((e) => fail(e.message));
}
module.exports = {
fetchKeyInfo, downgradeLegacyVerdict, registryUnreachableVerdict, discoveryWasMandatory,
parseArgs, main, USAGE, VENDORED_KEYS_PATH, RECEIPT_DOES_NOT_PROVE,
};