Files
stack/packages/bus/src/credentials.mjs
jason.woltje 1a45a7274b fix(organization): credentials issue and rotate refuse a value already in an organization definition (#1873)
credentials.issue runs a check after it normalizes the value and before the store, the
organization file or memory changes: the value must not appear in the definitions of any
organization the bus serves (instructions, personality and every other field or key). A hit
refuses definition-holds-credential naming the organization, definition and key, never the
value or the credential. An organization file the check cannot read refuses
definition-scan-unreadable (new TI entry). The check runs on the bus loop inside issue, so a
launch admission can no longer pass and then see its definition text become held before spawn.
The final admission re-scan stays as the backstop for values held outside the verb.
2026-11-11 08:49:15 -06:00

705 lines
34 KiB
JavaScript

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.<id> -> 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 <configDir>/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.<id> (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.<id> 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();
}
}