Files
stack/packages/bus/src/host-grant.mjs
jason.woltje a8d9eecf65 organization: move packages/business to packages/organization (rename r2 stage B1)
git mv packages/business -> packages/organization (@mosaic/organization). The
old package stays as one-line re-export shims of each moved module, with
src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs
forwarding to the moved executable. BusinessError is OrganizationConfigError
(one constructor, name kept BusinessError); OrganizationError stays distinct.
The bus adapter gains organization-context.mjs (busOrganization), with
business.mjs (busBusiness) as the same function.

Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh
COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure
walker (organization-imports.mjs, both roots required) move to the new path.
v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage
A contract tests stay where they were and pass as before.
2026-11-09 11:47:48 -06:00

358 lines
18 KiB
JavaScript

// Row 234 (#1740, #436a row a): the trusted half of host enrollment, run in the bus child. The broker
// gets it as `hostGrant` and holds every store write. This module holds what touches the network or
// a key file: the raise's ssh-keyscan (E1), the installer key pair (E2 steps 2 and 3) and the installer's
// `list` for hosts.check (E4). Row 235 (#1741, #436 row b) adds the host grant raise binding (R4), the
// grant executor's remote steps (X2 steps 2 to 4 and the `remove` undo) and `grantKeys`, the broker's
// narrow handle on Credentials.
//
// The installer key lives at <configDir>/secrets/hosts/<business>/<hostId>/installer, 0600 in 0700 dirs,
// written as 210's issue writes (tmp, fsync, rename). It is never read into an event or a message.
import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
closeSync, constants, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync,
rmdirSync, unlinkSync, writeSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
import { BusError } from './broker.mjs';
import { day, parseHostKey, privateDir } from './credentials.mjs';
import { runHost, HOST_LIMITS } from './host-run.mjs';
import { checkAddress, checkCommands, HOST_KEY, USER } from '../../organization/src/hosts.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
import { HOST_ID, ID_PATTERN } from '../../organization/src/vocabulary.mjs';
const deny = (code) => {
throw new BusError(code);
};
const PATH = '/usr/bin:/bin';
const KILL_WITH_PARENT = ['/usr/bin/setpriv', '--pdeathsig', 'KILL', '--'];
// E1: the users no enrollment may name. The host writer refuses them again (tools/host/mosaic-keys-write).
export const RESERVED_USERS = Object.freeze(['root', 'localadmin', 'mosaic-installer']);
const FINGERPRINT = /^SHA256:[A-Za-z0-9+/]{43}$/;
// E1: the three key types the keyscan asks for, so any other fingerprint can't match.
const SCAN_TYPES = 'ed25519,ecdsa,rsa';
export const INSTALLER_USER = 'mosaic-installer';
const INSTALLER_AGENT = '@installer';
const SCAN_MS = 10000;
// X2 step 2: `mosaic-` and the first 32 hex digits of sha256 over business, agent and host id joined
// with NUL. Names match ID_PATTERN or HOST_ID, neither holds NUL, so the join is one to one.
export function grantTag(business, agent, hostId) {
return 'mosaic-' + createHash('sha256').update([business, agent, hostId].join('\0')).digest('hex').slice(0, 32);
}
export const installerTag = (business, hostId) => grantTag(business, INSTALLER_AGENT, hostId);
// The OpenSSH fingerprint of a "<type> <base64>" line: sha256 over the decoded blob.
export function fingerprint(line) {
const blob = Buffer.from(line.split(' ')[1], 'base64');
return 'SHA256:' + createHash('sha256').update(blob).digest('base64').replace(/=+$/, '');
}
// E1's binding. Anything off refuses invalid-request; the broker adds the chief, action and enrolled
// checks. Returns a fresh object with only the bound keys.
export function enrollCheck(grant) {
const allowed = ['method', 'host', 'address', 'port', 'user', 'sudo', 'fingerprints'];
if (!grant || typeof grant !== 'object' || Array.isArray(grant)) deny('invalid-request');
const own = Object.keys(grant);
if (own.some((k) => !allowed.includes(k)) || allowed.some((k) => !own.includes(k))) deny('invalid-request');
if (grant.method !== 'host-enroll') deny('invalid-request');
if (typeof grant.host !== 'string' || !HOST_ID.test(grant.host)) deny('invalid-request');
try {
checkAddress(grant.address, 'grant.address');
} catch {
deny('invalid-request');
}
if (!Number.isInteger(grant.port) || grant.port < 1 || grant.port > 65535) deny('invalid-request');
if (typeof grant.user !== 'string' || !USER.test(grant.user) || RESERVED_USERS.includes(grant.user)) deny('invalid-request');
if (typeof grant.sudo !== 'boolean') deny('invalid-request');
const prints = grant.fingerprints;
if (!Array.isArray(prints) || prints.length < 1 || prints.length > 3) deny('invalid-request');
if (prints.some((f) => typeof f !== 'string' || !FINGERPRINT.test(f)) || new Set(prints).size !== prints.length) deny('invalid-request');
return { method: 'host-enroll', host: grant.host, address: grant.address, port: grant.port, user: grant.user, sudo: grant.sudo, fingerprints: [...prints] };
}
// R4's binding for a host grant. Anything off refuses invalid-request. `today` is the bus clock's UTC
// day. rotateBy must be a real calendar day after it (210's day check, credentials.mjs; sage-1782). The
// broker checks the agent, its definition and the host inside the raise transaction. Returns a fresh
// object with only the bound keys.
export function hostGrantCheck(grant, today) {
const allowed = ['method', 'agent', 'host', 'commands', 'rotateBy'];
if (!grant || typeof grant !== 'object' || Array.isArray(grant)) deny('invalid-request');
const own = Object.keys(grant);
if (own.some((k) => !allowed.includes(k)) || allowed.some((k) => !own.includes(k))) deny('invalid-request');
if (grant.method !== 'host') deny('invalid-request');
if (typeof grant.agent !== 'string' || !ID_PATTERN.test(grant.agent)) deny('invalid-request');
if (typeof grant.host !== 'string' || !HOST_ID.test(grant.host)) deny('invalid-request');
let commands;
try {
commands = checkCommands(grant.commands, 'grant.commands');
} catch {
deny('invalid-request');
}
if (!day(grant.rotateBy) || grant.rotateBy <= today) deny('invalid-request');
return { method: 'host', agent: grant.agent, host: grant.host, commands, rotateBy: grant.rotateBy };
}
// R4's definitionSha256: sha256 over the canonical JSON of the definition the business loaded, since
// the broker doesn't read roles/ (sage-0590). A change to any field the loader keeps changes it.
export const definitionSha256 = (definition) => createHash('sha256').update(canonicalJson(definition)).digest('hex');
// r6 X4: the store reference for an agent's host.<id> key, as Credentials checks it. The runtime's start
// load and the executor's register both use it.
export const hostReference = (storeRoot, business, agent, hostId, rotateBy) => ({
store: true,
file: storeRoot ? join(storeRoot, business, agent, 'host.' + hostId) : null,
rotateBy,
});
// Row 235 (r6 X2 to X5): the grant executor's calls on Credentials, keyed by (business, agent, hostId).
// The broker gets this and never Credentials itself, so it still has no use().
export function grantKeys({ credentials, storeRoot }) {
const at = (business, agent) => business + '/' + agent;
const service = (hostId) => 'host.' + hostId;
return {
claim: (business, agent, hostId) => credentials.claim(at(business, agent), service(hostId)),
release: (business, agent, hostId, signal) => credentials.release(at(business, agent), service(hostId), signal),
abortClaim: (business, agent, hostId) => credentials.abortClaim(at(business, agent), service(hostId)),
register: (business, agent, hostId, rotateBy) =>
credentials.register(at(business, agent), service(hostId), hostReference(storeRoot, business, agent, hostId, rotateBy)),
unregister: (business, agent, hostId) => credentials.unregister(at(business, agent), service(hostId)),
issue: (business, agent, hostId, text, rotateBy, commit) => credentials.issue(at(business, agent), service(hostId), text, rotateBy, commit),
revoke: (business, agent, hostId) => credentials.revoke(at(business, agent), service(hostId)),
};
}
// r6 section 6 and N1 of duckworth-0063: at start, before Credentials loads, unlink each host.<id> key
// file in a loaded business's agent store that has no host_grants record. `granted` holds
// 'business/agent/hostId' for each record. A file an interrupted executor wrote before its commit, or one
// a revoke failed to unlink, goes here. No reference loads for a file with no record (X4), so nothing
// holds it. A store dir that isn't private refuses credential-file, as Credentials would.
export function sweepHostKeys(storeRoot, businesses, granted) {
const removed = [];
if (!storeRoot || !privateDir(storeRoot, { missing: true })) return removed;
for (const business of businesses) {
const dir = join(storeRoot, business);
if (!privateDir(dir, { missing: true })) continue;
for (const agent of readdirSync(dir)) {
const agentDir = join(dir, agent);
if (!lstatSync(agentDir).isDirectory()) continue;
privateDir(agentDir);
for (const name of readdirSync(agentDir)) {
const hostId = name.startsWith('host.') ? name.slice(5) : null;
if (!hostId || !HOST_ID.test(hostId) || granted.has(business + '/' + agent + '/' + hostId)) continue;
unlinkSync(join(agentDir, name));
removed.push({ instance: business + '/' + agent, service: name });
}
}
}
return removed;
}
// The tags in `list` output, one per line, the last field of each.
export const listedTags = (stdout) => stdout.split('\n').filter(Boolean).map((line) => line.trim().split(/\s+/).at(-1));
// The keyscan's output lines, "<host> <type> <base64>", as "<type> <base64>". Comment lines and any
// other type drop out. A key listed twice counts once.
export function scanLines(stdout) {
const keys = [];
for (const line of stdout.split('\n')) {
if (!line || line.startsWith('#')) continue;
const parts = line.trim().split(/\s+/);
if (parts.length !== 3) continue;
const key = parts[1] + ' ' + parts[2];
if (HOST_KEY.test(key) && !keys.includes(key)) keys.push(key);
}
return keys;
}
// E1: `ssh-keyscan -T 5 -p <port> -t ed25519,ecdsa,rsa <address>` from the stack host. It reads public
// keys and logs in to nothing. A scan that can't start refuses host-failed. One that runs and gets no key,
// as from a host that doesn't answer, returns no lines, so the raise refuses host-key-mismatch
// (duckworth-0081 N3).
export function keyscan(address, port, { bin = 'ssh-keyscan', limitMs = SCAN_MS } = {}) {
return new Promise((resolve, reject) => {
const child = spawn(KILL_WITH_PARENT[0], [...KILL_WITH_PARENT.slice(1), bin, '-T', '5', '-p', String(port), '-t', SCAN_TYPES, '--', address], {
env: { PATH },
detached: true,
stdio: ['ignore', 'pipe', 'ignore'],
});
const out = [];
let size = 0;
child.stdout.on('data', (chunk) => {
size += chunk.length;
if (size <= 65536) out.push(chunk);
});
const timer = setTimeout(() => {
try {
process.kill(-child.pid, 'SIGKILL');
} catch {}
}, limitMs);
child.on('error', () => (clearTimeout(timer), reject(new BusError('host-failed'))));
child.on('close', () => (clearTimeout(timer), resolve(scanLines(Buffer.concat(out).toString('utf8')))));
});
}
// E1: the scanned set equals the requested set, or host-key-mismatch.
export function sameFingerprints(lines, prints) {
const scanned = new Set(lines.map(fingerprint));
return scanned.size === prints.length && prints.every((f) => scanned.has(f));
}
const run = (bin, args) =>
new Promise((resolve, reject) => {
const child = spawn(bin, args, { env: { PATH }, stdio: ['ignore', 'ignore', 'ignore'] });
child.on('error', () => reject(new BusError('host-failed')));
child.on('close', (code) => (code === 0 ? resolve() : reject(new BusError('host-failed'))));
});
// `installerUser` is the installer's login. Only the tests change it, because a scratch sshd can only
// log in as the test's own user.
export function hostGrant({ configDir, runRoot, limits = HOST_LIMITS, scan = keyscan, keygen = 'ssh-keygen', installerUser = INSTALLER_USER }) {
const keyRoot = join(configDir, 'secrets', 'hosts');
const keyPath = (business, hostId) => join(keyRoot, business, hostId, 'installer');
const readKey = (business, hostId) => {
const file = keyPath(business, hostId);
for (const dir of [keyRoot, dirname(dirname(file)), dirname(file)]) privateDir(dir);
let fd;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
return parseHostKey(readFileSync(fd, 'utf8'));
} catch (e) {
if (e instanceof BusError) throw e;
deny('credential-file');
} finally {
if (fd !== undefined) closeSync(fd);
}
};
// One installer request, `list`, `add` or `remove`, pinned to the record's host keys (E3).
const installer = (business, hostId, record, command, signal = null) => {
const key = readKey(business, hostId);
const entry = { address: record.address, port: record.port, user: installerUser, hostKeys: record.hostKeys };
return runHost({ root: runRoot, hostId, entry, key, timeout: 30, limits, command, signal });
};
const ok = (out) => {
if (out.exit !== 0) deny('host-failed');
return out;
};
return {
enrollCheck,
hostGrantCheck,
definitionSha256,
sameFingerprints,
fingerprint,
installerTag,
grantTag,
keyscan: (address, port) => scan(address, port),
// E2 steps 2 and 3. Returns the public line and fingerprint; the private half only reaches the file.
async enroll(business, hostId) {
mkdirSync(runRoot, { recursive: true, mode: 0o700 });
privateDir(runRoot);
const dir = mkdtempSync(join(runRoot, 'h-'));
try {
const tag = installerTag(business, hostId);
await run(keygen, ['-q', '-t', 'ed25519', '-N', '', '-C', INSTALLER_USER + '-' + tag.slice('mosaic-'.length), '-f', join(dir, 'key')]);
const key = parseHostKey(readFileSync(join(dir, 'key'), 'utf8'));
const file = keyPath(business, hostId);
mkdirSync(keyRoot, { recursive: true, mode: 0o700 });
privateDir(keyRoot);
for (const d of [dirname(dirname(file)), dirname(file)]) {
if (!privateDir(d, { missing: true })) mkdirSync(d, { mode: 0o700 });
privateDir(d);
}
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, key.text);
fsyncSync(fd);
} finally {
if (fd !== undefined) closeSync(fd);
}
renameSync(tmp, file);
const dirFd = openSync(dirname(file), constants.O_RDONLY);
try {
fsyncSync(dirFd);
} finally {
closeSync(dirFd);
}
return { publicLine: key.publicLine, fingerprint: key.fingerprint, tag };
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
// The X3 undo and hosts.remove: the installer key and its two dirs, if present.
unenroll(business, hostId) {
const file = keyPath(business, hostId);
for (const f of [file + '.tmp', file]) {
try {
unlinkSync(f);
} catch (e) {
if (e.code !== 'ENOENT') deny('credential-file');
}
}
for (const d of [dirname(file), dirname(dirname(file))]) {
try {
rmdirSync(d);
} catch (e) {
if (!['ENOENT', 'ENOTEMPTY'].includes(e.code)) deny('credential-file');
}
}
},
// E4: `list <user>` as mosaic-installer with the installer key and the pinned host keys. A wrong
// pin refuses host-key-mismatch (runHost). Returns the installer fingerprint and the exit.
async check(business, hostId, record) {
const key = readKey(business, hostId);
const entry = { address: record.address, port: record.port, user: installerUser, hostKeys: record.hostKeys };
const out = await runHost({ root: runRoot, hostId, entry, key, timeout: 30, limits, command: `list ${record.user}` });
return { installerFingerprint: key.fingerprint, exit: out.exit };
},
// X2 step 2: a new ed25519 pair with the tag as its comment, made in a 0700 dir under the run root
// and read back into memory. The dir goes before this returns, so no private half stays on disk.
async grantKey(tag) {
mkdirSync(runRoot, { recursive: true, mode: 0o700 });
privateDir(runRoot);
const dir = mkdtempSync(join(runRoot, 'h-'));
try {
await run(keygen, ['-q', '-t', 'ed25519', '-N', '', '-C', tag, '-f', join(dir, 'key')]);
return parseHostKey(readFileSync(join(dir, 'key'), 'utf8'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
// X2 step 3: `list`, `remove` the tag if it's there, then `add`. Any request that doesn't exit 0
// refuses host-failed. `signal` is the executor's claim, so a revoke stops the call.
async install(business, hostId, record, tag, publicLine, signal) {
const listed = ok(await installer(business, hostId, record, `list ${record.user}`, signal));
if (listedTags(listed.stdout).includes(tag)) ok(await installer(business, hostId, record, `remove ${record.user} ${tag}`, signal));
ok(await installer(business, hostId, record, `add ${record.user} ${publicLine} ${tag}`, signal));
},
// X2 step 4: log in as the record's user with the new key and run `true`.
async verify(hostId, record, key, signal) {
const entry = { address: record.address, port: record.port, user: record.user, hostKeys: record.hostKeys };
ok(await runHost({ root: runRoot, hostId, entry, key, timeout: 30, limits, command: 'true', signal }));
},
// X3's undo and sweep: `remove <user> <tag>`, with no signal, since it runs after an abort too.
async remove(business, hostId, record, tag) {
ok(await installer(business, hostId, record, `remove ${record.user} ${tag}`));
},
// X3's sweep (B4): `list <user>`, then `remove` for each listed tag in `tags`. A line with any other
// tag stays, so another business's line and an unrecorded one are left alone. Returns the listed
// tags and the removed ones.
async sweep(business, hostId, record, tags) {
const listed = listedTags(ok(await installer(business, hostId, record, `list ${record.user}`)).stdout);
const removed = [];
for (const tag of listed) {
if (!tags.has(tag) || removed.includes(tag)) continue;
ok(await installer(business, hostId, record, `remove ${record.user} ${tag}`));
removed.push(tag);
}
return { listed, removed };
},
};
}