@coderifts/agent-guard 17.1.0 → 17.3.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 +185 -9
- 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 +27 -2
- package/dist/cjs/vendor/keys/coderifts-keys.json +1 -0
- package/dist/cjs/vendor/verified-execution-binding.js +316 -0
- package/dist/cjs/vendor/verify-evidence.js +238 -0
- package/dist/cjs/vendor/verify-grant.js +564 -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 +184 -9
- 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 +27 -2
- package/dist/esm/vendor/keys/coderifts-keys.json +1 -0
- package/dist/esm/vendor/verified-execution-binding.js +316 -0
- package/dist/esm/vendor/verify-evidence.js +238 -0
- package/dist/esm/vendor/verify-grant.js +564 -0
- package/dist/esm/vendor/verify-prove-transcript.js +72 -0
- package/package.json +1 -1
|
@@ -0,0 +1,564 @@
|
|
|
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
|
+
/**
|
|
74
|
+
* cr.exec.v1 OPTIONAL signed fields — the ATOMIC profile's, and a DELIBERATE widening (1470).
|
|
75
|
+
*
|
|
76
|
+
* ── MEASURED BEFORE OPENING THE SET ─────────────────────────────────────────────────────────
|
|
77
|
+
*
|
|
78
|
+
* This verifier's allowed set was `['v', ...SIGNED_FIELDS]`, so a v1 grant carrying `state_nonce`
|
|
79
|
+
* read MALFORMED / unknown_field. That grant is not malformed: it is what the demo executor issues
|
|
80
|
+
* for the ATOMIC profile, and capability-demo's middleware has always signed and accepted it
|
|
81
|
+
* (OPTIONAL_SIGNED_FIELDS = ['state_nonce', 'deployment_id']). Fail-CLOSED, and wrong — this core
|
|
82
|
+
* is vendored into five consumers, so every one of them refused a valid ATOMIC grant.
|
|
83
|
+
*
|
|
84
|
+
* ── WHY WIDENING IS SAFE HERE, AND WHY IT IS STILL A DECISION ───────────────────────────────
|
|
85
|
+
*
|
|
86
|
+
* These fields are SIGNED: appended to the preimage, so a forger cannot add one without breaking
|
|
87
|
+
* the signature. The closed set never protected against injection; it protects against SEMANTIC
|
|
88
|
+
* DRIFT — a future field that RESTRICTS use must not be silently ignored by an older verifier that
|
|
89
|
+
* then says GRANT_CURRENT. `state_nonce` and `deployment_id` do not restrict: they NARROW, and a
|
|
90
|
+
* verifier that ignores them is not more permissive than one that does not know them.
|
|
91
|
+
*
|
|
92
|
+
* So the set is widened BY NAME, not opened. An actually-unknown field is still unknown_field, and
|
|
93
|
+
* test/v1-atomic-optional-fields.test.js records both halves so the next change is a decision
|
|
94
|
+
* rather than a rediscovery.
|
|
95
|
+
*
|
|
96
|
+
* ── APPENDED ONLY WHEN NON-EMPTY ────────────────────────────────────────────────────────────
|
|
97
|
+
*
|
|
98
|
+
* Byte-identical to capability-demo/packages/middleware/src/verify-grant.js. A BEARER grant's
|
|
99
|
+
* signing input must stay exactly what it was before ATOMIC existed, or every pre-ATOMIC issuance
|
|
100
|
+
* stops verifying — which is why presence, not declaration, decides the slot.
|
|
101
|
+
*/
|
|
102
|
+
const V1_OPTIONAL_SIGNED_FIELDS = Object.freeze(['state_nonce', 'deployment_id']);
|
|
103
|
+
|
|
104
|
+
function reconstructSignedInput(payload) {
|
|
105
|
+
const parts = [
|
|
106
|
+
SIGNING_PREFIX,
|
|
107
|
+
scalar(payload.kid),
|
|
108
|
+
scalar(payload.receipt_digest),
|
|
109
|
+
scalar(payload.scope_hash),
|
|
110
|
+
scalar(payload.audience),
|
|
111
|
+
scalar(payload.operation),
|
|
112
|
+
scalar(payload.target_id),
|
|
113
|
+
scalar(payload.jti),
|
|
114
|
+
scalar(payload.iat),
|
|
115
|
+
scalar(payload.exp),
|
|
116
|
+
];
|
|
117
|
+
for (const k of V1_OPTIONAL_SIGNED_FIELDS) {
|
|
118
|
+
if (payload[k] != null && String(payload[k]).length > 0) parts.push(String(payload[k]));
|
|
119
|
+
}
|
|
120
|
+
return parts.join('|');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sha256pref(s) {
|
|
124
|
+
return `sha256:${sha256hex(String(s))}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function canonicalizeTargetUri(raw) {
|
|
128
|
+
if (typeof raw !== 'string' || raw.length === 0) return null;
|
|
129
|
+
const m = raw.match(/^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^?#]*)$/);
|
|
130
|
+
if (!m) return null;
|
|
131
|
+
const scheme = m[1].toLowerCase();
|
|
132
|
+
if (!TARGET_SCHEMES.includes(scheme)) return null;
|
|
133
|
+
let rest = m[2];
|
|
134
|
+
if (/^[^\s/]*:/.test(rest) && rest.includes('@') && scheme !== 'git') return null;
|
|
135
|
+
if (rest.includes('..') || rest.includes('//') || /\s/.test(rest)) return null;
|
|
136
|
+
if (rest.endsWith('/') && rest.length > 1) rest = rest.replace(/\/+$/, '');
|
|
137
|
+
return `${scheme}://${rest}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function signingInputV2(body) {
|
|
141
|
+
return `${SIGNING_PREFIX_V2}|${canonicalJson(body)}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function computeScopeHash({ operation, target_id, after_payload }) {
|
|
145
|
+
const preimage = [
|
|
146
|
+
operation == null ? '' : String(operation),
|
|
147
|
+
target_id == null ? '' : String(target_id),
|
|
148
|
+
after_payload == null ? '' : String(after_payload),
|
|
149
|
+
].join(US);
|
|
150
|
+
return `sha256:${sha256hex(preimage)}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function receiptDigest(token) {
|
|
154
|
+
return `sha256:${sha256hex(String(token))}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resolveEntry(ctx, payload) {
|
|
158
|
+
if (ctx.keyring) {
|
|
159
|
+
const entry = ctx.keyring.get(payload.kid);
|
|
160
|
+
if (!entry) return null;
|
|
161
|
+
if (ctx.expectedKid !== null && payload.kid !== ctx.expectedKid) return null;
|
|
162
|
+
return entry;
|
|
163
|
+
}
|
|
164
|
+
if (ctx.expectedKid !== null && payload.kid !== ctx.expectedKid) return null;
|
|
165
|
+
return { publicKey: ctx.publicKey, status: null, retired_at: null, compromised_at: null };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* cr.exec.v2 — JSON-canonical preimage, exact executor/adapter/target/audience bind.
|
|
170
|
+
*/
|
|
171
|
+
function verifyExecutionGrantV2(payload, sigB64, ctx, opts = {}) {
|
|
172
|
+
for (const k of V2_REQUIRED_STRINGS) {
|
|
173
|
+
if (typeof payload[k] !== 'string' || payload[k].length === 0) {
|
|
174
|
+
return { valid: false, status: 'MALFORMED', reason: 'missing_field', payload };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (!Number.isInteger(payload.max_attempts) || payload.max_attempts < 1) {
|
|
178
|
+
return { valid: false, status: 'MALFORMED', reason: 'bad_max_attempts', payload };
|
|
179
|
+
}
|
|
180
|
+
const allowed = new Set([...V2_REQUIRED_STRINGS, 'max_attempts']);
|
|
181
|
+
for (const k of Object.keys(payload)) {
|
|
182
|
+
if (!allowed.has(k)) return { valid: false, status: 'MALFORMED', reason: 'unknown_field', payload };
|
|
183
|
+
}
|
|
184
|
+
if (!canonicalizeTargetUri(payload.target_uri)) {
|
|
185
|
+
return { valid: false, status: 'MALFORMED', reason: 'bad_target_uri', payload };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const entry = resolveEntry(ctx, payload);
|
|
189
|
+
if (!entry) {
|
|
190
|
+
return { valid: false, status: 'UNKNOWN_KEY', reason: 'unknown_kid', payload };
|
|
191
|
+
}
|
|
192
|
+
if (entry.status === 'retired') {
|
|
193
|
+
return { valid: false, status: 'UNKNOWN_KEY', reason: 'retired_kid', payload };
|
|
194
|
+
}
|
|
195
|
+
if (entry.status === 'revoked') {
|
|
196
|
+
return { valid: false, status: 'UNKNOWN_KEY', reason: 'revoked_kid', payload };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let ok = false;
|
|
200
|
+
try {
|
|
201
|
+
ok = crypto.verify(
|
|
202
|
+
null,
|
|
203
|
+
Buffer.from(signingInputV2(payload), 'utf8'),
|
|
204
|
+
entry.publicKey,
|
|
205
|
+
Buffer.from(sigB64, 'base64url'),
|
|
206
|
+
);
|
|
207
|
+
} catch (_) {
|
|
208
|
+
return { valid: false, status: 'INVALID_SIGNATURE', reason: 'signature_error', payload };
|
|
209
|
+
}
|
|
210
|
+
if (!ok) return { valid: false, status: 'INVALID_SIGNATURE', reason: 'signature_mismatch', payload };
|
|
211
|
+
|
|
212
|
+
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
213
|
+
const expMs = Date.parse(payload.expires_at);
|
|
214
|
+
const nbfMs = Date.parse(payload.not_before);
|
|
215
|
+
if (!Number.isFinite(expMs) || !Number.isFinite(nbfMs)) {
|
|
216
|
+
return { valid: false, status: 'MALFORMED', reason: 'bad_timestamp', payload };
|
|
217
|
+
}
|
|
218
|
+
const intended = opts.intended && typeof opts.intended === 'object' ? opts.intended : {};
|
|
219
|
+
if (isExpiredAt(expMs, now, intended)) {
|
|
220
|
+
return { valid: false, status: 'GRANT_EXPIRED', reason: 'expired', payload };
|
|
221
|
+
}
|
|
222
|
+
if (isIssuedInFuture(nbfMs, now, intended)) {
|
|
223
|
+
return { valid: false, status: 'GRANT_EXPIRED', reason: 'nbf_in_future', payload };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (intended.executor_id && payload.executor_id !== String(intended.executor_id)) {
|
|
227
|
+
return { valid: false, status: 'GRANT_UNBOUND', reason: 'executor_mismatch', payload };
|
|
228
|
+
}
|
|
229
|
+
if (intended.adapter_id && payload.adapter_id !== String(intended.adapter_id)) {
|
|
230
|
+
return { valid: false, status: 'GRANT_UNBOUND', reason: 'adapter_mismatch', payload };
|
|
231
|
+
}
|
|
232
|
+
if (intended.target_uri) {
|
|
233
|
+
const want = canonicalizeTargetUri(String(intended.target_uri)) || String(intended.target_uri);
|
|
234
|
+
if (payload.target_uri !== want) {
|
|
235
|
+
return { valid: false, status: 'GRANT_UNBOUND', reason: 'target_mismatch', payload };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (intended.audience) {
|
|
239
|
+
if (payload.audience_hash !== sha256pref(intended.audience)) {
|
|
240
|
+
return { valid: false, status: 'GRANT_UNBOUND', reason: 'audience_mismatch', payload };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (intended.audience_hash && payload.audience_hash !== String(intended.audience_hash)) {
|
|
244
|
+
return { valid: false, status: 'GRANT_UNBOUND', reason: 'audience_mismatch', payload };
|
|
245
|
+
}
|
|
246
|
+
if (intended.after_payload != null) {
|
|
247
|
+
if (payload.after_payload_hash !== sha256pref(intended.after_payload)) {
|
|
248
|
+
return { valid: false, status: 'GRANT_UNBOUND', reason: 'after_payload_mismatch', payload };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (intended.operation && payload.operation !== String(intended.operation)) {
|
|
252
|
+
return { valid: false, status: 'GRANT_UNBOUND', reason: 'operation_mismatch', payload };
|
|
253
|
+
}
|
|
254
|
+
if (intended.receipt_token) {
|
|
255
|
+
if (sha256pref(intended.receipt_token) !== payload.receipt_hash) {
|
|
256
|
+
return { valid: false, status: 'GRANT_UNBOUND', reason: 'receipt_hash_mismatch', payload };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { valid: true, status: 'GRANT_CURRENT', payload };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Verify a cr.exec.v1 or cr.exec.v2 grant. 10-step algorithm from docs/cr-exec-v1.md for v1.
|
|
264
|
+
* @param {string} token
|
|
265
|
+
* @param {{ publicKey?: import('crypto').KeyObject, keyring?: Map, expectedKid: (string|null) }} ctx
|
|
266
|
+
* @param {{ intended?: object, now?: number }} [opts]
|
|
267
|
+
* @returns {{ valid: boolean, status: string, reason?: string, payload?: object }}
|
|
268
|
+
*/
|
|
269
|
+
function verifyExecutionGrantInner(token, ctx, opts = {}) {
|
|
270
|
+
// 1. structure
|
|
271
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
272
|
+
return { valid: false, status: 'MALFORMED', reason: 'malformed_structure' };
|
|
273
|
+
}
|
|
274
|
+
const segments = token.split('.');
|
|
275
|
+
if (segments.length !== 2 || segments.some((s) => !s)) {
|
|
276
|
+
return { valid: false, status: 'MALFORMED', reason: 'malformed_structure' };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 2. json + version + signed fields as strings; unknown keys MALFORMED
|
|
280
|
+
let payload;
|
|
281
|
+
try {
|
|
282
|
+
payload = JSON.parse(Buffer.from(segments[0], 'base64url').toString('utf8'));
|
|
283
|
+
} catch (_) {
|
|
284
|
+
return { valid: false, status: 'MALFORMED', reason: 'bad_json' };
|
|
285
|
+
}
|
|
286
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
287
|
+
return { valid: false, status: 'MALFORMED', reason: 'bad_json' };
|
|
288
|
+
}
|
|
289
|
+
if (payload.v === GRANT_VERSION_V2) {
|
|
290
|
+
return verifyExecutionGrantV2(payload, segments[1], ctx, opts);
|
|
291
|
+
}
|
|
292
|
+
if (payload.v !== GRANT_VERSION) {
|
|
293
|
+
return { valid: false, status: 'MALFORMED', reason: 'unsupported_version', payload };
|
|
294
|
+
}
|
|
295
|
+
for (const k of SIGNED_FIELDS) {
|
|
296
|
+
if (typeof payload[k] !== 'string') {
|
|
297
|
+
return { valid: false, status: 'MALFORMED', reason: 'missing_field', payload };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const allowed = new Set(['v', ...SIGNED_FIELDS, ...V1_OPTIONAL_SIGNED_FIELDS]);
|
|
301
|
+
for (const k of Object.keys(payload)) {
|
|
302
|
+
if (!allowed.has(k)) {
|
|
303
|
+
return { valid: false, status: 'MALFORMED', reason: 'unknown_field', payload };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (const k of V1_OPTIONAL_SIGNED_FIELDS) {
|
|
308
|
+
if (payload[k] != null && typeof payload[k] !== 'string') {
|
|
309
|
+
return { valid: false, status: 'MALFORMED', reason: 'missing_field', payload };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 3. delimiter guard
|
|
314
|
+
for (const k of SIGNED_FIELDS) {
|
|
315
|
+
if (payload[k].includes('|')) {
|
|
316
|
+
return { valid: false, status: 'INVALID_SIGNATURE', reason: 'delimiter_in_field', payload };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 4. kid — unknown OR retired → UNKNOWN_KEY.
|
|
321
|
+
// Grants are live execution permission. Receipts may forensically verify a
|
|
322
|
+
// retired key inside [valid_from, retired_at); grants must not.
|
|
323
|
+
const entry = resolveEntry(ctx, payload);
|
|
324
|
+
if (!entry) {
|
|
325
|
+
return { valid: false, status: 'UNKNOWN_KEY', reason: 'unknown_kid', payload };
|
|
326
|
+
}
|
|
327
|
+
if (entry.status === 'retired') {
|
|
328
|
+
return { valid: false, status: 'UNKNOWN_KEY', reason: 'retired_kid', payload };
|
|
329
|
+
}
|
|
330
|
+
// KEY STATUS GATE — RECEIPT_FORMAT.md 7.1 (normative).
|
|
331
|
+
//
|
|
332
|
+
// DECISION: grants KEEP their own status vocabulary (GRANT_* / UNKNOWN_KEY) rather than adopting
|
|
333
|
+
// REVOKED_KEY. A grant is a different artifact class with a different caller branch and a 300s
|
|
334
|
+
// TTL, and it already maps 'retired' to UNKNOWN_KEY rather than to the receipt statuses — so
|
|
335
|
+
// importing two receipt statuses here would give this caller a second vocabulary to learn for no
|
|
336
|
+
// decision it can act on differently. What is NOT optional is the VERDICT: a revoked key must
|
|
337
|
+
// never yield a valid grant, on any timestamp. The distinction survives in `reason`, which is
|
|
338
|
+
// where this verifier already carries its detail.
|
|
339
|
+
if (entry.status === 'revoked') {
|
|
340
|
+
const at = entry.compromised_at;
|
|
341
|
+
const boundary = typeof at === 'string' && at ? Date.parse(at) : NaN;
|
|
342
|
+
// iat is epoch SECONDS in this envelope; ts (when present) is ISO. Neither is guaranteed
|
|
343
|
+
// well-formed on a hostile token, so both are parsed defensively -- a throw here would turn a
|
|
344
|
+
// revoked-key rejection into a crash, which is a worse failure than the one being fixed.
|
|
345
|
+
// MEASURED, not assumed: iat in cr.exec.v1 is an ISO STRING (e.g. 2026-08-23T12:00:00Z).
|
|
346
|
+
// An earlier draft treated it as epoch seconds, which parsed to NaN and silently downgraded
|
|
347
|
+
// every decidable revocation to UNDECIDABLE -- the rule would have looked implemented and
|
|
348
|
+
// never decided. Numeric epoch is still accepted in case an older envelope carries one.
|
|
349
|
+
const rawIat = payload.iat;
|
|
350
|
+
const issued = typeof rawIat === "string" ? Date.parse(rawIat)
|
|
351
|
+
: (Number.isFinite(Number(rawIat)) ? Number(rawIat) * 1000 : Date.parse(payload.ts));
|
|
352
|
+
const decided = Number.isFinite(boundary) && Number.isFinite(issued) && issued >= boundary;
|
|
353
|
+
return {
|
|
354
|
+
valid: false,
|
|
355
|
+
status: 'UNKNOWN_KEY',
|
|
356
|
+
reason: decided ? 'revoked_kid' : 'revoked_kid_undecidable',
|
|
357
|
+
payload,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
if (entry.status != null && entry.status !== 'active') {
|
|
361
|
+
return { valid: false, status: 'UNKNOWN_KEY', reason: 'unknown_key_status', payload };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 5. Ed25519 over crexec.v1|… pipe input (not JCS of the JSON)
|
|
365
|
+
const sig = Buffer.from(segments[1], 'base64url');
|
|
366
|
+
let ok = false;
|
|
367
|
+
try {
|
|
368
|
+
ok = crypto.verify(null, Buffer.from(reconstructSignedInput(payload), 'utf8'), entry.publicKey, sig);
|
|
369
|
+
} catch (_) {
|
|
370
|
+
return { valid: false, status: 'INVALID_SIGNATURE', reason: 'signature_error', payload };
|
|
371
|
+
}
|
|
372
|
+
if (!ok) return { valid: false, status: 'INVALID_SIGNATURE', reason: 'signature_mismatch', payload };
|
|
373
|
+
|
|
374
|
+
// 6. exp/iat + 30s leeway (same CLOCK_SKEW_LEEWAY_MS as receipts)
|
|
375
|
+
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
376
|
+
const expMs = Date.parse(payload.exp);
|
|
377
|
+
const iatMs = Date.parse(payload.iat);
|
|
378
|
+
if (!Number.isFinite(expMs) || !Number.isFinite(iatMs)) {
|
|
379
|
+
return { valid: false, status: 'MALFORMED', reason: 'bad_timestamp', payload };
|
|
380
|
+
}
|
|
381
|
+
const intended = opts.intended && typeof opts.intended === 'object' ? opts.intended : {};
|
|
382
|
+
if (isExpiredAt(expMs, now, intended)) {
|
|
383
|
+
return { valid: false, status: 'GRANT_EXPIRED', reason: 'expired', payload };
|
|
384
|
+
}
|
|
385
|
+
if (isIssuedInFuture(iatMs, now, intended)) {
|
|
386
|
+
return { valid: false, status: 'GRANT_EXPIRED', reason: 'iat_in_future', payload };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// 7. receipt_digest
|
|
390
|
+
if (!payload.receipt_digest || !payload.receipt_digest.startsWith('sha256:')) {
|
|
391
|
+
return { valid: false, status: 'GRANT_UNBOUND', reason: 'missing_receipt_digest', payload };
|
|
392
|
+
}
|
|
393
|
+
if (intended.receipt_token != null && String(intended.receipt_token).length > 0) {
|
|
394
|
+
if (receiptDigest(intended.receipt_token) !== payload.receipt_digest) {
|
|
395
|
+
return { valid: false, status: 'GRANT_UNBOUND', reason: 'receipt_digest_mismatch', payload };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// 8. audience / operation / target_id when supplied
|
|
400
|
+
if (intended.audience != null && intended.audience !== '' && payload.audience !== String(intended.audience)) {
|
|
401
|
+
return { valid: false, status: 'GRANT_WRONG_AUDIENCE', reason: 'audience_mismatch', payload };
|
|
402
|
+
}
|
|
403
|
+
if (intended.operation != null && intended.operation !== '' && payload.operation !== String(intended.operation)) {
|
|
404
|
+
return { valid: false, status: 'GRANT_SCOPE_MISMATCH', reason: 'operation_mismatch', payload };
|
|
405
|
+
}
|
|
406
|
+
if (intended.target_id != null && intended.target_id !== '' && payload.target_id !== String(intended.target_id)) {
|
|
407
|
+
return { valid: false, status: 'GRANT_SCOPE_MISMATCH', reason: 'target_mismatch', payload };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 9. scope_hash recompute from after_payload (or compare supplied scope_hash)
|
|
411
|
+
let expectedScope = null;
|
|
412
|
+
if (intended.scope_hash != null && String(intended.scope_hash).length > 0) {
|
|
413
|
+
expectedScope = String(intended.scope_hash);
|
|
414
|
+
} else if (intended.after_payload != null) {
|
|
415
|
+
expectedScope = computeScopeHash({
|
|
416
|
+
operation: intended.operation != null ? intended.operation : payload.operation,
|
|
417
|
+
target_id: intended.target_id != null ? intended.target_id : payload.target_id,
|
|
418
|
+
after_payload: intended.after_payload,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
if (expectedScope != null && expectedScope !== payload.scope_hash) {
|
|
422
|
+
return { valid: false, status: 'GRANT_SCOPE_MISMATCH', reason: 'scope_hash_mismatch', payload };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// 10.
|
|
426
|
+
return { valid: true, status: 'GRANT_CURRENT', payload };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function parseArgs(argv) {
|
|
430
|
+
const opts = {
|
|
431
|
+
grant: null,
|
|
432
|
+
keyFile: null,
|
|
433
|
+
keysSource: null,
|
|
434
|
+
kid: null,
|
|
435
|
+
fetchUrl: null,
|
|
436
|
+
intendedOperation: null,
|
|
437
|
+
intendedTarget: null,
|
|
438
|
+
intendedAudience: null,
|
|
439
|
+
intendedExecutor: null,
|
|
440
|
+
intendedAdapter: null,
|
|
441
|
+
intendedAfterFile: null,
|
|
442
|
+
intendedScopeHash: null,
|
|
443
|
+
receipt: null,
|
|
444
|
+
help: false,
|
|
445
|
+
};
|
|
446
|
+
for (let i = 0; i < argv.length; i++) {
|
|
447
|
+
const a = argv[i];
|
|
448
|
+
if (a === '--key') opts.keyFile = argv[++i];
|
|
449
|
+
else if (a === '--keys') opts.keysSource = argv[++i];
|
|
450
|
+
else if (a === '--kid') opts.kid = argv[++i];
|
|
451
|
+
else if (a === '--fetch') opts.fetchUrl = argv[++i];
|
|
452
|
+
else if (a === '--intended-operation') opts.intendedOperation = argv[++i];
|
|
453
|
+
else if (a === '--intended-target') opts.intendedTarget = argv[++i];
|
|
454
|
+
else if (a === '--intended-audience') opts.intendedAudience = argv[++i];
|
|
455
|
+
else if (a === '--intended-executor') opts.intendedExecutor = argv[++i];
|
|
456
|
+
else if (a === '--intended-adapter') opts.intendedAdapter = argv[++i];
|
|
457
|
+
else if (a === '--intended-after-file') opts.intendedAfterFile = argv[++i];
|
|
458
|
+
else if (a === '--intended-scope-hash') opts.intendedScopeHash = argv[++i];
|
|
459
|
+
else if (a === '--receipt') opts.receipt = argv[++i];
|
|
460
|
+
else if (a === '-h' || a === '--help') opts.help = true;
|
|
461
|
+
else if (a.startsWith('--')) throw new Error(`unknown flag: ${a}`);
|
|
462
|
+
else if (opts.grant === null) opts.grant = a;
|
|
463
|
+
else throw new Error(`unexpected argument: ${a}`);
|
|
464
|
+
}
|
|
465
|
+
if (opts.keyFile && opts.keysSource) throw new Error('--key and --keys are mutually exclusive');
|
|
466
|
+
if (opts.intendedAfterFile && opts.intendedScopeHash) {
|
|
467
|
+
throw new Error('--intended-after-file and --intended-scope-hash are mutually exclusive');
|
|
468
|
+
}
|
|
469
|
+
return opts;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const USAGE =
|
|
473
|
+
'usage: node verify-grant.js <grant> [--key pub.pem | --keys <url|file>] [--kid <kid>] [--fetch <url>]\n'
|
|
474
|
+
+ ' [--intended-operation X] [--intended-target Y] [--intended-audience Z]\n'
|
|
475
|
+
+ ' [--intended-after-file PATH | --intended-scope-hash sha256:…]\n'
|
|
476
|
+
+ ' [--receipt <token>]\n';
|
|
477
|
+
|
|
478
|
+
function fail(msg) {
|
|
479
|
+
process.stderr.write(`${msg}\n${USAGE}`);
|
|
480
|
+
process.exit(2);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function main() {
|
|
484
|
+
let opts;
|
|
485
|
+
try {
|
|
486
|
+
opts = parseArgs(process.argv.slice(2));
|
|
487
|
+
} catch (e) {
|
|
488
|
+
return fail(e.message);
|
|
489
|
+
}
|
|
490
|
+
if (opts.help) {
|
|
491
|
+
process.stdout.write(USAGE);
|
|
492
|
+
process.exit(2);
|
|
493
|
+
}
|
|
494
|
+
if (!opts.grant || !String(opts.grant).trim()) {
|
|
495
|
+
return fail('no grant provided');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
let ctx;
|
|
499
|
+
try {
|
|
500
|
+
if (opts.keysSource) {
|
|
501
|
+
const keyring = await loadKeyring(opts.keysSource);
|
|
502
|
+
ctx = { keyring, expectedKid: opts.kid };
|
|
503
|
+
} else if (opts.keyFile) {
|
|
504
|
+
const pem = fs.readFileSync(opts.keyFile, 'utf8');
|
|
505
|
+
ctx = { publicKey: keyFromPem(pem), expectedKid: opts.kid };
|
|
506
|
+
} else {
|
|
507
|
+
const info = await fetchKeyInfo(opts.fetchUrl || DEFAULT_FETCH_URL);
|
|
508
|
+
ctx = { publicKey: info.publicKey, expectedKid: opts.kid || info.kid };
|
|
509
|
+
}
|
|
510
|
+
} catch (e) {
|
|
511
|
+
return fail(`could not load public key: ${e.message}`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const intended = {};
|
|
515
|
+
if (opts.intendedOperation != null) intended.operation = opts.intendedOperation;
|
|
516
|
+
if (opts.intendedTarget != null) {
|
|
517
|
+
intended.target_id = opts.intendedTarget;
|
|
518
|
+
intended.target_uri = opts.intendedTarget;
|
|
519
|
+
}
|
|
520
|
+
if (opts.intendedAudience != null) intended.audience = opts.intendedAudience;
|
|
521
|
+
if (opts.intendedExecutor != null) intended.executor_id = opts.intendedExecutor;
|
|
522
|
+
if (opts.intendedAdapter != null) intended.adapter_id = opts.intendedAdapter;
|
|
523
|
+
if (opts.intendedScopeHash != null) intended.scope_hash = opts.intendedScopeHash;
|
|
524
|
+
if (opts.receipt != null) intended.receipt_token = opts.receipt;
|
|
525
|
+
if (opts.intendedAfterFile) {
|
|
526
|
+
try {
|
|
527
|
+
intended.after_payload = fs.readFileSync(opts.intendedAfterFile, 'utf8');
|
|
528
|
+
} catch (e) {
|
|
529
|
+
return fail(`could not read --intended-after-file: ${e.message}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const result = verifyExecutionGrantInner(opts.grant, ctx, { intended });
|
|
534
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
535
|
+
process.exit(result.valid ? 0 : 1);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function verifyExecutionGrant(token, second, third) {
|
|
539
|
+
const { ctx, opts } = split3ary('verifyExecutionGrant', arguments.length, second, third);
|
|
540
|
+
return verifyExecutionGrantInner(token, ctx, opts);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
module.exports = {
|
|
544
|
+
verifyExecutionGrant,
|
|
545
|
+
reconstructSignedInput,
|
|
546
|
+
signingInputV2,
|
|
547
|
+
canonicalizeTargetUri,
|
|
548
|
+
computeScopeHash,
|
|
549
|
+
receiptDigest,
|
|
550
|
+
sha256pref,
|
|
551
|
+
resolveEntry,
|
|
552
|
+
GRANT_VERSION,
|
|
553
|
+
GRANT_VERSION_V2,
|
|
554
|
+
SIGNING_PREFIX,
|
|
555
|
+
SIGNING_PREFIX_V2,
|
|
556
|
+
SIGNED_FIELDS,
|
|
557
|
+
V1_OPTIONAL_SIGNED_FIELDS,
|
|
558
|
+
CLOCK_SKEW_LEEWAY_MS,
|
|
559
|
+
isIssuedInFuture,
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
if (require.main === module) {
|
|
563
|
+
main().catch((e) => fail(e.message));
|
|
564
|
+
}
|
|
@@ -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 };
|