@coderifts/agent-guard 17.1.0 → 17.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.
@@ -0,0 +1,522 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * CodeRifts cr.exec.v1 execution-grant verifier -- Node >= 20, zero dependencies
6
+ * (node:crypto only). Sibling of verify.js; a user who knows verify.js should
7
+ * feel at home.
8
+ *
9
+ * Usage:
10
+ * node verify-grant.js <grant> --keys <url|file>
11
+ * [--intended-operation X --intended-target Y --intended-audience Z]
12
+ * [--intended-after-file PATH | --intended-scope-hash sha256:…]
13
+ * [--receipt <token>]
14
+ * node verify-grant.js <grant> --key pub.pem [--kid <kid>] # offline pin
15
+ *
16
+ * Key discovery: --keys resolves by kid from a registry
17
+ * ({ keys: [{ kid, public_key_pem, status, valid_from, retired_at }] }).
18
+ * --key pins a single SPKI PEM (same as verify.js). --key and --keys are
19
+ * mutually exclusive. With neither, the public key is fetched from
20
+ * https://app.coderifts.com/api/v1/attestation/public-key (override --fetch).
21
+ *
22
+ * Output: JSON { valid, status, reason?, payload? } to stdout — byte-identical
23
+ * to verify_grant.py. Exit codes: 0 GRANT_CURRENT, 1 otherwise, 2 usage error.
24
+ *
25
+ * Retired kid → UNKNOWN_KEY. Grants are live execution permission; receipts
26
+ * may forensically verify a retired key inside [valid_from, retired_at),
27
+ * grants must not (see coderifts-app/docs/cr-exec-v1.md).
28
+ */
29
+
30
+ const crypto = require('node:crypto');
31
+ const fs = require('node:fs');
32
+ const {
33
+ loadKeyring,
34
+ keyFromPem,
35
+ fetchKeyInfo,
36
+ sha256hex,
37
+ canonicalJson,
38
+ CLOCK_SKEW_LEEWAY_MS,
39
+ isExpiredAt,
40
+ expiryLeewayMs,
41
+ } = require('./verify.js');
42
+ const { split3ary } = require('./arity');
43
+
44
+ const GRANT_VERSION = 'cr.exec.v1';
45
+ const GRANT_VERSION_V2 = 'cr.exec.v2';
46
+ const SIGNING_PREFIX = 'crexec.v1';
47
+ const SIGNING_PREFIX_V2 = 'crexec.v2';
48
+ // US (Unit Separator, 0x1F). Named for the byte it holds — it is NOT US, which is 0x00.
49
+ // The old name mirrored the server's, and that misnomer is what let RECEIPT_FORMAT.md §2.0
50
+ // give this separator for the single-spec preimage, which actually uses 0x00 — see the
51
+ // corrections note in that section. Renamed in coderifts-app 90c39cc; this mirror follows.
52
+ const US = '\x1f';
53
+ const SIGNED_FIELDS = Object.freeze([
54
+ 'kid', 'receipt_digest', 'scope_hash', 'audience', 'operation', 'target_id', 'jti', 'iat', 'exp',
55
+ ]);
56
+ const V2_REQUIRED_STRINGS = Object.freeze([
57
+ 'v', 'kid', 'grant_id', 'receipt_hash', 'tenant_id', 'executor_id', 'adapter_id',
58
+ 'operation', 'target_uri', 'expected_state_token', 'after_payload_hash',
59
+ 'nonce_hash', 'policy_hash', 'audience_hash', 'not_before', 'expires_at',
60
+ ]);
61
+ const TARGET_SCHEMES = Object.freeze(['fs', 'git', 'api', 'db', 'registry', 'deploy']);
62
+ const DEFAULT_FETCH_URL = 'https://app.coderifts.com/api/v1/attestation/public-key';
63
+
64
+ function isIssuedInFuture(issuedAtMs, nowMs, context) {
65
+ if (!Number.isFinite(issuedAtMs) || !Number.isFinite(nowMs)) return false;
66
+ return issuedAtMs > (nowMs + expiryLeewayMs(context));
67
+ }
68
+
69
+ function scalar(v) {
70
+ return v == null ? '' : String(v);
71
+ }
72
+
73
+ function reconstructSignedInput(payload) {
74
+ return [
75
+ SIGNING_PREFIX,
76
+ scalar(payload.kid),
77
+ scalar(payload.receipt_digest),
78
+ scalar(payload.scope_hash),
79
+ scalar(payload.audience),
80
+ scalar(payload.operation),
81
+ scalar(payload.target_id),
82
+ scalar(payload.jti),
83
+ scalar(payload.iat),
84
+ scalar(payload.exp),
85
+ ].join('|');
86
+ }
87
+
88
+ function sha256pref(s) {
89
+ return `sha256:${sha256hex(String(s))}`;
90
+ }
91
+
92
+ function canonicalizeTargetUri(raw) {
93
+ if (typeof raw !== 'string' || raw.length === 0) return null;
94
+ const m = raw.match(/^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^?#]*)$/);
95
+ if (!m) return null;
96
+ const scheme = m[1].toLowerCase();
97
+ if (!TARGET_SCHEMES.includes(scheme)) return null;
98
+ let rest = m[2];
99
+ if (/^[^\s/]*:/.test(rest) && rest.includes('@') && scheme !== 'git') return null;
100
+ if (rest.includes('..') || rest.includes('//') || /\s/.test(rest)) return null;
101
+ if (rest.endsWith('/') && rest.length > 1) rest = rest.replace(/\/+$/, '');
102
+ return `${scheme}://${rest}`;
103
+ }
104
+
105
+ function signingInputV2(body) {
106
+ return `${SIGNING_PREFIX_V2}|${canonicalJson(body)}`;
107
+ }
108
+
109
+ function computeScopeHash({ operation, target_id, after_payload }) {
110
+ const preimage = [
111
+ operation == null ? '' : String(operation),
112
+ target_id == null ? '' : String(target_id),
113
+ after_payload == null ? '' : String(after_payload),
114
+ ].join(US);
115
+ return `sha256:${sha256hex(preimage)}`;
116
+ }
117
+
118
+ function receiptDigest(token) {
119
+ return `sha256:${sha256hex(String(token))}`;
120
+ }
121
+
122
+ function resolveEntry(ctx, payload) {
123
+ if (ctx.keyring) {
124
+ const entry = ctx.keyring.get(payload.kid);
125
+ if (!entry) return null;
126
+ if (ctx.expectedKid !== null && payload.kid !== ctx.expectedKid) return null;
127
+ return entry;
128
+ }
129
+ if (ctx.expectedKid !== null && payload.kid !== ctx.expectedKid) return null;
130
+ return { publicKey: ctx.publicKey, status: null, retired_at: null, compromised_at: null };
131
+ }
132
+
133
+ /**
134
+ * cr.exec.v2 — JSON-canonical preimage, exact executor/adapter/target/audience bind.
135
+ */
136
+ function verifyExecutionGrantV2(payload, sigB64, ctx, opts = {}) {
137
+ for (const k of V2_REQUIRED_STRINGS) {
138
+ if (typeof payload[k] !== 'string' || payload[k].length === 0) {
139
+ return { valid: false, status: 'MALFORMED', reason: 'missing_field', payload };
140
+ }
141
+ }
142
+ if (!Number.isInteger(payload.max_attempts) || payload.max_attempts < 1) {
143
+ return { valid: false, status: 'MALFORMED', reason: 'bad_max_attempts', payload };
144
+ }
145
+ const allowed = new Set([...V2_REQUIRED_STRINGS, 'max_attempts']);
146
+ for (const k of Object.keys(payload)) {
147
+ if (!allowed.has(k)) return { valid: false, status: 'MALFORMED', reason: 'unknown_field', payload };
148
+ }
149
+ if (!canonicalizeTargetUri(payload.target_uri)) {
150
+ return { valid: false, status: 'MALFORMED', reason: 'bad_target_uri', payload };
151
+ }
152
+
153
+ const entry = resolveEntry(ctx, payload);
154
+ if (!entry) {
155
+ return { valid: false, status: 'UNKNOWN_KEY', reason: 'unknown_kid', payload };
156
+ }
157
+ if (entry.status === 'retired') {
158
+ return { valid: false, status: 'UNKNOWN_KEY', reason: 'retired_kid', payload };
159
+ }
160
+ if (entry.status === 'revoked') {
161
+ return { valid: false, status: 'UNKNOWN_KEY', reason: 'revoked_kid', payload };
162
+ }
163
+
164
+ let ok = false;
165
+ try {
166
+ ok = crypto.verify(
167
+ null,
168
+ Buffer.from(signingInputV2(payload), 'utf8'),
169
+ entry.publicKey,
170
+ Buffer.from(sigB64, 'base64url'),
171
+ );
172
+ } catch (_) {
173
+ return { valid: false, status: 'INVALID_SIGNATURE', reason: 'signature_error', payload };
174
+ }
175
+ if (!ok) return { valid: false, status: 'INVALID_SIGNATURE', reason: 'signature_mismatch', payload };
176
+
177
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
178
+ const expMs = Date.parse(payload.expires_at);
179
+ const nbfMs = Date.parse(payload.not_before);
180
+ if (!Number.isFinite(expMs) || !Number.isFinite(nbfMs)) {
181
+ return { valid: false, status: 'MALFORMED', reason: 'bad_timestamp', payload };
182
+ }
183
+ const intended = opts.intended && typeof opts.intended === 'object' ? opts.intended : {};
184
+ if (isExpiredAt(expMs, now, intended)) {
185
+ return { valid: false, status: 'GRANT_EXPIRED', reason: 'expired', payload };
186
+ }
187
+ if (isIssuedInFuture(nbfMs, now, intended)) {
188
+ return { valid: false, status: 'GRANT_EXPIRED', reason: 'nbf_in_future', payload };
189
+ }
190
+
191
+ if (intended.executor_id && payload.executor_id !== String(intended.executor_id)) {
192
+ return { valid: false, status: 'GRANT_UNBOUND', reason: 'executor_mismatch', payload };
193
+ }
194
+ if (intended.adapter_id && payload.adapter_id !== String(intended.adapter_id)) {
195
+ return { valid: false, status: 'GRANT_UNBOUND', reason: 'adapter_mismatch', payload };
196
+ }
197
+ if (intended.target_uri) {
198
+ const want = canonicalizeTargetUri(String(intended.target_uri)) || String(intended.target_uri);
199
+ if (payload.target_uri !== want) {
200
+ return { valid: false, status: 'GRANT_UNBOUND', reason: 'target_mismatch', payload };
201
+ }
202
+ }
203
+ if (intended.audience) {
204
+ if (payload.audience_hash !== sha256pref(intended.audience)) {
205
+ return { valid: false, status: 'GRANT_UNBOUND', reason: 'audience_mismatch', payload };
206
+ }
207
+ }
208
+ if (intended.audience_hash && payload.audience_hash !== String(intended.audience_hash)) {
209
+ return { valid: false, status: 'GRANT_UNBOUND', reason: 'audience_mismatch', payload };
210
+ }
211
+ if (intended.after_payload != null) {
212
+ if (payload.after_payload_hash !== sha256pref(intended.after_payload)) {
213
+ return { valid: false, status: 'GRANT_UNBOUND', reason: 'after_payload_mismatch', payload };
214
+ }
215
+ }
216
+ if (intended.operation && payload.operation !== String(intended.operation)) {
217
+ return { valid: false, status: 'GRANT_UNBOUND', reason: 'operation_mismatch', payload };
218
+ }
219
+ if (intended.receipt_token) {
220
+ if (sha256pref(intended.receipt_token) !== payload.receipt_hash) {
221
+ return { valid: false, status: 'GRANT_UNBOUND', reason: 'receipt_hash_mismatch', payload };
222
+ }
223
+ }
224
+ return { valid: true, status: 'GRANT_CURRENT', payload };
225
+ }
226
+
227
+ /**
228
+ * Verify a cr.exec.v1 or cr.exec.v2 grant. 10-step algorithm from docs/cr-exec-v1.md for v1.
229
+ * @param {string} token
230
+ * @param {{ publicKey?: import('crypto').KeyObject, keyring?: Map, expectedKid: (string|null) }} ctx
231
+ * @param {{ intended?: object, now?: number }} [opts]
232
+ * @returns {{ valid: boolean, status: string, reason?: string, payload?: object }}
233
+ */
234
+ function verifyExecutionGrantInner(token, ctx, opts = {}) {
235
+ // 1. structure
236
+ if (typeof token !== 'string' || token.length === 0) {
237
+ return { valid: false, status: 'MALFORMED', reason: 'malformed_structure' };
238
+ }
239
+ const segments = token.split('.');
240
+ if (segments.length !== 2 || segments.some((s) => !s)) {
241
+ return { valid: false, status: 'MALFORMED', reason: 'malformed_structure' };
242
+ }
243
+
244
+ // 2. json + version + signed fields as strings; unknown keys MALFORMED
245
+ let payload;
246
+ try {
247
+ payload = JSON.parse(Buffer.from(segments[0], 'base64url').toString('utf8'));
248
+ } catch (_) {
249
+ return { valid: false, status: 'MALFORMED', reason: 'bad_json' };
250
+ }
251
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
252
+ return { valid: false, status: 'MALFORMED', reason: 'bad_json' };
253
+ }
254
+ if (payload.v === GRANT_VERSION_V2) {
255
+ return verifyExecutionGrantV2(payload, segments[1], ctx, opts);
256
+ }
257
+ if (payload.v !== GRANT_VERSION) {
258
+ return { valid: false, status: 'MALFORMED', reason: 'unsupported_version', payload };
259
+ }
260
+ for (const k of SIGNED_FIELDS) {
261
+ if (typeof payload[k] !== 'string') {
262
+ return { valid: false, status: 'MALFORMED', reason: 'missing_field', payload };
263
+ }
264
+ }
265
+ const allowed = new Set(['v', ...SIGNED_FIELDS]);
266
+ for (const k of Object.keys(payload)) {
267
+ if (!allowed.has(k)) {
268
+ return { valid: false, status: 'MALFORMED', reason: 'unknown_field', payload };
269
+ }
270
+ }
271
+
272
+ // 3. delimiter guard
273
+ for (const k of SIGNED_FIELDS) {
274
+ if (payload[k].includes('|')) {
275
+ return { valid: false, status: 'INVALID_SIGNATURE', reason: 'delimiter_in_field', payload };
276
+ }
277
+ }
278
+
279
+ // 4. kid — unknown OR retired → UNKNOWN_KEY.
280
+ // Grants are live execution permission. Receipts may forensically verify a
281
+ // retired key inside [valid_from, retired_at); grants must not.
282
+ const entry = resolveEntry(ctx, payload);
283
+ if (!entry) {
284
+ return { valid: false, status: 'UNKNOWN_KEY', reason: 'unknown_kid', payload };
285
+ }
286
+ if (entry.status === 'retired') {
287
+ return { valid: false, status: 'UNKNOWN_KEY', reason: 'retired_kid', payload };
288
+ }
289
+ // KEY STATUS GATE — RECEIPT_FORMAT.md 7.1 (normative).
290
+ //
291
+ // DECISION: grants KEEP their own status vocabulary (GRANT_* / UNKNOWN_KEY) rather than adopting
292
+ // REVOKED_KEY. A grant is a different artifact class with a different caller branch and a 300s
293
+ // TTL, and it already maps 'retired' to UNKNOWN_KEY rather than to the receipt statuses — so
294
+ // importing two receipt statuses here would give this caller a second vocabulary to learn for no
295
+ // decision it can act on differently. What is NOT optional is the VERDICT: a revoked key must
296
+ // never yield a valid grant, on any timestamp. The distinction survives in `reason`, which is
297
+ // where this verifier already carries its detail.
298
+ if (entry.status === 'revoked') {
299
+ const at = entry.compromised_at;
300
+ const boundary = typeof at === 'string' && at ? Date.parse(at) : NaN;
301
+ // iat is epoch SECONDS in this envelope; ts (when present) is ISO. Neither is guaranteed
302
+ // well-formed on a hostile token, so both are parsed defensively -- a throw here would turn a
303
+ // revoked-key rejection into a crash, which is a worse failure than the one being fixed.
304
+ // MEASURED, not assumed: iat in cr.exec.v1 is an ISO STRING (e.g. 2026-08-23T12:00:00Z).
305
+ // An earlier draft treated it as epoch seconds, which parsed to NaN and silently downgraded
306
+ // every decidable revocation to UNDECIDABLE -- the rule would have looked implemented and
307
+ // never decided. Numeric epoch is still accepted in case an older envelope carries one.
308
+ const rawIat = payload.iat;
309
+ const issued = typeof rawIat === "string" ? Date.parse(rawIat)
310
+ : (Number.isFinite(Number(rawIat)) ? Number(rawIat) * 1000 : Date.parse(payload.ts));
311
+ const decided = Number.isFinite(boundary) && Number.isFinite(issued) && issued >= boundary;
312
+ return {
313
+ valid: false,
314
+ status: 'UNKNOWN_KEY',
315
+ reason: decided ? 'revoked_kid' : 'revoked_kid_undecidable',
316
+ payload,
317
+ };
318
+ }
319
+ if (entry.status != null && entry.status !== 'active') {
320
+ return { valid: false, status: 'UNKNOWN_KEY', reason: 'unknown_key_status', payload };
321
+ }
322
+
323
+ // 5. Ed25519 over crexec.v1|… pipe input (not JCS of the JSON)
324
+ const sig = Buffer.from(segments[1], 'base64url');
325
+ let ok = false;
326
+ try {
327
+ ok = crypto.verify(null, Buffer.from(reconstructSignedInput(payload), 'utf8'), entry.publicKey, sig);
328
+ } catch (_) {
329
+ return { valid: false, status: 'INVALID_SIGNATURE', reason: 'signature_error', payload };
330
+ }
331
+ if (!ok) return { valid: false, status: 'INVALID_SIGNATURE', reason: 'signature_mismatch', payload };
332
+
333
+ // 6. exp/iat + 30s leeway (same CLOCK_SKEW_LEEWAY_MS as receipts)
334
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
335
+ const expMs = Date.parse(payload.exp);
336
+ const iatMs = Date.parse(payload.iat);
337
+ if (!Number.isFinite(expMs) || !Number.isFinite(iatMs)) {
338
+ return { valid: false, status: 'MALFORMED', reason: 'bad_timestamp', payload };
339
+ }
340
+ const intended = opts.intended && typeof opts.intended === 'object' ? opts.intended : {};
341
+ if (isExpiredAt(expMs, now, intended)) {
342
+ return { valid: false, status: 'GRANT_EXPIRED', reason: 'expired', payload };
343
+ }
344
+ if (isIssuedInFuture(iatMs, now, intended)) {
345
+ return { valid: false, status: 'GRANT_EXPIRED', reason: 'iat_in_future', payload };
346
+ }
347
+
348
+ // 7. receipt_digest
349
+ if (!payload.receipt_digest || !payload.receipt_digest.startsWith('sha256:')) {
350
+ return { valid: false, status: 'GRANT_UNBOUND', reason: 'missing_receipt_digest', payload };
351
+ }
352
+ if (intended.receipt_token != null && String(intended.receipt_token).length > 0) {
353
+ if (receiptDigest(intended.receipt_token) !== payload.receipt_digest) {
354
+ return { valid: false, status: 'GRANT_UNBOUND', reason: 'receipt_digest_mismatch', payload };
355
+ }
356
+ }
357
+
358
+ // 8. audience / operation / target_id when supplied
359
+ if (intended.audience != null && intended.audience !== '' && payload.audience !== String(intended.audience)) {
360
+ return { valid: false, status: 'GRANT_WRONG_AUDIENCE', reason: 'audience_mismatch', payload };
361
+ }
362
+ if (intended.operation != null && intended.operation !== '' && payload.operation !== String(intended.operation)) {
363
+ return { valid: false, status: 'GRANT_SCOPE_MISMATCH', reason: 'operation_mismatch', payload };
364
+ }
365
+ if (intended.target_id != null && intended.target_id !== '' && payload.target_id !== String(intended.target_id)) {
366
+ return { valid: false, status: 'GRANT_SCOPE_MISMATCH', reason: 'target_mismatch', payload };
367
+ }
368
+
369
+ // 9. scope_hash recompute from after_payload (or compare supplied scope_hash)
370
+ let expectedScope = null;
371
+ if (intended.scope_hash != null && String(intended.scope_hash).length > 0) {
372
+ expectedScope = String(intended.scope_hash);
373
+ } else if (intended.after_payload != null) {
374
+ expectedScope = computeScopeHash({
375
+ operation: intended.operation != null ? intended.operation : payload.operation,
376
+ target_id: intended.target_id != null ? intended.target_id : payload.target_id,
377
+ after_payload: intended.after_payload,
378
+ });
379
+ }
380
+ if (expectedScope != null && expectedScope !== payload.scope_hash) {
381
+ return { valid: false, status: 'GRANT_SCOPE_MISMATCH', reason: 'scope_hash_mismatch', payload };
382
+ }
383
+
384
+ // 10.
385
+ return { valid: true, status: 'GRANT_CURRENT', payload };
386
+ }
387
+
388
+ function parseArgs(argv) {
389
+ const opts = {
390
+ grant: null,
391
+ keyFile: null,
392
+ keysSource: null,
393
+ kid: null,
394
+ fetchUrl: null,
395
+ intendedOperation: null,
396
+ intendedTarget: null,
397
+ intendedAudience: null,
398
+ intendedExecutor: null,
399
+ intendedAdapter: null,
400
+ intendedAfterFile: null,
401
+ intendedScopeHash: null,
402
+ receipt: null,
403
+ help: false,
404
+ };
405
+ for (let i = 0; i < argv.length; i++) {
406
+ const a = argv[i];
407
+ if (a === '--key') opts.keyFile = argv[++i];
408
+ else if (a === '--keys') opts.keysSource = argv[++i];
409
+ else if (a === '--kid') opts.kid = argv[++i];
410
+ else if (a === '--fetch') opts.fetchUrl = argv[++i];
411
+ else if (a === '--intended-operation') opts.intendedOperation = argv[++i];
412
+ else if (a === '--intended-target') opts.intendedTarget = argv[++i];
413
+ else if (a === '--intended-audience') opts.intendedAudience = argv[++i];
414
+ else if (a === '--intended-executor') opts.intendedExecutor = argv[++i];
415
+ else if (a === '--intended-adapter') opts.intendedAdapter = argv[++i];
416
+ else if (a === '--intended-after-file') opts.intendedAfterFile = argv[++i];
417
+ else if (a === '--intended-scope-hash') opts.intendedScopeHash = argv[++i];
418
+ else if (a === '--receipt') opts.receipt = argv[++i];
419
+ else if (a === '-h' || a === '--help') opts.help = true;
420
+ else if (a.startsWith('--')) throw new Error(`unknown flag: ${a}`);
421
+ else if (opts.grant === null) opts.grant = a;
422
+ else throw new Error(`unexpected argument: ${a}`);
423
+ }
424
+ if (opts.keyFile && opts.keysSource) throw new Error('--key and --keys are mutually exclusive');
425
+ if (opts.intendedAfterFile && opts.intendedScopeHash) {
426
+ throw new Error('--intended-after-file and --intended-scope-hash are mutually exclusive');
427
+ }
428
+ return opts;
429
+ }
430
+
431
+ const USAGE =
432
+ 'usage: node verify-grant.js <grant> [--key pub.pem | --keys <url|file>] [--kid <kid>] [--fetch <url>]\n'
433
+ + ' [--intended-operation X] [--intended-target Y] [--intended-audience Z]\n'
434
+ + ' [--intended-after-file PATH | --intended-scope-hash sha256:…]\n'
435
+ + ' [--receipt <token>]\n';
436
+
437
+ function fail(msg) {
438
+ process.stderr.write(`${msg}\n${USAGE}`);
439
+ process.exit(2);
440
+ }
441
+
442
+ async function main() {
443
+ let opts;
444
+ try {
445
+ opts = parseArgs(process.argv.slice(2));
446
+ } catch (e) {
447
+ return fail(e.message);
448
+ }
449
+ if (opts.help) {
450
+ process.stdout.write(USAGE);
451
+ process.exit(2);
452
+ }
453
+ if (!opts.grant || !String(opts.grant).trim()) {
454
+ return fail('no grant provided');
455
+ }
456
+
457
+ let ctx;
458
+ try {
459
+ if (opts.keysSource) {
460
+ const keyring = await loadKeyring(opts.keysSource);
461
+ ctx = { keyring, expectedKid: opts.kid };
462
+ } else if (opts.keyFile) {
463
+ const pem = fs.readFileSync(opts.keyFile, 'utf8');
464
+ ctx = { publicKey: keyFromPem(pem), expectedKid: opts.kid };
465
+ } else {
466
+ const info = await fetchKeyInfo(opts.fetchUrl || DEFAULT_FETCH_URL);
467
+ ctx = { publicKey: info.publicKey, expectedKid: opts.kid || info.kid };
468
+ }
469
+ } catch (e) {
470
+ return fail(`could not load public key: ${e.message}`);
471
+ }
472
+
473
+ const intended = {};
474
+ if (opts.intendedOperation != null) intended.operation = opts.intendedOperation;
475
+ if (opts.intendedTarget != null) {
476
+ intended.target_id = opts.intendedTarget;
477
+ intended.target_uri = opts.intendedTarget;
478
+ }
479
+ if (opts.intendedAudience != null) intended.audience = opts.intendedAudience;
480
+ if (opts.intendedExecutor != null) intended.executor_id = opts.intendedExecutor;
481
+ if (opts.intendedAdapter != null) intended.adapter_id = opts.intendedAdapter;
482
+ if (opts.intendedScopeHash != null) intended.scope_hash = opts.intendedScopeHash;
483
+ if (opts.receipt != null) intended.receipt_token = opts.receipt;
484
+ if (opts.intendedAfterFile) {
485
+ try {
486
+ intended.after_payload = fs.readFileSync(opts.intendedAfterFile, 'utf8');
487
+ } catch (e) {
488
+ return fail(`could not read --intended-after-file: ${e.message}`);
489
+ }
490
+ }
491
+
492
+ const result = verifyExecutionGrantInner(opts.grant, ctx, { intended });
493
+ process.stdout.write(JSON.stringify(result) + '\n');
494
+ process.exit(result.valid ? 0 : 1);
495
+ }
496
+
497
+ function verifyExecutionGrant(token, second, third) {
498
+ const { ctx, opts } = split3ary('verifyExecutionGrant', arguments.length, second, third);
499
+ return verifyExecutionGrantInner(token, ctx, opts);
500
+ }
501
+
502
+ module.exports = {
503
+ verifyExecutionGrant,
504
+ reconstructSignedInput,
505
+ signingInputV2,
506
+ canonicalizeTargetUri,
507
+ computeScopeHash,
508
+ receiptDigest,
509
+ sha256pref,
510
+ resolveEntry,
511
+ GRANT_VERSION,
512
+ GRANT_VERSION_V2,
513
+ SIGNING_PREFIX,
514
+ SIGNING_PREFIX_V2,
515
+ SIGNED_FIELDS,
516
+ CLOCK_SKEW_LEEWAY_MS,
517
+ isIssuedInFuture,
518
+ };
519
+
520
+ if (require.main === module) {
521
+ main().catch((e) => fail(e.message));
522
+ }
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * cr.prove.transcript.v1 — the executor's signature over its own run summary.
5
+ *
6
+ * CANONICAL HOME. The function existed only inside capability-demo
7
+ * (demo/src/verify-transcript.js), so every other consumer that wanted to authenticate a prove
8
+ * artifact either re-implemented it or skipped it. Conformance skipped it, and 1423 is what that
9
+ * cost: a one-byte mutation of transcript_token graded COVERED.
10
+ *
11
+ * Deliberately the whole offline surface: a token, a public key, a verdict. No I/O, no clock,
12
+ * no config. Byte-identical in behaviour to the demo's copy.
13
+ */
14
+
15
+ const crypto = require('node:crypto');
16
+
17
+ const PROVE_V = 'cr.prove.transcript.v1';
18
+
19
+ /**
20
+ * @param {string} token `cr.prove.transcript.v1|<kid>|<b64url preimage>|<b64url sig>`
21
+ * @param {{ publicKey?: import('crypto').KeyObject, keyring?: Map, expectedKid?: string|null }} ctx
22
+ * @returns {{valid: boolean, status: string, reason?: string, kid?: string, payload?: object}}
23
+ */
24
+ function verifyProveTranscript(token, ctx = {}) {
25
+ if (typeof token !== 'string' || !token.startsWith(`${PROVE_V}|`)) {
26
+ return { valid: false, status: 'PROVE_MALFORMED', reason: 'not_a_prove_transcript' };
27
+ }
28
+ const seg = token.split('|');
29
+ if (seg.length !== 4 || seg.some((s) => !s)) {
30
+ return { valid: false, status: 'PROVE_MALFORMED', reason: 'malformed_structure' };
31
+ }
32
+ const kid = seg[1];
33
+
34
+ // The key is chosen by the kid the token CLAIMS, then the signature decides. A forged kid
35
+ // selects a key whose signature fails; it can never select "no check".
36
+ let publicKey = ctx.publicKey;
37
+ if (!publicKey && ctx.keyring) {
38
+ const entry = ctx.keyring instanceof Map ? ctx.keyring.get(kid) : ctx.keyring[kid];
39
+ if (entry) publicKey = entry.publicKey || entry.public_key || entry;
40
+ }
41
+ if (!publicKey) {
42
+ return { valid: false, status: 'PROVE_UNKNOWN_KEY', reason: 'unknown_kid', kid };
43
+ }
44
+ if (ctx.expectedKid != null && ctx.expectedKid !== '' && kid !== String(ctx.expectedKid)) {
45
+ return { valid: false, status: 'PROVE_UNKNOWN_KEY', reason: 'kid_mismatch', kid };
46
+ }
47
+
48
+ let preimage;
49
+ try {
50
+ preimage = Buffer.from(seg[2], 'base64url').toString('utf8');
51
+ } catch (_) {
52
+ return { valid: false, status: 'PROVE_MALFORMED', reason: 'bad_preimage', kid };
53
+ }
54
+ let ok = false;
55
+ try {
56
+ ok = crypto.verify(
57
+ null, Buffer.from(preimage, 'utf8'), publicKey, Buffer.from(seg[3], 'base64url'),
58
+ );
59
+ } catch (_) {
60
+ return { valid: false, status: 'PROVE_INVALID_SIGNATURE', reason: 'signature_error', kid };
61
+ }
62
+ if (!ok) {
63
+ return { valid: false, status: 'PROVE_INVALID_SIGNATURE', reason: 'signature_mismatch', kid };
64
+ }
65
+ let payload;
66
+ try { payload = JSON.parse(preimage); } catch (_) {
67
+ return { valid: false, status: 'PROVE_MALFORMED', reason: 'preimage_not_json', kid };
68
+ }
69
+ return { valid: true, status: 'PROVE_VALID', kid, payload };
70
+ }
71
+
72
+ module.exports = { verifyProveTranscript, PROVE_V };
@@ -120,6 +120,25 @@ export type CasEvidence = {
120
120
  export type ExecutorAttestationConfig = {
121
121
  /** Customer-pinned executor key registry. Required to attempt verification. */
122
122
  registry: ExecutorKeyRegistry;
123
+ /**
124
+ * The PINNED CodeRifts ISSUER keyring, used to AUTHENTICATE an execution grant before it can
125
+ * count as a kernel binding under an enforcing profile (1433).
126
+ *
127
+ * IT LIVES HERE, not on `executionGrant`, and the difference matters: `executionGrant` gates
128
+ * whether the guard REQUESTS a grant, and setting `enabled: true` merely to supply a key would
129
+ * turn on a request path the caller never asked for. This section is the verification side, and
130
+ * a key for checking evidence belongs with the other key for checking evidence.
131
+ *
132
+ * Absent under an enforcing profile is FAIL-CLOSED: a grant that cannot be authenticated does
133
+ * not become a binding.
134
+ */
135
+ issuerKeyring?: {
136
+ keys?: Array<{
137
+ kid?: string;
138
+ public_key_pem?: string;
139
+ status?: string;
140
+ }>;
141
+ } | null;
123
142
  };
124
143
  export type EvaluateCasEvidenceOpts = {
125
144
  registry?: ExecutorKeyRegistry | null;
@@ -131,9 +150,55 @@ export type EvaluateCasEvidenceOpts = {
131
150
  state_nonce?: string;
132
151
  receipt_digest?: string;
133
152
  } | null;
153
+ /**
154
+ * ISSUER keyring for the execution grant — the pinned CodeRifts public keys, NOT the executor
155
+ * registry above. Supplied, a grant is AUTHENTICATED before it counts as a kernel binding.
156
+ *
157
+ * ── WHY THIS EXISTS (1431) ───────────────────────────────────────────────────────────────
158
+ *
159
+ * MEASURED, then closed. The guard authenticated the receipt (vendored verify.js) and the
160
+ * executor attestation (SDK verifyExecutionAttestation) and never the grant. The SDK's
161
+ * cross-check DECODES the grant (`parseGrantFields`) to compare jti / scope_hash / state_nonce
162
+ * against the attestation's payload, so a token whose payload simply copied those three values
163
+ * — with the literal word NEM-ALAIRAS in the signature slot — passed, and ENFORCING_STRICT
164
+ * went from `authorized_not_committed` to `authorized_and_committed` on the strength of it.
165
+ *
166
+ * A binding checked against an unauthenticated document is not a binding.
167
+ *
168
+ * OMITTED IS FAIL-CLOSED, and this is a behaviour change worth stating: a caller who supplies a
169
+ * grant but no issuer keyring can no longer have it counted as a kernel binding, because
170
+ * nothing here can tell a real grant from a copied one. The observation degrades to
171
+ * `authorized_not_committed` / `commit_evidence_missing` — the honest name for "we could not
172
+ * check" — rather than keeping the old, unearned upgrade.
173
+ */
174
+ grant_keyring?: {
175
+ keys?: Array<{
176
+ kid?: string;
177
+ public_key_pem?: string;
178
+ status?: string;
179
+ }>;
180
+ } | null;
181
+ /** Clock injection for grant expiry, tests only. */
182
+ now?: number;
134
183
  /** Strict-only tightening of derived.authorized_and_committed. Absent = 9.0.0 formula. */
135
184
  profile?: 'ENFORCING_STRICT' | 'ENFORCING_ATOMIC';
136
185
  };
186
+ /** Result of authenticating a supplied grant against the pinned issuer keyring. */
187
+ export type GrantAuthentication = {
188
+ /** true only when a signature verified against a pinned issuer key. */
189
+ authenticated: boolean;
190
+ /** The verifier's status, or why authentication was not even attempted. */
191
+ status: string;
192
+ reason: string | null;
193
+ };
194
+ /**
195
+ * Authenticate an execution grant against the pinned ISSUER keyring, through the vendored
196
+ * canonical core (receipt-verifier verify-grant.js — cr.exec.v1 AND cr.exec.v2).
197
+ *
198
+ * Loaded lazily and behind try/catch so a host that never supplies a keyring never pays for it,
199
+ * and so a packaging fault surfaces as NOT authenticated rather than as a crash inside the guard.
200
+ */
201
+ export declare function authenticateGrant(token: string | null | undefined, keyring: EvaluateCasEvidenceOpts['grant_keyring'], now?: number): GrantAuthentication;
137
202
  /** Existing CasAttestation.derived name and its honest sibling — not a parallel taxonomy. */
138
203
  export type CommitLabel = 'authorized_and_committed' | 'authorized_not_committed' | 'authorized_and_host_reported_committed';
139
204
  export declare const COMMIT_EVIDENCE_MISSING: "commit_evidence_missing";