@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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coderifts/agent-guard",
|
|
3
|
-
"version": "17.
|
|
3
|
+
"version": "17.2.0",
|
|
4
4
|
"description": "Fail-closed guard for AI agent tool calls — preflight contract changes before they execute. Security core frozen (agent-guard-api v1.0); v1.1 adds client-side enforcement: receipt→envelope binding, decision↔action reconciliation, safe_for_agent + degraded fail-closed, and enforced⟺executed (P0 client-enforcement pack).",
|
|
5
5
|
"main": "./dist/cjs/index.js",
|
|
6
6
|
"module": "./dist/esm/index.js",
|