// Row 234 (#1740, #436a row a), design r6 E3: the four host files in tools/host, as G16 and G25. // The writer runs as the test user through a harness that loads it by path and passes its own dirs, // owner uid and user, group and sudo lookups, so nothing runs as root and nothing under /etc is read. // mosaic-keys runs from a copy whose sudo and logger paths point at stubs in a scratch dir. import test from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; const REPO = fileURLToPath(new URL('../../../', import.meta.url)); const TOOLS = join(REPO, 'tools', 'host'); const WRITER = join(TOOLS, 'mosaic-keys-write'); const REFUSED_GROUPS = ['sudo', 'admin', 'wheel', 'docker', 'lxd', 'disk', 'libvirt', 'incus-admin']; const tag = (n) => 'mosaic-' + createHash('sha256').update(String(n)).digest('hex').slice(0, 32); const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n); return b; }; const keyBlob = () => Buffer.concat([u32(11), Buffer.from('ssh-ed25519'), u32(32), randomBytes(32)]).toString('base64'); const fingerprint = (b64) => 'SHA256:' + createHash('sha256').update(Buffer.from(b64, 'base64')).digest('base64').replace(/=+$/, ''); const notAllowed = (u) => `User ${u} is not allowed to run sudo on testhost.\n`; const HARNESS = `import importlib.machinery, importlib.util, json, os, sys sys.dont_write_bytecode = True cfg = json.loads(sys.argv[1]) loader = importlib.machinery.SourceFileLoader('mosaic_keys_write', cfg['writer']) m = importlib.util.module_from_spec(importlib.util.spec_from_loader('mosaic_keys_write', loader)) loader.exec_module(m) users = cfg['users'] def log(msg): with open(cfg['log'], 'a') as f: f.write(msg + '\\n') def sudo(u): with open(cfg['sudoLog'], 'a') as f: f.write(u + '\\n') return users[u]['sudo'] if 'sudo' in users[u] else 'User %s is not allowed to run sudo on testhost.\\n' % u host = m.Host(key_dir=cfg['keyDir'], conf_dir=cfg['confDir'], owner=os.getuid(), passwd=lambda u: users[u]['uid'] if u in users else None, groups=lambda u: users[u].get('groups', []), sudo=sudo, hostname='testhost', log=log) sys.exit(m.main(sys.argv[2:], host)) `; // The users the harness's lookups know. users lists all of them but dave and eve. bob and dave are // privileged. carol has a sudo line. Each gX is in one refused group. function userTable() { const users = { alice: { uid: 1500 }, bob: { uid: 1501, groups: ['sudo', 'docker'], sudo: 'User bob may run the following commands on testhost:\n (ALL) ALL\n' }, carol: { uid: 1502, sudo: 'User carol may run the following commands on testhost:\n (root) /usr/bin/true\n' }, dave: { uid: 1503 }, eve: { uid: 1504 }, frank: { uid: 1505, groups: ['users', 'adm'] }, '../x': { uid: 1506 }, root: { uid: 0 }, toor: { uid: 0 }, daemon2: { uid: 999 }, localadmin: { uid: 1000 }, 'mosaic-installer': { uid: 1001 }, }; for (const [i, g] of REFUSED_GROUPS.entries()) users['g' + g] = { uid: 1600 + i, groups: ['users', g] }; return users; } function hostDirs() { const root = mkdtempSync(join(tmpdir(), 'hk234-')); const keyDir = join(root, 'keys'); const confDir = join(root, 'conf'); mkdirSync(keyDir, { mode: 0o755 }); mkdirSync(confDir, { mode: 0o755 }); chmodSync(keyDir, 0o755); const users = userTable(); const conf = (name, text) => { writeFileSync(join(confDir, name), text, { mode: 0o644 }); chmodSync(join(confDir, name), 0o644); }; conf('users', '# users the writer may touch\n' + Object.keys(users).filter((u) => !['dave', 'eve'].includes(u)).join('\n') + '\n'); conf('privileged', 'bob\ndave\n'); conf('from', '10.1.2.3/32\n'); const harness = join(root, 'harness.py'); writeFileSync(harness, HARNESS); const cfg = { writer: WRITER, keyDir, confDir, users, log: join(root, 'syslog'), sudoLog: join(root, 'sudo.log') }; const run = (...args) => { const r = spawnSync('python3', ['-B', '-I', harness, JSON.stringify(cfg), ...args], { encoding: 'utf8' }); return { status: r.status, stdout: r.stdout, stderr: r.stderr }; }; const read = (file) => readFileSync(join(keyDir, file), 'utf8'); const lastLog = () => readFileSync(cfg.log, 'utf8').trim().split('\n').at(-1); const sudoCalls = () => (existsSync(cfg.sudoLog) ? readFileSync(cfg.sudoLog, 'utf8').trim().split('\n') : []); return { root, keyDir, confDir, conf, run, read, lastLog, sudoCalls, done: () => rmSync(root, { recursive: true, force: true }) }; } const refused = (r, code) => { assert.equal(r.status, 1, r.stderr); assert.equal(r.stderr, `mosaic-keys-write: refused ${code}\n`); }; test('G16: the writer refuses each user, tag and key the design names, before any key file', () => { const h = hostDirs(); try { const key = keyBlob(); const t = tag(1); const add = (user) => h.run('add', user, 'ssh-ed25519', key, t); // The user pattern stops `../x` before any path, though the lookups and users know it. for (const user of ['../x', 'Alice', 'a'.repeat(33), '1alice', 'al ice', '']) { refused(add(user), 'user-pattern'); refused(h.run('list', user), 'user-pattern'); } // users is always required: privileged alone allows nothing. refused(add('dave'), 'user-not-listed'); refused(add('eve'), 'user-not-listed'); for (const user of ['root', 'localadmin', 'mosaic-installer']) refused(add(user), 'user-not-listed'); refused(add('toor'), 'user-system'); refused(add('daemon2'), 'user-system'); refused(add('ghost'), 'user-not-listed'); for (const g of REFUSED_GROUPS) { refused(add('g' + g), 'user-group'); refused(h.run('remove', 'g' + g, t), 'user-group'); } refused(add('carol'), 'user-sudo'); refused(h.run('list', 'carol'), 'user-sudo'); assert.equal(h.lastLog(), 'result=refused user-sudo user=carol'); for (const bad of ['mosaic-' + 'a'.repeat(31), 'mosaic-' + 'A'.repeat(32), 'mosaic-installer-' + 'a'.repeat(32), 'mosaic-' + 'g'.repeat(32)]) { refused(h.run('add', 'alice', 'ssh-ed25519', key, bad), 'tag-pattern'); refused(h.run('remove', 'alice', bad), 'tag-pattern'); } refused(h.run('add', 'alice', 'ssh-dss', key, t), 'key-format'); refused(h.run('add', 'alice', 'ssh-ed25519', key.slice(0, -2) + '!!', t), 'key-format'); refused(h.run('add', 'alice', 'ssh-ed25519', 'A', t), 'key-format'); refused(h.run('add', 'alice', 'ssh-ed25519', key + ' x', t), 'key-format'); for (const args of [[], ['list'], ['list', 'alice', 'bob'], ['add', 'alice', 'ssh-ed25519', key], ['remove', 'alice'], ['rm', 'alice', t]]) refused(h.run(...args), 'request'); // No refusal reached the key dir, so not even the lock exists. assert.deepEqual(readdirSync(h.keyDir), []); // Only users past the groups check reach sudo -l, and the privileged never do. assert.deepEqual([...new Set(h.sudoCalls())], ['carol']); } finally { h.done(); } }); test('G16: from= comes from the host file, a privileged user skips the group and sudo checks, and the line is exact', () => { const h = hostDirs(); try { const k1 = keyBlob(); const k2 = keyBlob(); assert.equal(h.run('add', 'alice', 'ssh-ed25519', k1, tag(1)).status, 0); assert.equal(h.lastLog(), `result=added user=alice tag=${tag(1)} key=${fingerprint(k1)}`); h.conf('from', '# the stack host\n10.9.9.9,192.168.1.0/24\n'); assert.equal(h.run('add', 'alice', 'ssh-ed25519', k2, tag(2)).status, 0); assert.equal( h.read('alice'), `from="10.1.2.3/32",restrict ssh-ed25519 ${k1} ${tag(1)}\nfrom="10.9.9.9,192.168.1.0/24",restrict ssh-ed25519 ${k2} ${tag(2)}\n`, ); // A tag already there changes nothing and exits 0. assert.equal(h.run('add', 'alice', 'ssh-ed25519', k2, tag(1)).status, 0); assert.equal(h.lastLog(), `result=present user=alice tag=${tag(1)} key=${fingerprint(k2)}`); assert.equal(h.read('alice').split('\n').length, 3); // bob is in sudo and docker with a sudo line, and privileged. assert.equal(h.run('add', 'bob', 'ssh-ed25519', k1, tag(3)).status, 0); // frank's groups are none of the refused ones. assert.equal(h.run('add', 'frank', 'ssh-ed25519', k1, tag(4)).status, 0); assert.deepEqual(h.sudoCalls(), ['alice', 'alice', 'alice', 'frank']); // A from value that could close the quote, two values, or none, refuses and writes nothing. for (const text of ['10.0.0.1",command="/bin/sh\n', '10.0.0.1\n10.0.0.2\n', '# none\n', '10.0.0.1 10.0.0.2\n']) { h.conf('from', text); refused(h.run('add', 'frank', 'ssh-ed25519', k2, tag(5)), 'config-from'); } rmSync(join(h.confDir, 'from')); refused(h.run('add', 'frank', 'ssh-ed25519', k2, tag(5)), 'config-from'); assert.ok(!h.read('frank').includes(tag(5))); // list and remove don't need from. assert.equal(h.run('list', 'frank').stdout, `from="10.9.9.9,192.168.1.0/24",restrict ssh-ed25519 ${k1} ${tag(4)}\n`); for (const name of ['users', 'privileged']) { chmodSync(join(h.confDir, name), 0o664); refused(h.run('list', 'alice'), 'not-root-owned'); chmodSync(join(h.confDir, name), 0o644); } rmSync(join(h.confDir, 'users')); refused(h.run('list', 'alice'), 'config-users'); } finally { h.done(); } }); test('G16: list shows tagged lines only, and add and remove never touch a line outside the tag pattern', () => { const h = hostDirs(); try { const k = keyBlob(); const hand = [ '# written by hand', `ssh-ed25519 ${k} mosaic-installer-${'a'.repeat(32)}`, '', `ssh-ed25519 ${k} ${tag(9).toUpperCase()}`, `ssh-ed25519 ${k} ${tag(1)} trailing`, ]; const tagged = `from="10.1.2.3/32",restrict ssh-ed25519 ${k} ${tag(7)}`; writeFileSync(join(h.keyDir, 'alice'), [hand[0], tagged, ...hand.slice(1)].join('\n') + '\n', { mode: 0o644 }); assert.equal(h.run('list', 'alice').stdout, tagged + '\n'); // The last field is the tag, so `... mosaic-<1> trailing` isn't tag 1. assert.equal(h.run('remove', 'alice', tag(1)).status, 0); assert.equal(h.lastLog(), `result=absent user=alice tag=${tag(1)}`); assert.equal(h.run('add', 'alice', 'ssh-ed25519', k, tag(8)).status, 0); assert.equal(h.run('remove', 'alice', tag(7)).status, 0); assert.equal(h.lastLog(), `result=removed user=alice tag=${tag(7)}`); assert.equal(h.read('alice'), [...hand, `from="10.1.2.3/32",restrict ssh-ed25519 ${k} ${tag(8)}`].join('\n') + '\n'); assert.equal(h.run('remove', 'alice', tag(8)).status, 0); assert.equal(h.read('alice'), hand.join('\n') + '\n'); // A missing file lists nothing and a remove there changes nothing. assert.equal(h.run('list', 'frank').stdout, ''); assert.equal(h.run('remove', 'frank', tag(1)).status, 0); assert.ok(!existsSync(join(h.keyDir, 'frank'))); } finally { h.done(); } }); test('G25: the writer refuses a symlinked dir or user file, a group-writable dir and an odd tmp, and writes 0644', () => { const h = hostDirs(); try { const k = keyBlob(); const add = (n, user = 'alice') => h.run('add', user, 'ssh-ed25519', k, tag(n)); // A first add creates the file, regular, 0644, owned by the owner uid, with no tmp left. assert.equal(add(1).status, 0); const st = statSync(join(h.keyDir, 'alice')); assert.equal(st.mode & 0o777, 0o644); assert.equal(st.uid, process.getuid()); assert.deepEqual(readdirSync(h.keyDir).sort(), ['.lock', 'alice']); // A leftover regular tmp is unlinked and the write goes ahead. writeFileSync(join(h.keyDir, '.alice.tmp'), 'half a write'); assert.equal(add(2).status, 0); assert.ok(h.read('alice').includes(tag(2))); assert.ok(!existsSync(join(h.keyDir, '.alice.tmp'))); // A tmp that is a symlink, a dir or a fifo refuses, and nothing is written. const decoy = join(h.root, 'decoy'); writeFileSync(decoy, 'decoy\n'); const before = h.read('alice'); for (const make of [ (p) => symlinkSync(decoy, p), (p) => mkdirSync(p), (p) => execFileSync('mkfifo', [p]), ]) { make(join(h.keyDir, '.alice.tmp')); refused(add(3), 'tmp-file'); assert.equal(h.read('alice'), before); assert.equal(readFileSync(decoy, 'utf8'), 'decoy\n'); rmSync(join(h.keyDir, '.alice.tmp'), { recursive: true }); } // A symlinked user file refuses, though its target is a regular file the owner owns. symlinkSync(decoy, join(h.keyDir, 'frank')); refused(add(4, 'frank'), 'user-file'); refused(h.run('list', 'frank'), 'user-file'); assert.equal(readFileSync(decoy, 'utf8'), 'decoy\n'); assert.ok(lstatSync(join(h.keyDir, 'frank')).isSymbolicLink()); // A user "file" that is a dir refuses. mkdirSync(join(h.keyDir, 'bob')); refused(add(5, 'bob'), 'not-root-owned'); // A group-writable dir refuses. chmodSync(h.keyDir, 0o775); refused(add(6), 'not-root-owned'); chmodSync(h.keyDir, 0o755); // A symlinked key dir refuses, and its target is untouched. const real = join(h.root, 'real-keys'); mkdirSync(real, { mode: 0o755 }); rmSync(h.keyDir, { recursive: true }); symlinkSync(real, h.keyDir); refused(add(7), 'key-dir'); assert.deepEqual(readdirSync(real), []); } finally { h.done(); } }); // mosaic-keys from a copy with its sudo and logger paths on stubs. The stub sudo records its argv, // prints a line and exits 3, so the test sees the exit and output pass through. function forcedCommand() { const root = mkdtempSync(join(tmpdir(), 'hk234-')); const sudo = join(root, 'sudo'); const logger = join(root, 'logger'); writeFileSync(sudo, `#!/bin/bash\nprintf '%s\\n' "$@" > ${root}/sudo.argv\necho from-writer\nexit 3\n`, { mode: 0o755 }); writeFileSync(logger, `#!/bin/bash\nprintf '%s\\n' "$*" >> ${root}/logger.log\n`, { mode: 0o755 }); const source = readFileSync(join(TOOLS, 'mosaic-keys'), 'utf8'); assert.equal(source.split('/usr/bin/sudo').length, 2); assert.equal(source.split('/usr/bin/logger').length, 2); const copy = join(root, 'mosaic-keys'); writeFileSync(copy, source.replace('/usr/bin/sudo', sudo).replace('/usr/bin/logger', logger), { mode: 0o755 }); const run = (request) => { rmSync(join(root, 'sudo.argv'), { force: true }); const env = { PATH: '/usr/bin:/bin', SSH_CONNECTION: '10.1.2.3 50000 10.1.2.9 22' }; if (request !== undefined) env.SSH_ORIGINAL_COMMAND = request; const r = spawnSync(copy, [], { encoding: 'utf8', env }); const argv = existsSync(join(root, 'sudo.argv')) ? readFileSync(join(root, 'sudo.argv'), 'utf8').trim().split('\n') : null; return { status: r.status, stdout: r.stdout, stderr: r.stderr, argv }; }; const logged = () => readFileSync(join(root, 'logger.log'), 'utf8').trim().split('\n'); return { run, logged, done: () => rmSync(root, { recursive: true, force: true }) }; } test('G16: mosaic-keys passes the three requests to the writer through sudo and refuses anything else without it', () => { const f = forcedCommand(); try { const k = keyBlob(); const t = tag(1); const writer = ['-n', '/usr/local/sbin/mosaic-keys-write']; for (const words of [['list', 'alice'], ['add', 'alice', 'ssh-ed25519', k, t], ['add', 'alice', 'ecdsa-sha2-nistp256', 'AAAA', t], ['remove', 'alice', t]]) { const r = f.run(words.join(' ')); assert.deepEqual(r.argv, [...writer, ...words]); assert.equal(r.status, 3); assert.equal(r.stdout, 'from-writer\n'); } const bad = [ undefined, '', 'list', 'list ../x', 'list Alice', 'list alice;id', 'list alice ', ' list alice', 'list alice', 'list\talice', 'list alice\nlist bob', 'list $(id)', 'list alice`id`', 'list *', 'rm alice', 'list ' + 'a'.repeat(33), `add alice ssh-dss ${k} ${t}`, `add alice ssh-ed25519 ${k}! ${t}`, `add alice ssh-ed25519 ${k}`, `add alice ssh-ed25519 ${k} ${t} x`, `remove alice mosaic-${'a'.repeat(31)}`, `remove alice ${t.toUpperCase()}`, `remove alice ${t}\n`, `remove alice ${t}; rm -rf /`, ]; for (const request of bad) { const r = f.run(request); assert.equal(r.argv, null, JSON.stringify(request)); assert.equal(r.status, 1); assert.equal(r.stderr, 'mosaic-keys: refused\n'); } // Two bad requests hold a newline, so count the logger calls, not the lines. const logged = f.logged().filter((l) => l.startsWith('-t mosaic-keys -- ')); assert.equal(logged.length, 4 + bad.length); assert.equal(logged[0], '-t mosaic-keys -- connection=10.1.2.3 50000 10.1.2.9 22 request=list alice'); } finally { f.done(); } }); test('E3: the sudoers line and the sshd drop-in are the design lines and parse', () => { const lines = (file) => readFileSync(join(TOOLS, file), 'utf8').split('\n').filter((l) => l && !l.startsWith('#')); assert.deepEqual(lines('mosaic-keys.sudoers'), ['mosaic-installer ALL=(root) NOPASSWD: /usr/local/sbin/mosaic-keys-write']); assert.deepEqual(lines('mosaic-keys.sshd.conf'), ['AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2 /etc/ssh/mosaic-keys/%u']); assert.ok(existsSync('/usr/bin/visudo'), 'visudo on the test host'); execFileSync('/usr/bin/visudo', ['-c', '-q', '-f', join(TOOLS, 'mosaic-keys.sudoers')]); const dir = mkdtempSync(join(tmpdir(), 'hk234-')); try { execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', join(dir, 'hk')]); writeFileSync(join(dir, 'cfg'), `HostKey ${join(dir, 'hk')}\nInclude ${join(TOOLS, 'mosaic-keys.sshd.conf')}\n`); const out = execFileSync('/usr/bin/sshd', ['-T', '-f', join(dir, 'cfg'), '-C', 'user=alice,host=x,addr=127.0.0.1'], { encoding: 'utf8' }); // sshd -T prints this key as AuthorizedKeysFile, unlike most. const line = out.split('\n').filter((l) => /^authorizedkeysfile /i.test(l)); assert.deepEqual(line.map((l) => l.split(' ').slice(1).join(' ')), ['.ssh/authorized_keys .ssh/authorized_keys2 /etc/ssh/mosaic-keys/%u']); } finally { rmSync(dir, { recursive: true, force: true }); } for (const file of ['mosaic-keys', 'mosaic-keys-write']) assert.equal(statSync(join(TOOLS, file)).mode & 0o111, 0o111, file); assert.equal(readFileSync(WRITER, 'utf8').split('\n')[0], '#!/usr/bin/python3 -I'); });