@passioncode-ai/fabric-agent-adapter 0.10.0 → 0.12.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/CHANGELOG.md +92 -0
- package/README.md +3 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +98 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +8 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +492 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +865 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +47 -12
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
// The callee's side of fabric-delegation/0.1 (Fabric Agent Contract DEC-0039, Fabric ADR-0144): check a
|
|
2
|
+
// delegation token or an approval offline against the estate's published keys, and answer `ok` or one refusal
|
|
3
|
+
// code. A port of the contract's `src/delegation.ts` at the pinned revision; the shared vectors in
|
|
4
|
+
// `test/fixtures/delegation/vectors.json` hold it to that verifier's exact answers. Node's own crypto verifies
|
|
5
|
+
// Ed25519; no dependencies.
|
|
6
|
+
//
|
|
7
|
+
// import { JwksCache, delegationGate, refusalToolResult } from './fabric-delegation.mjs';
|
|
8
|
+
// const gate = delegationGate({ audience: 'example-agent', jwks: new JwksCache(wk.auth.delegation.jwks),
|
|
9
|
+
// effects: { 'example.write': 'draft' }, issuer: wk.auth.delegation.issuer });
|
|
10
|
+
// const verdict = await gate(toolName, req.headers); // before the tool runs
|
|
11
|
+
// if (!verdict.ok) return refusalToolResult(verdict);
|
|
12
|
+
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import net from 'node:net';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
export const REFUSAL_CODES = Object.freeze([
|
|
19
|
+
'unauthenticated', 'token-expired', 'wrong-audience', 'scope-not-granted', 'effect-above-ceiling', 'no-grant',
|
|
20
|
+
'grant-expired', 'grant-revoked', 'limit-reached', 'approval-required', 'approval-invalid', 'approval-reused',
|
|
21
|
+
'principal-unproven', 'credential-source-refused', 'chain-too-deep', 'issuer-unavailable', 'unknown-state',
|
|
22
|
+
]);
|
|
23
|
+
export const EFFECT_CLASSES = Object.freeze(['none', 'draft', 'publish', 'send', 'reply', 'charge', 'delete', 'merge', 'deploy', 'change-policy']);
|
|
24
|
+
export const DELEGATION_HEADER = 'Fabric-Delegation';
|
|
25
|
+
export const DELEGATION_TYP = 'fabric-delegation+jwt';
|
|
26
|
+
export const APPROVAL_TYP = 'fabric-approval+jwt';
|
|
27
|
+
export const MAX_DELEGATION_LIFETIME_SECONDS = 600;
|
|
28
|
+
export const MAX_APPROVAL_LIFETIME_SECONDS = 86_400;
|
|
29
|
+
export const MAX_CHAIN_DEPTH = 4;
|
|
30
|
+
export const LEEWAY_SECONDS = 60;
|
|
31
|
+
/** DL-15: cache the key set at most 10 minutes; re-read it at most once a minute for an unknown kid. */
|
|
32
|
+
export const JWKS_MAX_AGE_SECONDS = 600;
|
|
33
|
+
export const JWKS_REREAD_SECONDS = 60;
|
|
34
|
+
/** After a failed read, wait this long before trying again. */
|
|
35
|
+
export const JWKS_RETRY_SECONDS = 10;
|
|
36
|
+
|
|
37
|
+
const REFUSAL_STATUS = { unauthenticated: 401, 'token-expired': 401, 'principal-unproven': 401, 'issuer-unavailable': 503, 'unknown-state': 503 };
|
|
38
|
+
export const REFUSAL_MESSAGES = Object.freeze({
|
|
39
|
+
unauthenticated: 'No delegation token, or one not signed by a published key or not of this profile.',
|
|
40
|
+
'token-expired': 'The delegation token has expired; mint a new one.',
|
|
41
|
+
'wrong-audience': 'The delegation token is for another callee.',
|
|
42
|
+
'scope-not-granted': "The called capability is not in the token's scope.",
|
|
43
|
+
'effect-above-ceiling': "The capability's effect is above the token's ceiling.",
|
|
44
|
+
'no-grant': 'No live grant covers this call.',
|
|
45
|
+
'grant-expired': 'The grant has expired.',
|
|
46
|
+
'grant-revoked': 'The grant was revoked.',
|
|
47
|
+
'limit-reached': 'A spend window is exhausted.',
|
|
48
|
+
'approval-required': 'This action needs an approval; ask the hub for one with the action digest.',
|
|
49
|
+
'approval-invalid': 'The approval is not valid for this action, callee or time.',
|
|
50
|
+
'approval-reused': 'The approval was already used.',
|
|
51
|
+
'principal-unproven': "The caller's proof is missing or stale.",
|
|
52
|
+
'credential-source-refused': 'The grant names a credential source that is not allowed.',
|
|
53
|
+
'chain-too-deep': 'The actor chain is deeper than four.',
|
|
54
|
+
'issuer-unavailable': "The estate's published keys cannot be read.",
|
|
55
|
+
'unknown-state': 'The state cannot be established, so the call is refused.',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const refuse = (code, detail, data) => (data ? { ok: false, code, detail, data } : { ok: false, code, detail });
|
|
59
|
+
const B64URL = /^[A-Za-z0-9_-]+$/;
|
|
60
|
+
/** DL-2's grammar, as the contract's verifier types the claims (DEC-0045). */
|
|
61
|
+
export const AGENT_ID = /^[a-z][a-z0-9-]{1,62}(\.[a-z][a-z0-9-]{0,31})?$/;
|
|
62
|
+
export const OPAQUE_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
63
|
+
const URI = /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/;
|
|
64
|
+
const JTI = /^[A-Za-z0-9._~-]{16,128}$/;
|
|
65
|
+
const SCOPE = /^[a-z][a-z0-9._-]{1,127}( [a-z][a-z0-9._-]{1,127})*$/;
|
|
66
|
+
const SPEND_WINDOWS = ['total', 'task', 'project', 'day', 'week', 'month'];
|
|
67
|
+
const isUri = (value) => typeof value === 'string' && URI.test(value);
|
|
68
|
+
const isAmount = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
69
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
70
|
+
const isString = (value) => typeof value === 'string' && value.length > 0;
|
|
71
|
+
const isTime = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
72
|
+
|
|
73
|
+
/** Whether a JSON text repeats a member name in one object: refused, never resolved (RFC 7515 §5.2, RFC 8725 §3.2). */
|
|
74
|
+
export function hasDuplicateMembers(text) {
|
|
75
|
+
const stack = [];
|
|
76
|
+
for (let i = 0; i < text.length; i++) {
|
|
77
|
+
const c = text[i];
|
|
78
|
+
if (c === '"') {
|
|
79
|
+
let j = i + 1;
|
|
80
|
+
while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1;
|
|
81
|
+
const top = stack[stack.length - 1];
|
|
82
|
+
if (top?.keys && top.expectKey) {
|
|
83
|
+
const key = JSON.parse(text.slice(i, j + 1));
|
|
84
|
+
if (top.keys.has(key)) return true;
|
|
85
|
+
top.keys.add(key);
|
|
86
|
+
top.expectKey = false;
|
|
87
|
+
}
|
|
88
|
+
i = j;
|
|
89
|
+
} else if (c === '{') stack.push({ keys: new Set(), expectKey: true });
|
|
90
|
+
else if (c === '[') stack.push({ expectKey: false });
|
|
91
|
+
else if (c === '}' || c === ']') stack.pop();
|
|
92
|
+
else if (c === ',') { const top = stack[stack.length - 1]; if (top?.keys) top.expectKey = true; }
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function decodeSegment(segment) {
|
|
98
|
+
if (!B64URL.test(segment)) return { problem: 'malformed' };
|
|
99
|
+
try {
|
|
100
|
+
const text = Buffer.from(segment, 'base64url').toString('utf8');
|
|
101
|
+
const value = JSON.parse(text);
|
|
102
|
+
if (!isObject(value)) return { problem: 'malformed' };
|
|
103
|
+
return hasDuplicateMembers(text) ? { problem: 'duplicate-member' } : { value };
|
|
104
|
+
} catch {
|
|
105
|
+
return { problem: 'malformed' };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseCompact(token) {
|
|
110
|
+
if (typeof token !== 'string' || token.length > 8192) return { problem: 'malformed' };
|
|
111
|
+
const parts = token.split('.');
|
|
112
|
+
if (parts.length !== 3) return { problem: 'malformed' };
|
|
113
|
+
const [h, p, s] = parts;
|
|
114
|
+
const header = decodeSegment(h);
|
|
115
|
+
const payload = decodeSegment(p);
|
|
116
|
+
if (!B64URL.test(s)) return { problem: 'malformed' };
|
|
117
|
+
for (const part of [header, payload]) if ('problem' in part) return { problem: part.problem };
|
|
118
|
+
return { header: header.value, payload: payload.value, signingInput: Buffer.from(`${h}.${p}`, 'ascii'), signature: Buffer.from(s, 'base64url') };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function headerProblem(header, typ) {
|
|
122
|
+
if (header.alg !== 'EdDSA') return 'alg-not-eddsa';
|
|
123
|
+
if (header.typ !== typ) return 'wrong-typ';
|
|
124
|
+
if (!isString(header.kid)) return 'no-kid';
|
|
125
|
+
for (const member of ['crit', 'jku', 'jwk', 'x5u', 'x5c']) if (header[member] !== undefined) return `header-${member}`;
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function keyFor(jwks, kid) {
|
|
130
|
+
const matches = jwks.keys.filter((candidate) => isObject(candidate) && candidate.kid === kid);
|
|
131
|
+
if (matches.length > 1) return { problem: 'duplicate-kid' };
|
|
132
|
+
const jwk = matches[0];
|
|
133
|
+
if (!jwk) return { problem: 'unknown-key' };
|
|
134
|
+
// A key published for another use or algorithm is never a candidate (RFC 7517 §4.2, §4.4).
|
|
135
|
+
if ((jwk.use !== undefined && jwk.use !== 'sig') || (jwk.alg !== undefined && jwk.alg !== 'EdDSA')) return { problem: 'key-not-for-signing' };
|
|
136
|
+
if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || !isString(jwk.x) || jwk.d !== undefined) return { problem: 'unusable-key' };
|
|
137
|
+
const raw = Buffer.from(jwk.x, 'base64url');
|
|
138
|
+
if (raw.length !== 32) return { problem: 'unusable-key' };
|
|
139
|
+
if (isSmallOrderKey(raw)) return { problem: 'small-order-key' }; // before any verification (DEC-0045, CT-29)
|
|
140
|
+
try {
|
|
141
|
+
return { key: crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, format: 'jwk' }), raw };
|
|
142
|
+
} catch {
|
|
143
|
+
return { problem: 'unusable-key' };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The small-order points of edwards25519 and their non-canonical encodings, compared with the sign bit cleared
|
|
148
|
+
// (libsodium's has_small_order list). Under such a key, R = [S]B verifies for a quarter or more of all messages, and
|
|
149
|
+
// Node before 26 lets OpenSSL accept it: the kit refuses these keys itself rather than trust the runtime.
|
|
150
|
+
const SMALL_ORDER = [
|
|
151
|
+
'0000000000000000000000000000000000000000000000000000000000000000', '0100000000000000000000000000000000000000000000000000000000000000',
|
|
152
|
+
'26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05', 'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
|
|
153
|
+
'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f', 'edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
|
|
154
|
+
'eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
|
|
155
|
+
];
|
|
156
|
+
/** Whether a 32-byte Ed25519 public key is a point of small order (in any encoding). */
|
|
157
|
+
export function isSmallOrderKey(key) {
|
|
158
|
+
if (!Buffer.isBuffer(key) || key.length !== 32) return false;
|
|
159
|
+
const cleared = Buffer.from(key);
|
|
160
|
+
cleared[31] &= 0x7f;
|
|
161
|
+
return SMALL_ORDER.includes(cleared.toString('hex'));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const hasKeys = (jwks) => isObject(jwks) && Array.isArray(jwks.keys) && jwks.keys.length > 0;
|
|
165
|
+
|
|
166
|
+
/** Parse, check the header, find the key, verify the signature. A fault of the published keys is the issuer's. */
|
|
167
|
+
function authentic(token, jwks, typ) {
|
|
168
|
+
const compact = parseCompact(token);
|
|
169
|
+
if ('problem' in compact) return { problem: compact.problem };
|
|
170
|
+
const header = headerProblem(compact.header, typ);
|
|
171
|
+
if (header) return { problem: header };
|
|
172
|
+
const kid = compact.header.kid;
|
|
173
|
+
const found = keyFor(jwks, kid);
|
|
174
|
+
// Only an unknown kid is the token's fault; a published key that cannot be used is the issuer's (DL-13 row 3).
|
|
175
|
+
if ('problem' in found) return found.problem === 'unknown-key' ? { problem: found.problem } : { problem: found.problem, issuer: true };
|
|
176
|
+
if (compact.signature.length !== 64 || isSmallOrderKey(found.raw) || !crypto.verify(null, compact.signingInput, found.key, compact.signature)) return { problem: 'bad-signature' };
|
|
177
|
+
return { compact, kid };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Depth of an actor chain; Infinity for a chain that is not one (a missing or non-string sub at any level). */
|
|
181
|
+
export function chainDepth(act) {
|
|
182
|
+
let depth = 0;
|
|
183
|
+
let node = act;
|
|
184
|
+
while (node !== undefined) {
|
|
185
|
+
if (!isObject(node) || !isString(node.sub)) return Number.POSITIVE_INFINITY;
|
|
186
|
+
depth += 1;
|
|
187
|
+
if (depth > 64) return depth;
|
|
188
|
+
node = node.act;
|
|
189
|
+
}
|
|
190
|
+
return depth;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function chainOf(act) {
|
|
194
|
+
const chain = [];
|
|
195
|
+
for (let node = act; node; node = node.act) chain.push(node.sub);
|
|
196
|
+
return chain;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** `none` is below every class and `draft` below every external one; external classes are not ordered. */
|
|
200
|
+
export function effectClosure(ceiling) {
|
|
201
|
+
const admitted = new Set(ceiling);
|
|
202
|
+
if (ceiling.length > 0) admitted.add('none');
|
|
203
|
+
if (ceiling.some((effect) => effect !== 'none')) admitted.add('draft');
|
|
204
|
+
return admitted;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const effectWithin = (effect, ceiling) => effectClosure(ceiling).has(effect);
|
|
208
|
+
|
|
209
|
+
/** The chain's depth when every actor is DL-2's (agent id `sub`, optional session id), else undefined. No `act`, no chain. */
|
|
210
|
+
function actorChainDepth(act) {
|
|
211
|
+
if (act === undefined) return undefined;
|
|
212
|
+
let depth = 0;
|
|
213
|
+
for (let node = act; node !== undefined; node = node.act) {
|
|
214
|
+
if (!isObject(node) || typeof node.sub !== 'string' || !AGENT_ID.test(node.sub)) return undefined;
|
|
215
|
+
if (node.session !== undefined && !(typeof node.session === 'string' && OPAQUE_ID.test(node.session))) return undefined;
|
|
216
|
+
depth += 1;
|
|
217
|
+
}
|
|
218
|
+
return depth;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function spendShaped(spend) {
|
|
222
|
+
if (!isObject(spend)) return false;
|
|
223
|
+
if (spend.mode === 'unlimited') return true;
|
|
224
|
+
if (spend.mode !== 'limited' || !Array.isArray(spend.remaining) || spend.remaining.length === 0 || spend.remaining.length > 6) return false;
|
|
225
|
+
if (!spend.remaining.every((entry) => isObject(entry) && SPEND_WINDOWS.includes(entry.window) && isAmount(entry.remainingUsd))) return false;
|
|
226
|
+
return spend.perCallMaxUsd === undefined || isAmount(spend.perCallMaxUsd);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** DL-13 row 5 as DEC-0045 types it: {claims, depth} or {problem}. A claim the profile does not know is ignored. */
|
|
230
|
+
function delegationShape(payload) {
|
|
231
|
+
const { iss, sub, act, aud, scope, effect, grant, spend, project, task, iat, exp, nbf, jti } = payload;
|
|
232
|
+
const depth = actorChainDepth(act);
|
|
233
|
+
if (depth === undefined) return { problem: 'malformed-chain' };
|
|
234
|
+
const typed = isUri(iss) && isUri(sub) && typeof aud === 'string' && AGENT_ID.test(aud)
|
|
235
|
+
&& typeof scope === 'string' && scope.length <= 4096 && SCOPE.test(scope)
|
|
236
|
+
&& Array.isArray(effect) && effect.length > 0 && new Set(effect).size === effect.length && effect.every((e) => EFFECT_CLASSES.includes(e))
|
|
237
|
+
&& Array.isArray(grant) && grant.length === 1 && typeof grant[0] === 'string' && grant[0].length >= 1 && grant[0].length <= 256
|
|
238
|
+
&& (spend === undefined || spendShaped(spend)) && (project === undefined || isUri(project))
|
|
239
|
+
&& (task === undefined || (typeof task === 'string' && OPAQUE_ID.test(task)))
|
|
240
|
+
&& isTime(iat) && isTime(exp) && (nbf === undefined || isTime(nbf)) && typeof jti === 'string' && JTI.test(jti);
|
|
241
|
+
return typed ? { claims: payload, depth } : { problem: 'malformed-claims' };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function approvalShape(payload) {
|
|
245
|
+
const { iss, actionDigest: digest, aud, approver, method, iat, exp, jti } = payload;
|
|
246
|
+
if (!isString(iss) || !isString(digest) || !isString(aud) || !isString(approver) || !isString(jti) || !isTime(iat) || !isTime(exp)) return undefined;
|
|
247
|
+
if (method !== 'prompt' && method !== 'operator-channel') return undefined; // DEC-0045: `device` is removed
|
|
248
|
+
return payload;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* DL-13, in its fixed order: nothing about a token is believed before its signature is. `now` is seconds since
|
|
253
|
+
* the epoch. Answers {ok: true, claims, principal, caller, chain, kid} or {ok: false, code, detail}.
|
|
254
|
+
*/
|
|
255
|
+
export function verifyDelegation(token, { audience, jwks, now, capability, effect, issuer, readOnly } = {}) {
|
|
256
|
+
if (!isString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
|
|
257
|
+
if (!hasKeys(jwks)) return refuse('issuer-unavailable', 'no-published-keys');
|
|
258
|
+
const checked = authentic(token, jwks, DELEGATION_TYP);
|
|
259
|
+
if ('problem' in checked) return refuse(checked.issuer ? 'issuer-unavailable' : 'unauthenticated', checked.problem);
|
|
260
|
+
const shaped = delegationShape(checked.compact.payload);
|
|
261
|
+
if ('problem' in shaped) return refuse('unauthenticated', shaped.problem);
|
|
262
|
+
const { claims, depth } = shaped;
|
|
263
|
+
if (claims.iss !== issuer) return refuse('unauthenticated', 'wrong-issuer');
|
|
264
|
+
if (claims.exp <= claims.iat || claims.exp - claims.iat > MAX_DELEGATION_LIFETIME_SECONDS) return refuse('unauthenticated', 'lifetime-above-profile');
|
|
265
|
+
if (claims.iat > now + LEEWAY_SECONDS) return refuse('unauthenticated', 'issued-in-future');
|
|
266
|
+
const { nbf } = checked.compact.payload;
|
|
267
|
+
if (nbf !== undefined && nbf > now + LEEWAY_SECONDS) return refuse('unauthenticated', 'not-yet-valid');
|
|
268
|
+
if (now > claims.exp + LEEWAY_SECONDS) return refuse('token-expired', 'expired');
|
|
269
|
+
if (claims.aud !== audience) return refuse('wrong-audience', 'audience-mismatch');
|
|
270
|
+
if (depth > MAX_CHAIN_DEPTH) return refuse('chain-too-deep', `depth-${depth}`);
|
|
271
|
+
if (!claims.scope.split(' ').includes(capability)) return refuse('scope-not-granted', 'capability-not-in-scope');
|
|
272
|
+
if (readOnly && effect !== 'none') return refuse('effect-above-ceiling', 'read-only-door');
|
|
273
|
+
if (!effectWithin(effect, claims.effect)) return refuse('effect-above-ceiling', `effect-${effect}`);
|
|
274
|
+
return { ok: true, claims, principal: claims.sub, caller: claims.act.sub, chain: chainOf(claims.act), kid: checked.kid };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** DL-18: approval-required carries the digest; any fault is approval-invalid; the jti is consumed last. */
|
|
278
|
+
export function verifyApproval(approval, { audience, jwks, now, actionDigest: expected, issuer, jtiStore } = {}) {
|
|
279
|
+
if (approval === undefined || approval === null || approval === '') return refuse('approval-required', 'no-approval', { actionDigest: expected });
|
|
280
|
+
if (!isObject(jtiStore) || typeof jtiStore.consume !== 'function') return refuse('unknown-state', 'no-jti-store');
|
|
281
|
+
if (!isString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
|
|
282
|
+
if (!hasKeys(jwks)) return refuse('issuer-unavailable', 'no-published-keys');
|
|
283
|
+
const checked = authentic(approval, jwks, APPROVAL_TYP);
|
|
284
|
+
if ('problem' in checked) return refuse(checked.issuer ? 'issuer-unavailable' : 'approval-invalid', checked.problem);
|
|
285
|
+
const claims = approvalShape(checked.compact.payload);
|
|
286
|
+
if (!claims) return refuse('approval-invalid', 'malformed-claims');
|
|
287
|
+
if (claims.iss !== issuer) return refuse('approval-invalid', 'wrong-issuer');
|
|
288
|
+
if (claims.exp <= claims.iat || claims.exp - claims.iat > MAX_APPROVAL_LIFETIME_SECONDS) return refuse('approval-invalid', 'lifetime-above-profile');
|
|
289
|
+
if (claims.iat > now + LEEWAY_SECONDS) return refuse('approval-invalid', 'issued-in-future');
|
|
290
|
+
if (now > claims.exp + LEEWAY_SECONDS) return refuse('approval-invalid', 'expired');
|
|
291
|
+
if (claims.aud !== audience) return refuse('approval-invalid', 'audience-mismatch');
|
|
292
|
+
if (claims.actionDigest !== expected) return refuse('approval-invalid', 'digest-mismatch', { actionDigest: expected });
|
|
293
|
+
let fresh;
|
|
294
|
+
try { fresh = jtiStore.consume(claims.jti, claims.exp, now); } catch { return refuse('unknown-state', 'jti-store-failed'); }
|
|
295
|
+
if (!fresh) return refuse('approval-reused', 'jti-consumed');
|
|
296
|
+
return { ok: true, claims, approver: claims.approver, method: claims.method, kid: checked.kid };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Consumed approval ids in memory: for tests and a single process that never restarts mid-approval. */
|
|
300
|
+
export function createMemoryJtiStore() {
|
|
301
|
+
const seen = new Map();
|
|
302
|
+
return {
|
|
303
|
+
consume(jti, expiresAt, now) {
|
|
304
|
+
for (const [id, until] of seen) if (until + LEEWAY_SECONDS < now) seen.delete(id);
|
|
305
|
+
if (seen.has(jti)) return false;
|
|
306
|
+
seen.set(jti, expiresAt);
|
|
307
|
+
return true;
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** A jti store that cannot tell whether an id was consumed; the verifier answers unknown-state. */
|
|
313
|
+
export class JtiStoreError extends Error {}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Consumed approval ids in a 0600 JSON file, so a second use is refused across a restart (DL-18). A file that
|
|
317
|
+
* cannot be read throws JtiStoreError: forgetting every consumed id would let each approval be replayed.
|
|
318
|
+
*/
|
|
319
|
+
export class FileJtiStore {
|
|
320
|
+
constructor(file) { this.file = file; }
|
|
321
|
+
|
|
322
|
+
consume(jti, expiresAt, now) {
|
|
323
|
+
let read = {};
|
|
324
|
+
try {
|
|
325
|
+
read = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (error?.code !== 'ENOENT') throw new JtiStoreError(`the consumed-approval file cannot be read: ${error?.code ?? error?.name}`);
|
|
328
|
+
}
|
|
329
|
+
if (!isObject(read)) throw new JtiStoreError('the consumed-approval file is not a JSON object');
|
|
330
|
+
const seen = new Map(Object.entries(read).filter(([, until]) => typeof until === 'number' && until + LEEWAY_SECONDS >= now));
|
|
331
|
+
if (seen.has(jti)) return false;
|
|
332
|
+
seen.set(jti, expiresAt);
|
|
333
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
334
|
+
const temp = path.join(path.dirname(this.file), `.${path.basename(this.file)}.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
335
|
+
const fd = fs.openSync(temp, 'wx', 0o600);
|
|
336
|
+
try { fs.writeSync(fd, JSON.stringify(Object.fromEntries(seen))); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
337
|
+
fs.renameSync(temp, this.file);
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Every approval may be replayed until it expires. Only on purpose, by name. */
|
|
343
|
+
export const UNSAFE_NO_JTI_STORE = Object.freeze({ consume: () => true });
|
|
344
|
+
|
|
345
|
+
function header(headers, name) {
|
|
346
|
+
if (!headers) return undefined;
|
|
347
|
+
if (typeof headers.get === 'function' && !(headers instanceof Map)) { const v = headers.get(name); return v === null ? undefined : v; }
|
|
348
|
+
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];
|
|
349
|
+
if (entry === null || entry === undefined) return undefined;
|
|
350
|
+
return Array.isArray(entry) ? (entry[0] ?? '') : entry;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** DL-9, DL-10: Fabric-Delegation first (present but empty is still the token), then a delegation-typ Bearer JWT. */
|
|
354
|
+
export function readDelegationToken(headers) {
|
|
355
|
+
const forwarded = header(headers, DELEGATION_HEADER);
|
|
356
|
+
if (forwarded !== undefined) return { token: String(forwarded).trim(), via: 'fabric-delegation' };
|
|
357
|
+
const bearer = /^Bearer\s+(\S+)$/i.exec(header(headers, 'Authorization') ?? '')?.[1];
|
|
358
|
+
const parsed = bearer ? parseCompact(bearer) : undefined;
|
|
359
|
+
if (bearer && parsed && !('problem' in parsed) && parsed.header.typ === DELEGATION_TYP) return { token: bearer, via: 'authorization' };
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** RFC 8785: members sorted by UTF-16 code units, numbers in ECMAScript form; refuses what has no canonical form. */
|
|
364
|
+
export function jcs(value) {
|
|
365
|
+
if (value === null || typeof value === 'boolean') return JSON.stringify(value);
|
|
366
|
+
if (typeof value === 'number') {
|
|
367
|
+
if (!Number.isFinite(value)) throw new TypeError('a non-finite number has no canonical form');
|
|
368
|
+
return JSON.stringify(value);
|
|
369
|
+
}
|
|
370
|
+
if (typeof value === 'string') {
|
|
371
|
+
if (/\p{Surrogate}/u.test(value)) throw new TypeError('a lone surrogate has no canonical form');
|
|
372
|
+
return JSON.stringify(value);
|
|
373
|
+
}
|
|
374
|
+
if (Array.isArray(value)) return `[${Array.from(value, (item) => jcs(item)).join(',')}]`;
|
|
375
|
+
if (isObject(value)) {
|
|
376
|
+
const prototype = Object.getPrototypeOf(value);
|
|
377
|
+
if (prototype !== Object.prototype && prototype !== null) throw new TypeError('only plain JSON objects have a canonical form');
|
|
378
|
+
const keys = Object.keys(value).sort();
|
|
379
|
+
return `{${keys.map((key) => `${jcs(key)}:${jcs(value[key])}`).join(',')}}`;
|
|
380
|
+
}
|
|
381
|
+
throw new TypeError(`${typeof value} has no canonical form`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** The digest an approval binds: sha256: and the hex SHA-256 of the action's RFC 8785 form. */
|
|
385
|
+
export const actionDigest = (action) => `sha256:${crypto.createHash('sha256').update(jcs(action), 'utf8').digest('hex')}`;
|
|
386
|
+
|
|
387
|
+
const loopback = (host) => host === 'localhost' || (net.isIP(host.replace(/^\[|\]$/g, '')) !== 0 && (/^127\./.test(host) || host.replace(/^\[|\]$/g, '') === '::1'));
|
|
388
|
+
|
|
389
|
+
async function defaultFetch(source) {
|
|
390
|
+
if (/^https?:\/\//.test(source)) {
|
|
391
|
+
const response = await fetch(source, { headers: { Accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(5000) });
|
|
392
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
393
|
+
if (Number(response.headers.get('content-length') ?? 0) > 65536) throw new Error('the key set is too large');
|
|
394
|
+
const text = await response.text();
|
|
395
|
+
if (text.length > 65536) throw new Error('the key set is too large');
|
|
396
|
+
return JSON.parse(text);
|
|
397
|
+
}
|
|
398
|
+
return JSON.parse(fs.readFileSync(source.replace(/^~(?=\/)/, process.env.HOME ?? '~'), 'utf8'));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The estate's key set from `auth.delegation.jwks` (https, http on loopback, or a file), cached as DL-15 says.
|
|
403
|
+
* `await get(kid)` answers null when no set younger than 10 minutes can be had (issuer-unavailable).
|
|
404
|
+
*/
|
|
405
|
+
export class JwksCache {
|
|
406
|
+
constructor(source, { fetch: fetcher = defaultFetch, clock = () => Date.now() / 1000 } = {}) {
|
|
407
|
+
if (typeof source !== 'string' || !source) throw new Error('the key set needs a source');
|
|
408
|
+
if (/^https?:\/\//.test(source)) {
|
|
409
|
+
const url = new URL(source);
|
|
410
|
+
if (url.username || url.password) throw new Error('a key set URL carries no credentials');
|
|
411
|
+
if (url.protocol === 'http:' && !loopback(url.hostname)) throw new Error('a key set is read over https, or over http on loopback only');
|
|
412
|
+
} else if (source.includes('://')) {
|
|
413
|
+
throw new Error('a key set is an https URL, a loopback http URL or a file path');
|
|
414
|
+
}
|
|
415
|
+
Object.assign(this, { source, fetcher, clock, keys: null, readAt: -Infinity, triedAt: -Infinity, rereadAt: -Infinity, pending: null });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async read(now) {
|
|
419
|
+
this.triedAt = now;
|
|
420
|
+
try {
|
|
421
|
+
const value = await this.fetcher(this.source);
|
|
422
|
+
if (hasKeys(value)) { this.keys = value; this.readAt = now; }
|
|
423
|
+
} catch { /* the age check below decides */ }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async get(kid) {
|
|
427
|
+
const now = this.clock();
|
|
428
|
+
const fresh = now - this.readAt < JWKS_MAX_AGE_SECONDS;
|
|
429
|
+
const known = this.keys !== null && (kid === undefined || kid === null || this.keys.keys.some((k) => isObject(k) && k.kid === kid));
|
|
430
|
+
if (!fresh) { if (now - this.triedAt >= JWKS_RETRY_SECONDS) await (this.pending ??= this.read(now).finally(() => { this.pending = null; })); }
|
|
431
|
+
else if (!known && now - this.rereadAt >= JWKS_REREAD_SECONDS) { this.rereadAt = now; await (this.pending ??= this.read(now).finally(() => { this.pending = null; })); }
|
|
432
|
+
return this.clock() - this.readAt < JWKS_MAX_AGE_SECONDS ? this.keys : null;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const kidOf = (token) => { const parsed = parseCompact(token); return 'problem' in parsed || typeof parsed.header.kid !== 'string' ? null : parsed.header.kid; };
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* A check for one tools/call: `await gate(tool, headers)` answers the accepted delegation or a DL-19 refusal.
|
|
440
|
+
* `effects` maps each tool to its declared effect class; a tool with none is refused rather than guessed.
|
|
441
|
+
*/
|
|
442
|
+
export const TIERS = Object.freeze(['T0', 'T1', 'T2']);
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* `await gate(tool, headers, tier = 'T2')`. DL-25, DL-26: T0 is a loopback caller under the owner's account that the
|
|
446
|
+
* service's own local floor authenticated, T1 an enrolled machine of the same owner, T2 everything else (and any call
|
|
447
|
+
* relayed from off the machine). A T0 or T1 caller is never required to present a token: with none, the call passes
|
|
448
|
+
* with no delegation. A present token is verified at every tier. `issuer` (auth.delegation.issuer) is required.
|
|
449
|
+
*/
|
|
450
|
+
export function delegationGate({ audience, jwks, effects, issuer, readOnly = false, clock = () => Date.now() / 1000 }) {
|
|
451
|
+
if (!isString(issuer)) throw new Error('the gate needs the issuer it trusts (auth.delegation.issuer)');
|
|
452
|
+
return async (tool, headers, tier = 'T2') => {
|
|
453
|
+
const effect = effects?.[tool];
|
|
454
|
+
if (!EFFECT_CLASSES.includes(effect)) return refuse('unknown-state', 'no-declared-effect');
|
|
455
|
+
const carried = readDelegationToken(headers);
|
|
456
|
+
if (!carried) {
|
|
457
|
+
if (tier === 'T0' || tier === 'T1') return { ok: true, tier, principal: null, caller: null, chain: [], claims: null, kid: null };
|
|
458
|
+
return refuse('unauthenticated', 'no-token');
|
|
459
|
+
}
|
|
460
|
+
const keys = jwks instanceof JwksCache ? await jwks.get(kidOf(carried.token)) : jwks;
|
|
461
|
+
const verdict = verifyDelegation(carried.token, { audience, jwks: keys, now: clock(), capability: tool, effect, readOnly, issuer });
|
|
462
|
+
if (verdict.ok) verdict.tier = TIERS.includes(tier) ? tier : 'T2';
|
|
463
|
+
return verdict;
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** DL-21 step 6 (DEC-0045): the refusal a callee answers after the meter denied a grant check, the meter's code unchanged. */
|
|
468
|
+
export function refusalFromDenial(denial) {
|
|
469
|
+
const data = {
|
|
470
|
+
...(denial.window === undefined ? {} : { window: denial.window }),
|
|
471
|
+
...(denial.level === null || denial.level === undefined ? {} : { level: denial.level }),
|
|
472
|
+
...(denial.approvalRequest === undefined ? {} : { approvalRequest: denial.approvalRequest }),
|
|
473
|
+
};
|
|
474
|
+
const message = `the meter denied the spend: ${denial.reason ?? ''}`.slice(0, 500);
|
|
475
|
+
return { error: { code: denial.code, message, ...(Object.keys(data).length > 0 ? { data } : {}) } };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** The DL-19 body: {error: {code, message, data: {detail, ...}}}; never a token or a key. */
|
|
479
|
+
export const refusalError = (refusal) => ({ error: { code: refusal.code, message: REFUSAL_MESSAGES[refusal.code] ?? 'Refused.', data: { detail: refusal.detail, ...(refusal.data ?? {}) } } });
|
|
480
|
+
|
|
481
|
+
/** An MCP tools/call result: the refusal as structuredContent with isError true (DL-19). */
|
|
482
|
+
export function refusalToolResult(refusal, traceparent) {
|
|
483
|
+
const structured = refusalError(refusal);
|
|
484
|
+
return { resultType: 'complete', content: [{ type: 'text', text: JSON.stringify(structured) }], structuredContent: structured, isError: true, ...(traceparent ? { _meta: { traceparent } } : {}) };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** An HTTP route's answer: {status, headers, body}; a 401 names the bearer scheme (RFC 6750 §3). */
|
|
488
|
+
export function refusalHttp(refusal) {
|
|
489
|
+
const status = REFUSAL_STATUS[refusal.code] ?? 403;
|
|
490
|
+
const headers = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...(status === 401 ? { 'WWW-Authenticate': 'Bearer error="invalid_token"' } : {}) };
|
|
491
|
+
return { status, headers, body: JSON.stringify(refusalError(refusal)) };
|
|
492
|
+
}
|