@coderifts/capability-express 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CodeRifts
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # @coderifts/capability-express
2
+
3
+ Express middleware that verifies `cr.exec.v1` execution grants **offline**, against a public key you
4
+ pin. No network call on the request path.
5
+
6
+ Zero runtime dependencies — Node builtins only. Express is a peer of your app, not of this package.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @coderifts/capability-express
12
+ ```
13
+
14
+ ## Quickstart
15
+
16
+ ```js
17
+ const express = require('express');
18
+ const { requireExecutionGrant, captureRawBody } = require('@coderifts/capability-express');
19
+
20
+ const app = express();
21
+
22
+ const guard = requireExecutionGrant({
23
+ // One of these two. keysFile is a registry document: { keys: [{ kid, public_key_pem, status }] }
24
+ keysFile: '/etc/coderifts/keys/executor-keys.json',
25
+ // publicKeyPem: fs.readFileSync('executor-public.pem', 'utf8'),
26
+
27
+ // Which operation each route represents. A route that is not in this map is REFUSED:
28
+ // an unmapped mutation is not an authorized mutation.
29
+ operationMap: {
30
+ 'POST /articles': 'publish',
31
+ 'DELETE /articles/:id': 'deploy',
32
+ },
33
+ });
34
+
35
+ // captureRawBody must run BEFORE the guard: the raw request body IS the after-payload the
36
+ // grant is bound to, and a re-serialized body is different bytes.
37
+ app.post('/articles', captureRawBody(), guard, (req, res) => {
38
+ res.status(201).json({ created: true, jti: req.coderifts.payload.jti });
39
+ });
40
+ ```
41
+
42
+ A request without a valid grant gets `403` and never reaches your handler.
43
+
44
+ ## What a refusal looks like
45
+
46
+ ```json
47
+ {
48
+ "error": "execution_grant_required",
49
+ "status": "MALFORMED",
50
+ "reason": "missing_grant_header",
51
+ "remedy": {
52
+ "error": "CODERIFTS_GRANT_REQUIRED",
53
+ "target": "POST /articles",
54
+ "fingerprint": "sha256:…",
55
+ "action_required": { "tool": "preflight_change_set", "mode": "authorize", "args_shape": { } },
56
+ "does_not_promise": "a grant does not guarantee execution (CAS may still fail)"
57
+ }
58
+ }
59
+ ```
60
+
61
+ `remedy` is present only when the refusal maps to one of three grant error classes
62
+ (`CODERIFTS_GRANT_REQUIRED` / `_INVALID` / `_MISMATCH`). A refusal outside them — an unmapped route,
63
+ for instance — carries no remedy, because no grant the caller could obtain would change the answer.
64
+
65
+ ## Options
66
+
67
+ | option | required | meaning |
68
+ |---|---|---|
69
+ | `keysFile` | one of | Path to a registry document `{ keys: [{ kid, public_key_pem, status }] }` |
70
+ | `publicKeyPem` | one of | A single PEM, for the air-gapped case |
71
+ | `kid` | no | Select a specific key from `keysFile` |
72
+ | `operationMap` | yes | `'<METHOD> <route path>' -> operation`. Unmapped routes are refused |
73
+ | `audience` | no | Grants must be bound to this audience when set |
74
+ | `targetId` | no | `(req) => string` — what the grant is bound to. Defaults to `req.params.id` |
75
+ | `header` | no | Grant header name. Default `coderifts-execution-grant` |
76
+ | `now` | no | Clock injection, for tests |
77
+
78
+ Key material is resolved **once at construction**. There is no request-time key I/O.
79
+
80
+ ## Exports
81
+
82
+ `requireExecutionGrant`, `captureRawBody`, `verifyExecutionGrant`, `computeScopeHash`,
83
+ `DEFAULT_HEADER`.
84
+
85
+ ## What this does and does not prove
86
+
87
+ It proves that a request carried a grant that verifies against the key you pinned, is bound to this
88
+ operation and target, and covers these exact after-payload bytes.
89
+
90
+ It does not prove the write happened, that it happened atomically, or that anything downstream
91
+ honoured the decision. A grant is permission to attempt, not evidence of a result.
92
+
93
+ ## License
94
+
95
+ MIT
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@coderifts/capability-express",
3
+ "version": "1.0.0",
4
+ "description": "Express middleware that verifies cr.exec.v1 execution grants OFFLINE against a pinned Ed25519 public key.",
5
+ "license": "MIT",
6
+ "main": "src/index.js",
7
+ "type": "commonjs",
8
+ "engines": {
9
+ "node": ">=20"
10
+ },
11
+ "scripts": {
12
+ "test": "node --test \"test/*.test.js\"",
13
+ "prepublishOnly": "npm test"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/coderifts/capability-demo.git",
23
+ "directory": "packages/middleware"
24
+ },
25
+ "homepage": "https://github.com/coderifts/capability-demo/tree/main/packages/middleware",
26
+ "bugs": {
27
+ "url": "https://github.com/coderifts/capability-demo/issues"
28
+ },
29
+ "keywords": [
30
+ "express",
31
+ "middleware",
32
+ "ed25519",
33
+ "authorization",
34
+ "offline-verification"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ }
39
+ }
package/src/attest.js ADDED
@@ -0,0 +1,292 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * cr.exec.attest.v1 — the executor's signed commit statement.
5
+ *
6
+ * Issuance AND offline verification. Mirrors the reference kernel
7
+ * (coderifts-app src/verdict-core/execution-attestation.js) and the algorithm in
8
+ * docs/cr-exec-attest-v1.md. Statuses and reason strings are IDENTICAL to that family —
9
+ * this file adds none of its own.
10
+ *
11
+ * The executor key is CUSTOMER-HELD. CodeRifts never receives it. Verification reads a
12
+ * customer-pinned registry document (same JSON shape as .well-known/coderifts-keys.json),
13
+ * passed in as opts.registry — never fetched.
14
+ *
15
+ * Honesty: a valid attestation proves *a holder of the executor key asserts this commit*.
16
+ * It does not prove the executor's code is unmodified (deploy attestation is out of scope),
17
+ * that a human saw anything, or that the grant is still currently authorized.
18
+ */
19
+
20
+ const crypto = require('node:crypto');
21
+
22
+ const ATTEST_VERSION = 'cr.exec.attest.v1';
23
+ const ENVELOPE_TAG = 'cr.exec.attest.v1';
24
+ const SIGNING_PREFIX = 'crexecattest.v1';
25
+ const CLOCK_SKEW_LEEWAY_MS = 30_000;
26
+
27
+ const REQUIRED_FIELDS = Object.freeze([
28
+ 'executor_kid', 'grant_jti', 'receipt_digest', 'scope_hash', 'committed_at',
29
+ ]);
30
+ const OPTIONAL_STRINGS = Object.freeze(['state_nonce', 'result_digest']);
31
+ const ALLOWED_KEYS = new Set(['v', ...REQUIRED_FIELDS, ...OPTIONAL_STRINGS, 'meta']);
32
+
33
+ const STATUSES = Object.freeze({
34
+ ATTEST_VALID: 'ATTEST_VALID',
35
+ ATTEST_INVALID_SIGNATURE: 'ATTEST_INVALID_SIGNATURE',
36
+ ATTEST_UNKNOWN_KEY: 'ATTEST_UNKNOWN_KEY',
37
+ ATTEST_RETIRED_KEY_VALID_AT_ISSUE: 'ATTEST_RETIRED_KEY_VALID_AT_ISSUE',
38
+ ATTEST_MALFORMED: 'ATTEST_MALFORMED',
39
+ ATTEST_UNBOUND: 'ATTEST_UNBOUND',
40
+ });
41
+
42
+ const scalar = (v) => (v == null ? '' : String(v));
43
+ const b64url = (b) => Buffer.from(b).toString('base64url');
44
+ const sha256hex = (s) => crypto.createHash('sha256').update(String(s), 'utf8').digest('hex');
45
+
46
+ /** JSON.stringify with keys sorted — appended to the signing input only when meta is present. */
47
+ function canonicalMeta(meta) {
48
+ const out = {};
49
+ for (const k of Object.keys(meta).sort()) out[k] = meta[k];
50
+ return JSON.stringify(out);
51
+ }
52
+
53
+ /** meta is advisory and bounded: <=8 keys, key <=64 chars, string values <=256 chars. */
54
+ function metaOk(meta) {
55
+ if (meta == null) return true;
56
+ if (typeof meta !== 'object' || Array.isArray(meta)) return false;
57
+ const keys = Object.keys(meta);
58
+ if (keys.length > 8) return false;
59
+ for (const k of keys) {
60
+ if (k.length > 64) return false;
61
+ const v = meta[k];
62
+ const t = typeof v;
63
+ if (t !== 'string' && t !== 'number' && t !== 'boolean') return false;
64
+ if (t === 'string' && v.length > 256) return false;
65
+ }
66
+ return true;
67
+ }
68
+
69
+ /**
70
+ * Signing input (pipe-delimited, NOT JCS):
71
+ * crexecattest.v1|kid|grant_jti|receipt_digest|scope_hash|state_nonce|committed_at|result_digest[|meta]
72
+ * state_nonce and result_digest are FIXED SLOTS — empty strings when absent.
73
+ */
74
+ function signingInput(body) {
75
+ const parts = [
76
+ SIGNING_PREFIX,
77
+ scalar(body.executor_kid),
78
+ scalar(body.grant_jti),
79
+ scalar(body.receipt_digest),
80
+ scalar(body.scope_hash),
81
+ body.state_nonce != null && String(body.state_nonce).length > 0 ? String(body.state_nonce) : '',
82
+ scalar(body.committed_at),
83
+ body.result_digest != null && String(body.result_digest).length > 0 ? String(body.result_digest) : '',
84
+ ];
85
+ if (body.meta && typeof body.meta === 'object') parts.push(canonicalMeta(body.meta));
86
+ return parts.join('|');
87
+ }
88
+
89
+ function fieldHasDelimiter(body) {
90
+ for (const k of Object.keys(body)) {
91
+ if (k.includes('|')) return true;
92
+ const v = body[k];
93
+ if (typeof v === 'string' && v.includes('|')) return true;
94
+ if (k === 'meta' && v && typeof v === 'object') {
95
+ for (const mk of Object.keys(v)) {
96
+ if (mk.includes('|')) return true;
97
+ if (typeof v[mk] === 'string' && v[mk].includes('|')) return true;
98
+ }
99
+ }
100
+ }
101
+ return false;
102
+ }
103
+
104
+ const fail = (status, reason, payload) => ({ valid: false, status, reason, payload });
105
+ const okStatus = (status, payload) => ({ valid: true, status, reason: null, payload });
106
+
107
+ function toUtcSeconds(d) { return d.toISOString().replace(/\.\d{3}Z$/, 'Z'); }
108
+
109
+ /**
110
+ * Sign an attestation with a CALLER-SUPPLIED executor private key.
111
+ * Never touches any CodeRifts signer.
112
+ *
113
+ * @param {object} a
114
+ * @param {import('node:crypto').KeyObject|string} a.privateKey executor key (customer-held)
115
+ * @param {string} a.executor_kid
116
+ * @param {string} a.grant_jti
117
+ * @param {string} a.receipt_digest
118
+ * @param {string} a.scope_hash
119
+ * @param {string} [a.state_nonce] copied from the grant iff it carried one (ATOMIC)
120
+ * @param {string} [a.result_digest] executor-defined bytes; NOT a CodeRifts fingerprint
121
+ * @param {object} [a.meta]
122
+ * @param {Date|number} [a.now]
123
+ * @returns {string} cr.exec.attest.v1|kid|payload_b64|sig_b64
124
+ */
125
+ function issueExecutionAttestation(a) {
126
+ const key = typeof a.privateKey === 'string' ? crypto.createPrivateKey(a.privateKey) : a.privateKey;
127
+ const now = a.now != null ? new Date(a.now) : new Date();
128
+ const payload = { v: ATTEST_VERSION, executor_kid: String(a.executor_kid) };
129
+ payload.grant_jti = String(a.grant_jti);
130
+ payload.receipt_digest = String(a.receipt_digest);
131
+ payload.scope_hash = String(a.scope_hash);
132
+ if (a.state_nonce != null && String(a.state_nonce).length > 0) payload.state_nonce = String(a.state_nonce);
133
+ payload.committed_at = toUtcSeconds(now);
134
+ if (a.result_digest != null && String(a.result_digest).length > 0) payload.result_digest = String(a.result_digest);
135
+ if (a.meta && typeof a.meta === 'object') payload.meta = a.meta;
136
+
137
+ if (fieldHasDelimiter(payload)) throw new Error('issueExecutionAttestation: | in a signed field');
138
+ if (!metaOk(payload.meta)) throw new Error('issueExecutionAttestation: meta exceeds bounds');
139
+
140
+ const sig = crypto.sign(null, Buffer.from(signingInput(payload), 'utf8'), key);
141
+ return [ENVELOPE_TAG, payload.executor_kid, b64url(Buffer.from(JSON.stringify(payload), 'utf8')), b64url(sig)].join('|');
142
+ }
143
+
144
+ /** Steps 1-2. Envelope is 4 pipe segments; kid lives in the prefix so a registry can be consulted first. */
145
+ function parseAttestToken(token) {
146
+ if (typeof token !== 'string' || token.length === 0) {
147
+ return { ok: false, status: STATUSES.ATTEST_MALFORMED, reason: 'malformed_structure' };
148
+ }
149
+ const seg = token.split('|');
150
+ if (seg.length !== 4 || seg.some((x) => !x) || seg[0] !== ENVELOPE_TAG) {
151
+ return { ok: false, status: STATUSES.ATTEST_MALFORMED, reason: 'malformed_structure' };
152
+ }
153
+ let payload;
154
+ try {
155
+ payload = JSON.parse(Buffer.from(seg[2], 'base64url').toString('utf8'));
156
+ } catch (_) {
157
+ return { ok: false, status: STATUSES.ATTEST_MALFORMED, reason: 'bad_json' };
158
+ }
159
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
160
+ return { ok: false, status: STATUSES.ATTEST_MALFORMED, reason: 'bad_json' };
161
+ }
162
+ if (payload.v !== ATTEST_VERSION) {
163
+ return { ok: false, status: STATUSES.ATTEST_MALFORMED, reason: 'unsupported_version', payload };
164
+ }
165
+ for (const k of REQUIRED_FIELDS) {
166
+ if (typeof payload[k] !== 'string' || payload[k].length === 0) {
167
+ return { ok: false, status: STATUSES.ATTEST_MALFORMED, reason: 'missing_field', payload };
168
+ }
169
+ }
170
+ for (const k of OPTIONAL_STRINGS) {
171
+ if (payload[k] !== undefined && typeof payload[k] !== 'string') {
172
+ return { ok: false, status: STATUSES.ATTEST_MALFORMED, reason: 'bad_optional', payload };
173
+ }
174
+ }
175
+ if (payload.executor_kid !== seg[1]) {
176
+ return { ok: false, status: STATUSES.ATTEST_MALFORMED, reason: 'kid_mismatch', payload };
177
+ }
178
+ for (const k of Object.keys(payload)) {
179
+ if (!ALLOWED_KEYS.has(k)) {
180
+ return { ok: false, status: STATUSES.ATTEST_MALFORMED, reason: 'unknown_field', payload };
181
+ }
182
+ }
183
+ if (!metaOk(payload.meta)) {
184
+ return { ok: false, status: STATUSES.ATTEST_MALFORMED, reason: 'meta_bounds', payload };
185
+ }
186
+ if (payload.result_digest !== undefined && !/^sha256:[0-9a-f]{64}$/.test(payload.result_digest)) {
187
+ return { ok: false, status: STATUSES.ATTEST_MALFORMED, reason: 'bad_result_digest', payload };
188
+ }
189
+ if (!/^sha256:[0-9a-f]{64}$/.test(payload.receipt_digest)) {
190
+ return { ok: false, status: STATUSES.ATTEST_MALFORMED, reason: 'bad_receipt_digest', payload };
191
+ }
192
+ return { ok: true, payload, sig: seg[3] };
193
+ }
194
+
195
+ function resolveExecutorKey(registry, kid) {
196
+ const keys = registry && Array.isArray(registry.keys) ? registry.keys : null;
197
+ if (!keys) return null;
198
+ const e = keys.find((k) => k && k.kid === kid);
199
+ if (!e || !e.public_key_pem) return null;
200
+ return {
201
+ publicKey: crypto.createPublicKey(e.public_key_pem),
202
+ status: e.status || 'active',
203
+ valid_from: e.valid_from || null,
204
+ retired_at: e.retired_at || null,
205
+ };
206
+ }
207
+
208
+ const nonceOf = (o) => (o && o.state_nonce != null && String(o.state_nonce).length > 0 ? String(o.state_nonce) : '');
209
+
210
+ /**
211
+ * Offline verification, spec steps 1-8.
212
+ * @param {string} token
213
+ * @param {{registry: object, intended?: {grant?: object, receipt_digest?: string}, now?: number}} opts
214
+ */
215
+ function verifyExecutionAttestation(token, opts = {}) {
216
+ const parsed = parseAttestToken(token);
217
+ if (!parsed.ok) return fail(parsed.status, parsed.reason, parsed.payload);
218
+ const payload = parsed.payload;
219
+
220
+ if (fieldHasDelimiter(payload)) {
221
+ return fail(STATUSES.ATTEST_INVALID_SIGNATURE, 'delimiter_in_field', payload);
222
+ }
223
+
224
+ const entry = resolveExecutorKey(opts.registry, payload.executor_kid);
225
+ if (!entry) return fail(STATUSES.ATTEST_UNKNOWN_KEY, 'unknown_kid', payload);
226
+
227
+ let ok = false;
228
+ try {
229
+ ok = crypto.verify(null, Buffer.from(signingInput(payload), 'utf8'), entry.publicKey,
230
+ Buffer.from(parsed.sig, 'base64url'));
231
+ } catch (_) {
232
+ return fail(STATUSES.ATTEST_INVALID_SIGNATURE, 'signature_error', payload);
233
+ }
234
+ if (!ok) return fail(STATUSES.ATTEST_INVALID_SIGNATURE, 'signature_mismatch', payload);
235
+
236
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
237
+ const committedMs = Date.parse(payload.committed_at);
238
+ if (!Number.isFinite(committedMs)) return fail(STATUSES.ATTEST_MALFORMED, 'bad_timestamp', payload);
239
+ if (committedMs > now + CLOCK_SKEW_LEEWAY_MS) {
240
+ return fail(STATUSES.ATTEST_MALFORMED, 'committed_at_in_future', payload);
241
+ }
242
+
243
+ // Step 6 — HISTORICAL retired-key rule. Unlike a GRANT (live permission, where a retired
244
+ // kid is always UNKNOWN_KEY), an attestation is a statement about a PAST commit, so a
245
+ // retired key still proves it if committed_at fell inside [valid_from, retired_at).
246
+ let retiredHistorical = false;
247
+ if (entry.status === 'retired') {
248
+ const from = entry.valid_from ? Date.parse(entry.valid_from) : null;
249
+ const until = entry.retired_at ? Date.parse(entry.retired_at) : null;
250
+ const inWindow = Number.isFinite(until) && committedMs < until
251
+ && (!Number.isFinite(from) || committedMs >= from);
252
+ if (!inWindow) return fail(STATUSES.ATTEST_UNKNOWN_KEY, 'retired_key_outside_window', payload);
253
+ retiredHistorical = true;
254
+ }
255
+
256
+ // Step 7 — cross-checks only when the caller holds the grant. Field equality is the bind;
257
+ // GRANT_CURRENT is deliberately NOT required (a grant may expire after the commit).
258
+ const intended = opts.intended && typeof opts.intended === 'object' ? opts.intended : null;
259
+ if (intended) {
260
+ const g = intended.grant;
261
+ if (g !== undefined) {
262
+ if (!g || typeof g !== 'object') return fail(STATUSES.ATTEST_UNBOUND, 'grant_unparseable', payload);
263
+ if (String(g.jti || '') !== payload.grant_jti) {
264
+ return fail(STATUSES.ATTEST_UNBOUND, 'grant_jti_mismatch', payload);
265
+ }
266
+ if (String(g.scope_hash || '') !== payload.scope_hash) {
267
+ return fail(STATUSES.ATTEST_UNBOUND, 'scope_hash_mismatch', payload);
268
+ }
269
+ if (nonceOf(g) !== nonceOf(payload)) {
270
+ return fail(STATUSES.ATTEST_UNBOUND, 'state_nonce_mismatch', payload);
271
+ }
272
+ if (g.receipt_digest && String(g.receipt_digest) !== payload.receipt_digest) {
273
+ return fail(STATUSES.ATTEST_UNBOUND, 'receipt_digest_mismatch', payload);
274
+ }
275
+ }
276
+ if (intended.receipt_digest != null && String(intended.receipt_digest).length > 0
277
+ && String(intended.receipt_digest) !== payload.receipt_digest) {
278
+ return fail(STATUSES.ATTEST_UNBOUND, 'receipt_digest_mismatch', payload);
279
+ }
280
+ }
281
+
282
+ return retiredHistorical
283
+ ? okStatus(STATUSES.ATTEST_RETIRED_KEY_VALID_AT_ISSUE, payload)
284
+ : okStatus(STATUSES.ATTEST_VALID, payload);
285
+ }
286
+
287
+ module.exports = {
288
+ ATTEST_VERSION, ENVELOPE_TAG, SIGNING_PREFIX, STATUSES, CLOCK_SKEW_LEEWAY_MS,
289
+ REQUIRED_FIELDS, OPTIONAL_STRINGS,
290
+ signingInput, canonicalMeta, metaOk, sha256hex,
291
+ issueExecutionAttestation, parseAttestToken, verifyExecutionAttestation, resolveExecutorKey,
292
+ };
@@ -0,0 +1,131 @@
1
+ /**
2
+ * deny-remedy.v1 — the machine-readable next step attached to a refusal.
3
+ *
4
+ * CANONICAL SCHEMA: capability-demo/docs/deny-remedy.v1.json
5
+ * sha256 3f51c5afd1708a9185075a4f19f6386ea7d63ad39b0104e31f0e4e887b6e167f
6
+ *
7
+ * This builder is copied byte-for-byte into every repo that emits a deny, and
8
+ * must stay equivalent to that schema. It is a copy rather than a shared
9
+ * package on purpose: these repos are installed independently and a shared
10
+ * dependency would make one of them unable to refuse a request because another
11
+ * failed to resolve.
12
+ *
13
+ * WHERE IT SITS IN THE FLOW. The remedy is attached AFTER a verdict is reached,
14
+ * never inside verification. Nothing here reads a key, checks a signature, or
15
+ * can change allow into deny. A surface that emits a remedy has already refused.
16
+ *
17
+ * WHAT IT REFUSES TO EMIT. Three error classes exist and no more. A refusal
18
+ * whose reason does not map to one of them gets NO remedy — an unmapped reason
19
+ * is a refusal we cannot yet describe as a next step, and inventing a fourth
20
+ * class, or defaulting to the nearest one, would send a caller to a step that
21
+ * does not address why they were refused.
22
+ */
23
+ 'use strict';
24
+
25
+ /** The closed set from the schema. */
26
+ const DENY_ERROR = Object.freeze({
27
+ GRANT_REQUIRED: 'CODERIFTS_GRANT_REQUIRED',
28
+ GRANT_INVALID: 'CODERIFTS_GRANT_INVALID',
29
+ GRANT_MISMATCH: 'CODERIFTS_GRANT_MISMATCH',
30
+ });
31
+
32
+ /** Carried verbatim from the schema so it cannot be softened in rendering. */
33
+ const DOES_NOT_PROMISE = 'a grant does not guarantee execution (CAS may still fail)';
34
+
35
+ const ARGS_SHAPE = Object.freeze({
36
+ artifacts: 'Array<{ id, type, before, after }> — the change set being authorized',
37
+ context: '{ operation, environment?, repository?, branch?, pull_request? } — operation is required for authorize',
38
+ });
39
+
40
+ const FINGERPRINT_RE = /^sha256:[0-9a-f]{64}$/;
41
+
42
+ /**
43
+ * Build the remedy object, or return null when this refusal has no remedy.
44
+ *
45
+ * @param {string} o.error one of DENY_ERROR; anything else returns null
46
+ * @param {string} [o.target] the surface's own addressing for what was refused
47
+ * @param {string} [o.fingerprint] the change-set fingerprint the deny was evaluated
48
+ * against, when the surface has one
49
+ * @param {object} [o.observed] free-form, for an operator reading a log
50
+ * @returns {object|null}
51
+ */
52
+ function buildDenyRemedy({ error, target = null, fingerprint = null, observed = null } = {}) {
53
+ if (!Object.values(DENY_ERROR).includes(error)) return null;
54
+
55
+ const remedy = {
56
+ error,
57
+ // Null, never a wildcard: a surface that cannot name what it refused must
58
+ // not emit a value a reader could take for "everything".
59
+ target: typeof target === 'string' && target.length > 0 ? target : null,
60
+ // Only a well-formed fingerprint. A malformed one is dropped rather than
61
+ // passed through, because a caller comparing it would get a false mismatch.
62
+ fingerprint: typeof fingerprint === 'string' && FINGERPRINT_RE.test(fingerprint)
63
+ ? fingerprint
64
+ : null,
65
+ action_required: {
66
+ tool: 'preflight_change_set',
67
+ mode: 'authorize',
68
+ args_shape: { ...ARGS_SHAPE },
69
+ },
70
+ does_not_promise: DOES_NOT_PROMISE,
71
+ };
72
+ if (observed && typeof observed === 'object') remedy.observed = observed;
73
+ return remedy;
74
+ }
75
+
76
+ /**
77
+ * Map a surface's own refusal reason to an error class.
78
+ *
79
+ * The mapping is explicit and closed. An unlisted reason returns null, which
80
+ * means no remedy — see the note at the top of this file.
81
+ */
82
+ function denyErrorForReason(reason) {
83
+ switch (String(reason || '').toLowerCase()) {
84
+ // Nothing was presented.
85
+ case 'receipt_missing':
86
+ case 'missing_receipt':
87
+ case 'missing_grant_header':
88
+ case 'decision_missing':
89
+ case 'missing_decision_result':
90
+ case 'no_preflight_response':
91
+ case 'grant_not_supplied':
92
+ return DENY_ERROR.GRANT_REQUIRED;
93
+
94
+ // Something was presented and did not hold up.
95
+ case 'receipt_invalid':
96
+ case 'receipt_unverified':
97
+ case 'invalid_signature':
98
+ case 'unknown_key':
99
+ case 'unknown_kid':
100
+ case 'dsse_malformed':
101
+ case 'dsse_unsupported':
102
+ case 'dsse_predicate_mismatch':
103
+ case 'malformed':
104
+ case 'grant_malformed':
105
+ case 'grant_unverified':
106
+ case 'grant_expired':
107
+ return DENY_ERROR.GRANT_INVALID;
108
+
109
+ // It held up, but it is not about this request.
110
+ case 'scope_mismatch':
111
+ case 'target_mismatch':
112
+ case 'operation_mismatch':
113
+ case 'grant_scope_mismatch':
114
+ case 'mode_mismatch':
115
+ case 'repo_mismatch':
116
+ case 'base_mismatch':
117
+ case 'head_mismatch':
118
+ case 'grant_does_not_cover_path':
119
+ case 'grant_bound_elsewhere':
120
+ case 'receipt_envelope_mismatch':
121
+ case 'artifact_mismatch':
122
+ case 'grant_unbound':
123
+ case 'grant_wrong_audience':
124
+ return DENY_ERROR.GRANT_MISMATCH;
125
+
126
+ default:
127
+ return null;
128
+ }
129
+ }
130
+
131
+ module.exports = { buildDenyRemedy, denyErrorForReason, DENY_ERROR, DOES_NOT_PROMISE, ARGS_SHAPE };
package/src/index.js ADDED
@@ -0,0 +1,216 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @coderifts/capability-express — requireExecutionGrant()
5
+ *
6
+ * An Express middleware that refuses a mutation unless the request carries a
7
+ * cr.exec.v1 execution grant that verifies OFFLINE against a PINNED Ed25519
8
+ * public key AND whose signed scope covers exactly this request.
9
+ *
10
+ * OFFLINE IS THE POINT. This middleware performs no network I/O at request time:
11
+ * no key fetch, no CodeRifts call, no registry lookup. Unplugging the network does
12
+ * not change a single verdict. The key is supplied once at construction, from a PEM
13
+ * string or a keys file read at startup.
14
+ *
15
+ * HEADER NAME. docs/cr-exec-v1.md specifies the token format and the verification
16
+ * algorithm but is SILENT on HTTP transport. This middleware defines
17
+ * CodeRifts-Execution-Grant: <token>
18
+ * as the REFERENCE CONVENTION for cr.exec.v1 over HTTP. It is established here,
19
+ * not measured from the spec. Override with the `header` option.
20
+ *
21
+ * BINDING RULE (this middleware's contract; see README § Binding rule):
22
+ * after_payload := the RAW request body bytes, exactly as received
23
+ * The request body IS the after-payload. Byte-for-byte: a 1-byte change to the body
24
+ * produces a different scope_hash and a 403 GRANT_SCOPE_MISMATCH. Reordering JSON
25
+ * keys is a byte change and therefore also fails — the grant binds bytes, not meaning.
26
+ *
27
+ * SCOPE. Enforcement is per-adapter. Mounting this on a route proves something about
28
+ * that route only. It makes no claim about any other path into the same data.
29
+ */
30
+
31
+ const crypto = require('node:crypto');
32
+ const fs = require('node:fs');
33
+ const { verifyExecutionGrant, computeScopeHash } = require('./verify-grant');
34
+ const { buildDenyRemedy, denyErrorForReason } = require('./deny-remedy.js');
35
+
36
+ /** Reference convention established by this package (spec is silent on transport). */
37
+ const DEFAULT_HEADER = 'coderifts-execution-grant';
38
+
39
+ /**
40
+ * Express body parser that ALSO retains the raw bytes.
41
+ *
42
+ * The binding rule hashes what arrived on the wire, so the raw buffer must be kept
43
+ * before any JSON round-trip. `express.json({ verify })` would also work; this keeps
44
+ * the demo dependency-light and makes the captured bytes explicit.
45
+ *
46
+ * Sets `req.rawBody` (Buffer) and, for JSON content, `req.body`.
47
+ * @param {{ limit?: number }} [opts]
48
+ */
49
+ function captureRawBody(opts = {}) {
50
+ const limit = Number.isFinite(opts.limit) ? opts.limit : 1_048_576;
51
+ return function rawBodyMiddleware(req, res, next) {
52
+ const chunks = [];
53
+ let size = 0;
54
+ let done = false;
55
+ const finish = (err) => {
56
+ if (done) return;
57
+ done = true;
58
+ if (err) return next(err);
59
+ req.rawBody = Buffer.concat(chunks);
60
+ const ct = String(req.headers['content-type'] || '');
61
+ if (req.rawBody.length && ct.includes('application/json')) {
62
+ try { req.body = JSON.parse(req.rawBody.toString('utf8')); } catch (_) { req.body = undefined; }
63
+ } else if (!req.rawBody.length) {
64
+ req.body = undefined;
65
+ }
66
+ next();
67
+ };
68
+ req.on('data', (c) => {
69
+ size += c.length;
70
+ if (size > limit) {
71
+ res.status(413).json({ error: 'payload_too_large', status: 'MALFORMED', reason: 'body_limit' });
72
+ done = true;
73
+ req.destroy();
74
+ return;
75
+ }
76
+ chunks.push(c);
77
+ });
78
+ req.on('end', () => finish());
79
+ req.on('error', finish);
80
+ };
81
+ }
82
+
83
+ function loadPublicKey({ publicKeyPem, keysFile, kid }) {
84
+ if (publicKeyPem) {
85
+ return { publicKey: crypto.createPublicKey(publicKeyPem), kid: kid || null, status: 'active' };
86
+ }
87
+ if (keysFile) {
88
+ // Same registry SHAPE as .well-known/coderifts-keys.json, read from disk at
89
+ // STARTUP. A URL is intentionally not accepted: request-time fetching would
90
+ // break the offline guarantee this middleware exists to demonstrate.
91
+ const doc = JSON.parse(fs.readFileSync(keysFile, 'utf8'));
92
+ const keys = doc && Array.isArray(doc.keys) ? doc.keys : null;
93
+ if (!keys || keys.length === 0) throw new Error(`requireExecutionGrant: no keys[] in ${keysFile}`);
94
+ const entry = kid ? keys.find((k) => k.kid === kid) : keys.find((k) => (k.status || 'active') === 'active');
95
+ if (!entry) throw new Error(`requireExecutionGrant: no usable key in ${keysFile}${kid ? ` for kid ${kid}` : ''}`);
96
+ if (!entry.public_key_pem) throw new Error(`requireExecutionGrant: entry ${entry.kid} has no public_key_pem`);
97
+ return {
98
+ publicKey: crypto.createPublicKey(entry.public_key_pem),
99
+ kid: entry.kid || null,
100
+ status: entry.status || 'active',
101
+ };
102
+ }
103
+ throw new Error('requireExecutionGrant: publicKeyPem or keysFile is required');
104
+ }
105
+
106
+ /**
107
+ * Build the guard.
108
+ *
109
+ * @param {object} options
110
+ * @param {string} [options.publicKeyPem] pinned Ed25519 SPKI PEM (this or keysFile)
111
+ * @param {string} [options.keysFile] path to a coderifts-keys.json-shaped file (read at startup)
112
+ * @param {string} [options.kid] require this exact kid
113
+ * @param {string} [options.audience] required audience; '' / omitted = unbound (not checked)
114
+ * @param {Record<string,string>} [options.operationMap]
115
+ * 'METHOD /route/path' -> operation, e.g. { 'POST /articles': 'publish' }.
116
+ * Keys use the Express route pattern (req.route.path), not the concrete URL.
117
+ * A request with no mapping is REFUSED (fail-closed), never allowed through.
118
+ * @param {(req: import('express').Request) => string} [options.targetId]
119
+ * Resolve target_id. Default: req.params.id ?? '' — configure for other shapes.
120
+ * @param {string} [options.header] header name; default CodeRifts-Execution-Grant
121
+ * @param {() => number} [options.now] clock injection (tests)
122
+ * @returns {import('express').RequestHandler}
123
+ */
124
+ function requireExecutionGrant(options = {}) {
125
+ const {
126
+ publicKeyPem, keysFile, kid, audience,
127
+ operationMap = {},
128
+ targetId = (req) => (req.params && req.params.id != null ? String(req.params.id) : ''),
129
+ header = DEFAULT_HEADER,
130
+ now,
131
+ } = options;
132
+
133
+ // Resolved ONCE at construction. No request-time key I/O, ever.
134
+ const pinned = loadPublicKey({ publicKeyPem, keysFile, kid });
135
+ const headerName = String(header).toLowerCase();
136
+
137
+ // The 403 body, plus the next step when the caller can act on one.
138
+ //
139
+ // `error`, `status` and `reason` are byte-identical to what this returned
140
+ // before the remedy existed; the remedy is an additive key. The error class is
141
+ // decided by the CALLER of deny, not derived here, because one refusal on this
142
+ // surface — an unmapped route — is not something a grant can fix, and mapping
143
+ // it by its status would send the caller to mint a grant that still gets 403.
144
+ const deny = (res, status, reason, remedy = null) => res.status(403).json({
145
+ error: 'execution_grant_required', status, reason, ...(remedy ? { remedy } : {}),
146
+ });
147
+
148
+ // The request line is this surface's own addressing for what it refused.
149
+ const targetOf = (req, routePath) => `${req.method} ${routePath}`;
150
+
151
+ return function executionGrantGuard(req, res, next) {
152
+ const routePath = (req.route && req.route.path) || req.path;
153
+ const operation = operationMap[`${req.method} ${routePath}`];
154
+ if (!operation) {
155
+ // Fail closed: an unmapped mutation is not an authorized mutation.
156
+ // NO remedy: this route has no operation to authorize, so no grant the
157
+ // caller could obtain would change this answer. An unactionable refusal is
158
+ // reported as unactionable.
159
+ return deny(res, 'GRANT_SCOPE_MISMATCH', 'unmapped_operation');
160
+ }
161
+
162
+ // The binding rule: the raw request body IS the after-payload.
163
+ const afterPayload = req.rawBody != null ? req.rawBody.toString('utf8') : '';
164
+ // The scope hash of the request being refused — the same value a grant for
165
+ // this request would have to carry, so the caller can match it.
166
+ const scope = computeScopeHash({ operation, target_id: targetId(req), after_payload: afterPayload });
167
+
168
+ const token = req.headers[headerName];
169
+ if (!token || typeof token !== 'string') {
170
+ return deny(res, 'MALFORMED', 'missing_grant_header', buildDenyRemedy({
171
+ error: denyErrorForReason('missing_grant_header'),
172
+ target: targetOf(req, routePath),
173
+ fingerprint: scope,
174
+ observed: { status: 'MALFORMED', reason: 'missing_grant_header' },
175
+ }));
176
+ }
177
+
178
+ const result = verifyExecutionGrant(token, {
179
+ publicKey: pinned.publicKey,
180
+ keyKid: pinned.kid,
181
+ keyStatus: pinned.status,
182
+ now: typeof now === 'function' ? now() : undefined,
183
+ intended: {
184
+ audience: audience || '',
185
+ operation,
186
+ target_id: targetId(req),
187
+ after_payload: afterPayload,
188
+ },
189
+ });
190
+
191
+ if (!result.valid) {
192
+ // The specific reason first; its status is the fallback, so a new reason
193
+ // string still lands in the right class rather than silently losing its
194
+ // remedy. A reason and a status that both fall outside the three classes
195
+ // produce no remedy at all.
196
+ const error = denyErrorForReason(result.reason) || denyErrorForReason(result.status);
197
+ return deny(res, result.status, result.reason, buildDenyRemedy({
198
+ error,
199
+ target: targetOf(req, routePath),
200
+ fingerprint: scope,
201
+ observed: { status: result.status, reason: result.reason },
202
+ }));
203
+ }
204
+
205
+ req.coderifts = { payload: result.payload };
206
+ return next();
207
+ };
208
+ }
209
+
210
+ module.exports = {
211
+ requireExecutionGrant,
212
+ captureRawBody,
213
+ computeScopeHash,
214
+ verifyExecutionGrant,
215
+ DEFAULT_HEADER,
216
+ };
@@ -0,0 +1,267 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * cr.exec.v1 OFFLINE grant verifier.
5
+ *
6
+ * Logic shape mirrors the reference implementation
7
+ * (coderifts-app src/verdict-core/execution-grant.js) and the 10-step algorithm in
8
+ * coderifts-app docs/cr-exec-v1.md § "Verification algorithm". Statuses and reason
9
+ * strings are IDENTICAL to that family — this file adds no status of its own.
10
+ *
11
+ * Differences from the reference, all deliberate for a standalone enforcement point:
12
+ * - keys come from a PINNED public key (PEM string or a keys file), never from a
13
+ * network lookup. There is no resolveVerifyKey() and no registry fetch: the
14
+ * middleware must behave identically with the network unplugged.
15
+ * - a pinned key with status 'retired' is refused (UNKNOWN_KEY / retired_kid),
16
+ * matching spec step 4: a grant is live execution permission, so retired keys
17
+ * never yield GRANT_CURRENT.
18
+ *
19
+ * Dependency-free: node:crypto only.
20
+ */
21
+
22
+ const crypto = require('node:crypto');
23
+
24
+ const GRANT_VERSION = 'cr.exec.v1';
25
+ const SIGNING_PREFIX = 'crexec.v1';
26
+
27
+ /**
28
+ * Field separator in the scope_hash preimage.
29
+ * docs/cr-exec-v1.md § Derivation specifies \x1f (the reference calls the constant
30
+ * NUL, but the byte it uses is 0x1F — Unit Separator; the byte, not the name, is
31
+ * normative and is what we reproduce).
32
+ */
33
+ const US = '\x1f';
34
+
35
+ /** ID104 verification leeway. `exp + leeway < now` → expired. Same 30s as receipts. */
36
+ const CLOCK_SKEW_LEEWAY_MS = 30_000;
37
+
38
+ const SIGNED_FIELDS = Object.freeze([
39
+ 'kid', 'receipt_digest', 'scope_hash', 'audience', 'operation', 'target_id', 'jti', 'iat', 'exp',
40
+ ]);
41
+
42
+ /**
43
+ * ATOMIC-profile field (docs/cr-exec-v1.md § Profiles). Optional and additive:
44
+ * present → ATOMIC (one-use consumption is the EXECUTOR's job — see demo/src/db.js)
45
+ * absent → BEARER (today's grant, unchanged)
46
+ * It is a SEPARATE signed field and is deliberately NOT folded into scope_hash:
47
+ * after-payload binding and state binding are independent facts, so rotating a nonce
48
+ * must not look like a different after-shape.
49
+ */
50
+ const OPTIONAL_SIGNED_FIELDS = Object.freeze(['state_nonce', 'deployment_id']);
51
+
52
+ function sha256hex(str) {
53
+ return crypto.createHash('sha256').update(String(str), 'utf8').digest('hex');
54
+ }
55
+
56
+ function scalar(v) {
57
+ return v == null ? '' : String(v);
58
+ }
59
+
60
+ /**
61
+ * scope_hash preimage: operation \x1f target_id \x1f after_payload.
62
+ * @returns {string} 'sha256:'+hex
63
+ */
64
+ function computeScopeHash({ operation, target_id, after_payload }) {
65
+ const preimage = [scalar(operation), scalar(target_id), scalar(after_payload)].join(US);
66
+ return `sha256:${sha256hex(preimage)}`;
67
+ }
68
+
69
+ /** sha256 of the receipt TOKEN STRING (not of any decoded body). */
70
+ function receiptDigest(token) {
71
+ return `sha256:${sha256hex(String(token))}`;
72
+ }
73
+
74
+ function signingInput(body) {
75
+ const parts = [
76
+ SIGNING_PREFIX,
77
+ scalar(body.kid), scalar(body.receipt_digest), scalar(body.scope_hash),
78
+ scalar(body.audience), scalar(body.operation), scalar(body.target_id),
79
+ scalar(body.jti), scalar(body.iat), scalar(body.exp),
80
+ ];
81
+ // The |{state_nonce} slot is appended ONLY when non-empty, so a BEARER grant's
82
+ // signing input stays byte-identical to pre-ATOMIC issuances.
83
+ if (body.state_nonce != null && String(body.state_nonce).length > 0) {
84
+ parts.push(String(body.state_nonce));
85
+ }
86
+ // deployment_id is optional-additive (STEP 4). Appended only when present so
87
+ // grants issued before this field stay byte-identical.
88
+ if (body.deployment_id != null && String(body.deployment_id).length > 0) {
89
+ parts.push(String(body.deployment_id));
90
+ }
91
+ return parts.join('|');
92
+ }
93
+
94
+ /** ATOMIC iff a non-empty state_nonce is carried. */
95
+ function grantProfile(payload) {
96
+ return payload && payload.state_nonce != null && String(payload.state_nonce).length > 0
97
+ ? 'ATOMIC' : 'BEARER';
98
+ }
99
+
100
+ function fieldHasDelimiter(body) {
101
+ for (const k of [...SIGNED_FIELDS, ...OPTIONAL_SIGNED_FIELDS]) {
102
+ if (typeof body[k] === 'string' && body[k].includes('|')) return true;
103
+ }
104
+ return false;
105
+ }
106
+
107
+ /** Steps 1-3 (structure / JSON / field presence / unknown keys). */
108
+ function parseGrantToken(token) {
109
+ if (typeof token !== 'string' || token.length === 0) {
110
+ return { ok: false, status: 'MALFORMED', reason: 'malformed_structure' };
111
+ }
112
+ const segments = token.split('.');
113
+ if (segments.length !== 2 || segments.some((s) => !s)) {
114
+ return { ok: false, status: 'MALFORMED', reason: 'malformed_structure' };
115
+ }
116
+ let payload;
117
+ try {
118
+ payload = JSON.parse(Buffer.from(segments[0], 'base64url').toString('utf8'));
119
+ } catch (_) {
120
+ return { ok: false, status: 'MALFORMED', reason: 'bad_json' };
121
+ }
122
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
123
+ return { ok: false, status: 'MALFORMED', reason: 'bad_json' };
124
+ }
125
+ if (payload.v !== GRANT_VERSION) {
126
+ return { ok: false, status: 'MALFORMED', reason: 'unsupported_version', payload };
127
+ }
128
+ for (const k of SIGNED_FIELDS) {
129
+ if (typeof payload[k] !== 'string') {
130
+ return { ok: false, status: 'MALFORMED', reason: 'missing_field', payload };
131
+ }
132
+ }
133
+ for (const k of OPTIONAL_SIGNED_FIELDS) {
134
+ if (payload[k] !== undefined && typeof payload[k] !== 'string') {
135
+ return { ok: false, status: 'MALFORMED', reason: 'bad_optional', payload };
136
+ }
137
+ }
138
+ // Reserved keys (cnf, nbf, max_uses, …) are NOT accepted in this phase.
139
+ const allowed = new Set(['v', ...SIGNED_FIELDS, ...OPTIONAL_SIGNED_FIELDS]);
140
+ for (const k of Object.keys(payload)) {
141
+ if (!allowed.has(k)) {
142
+ return { ok: false, status: 'MALFORMED', reason: 'unknown_field', payload };
143
+ }
144
+ }
145
+ return { ok: true, payload, sig: segments[1] };
146
+ }
147
+
148
+ /**
149
+ * Offline verification, spec steps 1-10.
150
+ *
151
+ * @param {string} token
152
+ * @param {object} opts
153
+ * @param {import('node:crypto').KeyObject} opts.publicKey PINNED key (required)
154
+ * @param {string} [opts.keyStatus] 'active' | 'retired' — retired refuses (step 4)
155
+ * @param {string} [opts.keyKid] when set, payload.kid must equal it (step 4)
156
+ * @param {object} [opts.intended] { operation, target_id, audience, after_payload, scope_hash, receipt_token }
157
+ * @param {number} [opts.now] epoch ms
158
+ * @returns {{ valid: boolean, status: string, reason: string|null, payload?: object }}
159
+ */
160
+ function verifyExecutionGrant(token, opts = {}) {
161
+ // 1-3
162
+ const parsed = parseGrantToken(token);
163
+ if (!parsed.ok) {
164
+ return { valid: false, status: parsed.status, reason: parsed.reason, payload: parsed.payload };
165
+ }
166
+ const payload = parsed.payload;
167
+ if (fieldHasDelimiter(payload)) {
168
+ return { valid: false, status: 'INVALID_SIGNATURE', reason: 'delimiter_in_field', payload };
169
+ }
170
+
171
+ // 4 — pinned key. No registry, no fetch: unknown kid / retired key never verifies.
172
+ if (!opts.publicKey) {
173
+ return { valid: false, status: 'UNKNOWN_KEY', reason: 'unknown_kid', payload };
174
+ }
175
+ if (opts.keyKid != null && opts.keyKid !== '' && payload.kid !== String(opts.keyKid)) {
176
+ return { valid: false, status: 'UNKNOWN_KEY', reason: 'unknown_kid', payload };
177
+ }
178
+ if (opts.keyStatus === 'retired') {
179
+ return { valid: false, status: 'UNKNOWN_KEY', reason: 'retired_kid', payload };
180
+ }
181
+
182
+ // 5
183
+ let ok = false;
184
+ try {
185
+ ok = crypto.verify(
186
+ null,
187
+ Buffer.from(signingInput(payload), 'utf8'),
188
+ opts.publicKey,
189
+ Buffer.from(parsed.sig, 'base64url'),
190
+ );
191
+ } catch (_) {
192
+ return { valid: false, status: 'INVALID_SIGNATURE', reason: 'signature_error', payload };
193
+ }
194
+ if (!ok) {
195
+ return { valid: false, status: 'INVALID_SIGNATURE', reason: 'signature_mismatch', payload };
196
+ }
197
+
198
+ // 6 — expiry with the same 30s leeway as receipt verification.
199
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
200
+ const expMs = Date.parse(payload.exp);
201
+ const iatMs = Date.parse(payload.iat);
202
+ if (!Number.isFinite(expMs) || !Number.isFinite(iatMs)) {
203
+ return { valid: false, status: 'MALFORMED', reason: 'bad_timestamp', payload };
204
+ }
205
+ if (expMs + CLOCK_SKEW_LEEWAY_MS < now) {
206
+ return { valid: false, status: 'GRANT_EXPIRED', reason: 'expired', payload };
207
+ }
208
+ if (iatMs > now + CLOCK_SKEW_LEEWAY_MS) {
209
+ return { valid: false, status: 'GRANT_EXPIRED', reason: 'iat_in_future', payload };
210
+ }
211
+
212
+ // 7 — receipt binding.
213
+ if (!payload.receipt_digest || !payload.receipt_digest.startsWith('sha256:')) {
214
+ return { valid: false, status: 'GRANT_UNBOUND', reason: 'missing_receipt_digest', payload };
215
+ }
216
+ const intended = opts.intended && typeof opts.intended === 'object' ? opts.intended : {};
217
+ if (intended.receipt_token != null && String(intended.receipt_token).length > 0) {
218
+ if (receiptDigest(intended.receipt_token) !== payload.receipt_digest) {
219
+ return { valid: false, status: 'GRANT_UNBOUND', reason: 'receipt_digest_mismatch', payload };
220
+ }
221
+ }
222
+
223
+ // 8 — audience / operation / target.
224
+ if (intended.audience != null && intended.audience !== '' && payload.audience !== String(intended.audience)) {
225
+ return { valid: false, status: 'GRANT_WRONG_AUDIENCE', reason: 'audience_mismatch', payload };
226
+ }
227
+ if (intended.operation != null && intended.operation !== '' && payload.operation !== String(intended.operation)) {
228
+ return { valid: false, status: 'GRANT_SCOPE_MISMATCH', reason: 'operation_mismatch', payload };
229
+ }
230
+ if (intended.target_id != null && intended.target_id !== '' && payload.target_id !== String(intended.target_id)) {
231
+ return { valid: false, status: 'GRANT_SCOPE_MISMATCH', reason: 'target_mismatch', payload };
232
+ }
233
+
234
+ // 9 — recompute scope_hash from the after-payload about to be applied.
235
+ let expectedScope = null;
236
+ if (intended.scope_hash != null && String(intended.scope_hash).length > 0) {
237
+ expectedScope = String(intended.scope_hash);
238
+ } else if (intended.after_payload != null) {
239
+ expectedScope = computeScopeHash({
240
+ operation: intended.operation != null ? intended.operation : payload.operation,
241
+ target_id: intended.target_id != null ? intended.target_id : payload.target_id,
242
+ after_payload: intended.after_payload,
243
+ });
244
+ }
245
+ if (expectedScope != null && expectedScope !== payload.scope_hash) {
246
+ return { valid: false, status: 'GRANT_SCOPE_MISMATCH', reason: 'scope_hash_mismatch', payload };
247
+ }
248
+
249
+ // 10
250
+ return { valid: true, status: 'GRANT_CURRENT', reason: null, payload };
251
+ }
252
+
253
+ module.exports = {
254
+ GRANT_VERSION,
255
+ grantProfile,
256
+ OPTIONAL_SIGNED_FIELDS,
257
+ SIGNING_PREFIX,
258
+ CLOCK_SKEW_LEEWAY_MS,
259
+ SIGNED_FIELDS,
260
+ US,
261
+ sha256hex,
262
+ computeScopeHash,
263
+ receiptDigest,
264
+ signingInput,
265
+ parseGrantToken,
266
+ verifyExecutionGrant,
267
+ };