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.
705 lines
34 KiB
JavaScript
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();
|
|
}
|
|
}
|