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.
358 lines
18 KiB
JavaScript
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 };
|
|
},
|
|
};
|
|
}
|