@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.
- package/dist/cjs/cas-attestation.d.ts +65 -0
- package/dist/cjs/cas-attestation.d.ts.map +1 -1
- package/dist/cjs/cas-attestation.js +87 -3
- package/dist/cjs/cas-attestation.js.map +1 -1
- package/dist/cjs/guard.d.ts.map +1 -1
- package/dist/cjs/guard.js +8 -0
- package/dist/cjs/guard.js.map +1 -1
- package/dist/cjs/vendor/VENDOR.sha256 +25 -2
- package/dist/cjs/vendor/keys/coderifts-keys.json +1 -0
- package/dist/cjs/vendor/verify-evidence.js +238 -0
- package/dist/cjs/vendor/verify-grant.js +522 -0
- package/dist/cjs/vendor/verify-prove-transcript.js +72 -0
- package/dist/esm/cas-attestation.d.ts +65 -0
- package/dist/esm/cas-attestation.d.ts.map +1 -1
- package/dist/esm/cas-attestation.js +86 -3
- package/dist/esm/cas-attestation.js.map +1 -1
- package/dist/esm/guard.d.ts.map +1 -1
- package/dist/esm/guard.js +8 -0
- package/dist/esm/guard.js.map +1 -1
- package/dist/esm/vendor/VENDOR.sha256 +25 -2
- package/dist/esm/vendor/keys/coderifts-keys.json +1 -0
- package/dist/esm/vendor/verify-evidence.js +238 -0
- package/dist/esm/vendor/verify-grant.js +522 -0
- package/dist/esm/vendor/verify-prove-transcript.js +72 -0
- package/package.json +1 -1
|
@@ -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";
|