import { openSync, closeSync, fstatSync, readSync, readFileSync, lstatSync, realpathSync, constants, mkdirSync, writeSync, fsyncSync, renameSync, unlinkSync, rmdirSync, readdirSync, } from 'node:fs'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { createHash } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import { BusError } from './broker.mjs'; import { credentialKind, isHostKind, ID_PATTERN } from '../../organization/src/vocabulary.mjs'; const deny = (code) => { throw new BusError(code); }; const inside = (root, path) => { const r = relative(root, path); return r === '' || (!r.startsWith('..') && !isAbsolute(r)); }; export const day = (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value; const TOKEN = /^[-A-Za-z0-9._~]+\n?$/; const PEM_BEGIN = '-----BEGIN OPENSSH PRIVATE KEY-----'; const PEM_END = '-----END OPENSSH PRIVATE KEY-----'; // Row 210 (#1707, design D2): an unencrypted openssh-key-v1 ed25519 key, parsed with Buffer so the // check runs at bus start with no process. Returns the public line and what D8 searches for. export function parseHostKey(text) { if (typeof text !== 'string' || text.length > 16384 || text.includes('\r')) deny('credential-format'); const lines = text.replace(/\n$/, '').split('\n'); if (lines.length < 3 || lines[0] !== PEM_BEGIN || lines.at(-1) !== PEM_END) deny('credential-format'); const body = lines.slice(1, -1); if (body.some((l) => !/^[A-Za-z0-9+/]+={0,2}$/.test(l))) deny('credential-format'); const joined = body.join(''); const blob = Buffer.from(joined, 'base64'); if (blob.toString('base64') !== joined) deny('credential-format'); let at = 0; const bytes = (n) => { if (n < 0 || at + n > blob.length) deny('credential-format'); const out = blob.subarray(at, at + n); at += n; return out; }; const u32 = () => bytes(4).readUInt32BE(0); const str = () => bytes(u32()); const magic = Buffer.from('openssh-key-v1\0'); if (!bytes(magic.length).equals(magic)) deny('credential-format'); if (str().toString() !== 'none' || str().toString() !== 'none' || str().length !== 0) deny('credential-format'); if (u32() !== 1) deny('credential-format'); const publicBlob = str(); const priv = str(); if (at !== blob.length) deny('credential-format'); if ( publicBlob.length !== 51 || publicBlob.readUInt32BE(0) !== 11 || publicBlob.subarray(4, 15).toString() !== 'ssh-ed25519' || publicBlob.readUInt32BE(15) !== 32 ) deny('credential-format'); const pub = publicBlob.subarray(19); // The private section: check1, check2, key type, public, private (seed then public), comment, padding. let p = 0; const take = (n) => { if (n < 0 || p + n > priv.length) deny('credential-format'); const out = priv.subarray(p, p + n); p += n; return out; }; const pu32 = () => take(4).readUInt32BE(0); const pstr = () => take(pu32()); if (pu32() !== pu32()) deny('credential-format'); if (pstr().toString() !== 'ssh-ed25519') deny('credential-format'); if (!pstr().equals(pub)) deny('credential-format'); const secret = pstr(); if (secret.length !== 64 || !secret.subarray(32).equals(pub)) deny('credential-format'); pstr(); const padding = priv.subarray(p); if (padding.length >= 8 || padding.some((b, i) => b !== i + 1) || priv.length % 8 !== 0) deny('credential-format'); const seed = Buffer.from(secret.subarray(0, 32)); const publicLine = 'ssh-ed25519 ' + publicBlob.toString('base64'); const fingerprint = 'SHA256:' + createHash('sha256').update(publicBlob).digest('base64').replace(/=+$/, ''); // D8: each body line of 24 characters or more, the whole body, and the seed as hex and as base64 // at each of the three byte offsets (only the characters that depend on the seed alone). const needles = [...body.filter((l) => l.length >= 24), joined, seed.toString('hex')]; for (const offset of [0, 1, 2]) { const encoded = Buffer.concat([Buffer.alloc(offset), seed]).toString('base64'); needles.push(encoded.slice(Math.ceil((8 * offset) / 6), Math.floor((8 * (offset + 32)) / 6))); } return { text: lines.join('\n') + '\n', publicLine, fingerprint, seed, needles }; } // Credentials.assertClean's test: credential-leak when the value's JSON text holds an entry's token // or one of its retiring model keys. org-defscan (#1873): issue's check runs it on the value it is // about to hold. function assertNotHeld(value, entries) { let encoded; try { encoded = JSON.stringify(value); } catch { deny('invalid-request'); } for (const { token, model } of entries) if ([token, ...(model?.retiring ?? []).map(r => r.key)].some(k => encoded?.includes(k))) deny('credential-leak'); } // D8: refuses credential-leak when the value's JSON text holds any part of the key. export function assertCleanKey(value, key) { let encoded; try { encoded = JSON.stringify(value); } catch { deny('invalid-request'); } if (encoded === undefined) return; if (key.needles.some((n) => encoded.includes(n))) deny('credential-leak'); } // Row 199 (#1697, design D1, D4): a store dir is the stack instance's user's own and nobody else's. export const privateDir = (path, { missing = false } = {}) => { let st; try { st = lstatSync(path); } catch (e) { if (missing && e.code === 'ENOENT') return false; deny('credential-file'); } if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o077) !== 0) deny('credential-file'); return true; }; export class Credentials { #tokens = new Map(); #modelProofs = new Map(); #modelTokens = new WeakMap(); // Row 199: every reference the bus loaded, so issue, rotate, revoke and status work per entry. #refs = new Map(); #revoked = new Set(); // Row 210 (design D5, D9): instance:host. -> the AbortController of the call in flight. #inflight = new Map(); // Row 236 (#436b section 5): instance:service entries whose file memory never loads, the // grant-token-unknown pairs. A revoke or a new issue clears one. #held = new Set(); #storeRoot; #repoRoots; #dataRoot; // storeRoot is /secrets/agents (design D1); a reference with store: true must name the // path under it for its own business, agent and service. `held` lists "instance:service" keys whose // reference loads and whose file doesn't (#436b section 5). constructor({ references = {}, repoRoots = [], dataRoot, env = process.env, storeRoot = null, held = [], modelAgents = [] }) { this.#storeRoot = storeRoot; this.#repoRoots = repoRoots; this.#dataRoot = dataRoot; for (const k of held) this.#held.add(k); try { for (const [instance, services] of Object.entries(references)) for (const [service, ref] of Object.entries(services)) { const date = this.#checkRef(instance, service, ref); this.#refs.set(instance + ':' + service, { ref, date }); if (this.#held.has(instance + ':' + service)) continue; const value = this.#read(ref, env, service); if (value !== null) this.#tokens.set(instance + ':' + service, { ...value, date }); } for (const instance of modelAgents) this.registerModel(instance); } catch (e) { this.close(); throw e; } } // One reference's checks, for the constructor and register (row 235, r6 X4). Returns its date. #checkRef(instance, service, ref) { if (!(credentialKind(service) || service === 'model') || !ref || typeof ref !== 'object') deny('credential-reference'); const date = service === 'vikunja' ? ref.expires : ref.rotateBy; if (!day(date)) deny('credential-date'); if ( (ref.service !== undefined && ref.service !== service) || Object.keys(ref).some( (k) => !['service', 'file', 'env', 'store', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k), ) || Boolean(ref.file) === Boolean(ref.env) || (ref.store !== undefined && (ref.store !== true || ref.file !== this.#storePath(instance, service))) ) deny('credential-reference'); return date; } // Row 235 (#1741, r6 X4): the grant executor adds a reference before `issue`, which refuses an unknown // one. It reads no file, so a key file a failed revoke left can't come back. An entry with a live // token refuses, and an entry with none, revoked or never issued, is replaced. register(instance, service, ref) { const k = instance + ':' + service; if (this.#tokens.has(k)) deny('credential-reference'); const date = this.#checkRef(instance, service, ref); this.#invalidateModelProof(instance, service); this.#refs.set(k, { ref, date }); this.#revoked.delete(k); } // r6 X3, X4: the executor's undo for a reference it registered. An entry with a live token refuses. unregister(instance, service) { const k = instance + ':' + service; if (this.#tokens.has(k)) deny('credential-reference'); this.#invalidateModelProof(instance, service); this.#refs.delete(k); this.#revoked.delete(k); } // Host-only implicit kind. It is deliberately absent from role service vocabulary: // neither role authority nor an agent's credential reference can opt into it. registerModel(instance) { const parts = typeof instance === 'string' ? instance.split('/') : []; if (parts.length !== 2 || parts.some(p => !ID_PATTERN.test(p))) deny('credential-reference'); const k = instance + ':model'; if (this.#refs.has(k)) return; const file = this.#storePath(instance, 'model'); const ref = { store: true, file, rotateBy: '9999-12-31' }; this.#invalidateModelProof(instance); this.#refs.set(k, { ref, date: ref.rotateBy }); // An instance may have no credential directory before its first hire/launch. if (!privateDir(this.#storeRoot, { missing: true })) return; const value = this.#read(ref, {}, 'model'); if (value) { this.#tokens.set(k, { ...value, date: value.model.rotateBy }); this.#refs.set(k, { ref: { ...ref, rotateBy: value.model.rotateBy }, date: value.model.rotateBy }); } } // Trusted lifecycle use only; no socket verb returns this private record. modelRecord(instance) { const entry = this.#tokens.get(instance + ':model'); return entry?.model ? structuredClone(entry.model) : null; } writeModel(instance, record) { this.registerModel(instance); this.issue(instance, 'model', JSON.stringify(record), record?.rotateBy); return this.status().find(e => e.instance === instance && e.service === 'model'); } // Host-only proof state, never returned by status or an agent/IPC verb. #invalidateModelProof(instance, service = 'model') { if (service === 'model') this.#modelProofs.delete(instance); } #modelFileSnapshot(file, optional = false) { let fd; try { try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch (error) { if (optional && error.code === 'ENOENT') return null; throw error; } const first = fstatSync(fd, { bigint: true }); if (!first.isFile() || first.nlink !== 1n || first.uid !== BigInt(process.getuid()) || (first.mode & 0o777n) !== 0o600n || first.size > 16384n) deny('credential-revoke-failed'); const bytes = Buffer.alloc(16385); let length = 0; while (length < bytes.length) { const count = readSync(fd, bytes, length, bytes.length - length, null); if (!count) break; length += count; } const identity = stat => ({ dev: stat.dev, ino: stat.ino, size: stat.size, mode: stat.mode, uid: stat.uid, nlink: stat.nlink, mtime: stat.mtimeNs, ctime: stat.ctimeNs }); const last = fstatSync(fd, { bigint: true }), path = lstatSync(file, { bigint: true }); if (length > 16384 || BigInt(length) !== first.size || !isDeepStrictEqual(identity(first), identity(last)) || !isDeepStrictEqual(identity(first), identity(path))) deny('credential-revoke-failed'); const content = Buffer.from(bytes.subarray(0, length)); return { identity: identity(first), content, model: Credentials.#format('model', content.toString('utf8')).model }; } catch { deny('credential-revoke-failed'); } finally { if (fd !== undefined) closeSync(fd); } } #modelRecoveryPaths(instance) { const known = this.#known(instance, 'model'), file = this.#storePath(instance, 'model'); if (!known.ref.store || known.ref.file !== file) deny('credential-revoke-failed'); const directories = [this.#storeRoot, dirname(dirname(file)), dirname(file)].map(path => { privateDir(path); const stat = lstatSync(path, { bigint: true }); if (!stat.isDirectory() || stat.uid !== BigInt(process.getuid()) || (stat.mode & 0o777n) !== 0o700n) deny('credential-revoke-failed'); return { dev: stat.dev, ino: stat.ino, mode: stat.mode, uid: stat.uid }; }); return { file, directories }; } #modelRecoveryFence(file) { // Read-only refusal, never a generic cleanup/deletion allowlist. if (readdirSync(dirname(file)).some(name => name !== 'model' && name !== 'model.tmp' && !credentialKind(name.replace(/\.tmp$/, '')))) deny('credential-revoke-failed'); } #modelProof(instance, token) { const proof = this.#modelTokens.get(token); if (!proof || proof.instance !== instance || this.#modelProofs.get(instance) !== proof) deny('credential-revoke-failed'); return proof; } #verifyModelProof(instance, proof, temporaryExpected) { const paths = this.#modelRecoveryPaths(instance), k = instance + ':model'; if (!isDeepStrictEqual(paths.directories, proof.directories) || this.#held.has(k) || this.#inflight.has(k) || !isDeepStrictEqual(this.modelRecord(instance), proof.final.model)) deny('credential-revoke-failed'); this.#modelRecoveryFence(paths.file); const final = this.#modelFileSnapshot(paths.file), temporary = this.#modelFileSnapshot(paths.file + '.tmp', true); if (!isDeepStrictEqual(final, proof.final) || !isDeepStrictEqual(temporary, temporaryExpected)) deny('credential-revoke-failed'); return paths.file; } captureModelRevocation(instance) { this.#invalidateModelProof(instance); try { const { file, directories } = this.#modelRecoveryPaths(instance), k = instance + ':model'; const final = this.#modelFileSnapshot(file), temporary = this.#modelFileSnapshot(file + '.tmp', true); const record = this.modelRecord(instance); if (record?.state !== 'revoking' || this.#held.has(k) || this.#inflight.has(k) || !isDeepStrictEqual(record, final.model) || (temporary && !isDeepStrictEqual(final.model, temporary.model))) deny('credential-revoke-failed'); this.#modelRecoveryFence(file); const token = Object.freeze({}), proof = { instance, directories, final, temporary, phase: 'captured' }; this.#modelProofs.set(instance, proof); this.#modelTokens.set(token, proof); return token; } catch { deny('credential-revoke-failed'); } } prepareModelCompletion(instance, token) { try { const proof = this.#modelProof(instance, token); if (!['captured', 'tmp-removed', 'prepared'].includes(proof.phase)) deny('credential-revoke-failed'); const expected = proof.phase === 'captured' ? proof.temporary : null; const file = this.#verifyModelProof(instance, proof, expected); if (expected) { // Cooperating single-controller/private0700 model writers only. NO await // or callback after final verification. NOT inode/content CAS against an // arbitrary external same-UID path replacement in the final syscall gap. unlinkSync(file + '.tmp'); proof.phase = 'tmp-removed'; // Own successful unlink provenance before absence read. try { lstatSync(file + '.tmp'); deny('credential-revoke-failed'); } catch (error) { if (error.code !== 'ENOENT') throw error; } } proof.phase = 'prepared'; } catch { deny('credential-revoke-failed'); } } finishModelRevocation(instance, token) { try { const proof = this.#modelProof(instance, token), k = instance + ':model'; if (proof.phase === 'finished') { // Exact same private completion provenance, invalidated by every model // rewrite/reload/ref mutation/close. Not proof from initial absence. const { file, directories } = this.#modelRecoveryPaths(instance); if (!isDeepStrictEqual(directories, proof.directories) || this.#tokens.has(k) || this.#held.has(k) || this.#inflight.has(k)) deny('credential-revoke-failed'); this.#modelRecoveryFence(file); for (const path of [file, file + '.tmp']) { try { lstatSync(path); deny('credential-revoke-failed'); } catch (error) { if (error.code !== 'ENOENT') throw error; } } return; } if (proof.phase !== 'prepared') deny('credential-revoke-failed'); const file = this.#verifyModelProof(instance, proof, null); // Same qualified path-unlink limitation as tmp removal; no callback/await. unlinkSync(file); // ONE final unlink only. NO further fallible filesystem operation or abort // callback after success. Ref/date remain as generic revoke bookkeeping; // model held/inflight slots were required absent BEFORE final verification. this.#tokens.delete(k); this.#revoked.add(k); this.#held.delete(k); proof.phase = 'finished'; proof.final = null; proof.temporary = null; } catch { deny('credential-revoke-failed'); } } // r6 X5: the executor holds the entry's #inflight slot for its steps, so a host.run refuses host-busy // and revoke's abort() reaches the executor. A slot already held refuses host-busy. Returns the signal. claim(instance, service) { const k = instance + ':' + service; if (this.#inflight.has(k)) deny('host-busy'); this.#invalidateModelProof(instance, service); const controller = new AbortController(); this.#inflight.set(k, controller); return controller.signal; } // Frees the slot only if `signal` is the claim's own, so a late release can't free a later claim. release(instance, service, signal) { const k = instance + ':' + service; if (this.#inflight.get(k)?.signal === signal) this.#inflight.delete(k); } // r6 X5: hosts.revoke with no reference to revoke. Aborts a live claim or call and says whether there // was one. abortClaim(instance, service) { const controller = this.#inflight.get(instance + ':' + service); controller?.abort(); return { pending: Boolean(controller) }; } #storePath(instance, service) { if (!this.#storeRoot) deny('credential-reference'); const [business, agent, ...rest] = instance.split('/'); if (!business || !agent || rest.length || agent.startsWith('@')) deny('credential-reference'); return join(this.#storeRoot, business, agent, service); } // One token, read and checked, or null for a store reference with no file yet (design D3a). #read(ref, env, service) { let token; if (ref.store) { const agentDir = dirname(ref.file); privateDir(this.#storeRoot); if (!privateDir(dirname(agentDir), { missing: true }) || !privateDir(agentDir, { missing: true })) return null; try { lstatSync(ref.file); } catch (e) { if (e.code === 'ENOENT') return null; deny('credential-file'); } } if (ref.file) { if (!isAbsolute(ref.file)) deny('credential-location'); let resolved, original; try { original = lstatSync(ref.file); resolved = realpathSync(ref.file); } catch { deny('credential-file'); } if (original.isSymbolicLink() || !original.isFile()) deny('credential-file'); if ( [...this.#repoRoots, this.#dataRoot].filter(Boolean).some((root) => inside( (() => { try { return realpathSync(root); } catch { return resolve(root); } })(), resolved, ), ) ) deny('credential-location'); let fd; try { fd = openSync(ref.file, constants.O_RDONLY | constants.O_NOFOLLOW); const s = fstatSync(fd); if ( !s.isFile() || s.uid !== process.getuid() || (s.mode & 0o777) !== 0o600 || s.size > 16384 || s.ino !== original.ino || s.dev !== original.dev ) deny('credential-file'); token = readFileSync(fd, 'utf8'); } catch (e) { if (e instanceof BusError) throw e; deny('credential-file'); } finally { if (fd !== undefined) closeSync(fd); } } else { if (!/^[A-Z_][A-Z0-9_]*$/.test(ref.env)) deny('credential-reference'); token = env[ref.env]; } return Credentials.#format(service, token); } // { token } for gitea and vikunja, { token, key } for host. (design D2). static #format(service, token) { if (service === 'model') { let m; try { m = JSON.parse(token); } catch { deny('credential-format'); } const keyOk = k => typeof k === 'string' && /^(?:msk-[a-z0-9]+|mso-[a-f0-9]{12}-[a-z0-9][a-z0-9._-]{0,63}-key)-[A-Za-z0-9_-]{43}$/.test(k); const keyId = k => createHash('sha256').update(k).digest('hex').slice(0, 12); const instant = s => typeof s === 'string' && !Number.isNaN(Date.parse(s)) && new Date(s).toISOString() === s; // Row 333 R3 (#1831): `previous` lists the agent's earlier key ids, ids only, oldest first, at // most 64. It is absent until a key first leaves the record, so a record a pre-R3 bus wrote // still loads. A pre-R3 bus refuses a record that carries it, credential-format. if (!m || Array.isArray(m) || Object.keys(m).some(k => !['key', 'keyId', 'issuedAt', 'rotateBy', 'state', 'revokingAt', 'retiring', 'previous'].includes(k)) || !keyOk(m.key) || m.keyId !== keyId(m.key) || !instant(m.issuedAt) || !day(m.rotateBy) || !['valid', 'revoking'].includes(m.state) || (m.state === 'revoking' ? !instant(m.revokingAt) : m.revokingAt !== undefined) || !Array.isArray(m.retiring) || m.retiring.length > 64 || m.retiring.some(r => !r || Array.isArray(r) || Object.keys(r).sort().join(',') !== 'issuedAt,key,keyId,retireAt' || !keyOk(r.key) || r.keyId !== keyId(r.key) || !instant(r.issuedAt) || !instant(r.retireAt)) || (m.previous !== undefined && (!Array.isArray(m.previous) || !m.previous.length || m.previous.length > 64 || m.previous.some(r => !r || Array.isArray(r) || typeof r !== 'object' || Object.keys(r).sort().join(',') !== 'keyId,retiredAt' || typeof r.keyId !== 'string' || !/^[a-f0-9]{12}$/.test(r.keyId) || !instant(r.retiredAt))))) deny('credential-format'); return { token: m.key, model: m }; } if (isHostKind(service)) { const key = parseHostKey(token); return { token: key.text, key }; } if (typeof token !== 'string' || token.length > 16384 || !TOKEN.test(token)) deny('credential-format'); token = token.replace(/\n$/, ''); if (token.length < 16) deny('credential-format'); return { token }; } #known(instance, service) { const known = this.#refs.get(instance + ':' + service); if (!known) deny('credential-unknown'); return known; } // Row 199 (design section 6): write the token to the agent's store path (tmp, fsync, rename), then // `commit` (the business file's reference becomes store), then memory. The token comes from the // caller's stdin and is never logged. Returns the entry's new status. // org-defscan (#1873): `check` runs before anything changes. It gets an assertClean for this value // alone, the test assertClean makes once the value is held; a throw leaves the store as it was. issue(instance, service, token, date, commit = () => {}, check = () => {}) { this.#known(instance, service); if (!day(date)) deny('credential-date'); const value = Credentials.#format(service, token); check((v) => assertNotHeld(v, [value])); this.#invalidateModelProof(instance, service); const file = this.#storePath(instance, service); const agentDir = dirname(file); mkdirSync(this.#storeRoot, { recursive: true, mode: 0o700 }); privateDir(this.#storeRoot); for (const dir of [dirname(agentDir), agentDir]) { if (!privateDir(dir, { missing: true })) mkdirSync(dir, { mode: 0o700 }); privateDir(dir); } const tmp = file + '.tmp'; try { unlinkSync(tmp); } catch (e) { if (e.code !== 'ENOENT') deny('credential-file'); } let fd; try { fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); writeSync(fd, service === 'model' ? JSON.stringify(value.model) + '\n' : value.key ? value.token : value.token + '\n'); fsyncSync(fd); } finally { if (fd !== undefined) closeSync(fd); } renameSync(tmp, file); const dirFd = openSync(agentDir, constants.O_RDONLY); try { fsyncSync(dirFd); } finally { closeSync(dirFd); } commit(); this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date }); this.#tokens.set(instance + ':' + service, { ...value, date }); this.#revoked.delete(instance + ':' + service); this.#held.delete(instance + ':' + service); return this.status().find((e) => e.instance === instance && e.service === service); } // Row 236 (#436b section 5, settle forward): memory takes the store file as it is now, with the // constructor's checks, and `date` becomes the entry's date. A held entry refuses. reload(instance, service, date) { const k = instance + ':' + service; const { ref } = this.#known(instance, service); if (this.#held.has(k) || !ref.store) deny('credential-reference'); if (!day(date)) deny('credential-date'); this.#invalidateModelProof(instance, service); const value = this.#read(ref, {}, service); if (value === null) deny('credential-file'); this.#refs.set(k, { ref: { ...ref, rotateBy: date }, date }); this.#tokens.set(k, { ...value, date }); this.#revoked.delete(k); } // Row 236 (#436b section 5, grant-token-unknown): memory drops the token, as revoke's memory step, // and a call in flight aborts. The file stays for the human to look at, and nothing loads it again // until a revoke or an issue. hold(instance, service) { const k = instance + ':' + service; this.#known(instance, service); this.#invalidateModelProof(instance, service); this.#tokens.delete(k); this.#held.add(k); this.#inflight.get(k)?.abort(); } held(instance, service) { return this.#held.has(instance + ':' + service); } // Row 236 (#436b section 5, settle): `operation` gets the token the store file holds now, read with // the constructor's checks, or null when there is no file. Memory is untouched. The result is checked // as use() checks it. async useFile(instance, service, operation) { const { ref } = this.#known(instance, service); if (!ref.store) deny('credential-reference'); const value = this.#read(ref, {}, service); const result = await operation(value?.token ?? null); this.assertClean(result); if (value && JSON.stringify(result)?.includes(value.token)) deny('credential-leak'); return result; } // Row 236 (#436b section 5, a first grant's open commit at restart): the store file goes, and with it // a token no record backs. An entry with a token in memory refuses: only a held entry is undone so. unlinkStore(instance, service) { const k = instance + ':' + service; const { ref } = this.#known(instance, service); if (this.#tokens.has(k) || !ref.store) deny('credential-reference'); try { unlinkSync(ref.file); } catch (e) { if (e.code !== 'ENOENT') deny('credential-file'); } } // Memory first, then the store file (design section 6, D12). A file or env reference outside the // store is never unlinked; the caller turns its reference into store, so a restart finds nothing. revoke(instance, service, { modelConfirmed = false } = {}) { const { ref } = this.#known(instance, service); if (service === 'model' && this.#tokens.has(instance + ':model') && !modelConfirmed) deny('credential-revoke-failed'); this.#invalidateModelProof(instance, service); this.#tokens.delete(instance + ':' + service); this.#revoked.add(instance + ':' + service); this.#held.delete(instance + ':' + service); // Row 210 (design D9): a host.run in flight for this agent and host is killed first. this.#inflight.get(instance + ':' + service)?.abort(); if (ref.store) { try { unlinkSync(ref.file); } catch (e) { if (e.code !== 'ENOENT') deny('credential-file'); } } return { file: Boolean(ref.store) }; } // revoke for every service, then the agent's dir. A file other than a token or its tmp refuses. remove(instance) { const services = [...this.#refs.keys()].filter((k) => k.startsWith(instance + ':')).map((k) => k.slice(instance.length + 1)); for (const service of services) { const proof = service === 'model' ? this.#modelProofs.get(instance) : null; const completedModel = proof?.instance === instance && proof.phase === 'finished' && !this.#tokens.has(instance + ':model') && !this.#held.has(instance + ':model') && !this.#inflight.has(instance + ':model'); if (!completedModel) this.revoke(instance, service); } const agentDir = dirname(this.#storePath(instance, 'gitea')); let names; try { names = readdirSync(agentDir); } catch (e) { if (e.code === 'ENOENT') return { services }; deny('credential-file'); } if (names.some((n) => !credentialKind(n.replace(/\.tmp$/, '')))) deny('credential-file'); for (const n of names) unlinkSync(join(agentDir, n)); rmdirSync(agentDir); return { services }; } // The agent-facing use (design D5): the key comes only from the broker's verified session. async useFor(session, service, operation, options) { if (!session || typeof session.business !== 'string' || typeof session.role !== 'string' || session.role.startsWith('@')) deny('credential-not-yours'); return this.use(session.business + '/' + session.role, service, operation, options); } assertClean(value) { assertNotHeld(value, this.#tokens.values()); } // Row 210 (design D5): whether a host.run for this agent and host is in flight. busy(instance, service) { return this.#inflight.has(instance + ':' + service); } // Row 210 (design D5, D8, D9): a host. key runs one call at a time per agent and host, gets // an abort signal that revoke fires, and is checked against `input` before and the result after. async use(instance, service, operation, { input } = {}) { const k = instance + ':' + service; const entry = this.#tokens.get(k); if (!entry) deny('credential-unavailable'); if (entry.model?.state === 'revoking') deny('proxy-key-revoking'); if (service === 'vikunja' && Date.now() >= Date.parse(entry.date + 'T00:00:00Z')) deny('credential-expired'); if (!entry.key) { let result; try { result = await operation(entry.token); } catch (e) { // Row 199: a host step's own refusal (publish) keeps its code; anything else is the service's. if (e instanceof BusError) throw e; deny('service-failed'); } this.assertClean(result); return result; } if (this.#inflight.has(k)) deny('host-busy'); assertCleanKey(input, entry.key); const controller = new AbortController(); this.#inflight.set(k, controller); let result; try { result = await operation(entry.token, { key: entry.key, signal: controller.signal }); } catch (e) { if (controller.signal.aborted) deny('credential-unavailable'); if (e instanceof BusError) throw e; deny('host-failed'); } finally { this.#inflight.delete(k); } if (controller.signal.aborted) deny('credential-unavailable'); assertCleanKey(result, entry.key); this.assertClean(result); return result; } status() { return [...this.#refs.entries()].map(([key, { date }]) => { const hostKey = this.#tokens.get(key)?.key; const model = this.#tokens.get(key)?.model; const i = key.lastIndexOf(':'), service = key.slice(i + 1), left = Date.parse(date + 'T00:00:00Z') - Date.now(); return { instance: key.slice(0, i), service, date, ...(hostKey ? { fingerprint: hostKey.fingerprint, publicKey: hostKey.publicLine } : {}), ...(model ? { keyId: model.keyId, issuedAt: model.issuedAt, ...(model.revokingAt ? { revokingAt: model.revokingAt } : {}), retiring: model.retiring.map(({ keyId, issuedAt, retireAt }) => ({ keyId, issuedAt, retireAt })), previous: (model.previous ?? []).map(({ keyId, retiredAt }) => ({ keyId, retiredAt })) } : {}), state: !this.#tokens.has(key) ? this.#held.has(key) ? 'held' : this.#revoked.has(key) ? 'revoked' : 'not-issued' : model?.state === 'revoking' ? 'revoking' : service !== 'vikunja' ? left <= 0 ? 'rotation-due' : 'valid' : left <= 0 ? 'expired' : left < 7 * 86400000 ? 'expiring' : 'valid', }; }); } close() { this.#modelProofs.clear(); this.#modelTokens = new WeakMap(); this.#tokens.clear(); this.#refs.clear(); } }