agent-credential-core.mjs
402 lines 11.7 KB
Raw
sha256:e4c529f14a0bb908c1caaaeb3f95f3623a1a82e636e7e3722ca2cd3dc9821263 security: npm audit fix pre-bridge 2026-07-29 Human 39 days ago
1 /**
2 * Phase C — scoped REST agent credential core (storage-agnostic).
3 *
4 * Opaque credentials use wire format `kt_agent_<id>.<secret>`, hash-at-rest, and
5 * never consume-on-use (unlike OAuth refresh rotation). See
6 * docs/DURABLE-AGENT-AUTH-PHASE-C-FREEZE.md.
7 */
8
9 import crypto from 'node:crypto';
10
11 /** Default credential lifetime: 90 days. */
12 export const DEFAULT_CREDENTIAL_TTL_MS = 90 * 24 * 60 * 60 * 1000;
13 /** Access JWT lifetime (seconds) — freeze §5.2. */
14 export const AGENT_ACCESS_TTL_SECONDS = 900;
15 /** Max non-revoked credentials per sub. */
16 export const MAX_CREDENTIALS_PER_SUB = 25;
17 /** Absolute max credential TTL (ms). */
18 export const MAX_CREDENTIAL_TTL_MS = DEFAULT_CREDENTIAL_TTL_MS;
19 /** Minimum credential TTL (ms). */
20 export const MIN_CREDENTIAL_TTL_MS = 60 * 60 * 1000;
21
22 export const AGENT_CREDENTIAL_PREFIX = 'kt_agent_';
23 export const AGENT_ACCESS_TYPE = 'agent_access';
24 export const AGENT_ACCESS_TYP = 'kt_agent_access';
25 export const AGENT_ACCESS_AUD = 'knowtation-hub-rest';
26
27 export const ALLOWED_AGENT_SCOPES = Object.freeze(['vault:read', 'propose', 'vault:write']);
28 export const FORBIDDEN_AGENT_SCOPES = Object.freeze(['admin', 'vault:admin']);
29 export const DEFAULT_AGENT_SCOPES = Object.freeze(['propose', 'vault:read']);
30
31 const SECRET_BYTES = 32;
32 const ID_BYTES = 16;
33 const CID_BYTES = 16;
34
35 /**
36 * @param {string} secret
37 * @returns {string}
38 */
39 export function hashSecret(secret) {
40 return crypto.createHash('sha256').update(String(secret)).digest('base64url');
41 }
42
43 /**
44 * @param {string} a
45 * @param {string} b
46 * @returns {boolean}
47 */
48 function safeEqualHashes(a, b) {
49 if (typeof a !== 'string' || typeof b !== 'string') return false;
50 const ab = Buffer.from(a);
51 const bb = Buffer.from(b);
52 if (ab.length !== bb.length) return false;
53 return crypto.timingSafeEqual(ab, bb);
54 }
55
56 /**
57 * @param {unknown} token
58 * @returns {{ id: string, secret: string } | null}
59 */
60 export function parseAgentCredential(token) {
61 if (typeof token !== 'string' || !token.startsWith(AGENT_CREDENTIAL_PREFIX)) return null;
62 const rest = token.slice(AGENT_CREDENTIAL_PREFIX.length);
63 const dot = rest.indexOf('.');
64 if (dot <= 0 || dot === rest.length - 1) return null;
65 const id = rest.slice(0, dot);
66 const secret = rest.slice(dot + 1);
67 if (!id || !secret || secret.includes('.')) return null;
68 return { id, secret };
69 }
70
71 /**
72 * @param {unknown} scopes
73 * @returns {string[]}
74 */
75 export function normalizeScopes(scopes) {
76 if (!Array.isArray(scopes)) return [...DEFAULT_AGENT_SCOPES];
77 const out = [];
78 for (const s of scopes) {
79 const t = String(s || '').trim();
80 if (!t) continue;
81 if (FORBIDDEN_AGENT_SCOPES.includes(t)) {
82 const err = new Error('admin scopes forbidden');
83 err.code = 'AGENT_SCOPE_FORBIDDEN';
84 throw err;
85 }
86 if (!ALLOWED_AGENT_SCOPES.includes(t)) {
87 const err = new Error(`unknown scope: ${t}`);
88 err.code = 'AGENT_SCOPE_UNKNOWN';
89 throw err;
90 }
91 if (!out.includes(t)) out.push(t);
92 }
93 if (out.length === 0) {
94 const err = new Error('scopes required');
95 err.code = 'AGENT_SCOPE_EMPTY';
96 throw err;
97 }
98 return out;
99 }
100
101 /**
102 * @param {string[]} requested
103 * @param {string[]} roleScopes
104 * @returns {string[]}
105 */
106 export function applyScopeCeiling(requested, roleScopes) {
107 const role = Array.isArray(roleScopes) ? roleScopes.map(String) : [];
108 const out = [];
109 for (const s of requested) {
110 if (s === 'propose') {
111 out.push(s);
112 continue;
113 }
114 if (role.includes(s) || (s === 'vault:write' && role.includes('vault:write'))) {
115 out.push(s);
116 }
117 }
118 if (out.length === 0) {
119 const err = new Error('scopes exceed caller ceiling');
120 err.code = 'AGENT_SCOPE_CEILING';
121 throw err;
122 }
123 return out;
124 }
125
126 /**
127 * @param {unknown} vaultIds
128 * @returns {string[]}
129 */
130 export function normalizeVaultIds(vaultIds) {
131 if (!Array.isArray(vaultIds) || vaultIds.length === 0) {
132 const err = new Error('vault_ids required');
133 err.code = 'AGENT_VAULT_IDS_REQUIRED';
134 throw err;
135 }
136 const out = [];
137 for (const v of vaultIds) {
138 const t = String(v || '').trim();
139 if (!t) continue;
140 if (!out.includes(t)) out.push(t.slice(0, 128));
141 if (out.length > 32) {
142 const err = new Error('too many vault_ids');
143 err.code = 'AGENT_VAULT_IDS_LIMIT';
144 throw err;
145 }
146 }
147 if (out.length === 0) {
148 const err = new Error('vault_ids required');
149 err.code = 'AGENT_VAULT_IDS_REQUIRED';
150 throw err;
151 }
152 return out;
153 }
154
155 /**
156 * @param {Record<string, object>} records
157 * @returns {Record<string, object>}
158 */
159 function cloneRecords(records) {
160 const out = {};
161 if (records && typeof records === 'object') {
162 for (const [k, v] of Object.entries(records)) {
163 if (v && typeof v === 'object') {
164 out[k] = {
165 ...v,
166 vault_ids: Array.isArray(v.vault_ids) ? [...v.vault_ids] : [],
167 scopes: Array.isArray(v.scopes) ? [...v.scopes] : [],
168 };
169 }
170 }
171 }
172 return out;
173 }
174
175 /**
176 * Count non-revoked credentials for a sub.
177 * @param {Record<string, object>} records
178 * @param {string} sub
179 * @returns {number}
180 */
181 export function countActiveForSub(records, sub) {
182 let n = 0;
183 for (const rec of Object.values(records || {})) {
184 if (rec && rec.sub === sub && !rec.revoked) n += 1;
185 }
186 return n;
187 }
188
189 /**
190 * @param {Record<string, object>} records
191 * @param {{
192 * sub: string,
193 * name: string,
194 * vault_ids: string[],
195 * scopes: string[],
196 * now?: number,
197 * ttlMs?: number,
198 * }} opts
199 */
200 export function mintCredential(records, opts) {
201 const sub = typeof opts.sub === 'string' ? opts.sub.trim() : '';
202 if (!sub) throw new Error('mintCredential: sub is required');
203 const name = String(opts.name || '').trim().slice(0, 128);
204 if (!name) {
205 const err = new Error('name required');
206 err.code = 'AGENT_NAME_REQUIRED';
207 throw err;
208 }
209 const vault_ids = normalizeVaultIds(opts.vault_ids);
210 const scopes = normalizeScopes(opts.scopes);
211 const now = Number.isFinite(opts.now) ? opts.now : Date.now();
212 let ttlMs = Number.isFinite(opts.ttlMs) ? opts.ttlMs : DEFAULT_CREDENTIAL_TTL_MS;
213 if (ttlMs < MIN_CREDENTIAL_TTL_MS) ttlMs = MIN_CREDENTIAL_TTL_MS;
214 if (ttlMs > MAX_CREDENTIAL_TTL_MS) ttlMs = MAX_CREDENTIAL_TTL_MS;
215
216 const next = cloneRecords(records);
217 if (countActiveForSub(next, sub) >= MAX_CREDENTIALS_PER_SUB) {
218 const err = new Error('credential limit');
219 err.code = 'AGENT_CREDENTIAL_LIMIT';
220 throw err;
221 }
222
223 const lookupId = crypto.randomBytes(ID_BYTES).toString('base64url');
224 const secret = crypto.randomBytes(SECRET_BYTES).toString('base64url');
225 const cid = crypto.randomBytes(CID_BYTES).toString('base64url');
226 const credential = `${AGENT_CREDENTIAL_PREFIX}${lookupId}.${secret}`;
227
228 next[cid] = {
229 sub,
230 name,
231 lookup_id: lookupId,
232 token_hash: hashSecret(secret),
233 vault_ids,
234 scopes,
235 created_at: now,
236 expires_at: now + ttlMs,
237 last_used_at: null,
238 revoked: false,
239 revoked_at: null,
240 };
241
242 return { records: next, credential, id: cid, record: next[cid] };
243 }
244
245 /**
246 * @param {Record<string, object>} records
247 * @param {string} credential
248 * @param {{ now?: number }} [opts]
249 */
250 export function verifyCredential(records, credential, opts = {}) {
251 const now = Number.isFinite(opts.now) ? opts.now : Date.now();
252 const parsed = parseAgentCredential(credential);
253 if (!parsed) return { ok: false, reason: 'invalid' };
254
255 let found = null;
256 let foundId = null;
257 for (const [cid, rec] of Object.entries(records || {})) {
258 if (rec && rec.lookup_id === parsed.id) {
259 found = rec;
260 foundId = cid;
261 break;
262 }
263 }
264 if (!found || !foundId) return { ok: false, reason: 'invalid' };
265 if (!safeEqualHashes(found.token_hash, hashSecret(parsed.secret))) {
266 return { ok: false, reason: 'invalid' };
267 }
268 if (found.revoked) return { ok: false, reason: 'revoked', id: foundId, sub: found.sub };
269 if (now >= found.expires_at) return { ok: false, reason: 'expired', id: foundId, sub: found.sub };
270
271 const next = cloneRecords(records);
272 next[foundId].last_used_at = now;
273 return {
274 ok: true,
275 records: next,
276 id: foundId,
277 sub: found.sub,
278 scopes: [...found.scopes],
279 vault_ids: [...found.vault_ids],
280 name: found.name,
281 };
282 }
283
284 /**
285 * @param {Record<string, object>} records
286 * @param {string} cid
287 * @param {string} sub
288 * @param {{ now?: number }} [opts]
289 */
290 export function revokeCredential(records, cid, sub, opts = {}) {
291 const now = Number.isFinite(opts.now) ? opts.now : Date.now();
292 const next = cloneRecords(records);
293 const rec = next[cid];
294 if (!rec || rec.sub !== sub) return { records: next, revoked: false };
295 if (!rec.revoked) {
296 rec.revoked = true;
297 rec.revoked_at = now;
298 }
299 return { records: next, revoked: true };
300 }
301
302 /**
303 * @param {Record<string, object>} records
304 * @param {string} cid
305 * @param {string} sub
306 * @param {{ now?: number }} [opts]
307 */
308 export function rotateCredential(records, cid, sub, opts = {}) {
309 const now = Number.isFinite(opts.now) ? opts.now : Date.now();
310 const next = cloneRecords(records);
311 const rec = next[cid];
312 if (!rec || rec.sub !== sub || rec.revoked) {
313 const err = new Error('not found');
314 err.code = 'AGENT_CREDENTIAL_NOT_FOUND';
315 throw err;
316 }
317 if (now >= rec.expires_at) {
318 const err = new Error('expired');
319 err.code = 'AGENT_CREDENTIAL_EXPIRED';
320 throw err;
321 }
322 const lookupId = crypto.randomBytes(ID_BYTES).toString('base64url');
323 const secret = crypto.randomBytes(SECRET_BYTES).toString('base64url');
324 rec.lookup_id = lookupId;
325 rec.token_hash = hashSecret(secret);
326 const credential = `${AGENT_CREDENTIAL_PREFIX}${lookupId}.${secret}`;
327 return { records: next, credential, id: cid, record: rec };
328 }
329
330 /**
331 * @param {Record<string, object>} records
332 * @param {string} sub
333 */
334 export function listCredentialsForSub(records, sub) {
335 const out = [];
336 for (const [cid, rec] of Object.entries(records || {})) {
337 if (!rec || rec.sub !== sub) continue;
338 out.push({
339 id: cid,
340 name: rec.name,
341 vault_ids: [...(rec.vault_ids || [])],
342 scopes: [...(rec.scopes || [])],
343 created_at: rec.created_at,
344 expires_at: rec.expires_at,
345 last_used_at: rec.last_used_at,
346 revoked: Boolean(rec.revoked),
347 });
348 }
349 out.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
350 return out;
351 }
352
353 /**
354 * Normalize request path for propose allowlist (freeze §7.3).
355 * @param {unknown} rawPath
356 * @returns {string}
357 */
358 export function normalizeAgentRequestPath(rawPath) {
359 let p = String(rawPath || '');
360 const q = p.indexOf('?');
361 if (q >= 0) p = p.slice(0, q);
362 if (p.startsWith('/')) p = p.slice(1);
363 return p;
364 }
365
366 const PROPOSE_CREATE_PATHS = new Set([
367 'api/v1/proposals',
368 'api/v1/tasks/proposals',
369 'api/v1/task-loops/proposals',
370 ]);
371
372 /**
373 * @param {unknown} scopes
374 * @param {string} method
375 * @param {string} path
376 * @returns {boolean}
377 */
378 export function agentScopesPermitMethod(scopes, method, path) {
379 const list = Array.isArray(scopes) ? scopes.map(String) : [];
380 const m = String(method || 'GET').toUpperCase();
381 const safe = m === 'GET' || m === 'HEAD' || m === 'OPTIONS';
382 const hasWrite =
383 list.includes('vault:write') || list.includes('vault:admin') || list.includes('admin');
384 if (hasWrite) return true;
385 if (safe) return list.includes('vault:read');
386 if (!list.includes('propose')) return false;
387 if (m !== 'POST') return false;
388 return PROPOSE_CREATE_PATHS.has(normalizeAgentRequestPath(path));
389 }
390
391 /**
392 * @param {object|null|undefined} payload
393 * @param {string} vaultId
394 * @returns {boolean}
395 */
396 export function assertAgentVaultAllowed(payload, vaultId) {
397 if (!payload || typeof payload !== 'object') return false;
398 if (payload.type !== AGENT_ACCESS_TYPE) return true;
399 const ids = Array.isArray(payload.vault_ids) ? payload.vault_ids.map(String) : [];
400 const vid = String(vaultId || 'default').trim() || 'default';
401 return ids.includes(vid);
402 }
File History 1 commit
sha256:e4c529f14a0bb908c1caaaeb3f95f3623a1a82e636e7e3722ca2cd3dc9821263 security: npm audit fix pre-bridge 2026-07-29 Human 39 days ago