agent-credential-core.mjs
454 lines 13.2 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 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', 'ingest:automation']);
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' || s === 'ingest:automation') {
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 last_failure_code: null,
239 last_failure_at: null,
240 revoked: false,
241 revoked_at: null,
242 };
243
244 return { records: next, credential, id: cid, record: next[cid] };
245 }
246
247 /**
248 * Persist last-failure health on a known credential id (Lane D §5.2).
249 * @param {Record<string, object>} records
250 * @param {string} cid
251 * @param {'invalid'|'revoked'|'expired'} reason
252 * @param {number} [now]
253 * @returns {Record<string, object>}
254 */
255 export function recordCredentialFailure(records, cid, reason, now) {
256 const allowed = new Set(['invalid', 'revoked', 'expired']);
257 if (!allowed.has(reason)) return cloneRecords(records);
258 const next = cloneRecords(records);
259 if (!next[cid]) return next;
260 const ts = Number.isFinite(now) ? now : Date.now();
261 next[cid].last_failure_code = reason;
262 next[cid].last_failure_at = ts;
263 return next;
264 }
265
266 /**
267 * @param {Record<string, object>} records
268 * @param {string} credential
269 * @param {{ now?: number }} [opts]
270 */
271 export function verifyCredential(records, credential, opts = {}) {
272 const now = Number.isFinite(opts.now) ? opts.now : Date.now();
273 const parsed = parseAgentCredential(credential);
274 if (!parsed) return { ok: false, reason: 'invalid' };
275
276 let found = null;
277 let foundId = null;
278 for (const [cid, rec] of Object.entries(records || {})) {
279 if (rec && rec.lookup_id === parsed.id) {
280 found = rec;
281 foundId = cid;
282 break;
283 }
284 }
285 if (!found || !foundId) return { ok: false, reason: 'invalid' };
286 if (!safeEqualHashes(found.token_hash, hashSecret(parsed.secret))) {
287 return {
288 ok: false,
289 reason: 'invalid',
290 id: foundId,
291 records: recordCredentialFailure(records, foundId, 'invalid', now),
292 };
293 }
294 if (found.revoked) {
295 return {
296 ok: false,
297 reason: 'revoked',
298 id: foundId,
299 sub: found.sub,
300 records: recordCredentialFailure(records, foundId, 'revoked', now),
301 };
302 }
303 if (now >= found.expires_at) {
304 return {
305 ok: false,
306 reason: 'expired',
307 id: foundId,
308 sub: found.sub,
309 records: recordCredentialFailure(records, foundId, 'expired', now),
310 };
311 }
312
313 const next = cloneRecords(records);
314 next[foundId].last_used_at = now;
315 return {
316 ok: true,
317 records: next,
318 id: foundId,
319 sub: found.sub,
320 scopes: [...found.scopes],
321 vault_ids: [...found.vault_ids],
322 name: found.name,
323 };
324 }
325
326 /**
327 * @param {Record<string, object>} records
328 * @param {string} cid
329 * @param {string} sub
330 * @param {{ now?: number }} [opts]
331 */
332 export function revokeCredential(records, cid, sub, opts = {}) {
333 const now = Number.isFinite(opts.now) ? opts.now : Date.now();
334 const next = cloneRecords(records);
335 const rec = next[cid];
336 if (!rec || rec.sub !== sub) return { records: next, revoked: false };
337 if (!rec.revoked) {
338 rec.revoked = true;
339 rec.revoked_at = now;
340 }
341 return { records: next, revoked: true };
342 }
343
344 /**
345 * @param {Record<string, object>} records
346 * @param {string} cid
347 * @param {string} sub
348 * @param {{ now?: number }} [opts]
349 */
350 export function rotateCredential(records, cid, sub, opts = {}) {
351 const now = Number.isFinite(opts.now) ? opts.now : Date.now();
352 const next = cloneRecords(records);
353 const rec = next[cid];
354 if (!rec || rec.sub !== sub || rec.revoked) {
355 const err = new Error('not found');
356 err.code = 'AGENT_CREDENTIAL_NOT_FOUND';
357 throw err;
358 }
359 if (now >= rec.expires_at) {
360 const err = new Error('expired');
361 err.code = 'AGENT_CREDENTIAL_EXPIRED';
362 throw err;
363 }
364 const lookupId = crypto.randomBytes(ID_BYTES).toString('base64url');
365 const secret = crypto.randomBytes(SECRET_BYTES).toString('base64url');
366 rec.lookup_id = lookupId;
367 rec.token_hash = hashSecret(secret);
368 const credential = `${AGENT_CREDENTIAL_PREFIX}${lookupId}.${secret}`;
369 return { records: next, credential, id: cid, record: rec };
370 }
371
372 /**
373 * @param {Record<string, object>} records
374 * @param {string} sub
375 */
376 export function listCredentialsForSub(records, sub) {
377 const out = [];
378 for (const [cid, rec] of Object.entries(records || {})) {
379 if (!rec || rec.sub !== sub) continue;
380 out.push({
381 id: cid,
382 name: rec.name,
383 vault_ids: [...(rec.vault_ids || [])],
384 scopes: [...(rec.scopes || [])],
385 created_at: rec.created_at ?? null,
386 expires_at: rec.expires_at ?? null,
387 last_used_at: rec.last_used_at ?? null,
388 last_failure_code: rec.last_failure_code ?? null,
389 last_failure_at: rec.last_failure_at ?? null,
390 revoked: Boolean(rec.revoked),
391 revoked_at: rec.revoked_at ?? null,
392 });
393 }
394 out.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
395 return out;
396 }
397
398 /**
399 * Normalize request path for propose allowlist (freeze §7.3).
400 * @param {unknown} rawPath
401 * @returns {string}
402 */
403 export function normalizeAgentRequestPath(rawPath) {
404 let p = String(rawPath || '');
405 const q = p.indexOf('?');
406 if (q >= 0) p = p.slice(0, q);
407 if (p.startsWith('/')) p = p.slice(1);
408 return p;
409 }
410
411 const PROPOSE_CREATE_PATHS = new Set([
412 'api/v1/proposals',
413 'api/v1/tasks/proposals',
414 'api/v1/task-loops/proposals',
415 ]);
416
417 /**
418 * @param {unknown} scopes
419 * @param {string} method
420 * @param {string} path
421 * @returns {boolean}
422 */
423 export function agentScopesPermitMethod(scopes, method, path) {
424 const list = Array.isArray(scopes) ? scopes.map(String) : [];
425 const m = String(method || 'GET').toUpperCase();
426 const np = normalizeAgentRequestPath(path);
427 if (np === 'api/v1/automation/ingest-rules' || np.startsWith('api/v1/automation/ingest-rules/')) {
428 return false;
429 }
430 const safe = m === 'GET' || m === 'HEAD' || m === 'OPTIONS';
431 const hasWrite =
432 list.includes('vault:write') || list.includes('vault:admin') || list.includes('admin');
433 if (hasWrite) return true;
434 if (safe) return list.includes('vault:read');
435 if (m === 'POST' && np === 'api/v1/automation/ingest') {
436 return list.includes('ingest:automation');
437 }
438 if (!list.includes('propose')) return false;
439 if (m !== 'POST') return false;
440 return PROPOSE_CREATE_PATHS.has(np);
441 }
442
443 /**
444 * @param {object|null|undefined} payload
445 * @param {string} vaultId
446 * @returns {boolean}
447 */
448 export function assertAgentVaultAllowed(payload, vaultId) {
449 if (!payload || typeof payload !== 'object') return false;
450 if (payload.type !== AGENT_ACCESS_TYPE) return true;
451 const ids = Array.isArray(payload.vault_ids) ? payload.vault_ids.map(String) : [];
452 const vid = String(vaultId || 'default').trim() || 'default';
453 return ids.includes(vid);
454 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago