diff --git a/packages/keyman/src/keyman.clear.ts b/packages/keyman/src/keyman.clear.ts new file mode 100644 index 0000000..6b67e69 --- /dev/null +++ b/packages/keyman/src/keyman.clear.ts @@ -0,0 +1,86 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import inquirer from 'inquirer'; +import { scanPrivateKeys } from './keyman.keys.js'; + +/** + * Writes a `.gitignore` beside the vault, once. + * + * The README told the user to do this by hand. A vault holds the age identity and, + * whenever anything has been decrypted, plaintext private keys โ€” committing it is + * the exact failure the tool exists to prevent, and it is one file to prevent it. + * + * Never overwritten: an existing file may say more than this one does. + */ +export function writeVaultGitignore(vaultRoot: string, tmpDir: string, keyPath: string) { + const gitignore = path.join(vaultRoot, '.gitignore'); + if (fs.existsSync(gitignore)) { + return; + } + + // Both are configurable and may be absolute, so either can sit outside the vault. + // A .gitignore cannot speak about a path above itself, and claiming to would be + // worse than saying nothing. + const inside = (target: string) => { + const relative = path.relative(vaultRoot, target); + return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : null; + }; + + const tmp = inside(tmpDir); + const key = inside(keyPath); + + const lines = [ + '# Written by keyman. The encrypted keys under the keys directory are safe to', + '# commit; nothing else here is.', + ...(key ? [key, `${key}.pub`] : []), + ...(tmp ? [`${tmp}/`] : []), + '', + ]; + + fs.writeFileSync(gitignore, lines.join('\n'), { mode: 0o600 }); +} + +/** + * Deletes the decrypted keys in the vault's tmp directory. + * + * The counterpart to `decrypt`, which had none: a plaintext private key stayed + * there until someone remembered it, and "someone remembered" is not a security + * control. Only the key pairs are removed โ€” anything else in the directory is not + * keyman's to delete. + */ +export async function clearDecryptedKeys(tmpDir: string) { + const { keys } = scanPrivateKeys(tmpDir); + + if (keys.length === 0) { + console.log(`โœ… Nothing decrypted in ${tmpDir}.`); + return; + } + + console.log(`\n๐Ÿ”“ Decrypted keys in ${tmpDir}:`); + for (const key of keys) { + console.log(` ${key}`); + } + + const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ + { + type: 'confirm', + name: 'confirmed', + message: `Delete ${keys.length === 1 ? 'this key' : `these ${keys.length} keys`}?`, + // A key that exists only here โ€” generated and not yet deployed โ€” is gone for + // good, so this is not a question to answer by pressing return. + default: false, + }, + ]); + + if (!confirmed) { + console.log('โญ๏ธ Nothing was deleted.'); + return; + } + + for (const key of keys) { + for (const file of [key, `${key}.pub`]) { + fs.rmSync(path.join(tmpDir, file), { force: true }); + } + console.log(`๐Ÿงน Removed ${key}`); + } +} diff --git a/packages/keyman/src/keyman.clipboard.ts b/packages/keyman/src/keyman.clipboard.ts new file mode 100644 index 0000000..7e5c77a --- /dev/null +++ b/packages/keyman/src/keyman.clipboard.ts @@ -0,0 +1,56 @@ +import { runTool, ToolNotFoundError } from './keyman.utils.js'; + +/** A clipboard command and the argv it wants, in the order they are tried. */ +interface ClipboardTool { + binary: string; + args: string[]; +} + +/** + * The clipboard commands worth trying on a platform, best first. + * + * Linux is a list rather than a choice because there is no single answer: + * `wl-copy` under Wayland, `xclip`/`xsel` under X11, and a user may have any + * subset installed. Trying them in order and moving on from an absent one costs a + * failed spawn and removes the need to detect the session type. + */ +export function clipboardTools(platform: string = process.platform): ClipboardTool[] { + switch (platform) { + case 'darwin': + return [{ binary: 'pbcopy', args: [] }]; + case 'win32': + return [{ binary: 'clip', args: [] }]; + default: + return [ + { binary: 'wl-copy', args: [] }, + { binary: 'xclip', args: ['-selection', 'clipboard'] }, + { binary: 'xsel', args: ['--clipboard', '--input'] }, + ]; + } +} + +/** + * Puts `text` on the system clipboard. + * + * keyman used to spawn `pbcopy` unconditionally, with a comment saying so โ€” which + * made "copy public key" a dead end on every platform but macOS, and reported it + * as a clipboard failure rather than as a missing tool. + * + * @returns the command that took it, or null if none was available + */ +export async function copyToClipboard(text: string, platform?: string): Promise { + for (const { binary, args } of clipboardTools(platform)) { + try { + await runTool(binary, args, { input: text }); + return binary; + } catch (error) { + // Only an absent tool is worth trying the next candidate for. One that ran + // and refused has an opinion, and repeating the paste elsewhere is not it. + if (!(error instanceof ToolNotFoundError)) { + throw error; + } + } + } + + return null; +} diff --git a/packages/keyman/src/keyman.copy.ts b/packages/keyman/src/keyman.copy.ts index 290e870..28dd59f 100644 --- a/packages/keyman/src/keyman.copy.ts +++ b/packages/keyman/src/keyman.copy.ts @@ -1,18 +1,19 @@ import fs from 'node:fs'; import path from 'node:path'; -import { execa } from 'execa'; import inquirer from 'inquirer'; +import { copyToClipboard } from './keyman.clipboard.js'; +import { reportSkippedKeys, scanPrivateKeys } from './keyman.keys.js'; export async function copyKey(sshDir: string, tmpDir: string) { - const getKeys = (dir: string) => { - if (!fs.existsSync(dir)) return []; - return fs.readdirSync(dir).filter((key) => key.startsWith('id_') && !key.endsWith('.pub')); - }; + const ssh = scanPrivateKeys(sshDir); + const tmp = scanPrivateKeys(tmpDir); - const sshKeys = getKeys(sshDir); - const tmpKeys = getKeys(tmpDir); + const keys = [...new Set([...ssh.keys, ...tmp.keys])]; - const keys = [...new Set([...sshKeys, ...tmpKeys])]; + // Before the empty check: "no SSH keys found" next to four unmanageable ones is + // the case the report exists for. + reportSkippedKeys(ssh.skipped, sshDir); + reportSkippedKeys(tmp.skipped, tmpDir); if (keys.length === 0) { console.log('โš ๏ธ No SSH keys found.'); @@ -40,19 +41,23 @@ export async function copyKey(sshDir: string, tmpDir: string) { return; } + const pubKeyContent = fs.readFileSync(pubKeyPath, 'utf-8').trim(); + try { - const pubKeyContent = fs.readFileSync(pubKeyPath, 'utf-8').trim(); + const tool = await copyToClipboard(pubKeyContent); - // Detect OS and use appropriate clipboard command - // Since the environment is Darwin, we prioritize pbcopy, but we can add others for completeness or use a simple check. - // For this specific request on Darwin: - const proc = execa('pbcopy'); - proc.stdin?.write(pubKeyContent); - proc.stdin?.end(); - await proc; - - console.log(`โœ… Public key for ${selectedKey} copied to clipboard!`); + if (tool) { + console.log(`โœ… Public key for ${selectedKey} copied to clipboard via ${tool}!`); + return; + } + console.warn('โš ๏ธ No clipboard command found.'); } catch (error) { - console.error(`โŒ Failed to copy to clipboard: ${error}`); + console.error( + `โŒ Failed to copy to clipboard: ${error instanceof Error ? error.message : error}` + ); } + + // Printing it is the point of the operation; the clipboard was only the + // convenient way to deliver it. A public key is not a secret. + console.log(`\n${pubKeyContent}\n`); } diff --git a/packages/keyman/src/keyman.encrypt.ts b/packages/keyman/src/keyman.encrypt.ts index a26097e..b64b9e0 100644 --- a/packages/keyman/src/keyman.encrypt.ts +++ b/packages/keyman/src/keyman.encrypt.ts @@ -1,27 +1,19 @@ -import fs from 'node:fs'; import path from 'node:path'; import inquirer from 'inquirer'; +import { reportSkippedKeys, scanPrivateKeys } from './keyman.keys.js'; import { ToolNotFoundError } from './keyman.utils.js'; import { storeInVault } from './keyman.vault.js'; -/** - * Private keys in a directory that may not exist. - * - * A first run has neither `~/.ssh` nor the tmp directory, and an unguarded - * readdir there threw before the "nothing to encrypt" message could be reached. - */ -function privateKeysIn(dir: string): string[] { - if (!fs.existsSync(dir)) { - return []; - } - return fs.readdirSync(dir).filter((key) => key.startsWith('id_') && !key.endsWith('.pub')); -} - export async function encryptKeys(sshDir: string, keysDir: string, tmpDir: string, pubkey: string) { - const sshKeys = privateKeysIn(sshDir); - const tmpKeys = privateKeysIn(tmpDir); + const ssh = scanPrivateKeys(sshDir); + const tmp = scanPrivateKeys(tmpDir); + const sshKeys = ssh.keys; + const tmpKeys = tmp.keys; const keys = [...new Set([...sshKeys, ...tmpKeys])]; + reportSkippedKeys(ssh.skipped, sshDir); + reportSkippedKeys(tmp.skipped, tmpDir); + if (keys.length === 0) { console.log('โš ๏ธ No private SSH keys found to encrypt.'); return; diff --git a/packages/keyman/src/keyman.home.ts b/packages/keyman/src/keyman.home.ts new file mode 100644 index 0000000..28b3e2e --- /dev/null +++ b/packages/keyman/src/keyman.home.ts @@ -0,0 +1,70 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +/** The answer the USER prompt defaults to: whoever is running keyman. */ +export const CURRENT_USER = '@current'; + +/** + * The home directory of the current user. + * + * `HOME` first, because a user who set it meant it, and `os.userInfo()` after, + * which reads the passwd database and so still answers when `HOME` is unset โ€” a + * cron job, a `su` without `-l`, a container entrypoint. `process.env.HOME || ''` + * treated all of those as a fatal error. + */ +function currentHome(): string | null { + if (process.env.HOME) { + return process.env.HOME; + } + try { + return os.userInfo().homedir || null; + } catch { + // uv_os_get_passwd can fail outright when there is no passwd entry for the uid. + return null; + } +} + +/** + * Where another user's home directory is, without asking the system. + * + * The sibling of the current user's home comes first because it is right wherever + * homes live together, whatever that directory is called โ€” `/Users` on macOS, + * `/home` on Linux, `/export/home` on the odd installation. keyman previously + * hardcoded `/home/`, which is wrong on the one platform it was written on. + * + * The candidates are checked for existence rather than guessed at, so a wrong one + * produces an error naming what was tried instead of an empty `readdir`. + */ +function candidateHomes(user: string): string[] { + const home = currentHome(); + const siblings = home ? [path.join(path.dirname(home), user)] : []; + + return [...new Set([...siblings, path.join('/home', user), path.join('/Users', user)])]; +} + +/** + * Resolves the home directory for an answer to the USER prompt. + * + * @returns the directory, or null with the reason already reported + */ +export function resolveHomeDir(user: string): string | null { + if (user === CURRENT_USER) { + const home = currentHome(); + if (!home) { + console.error('โŒ Unable to determine HOME directory for the current user.'); + return null; + } + return home; + } + + const candidates = candidateHomes(user); + const found = candidates.find((candidate) => fs.existsSync(candidate)); + + if (!found) { + console.error(`โŒ No home directory found for ${user}. Tried: ${candidates.join(', ')}`); + return null; + } + + return found; +} diff --git a/packages/keyman/src/keyman.keys.ts b/packages/keyman/src/keyman.keys.ts new file mode 100644 index 0000000..d9f710f --- /dev/null +++ b/packages/keyman/src/keyman.keys.ts @@ -0,0 +1,93 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** Present in the first line of every private key format ssh-keygen writes. */ +const PRIVATE_KEY_MARKER = 'PRIVATE KEY-----'; + +/** Enough for `-----BEGIN OPENSSH PRIVATE KEY-----`, and no more of a key than needed. */ +const HEADER_BYTES = 64; + +/** + * Whether a file opens with a private key header. + * + * A bounded read of the first line, not the file: classifying a key is no reason + * to pull one into memory. + */ +function looksLikePrivateKey(file: string): boolean { + let handle: number | undefined; + try { + handle = fs.openSync(file, 'r'); + const buffer = Buffer.alloc(HEADER_BYTES); + const read = fs.readSync(handle, buffer, 0, HEADER_BYTES, 0); + return buffer.subarray(0, read).toString('latin1').includes(PRIVATE_KEY_MARKER); + } catch { + // A directory, a socket, a file with no read permission โ€” none of them a key. + return false; + } finally { + if (handle !== undefined) { + fs.closeSync(handle); + } + } +} + +export interface PrivateKeyScan { + /** Keys keyman can manage: named `id_*`, which is what the vault layout assumes. */ + keys: string[]; + /** Private keys it found and cannot manage, because they are named otherwise. */ + skipped: string[]; +} + +/** + * The private keys in a directory that may not exist. + * + * A first run has neither `~/.ssh` nor the tmp directory, and an unguarded readdir + * there threw before the "nothing to encrypt" message could be reached. + * + * `skipped` exists because the `id_*` filter is silent: a key named + * `deploy_ed25519` was simply absent from every menu, and pre-existing keys are + * the population a key manager gets adopted to take over. Reporting them is not + * managing them โ€” see `reportSkippedKeys`. + */ +export function scanPrivateKeys(dir: string): PrivateKeyScan { + if (!fs.existsSync(dir)) { + return { keys: [], skipped: [] }; + } + + const keys: string[] = []; + const skipped: string[] = []; + + // Sorted, because readdir order is the filesystem's business and a menu's order + // should not depend on it. + for (const file of fs.readdirSync(dir).sort()) { + if (file.endsWith('.pub')) { + continue; + } + if (file.startsWith('id_')) { + // Not content-checked: what the menus offered has not changed. + keys.push(file); + } else if (looksLikePrivateKey(path.join(dir, file))) { + skipped.push(file); + } + } + + return { keys, skipped }; +} + +/** + * Says which private keys were found and left alone, and why. + * + * The vault stores a key as `/id_.age` and `decrypt` + * reconstructs the filename from the directory, so the prefix is baked into the + * on-disk layout โ€” which is why this is a report and not a fix. + */ +export function reportSkippedKeys(skipped: string[], dir: string): void { + if (skipped.length === 0) { + return; + } + + const plural = skipped.length === 1 ? 'key' : 'keys'; + console.log( + `โ„น๏ธ Skipped ${skipped.length} private ${plural} in ${dir} not named id_*: ${skipped.join(', ')}` + ); + console.log(' The vault layout requires the id_ prefix; rename to manage them here.'); +} diff --git a/packages/keyman/src/keyman.list.ts b/packages/keyman/src/keyman.list.ts index fcb9eb1..ec2c3cc 100644 --- a/packages/keyman/src/keyman.list.ts +++ b/packages/keyman/src/keyman.list.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { reportSkippedKeys, scanPrivateKeys } from './keyman.keys.js'; interface KeyInfo { name: string; @@ -105,6 +106,12 @@ export async function listKeys(sshDir: string, vaultDir: string, tmpDir: string) } } + // A listing that omits keys without saying so is the worst place for the id_ + // assumption to be invisible: this is the screen a user checks it against. + for (const dir of [sshDir, tmpDir]) { + reportSkippedKeys(scanPrivateKeys(dir).skipped, dir); + } + // Display results if (keyMap.size === 0) { console.log('โš ๏ธ No SSH keys found.\n'); diff --git a/packages/keyman/src/keyman.main.ts b/packages/keyman/src/keyman.main.ts index 51be4be..b70e4ad 100644 --- a/packages/keyman/src/keyman.main.ts +++ b/packages/keyman/src/keyman.main.ts @@ -1,11 +1,13 @@ import fs from 'node:fs'; import path from 'node:path'; import inquirer from 'inquirer'; +import { clearDecryptedKeys, writeVaultGitignore } from './keyman.clear.js'; import { loadConfig, resolveConfigPaths } from './keyman.config.js'; import { copyKey } from './keyman.copy.js'; import { decryptKeys } from './keyman.decrypt.js'; import { encryptKeys } from './keyman.encrypt.js'; import { generateKey } from './keyman.generate.js'; +import { CURRENT_USER, resolveHomeDir } from './keyman.home.js'; import { listKeys } from './keyman.list.js'; import { extractAgePublicKey } from './keyman.utils.js'; @@ -25,14 +27,13 @@ export async function keyman() { { type: 'input', name: 'user', - message: 'Specify USER (default: @current):', - default: '@current', + message: `Specify USER (default: ${CURRENT_USER}):`, + default: CURRENT_USER, }, ]); - const homeDir = user === '@current' ? process.env.HOME || '' : `/home/${user}`; + const homeDir = resolveHomeDir(user); if (!homeDir) { - console.error('Error: Unable to determine HOME directory.'); process.exit(1); } @@ -43,6 +44,7 @@ export async function keyman() { for (const dir of [paths.vaultRoot, paths.keysDir, paths.tmpDir]) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } + writeVaultGitignore(paths.vaultRoot, paths.tmpDir, paths.keyPath); // Resolved on demand, because only generate and encrypt need a recipient, and // remembered once it succeeds. Retried while it has not: creating the identity @@ -73,6 +75,7 @@ export async function keyman() { { name: '๐Ÿ†• Generate key', value: 'generate' }, { name: '๐Ÿ”’ Encrypt keys', value: 'encrypt' }, { name: '๐Ÿ”“ Decrypt keys', value: 'decrypt' }, + { name: '๐Ÿงน Clear decrypted keys', value: 'clear' }, { name: 'โŒ Quit', value: 'quit' }, ], }, @@ -102,6 +105,9 @@ export async function keyman() { case 'decrypt': await decryptKeys(sshDir, paths.keysDir, paths.tmpDir, paths.keyPath); break; + case 'clear': + await clearDecryptedKeys(paths.tmpDir); + break; case 'quit': console.log('\n๐Ÿ‘‹ Goodbye!\n'); running = false; diff --git a/packages/keyman/tests/clear.test.ts b/packages/keyman/tests/clear.test.ts new file mode 100644 index 0000000..57a4aa6 --- /dev/null +++ b/packages/keyman/tests/clear.test.ts @@ -0,0 +1,158 @@ +/** + * Tests for the plaintext hygiene helpers: the vault .gitignore and the + * clear-decrypted-keys operation. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { prompt } = vi.hoisted(() => ({ prompt: vi.fn() })); + +vi.mock('inquirer', () => ({ default: { prompt } })); + +import { clearDecryptedKeys, writeVaultGitignore } from '../src/keyman.clear.js'; + +describe('writeVaultGitignore', () => { + let vaultRoot: string; + + const read = () => fs.readFileSync(path.join(vaultRoot, '.gitignore'), 'utf-8'); + + beforeEach(() => { + vaultRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-ignore-'))); + }); + + afterEach(() => { + fs.rmSync(vaultRoot, { recursive: true, force: true }); + }); + + it('ignores the identity and the decrypted keys, not the encrypted ones', () => { + writeVaultGitignore(vaultRoot, path.join(vaultRoot, 'tmp'), path.join(vaultRoot, 'age.key')); + + const contents = read(); + expect(contents).toContain('age.key\n'); + expect(contents).toContain('age.key.pub'); + expect(contents).toContain('tmp/'); + // The encrypted keys are the thing worth committing. + expect(contents).not.toContain('keys/'); + }); + + it('uses the configured names', () => { + writeVaultGitignore( + vaultRoot, + path.join(vaultRoot, 'plain'), + path.join(vaultRoot, 'identity.age') + ); + + expect(read()).toContain('plain/'); + expect(read()).toContain('identity.age'); + }); + + it('says nothing about a directory outside the vault', () => { + writeVaultGitignore(vaultRoot, '/elsewhere/tmp', path.join(vaultRoot, 'age.key')); + + // A .gitignore cannot speak for a path above itself, and pretending otherwise + // would read as protection that is not there. + expect(read()).not.toContain('elsewhere'); + expect(read()).toContain('age.key'); + }); + + it('never overwrites an existing file', () => { + fs.writeFileSync(path.join(vaultRoot, '.gitignore'), 'mine\n'); + + writeVaultGitignore(vaultRoot, path.join(vaultRoot, 'tmp'), path.join(vaultRoot, 'age.key')); + + expect(read()).toBe('mine\n'); + }); + + it('creates it private to the owner', () => { + writeVaultGitignore(vaultRoot, path.join(vaultRoot, 'tmp'), path.join(vaultRoot, 'age.key')); + + expect(fs.statSync(path.join(vaultRoot, '.gitignore')).mode & 0o777).toBe(0o600); + }); +}); + +describe('clearDecryptedKeys', () => { + let tmpDir: string; + let logSpy: ReturnType; + + const messages = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + + const decrypted = (name: string) => { + fs.writeFileSync(path.join(tmpDir, name), 'PRIVATE'); + fs.writeFileSync(path.join(tmpDir, `${name}.pub`), 'PUBLIC'); + }; + + beforeEach(() => { + vi.clearAllMocks(); + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-clear-'))); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + prompt.mockResolvedValue({ confirmed: true }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('says so when there is nothing to clear', async () => { + await clearDecryptedKeys(tmpDir); + + expect(messages()).toContain('Nothing decrypted'); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('does not mind a tmp directory that was never created', async () => { + fs.rmSync(tmpDir, { recursive: true }); + + await expect(clearDecryptedKeys(tmpDir)).resolves.toBeUndefined(); + }); + + it('removes each key and its public half', async () => { + decrypted('id_prod'); + decrypted('id_stage'); + + await clearDecryptedKeys(tmpDir); + + expect(fs.readdirSync(tmpDir)).toEqual([]); + expect(messages()).toContain('Removed id_prod'); + }); + + it('lists what it is about to delete before asking', async () => { + decrypted('id_prod'); + + await clearDecryptedKeys(tmpDir); + + const askedAt = messages().indexOf('id_prod'); + expect(askedAt).toBeGreaterThanOrEqual(0); + expect(prompt.mock.calls[0][0][0]).toMatchObject({ type: 'confirm', default: false }); + }); + + it('keeps everything when the confirmation is declined', async () => { + decrypted('id_prod'); + prompt.mockResolvedValue({ confirmed: false }); + + await clearDecryptedKeys(tmpDir); + + expect(fs.existsSync(path.join(tmpDir, 'id_prod'))).toBe(true); + expect(messages()).toContain('Nothing was deleted'); + }); + + it('leaves files that are not keys alone', async () => { + decrypted('id_prod'); + fs.writeFileSync(path.join(tmpDir, 'notes.md'), 'mine'); + + await clearDecryptedKeys(tmpDir); + + expect(fs.readdirSync(tmpDir)).toEqual(['notes.md']); + }); + + it('does not fail on a key whose public half is missing', async () => { + fs.writeFileSync(path.join(tmpDir, 'id_prod'), 'PRIVATE'); + + await clearDecryptedKeys(tmpDir); + + expect(fs.readdirSync(tmpDir)).toEqual([]); + }); +}); diff --git a/packages/keyman/tests/clipboard.test.ts b/packages/keyman/tests/clipboard.test.ts new file mode 100644 index 0000000..e23970f --- /dev/null +++ b/packages/keyman/tests/clipboard.test.ts @@ -0,0 +1,85 @@ +/** + * Tests for the clipboard layer. + * + * The platform is passed in rather than stubbed, so every branch is reachable from + * the one machine the suite runs on. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { execa } = vi.hoisted(() => ({ execa: vi.fn() })); + +vi.mock('execa', () => ({ execa })); + +import { clipboardTools, copyToClipboard } from '../src/keyman.clipboard.js'; + +describe('clipboardTools', () => { + it.each([ + ['darwin', ['pbcopy']], + ['win32', ['clip']], + ['linux', ['wl-copy', 'xclip', 'xsel']], + // Anything unrecognised gets the X11/Wayland list rather than nothing: a BSD + // running the same desktop stack is closer to linux than to no answer. + ['freebsd', ['wl-copy', 'xclip', 'xsel']], + ])('offers the right commands on %s', (platform, expected) => { + expect(clipboardTools(platform).map((t) => t.binary)).toEqual(expected); + }); + + it('passes the clipboard selection to the X11 tools', () => { + const byBinary = new Map(clipboardTools('linux').map((t) => [t.binary, t.args])); + + // Without these, xclip and xsel write to the primary selection, which is not + // the clipboard a paste reads from. + expect(byBinary.get('xclip')).toEqual(['-selection', 'clipboard']); + expect(byBinary.get('xsel')).toEqual(['--clipboard', '--input']); + }); +}); + +describe('copyToClipboard', () => { + const notFound = () => + execa.mockImplementation(async () => { + throw Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }); + }); + + beforeEach(() => { + vi.clearAllMocks(); + execa.mockResolvedValue({ stdout: '' }); + }); + + it('pipes the text to the first available command', async () => { + const tool = await copyToClipboard('ssh-ed25519 AAAA', 'darwin'); + + expect(tool).toBe('pbcopy'); + expect(execa).toHaveBeenCalledWith('pbcopy', [], { input: 'ssh-ed25519 AAAA' }); + }); + + it('moves on from a command that is not installed', async () => { + execa.mockImplementation(async (binary: string) => { + if (binary !== 'xclip') { + throw Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }); + } + return { stdout: '' }; + }); + + expect(await copyToClipboard('key', 'linux')).toBe('xclip'); + expect(execa.mock.calls.map((c) => c[0])).toEqual(['wl-copy', 'xclip']); + }); + + it('reports that nothing was available rather than throwing', async () => { + notFound(); + + expect(await copyToClipboard('key', 'linux')).toBeNull(); + expect(execa).toHaveBeenCalledTimes(3); + }); + + it('surfaces a command that ran and refused', async () => { + execa.mockImplementation(async () => { + throw Object.assign(new Error('failed'), { stderr: 'Error: No protocol specified' }); + }); + + // A tool with an opinion is not an absent tool: trying the next one would + // hide a real problem behind a second failure. + await expect(copyToClipboard('key', 'linux')).rejects.toThrow('No protocol specified'); + expect(execa).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/keyman/tests/copy.test.ts b/packages/keyman/tests/copy.test.ts index 7c81adf..9938d6a 100644 --- a/packages/keyman/tests/copy.test.ts +++ b/packages/keyman/tests/copy.test.ts @@ -10,11 +10,7 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { execa, prompt, stdin } = vi.hoisted(() => ({ - execa: vi.fn(), - prompt: vi.fn(), - stdin: { write: vi.fn(), end: vi.fn() }, -})); +const { execa, prompt } = vi.hoisted(() => ({ execa: vi.fn(), prompt: vi.fn() })); vi.mock('execa', () => ({ execa })); vi.mock('inquirer', () => ({ default: { prompt } })); @@ -27,6 +23,7 @@ describe('copyKey', () => { let tmpDir: string; let logSpy: ReturnType; let errorSpy: ReturnType; + let warnSpy: ReturnType; const touch = (dir: string, file: string, contents = '') => { fs.mkdirSync(dir, { recursive: true }); @@ -39,6 +36,9 @@ describe('copyKey', () => { /** The choices offered by the last inquirer.prompt call. */ const choices = () => prompt.mock.calls.at(-1)?.[0][0].choices as string[]; + /** What was piped into the clipboard command. */ + const piped = () => (execa.mock.calls.at(-1)?.[2] as { input?: string } | undefined)?.input; + beforeEach(() => { vi.clearAllMocks(); root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-copy-'))); @@ -46,9 +46,9 @@ describe('copyKey', () => { tmpDir = path.join(root, 'tmp'); logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const proc = Object.assign(Promise.resolve({ exitCode: 0 }), { stdin }); - execa.mockReturnValue(proc); + execa.mockResolvedValue({ stdout: '' }); }); afterEach(() => { @@ -93,9 +93,7 @@ describe('copyKey', () => { await copyKey(sshDir, tmpDir); - expect(execa).toHaveBeenCalledWith('pbcopy'); - expect(stdin.write).toHaveBeenCalledWith('ssh-ed25519 AAAA tmp'); - expect(stdin.end).toHaveBeenCalled(); + expect(piped()).toBe('ssh-ed25519 AAAA tmp'); expect(messages(logSpy)).toContain('copied to clipboard'); }); @@ -106,7 +104,7 @@ describe('copyKey', () => { await copyKey(sshDir, tmpDir); - expect(stdin.write).toHaveBeenCalledWith('ssh-ed25519 AAAA ssh'); + expect(piped()).toBe('ssh-ed25519 AAAA ssh'); }); it('reports a missing public key without invoking the clipboard', async () => { @@ -123,11 +121,38 @@ describe('copyKey', () => { touch(sshDir, 'id_prod'); touch(sshDir, 'id_prod.pub', 'ssh-ed25519 AAAA ssh'); prompt.mockResolvedValue({ selectedKey: 'id_prod' }); - execa.mockImplementation(() => { - throw new Error('pbcopy missing'); - }); + execa.mockRejectedValue(Object.assign(new Error('refused'), { stderr: 'no display' })); await expect(copyKey(sshDir, tmpDir)).resolves.toBeUndefined(); expect(messages(errorSpy)).toContain('Failed to copy to clipboard'); }); + + it('prints the key when no clipboard command exists at all', async () => { + touch(sshDir, 'id_prod'); + touch(sshDir, 'id_prod.pub', 'ssh-ed25519 AAAA ssh'); + prompt.mockResolvedValue({ selectedKey: 'id_prod' }); + execa.mockImplementation(async () => { + throw Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }); + }); + + await copyKey(sshDir, tmpDir); + + // The operation is "give me this public key". Without a clipboard it is still + // answerable, and it used to be a dead end on every platform but macOS. + expect(messages(logSpy)).toContain('ssh-ed25519 AAAA ssh'); + expect(messages(warnSpy)).toContain('No clipboard command found'); + }); + + it('names the private keys it cannot manage', async () => { + touch(sshDir, 'id_prod'); + touch(sshDir, 'id_prod.pub', 'PUBLIC'); + touch(sshDir, 'deploy_ed25519', '-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n'); + prompt.mockResolvedValue({ selectedKey: 'id_prod' }); + + await copyKey(sshDir, tmpDir); + + expect(choices()).toEqual(['id_prod']); + expect(messages(logSpy)).toContain('deploy_ed25519'); + expect(messages(logSpy)).toContain('not named id_*'); + }); }); diff --git a/packages/keyman/tests/home.test.ts b/packages/keyman/tests/home.test.ts new file mode 100644 index 0000000..800e501 --- /dev/null +++ b/packages/keyman/tests/home.test.ts @@ -0,0 +1,120 @@ +/** + * Tests for home directory resolution. + * + * Real directories under os.tmpdir() stand in for home directories, since the + * whole point of the module is that it checks whether a path exists rather than + * assuming a layout. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CURRENT_USER, resolveHomeDir } from '../src/keyman.home.js'; + +describe('resolveHomeDir', () => { + let homes: string; + let originalHome: string | undefined; + let errorSpy: ReturnType; + + const messages = () => errorSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + + beforeEach(() => { + originalHome = process.env.HOME; + homes = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-homes-'))); + process.env.HOME = path.join(homes, 'alice'); + fs.mkdirSync(process.env.HOME, { recursive: true }); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + fs.rmSync(homes, { recursive: true, force: true }); + }); + + describe('the current user', () => { + it('uses HOME when it is set', () => { + expect(resolveHomeDir(CURRENT_USER)).toBe(path.join(homes, 'alice')); + }); + + it('falls back to the passwd entry when HOME is unset', () => { + delete process.env.HOME; + + // Not asserted as a literal: what matters is that an unset HOME is no longer + // a fatal error, which is what `process.env.HOME || ''` made it. + expect(resolveHomeDir(CURRENT_USER)).toBe(os.userInfo().homedir); + }); + + it('reports the failure when neither is available', () => { + delete process.env.HOME; + vi.spyOn(os, 'userInfo').mockImplementation(() => { + throw new Error('no passwd entry for uid'); + }); + + expect(resolveHomeDir(CURRENT_USER)).toBeNull(); + expect(messages()).toContain('Unable to determine HOME directory'); + }); + + it('treats an empty passwd home as no answer', () => { + delete process.env.HOME; + vi.spyOn(os, 'userInfo').mockReturnValue({ + ...os.userInfo(), + homedir: '', + }); + + expect(resolveHomeDir(CURRENT_USER)).toBeNull(); + }); + }); + + describe('another user', () => { + it('looks beside the current home, whatever that directory is called', () => { + const bob = path.join(homes, 'bob'); + fs.mkdirSync(bob); + + // The old code hardcoded /home/, which is wrong on macOS โ€” where homes + // live in /Users โ€” and on any host that puts them anywhere else. + expect(resolveHomeDir('bob')).toBe(bob); + }); + + it('still tries the conventional locations with no current home to go by', () => { + delete process.env.HOME; + vi.spyOn(os, 'userInfo').mockImplementation(() => { + throw new Error('no passwd entry for uid'); + }); + + // Not knowing where *this* user lives is no reason to give up on another. + expect(resolveHomeDir('nobody')).toBeNull(); + expect(messages()).toContain('/home/nobody, /Users/nobody'); + expect(messages()).not.toContain('Unable to determine HOME'); + }); + + it('reports every path it tried when there is no such home', () => { + expect(resolveHomeDir('nobody')).toBeNull(); + + expect(messages()).toContain('No home directory found for nobody'); + expect(messages()).toContain(path.join(homes, 'nobody')); + expect(messages()).toContain('/home/nobody'); + expect(messages()).toContain('/Users/nobody'); + }); + + it('still checks the conventional locations when HOME is somewhere odd', () => { + process.env.HOME = path.join(homes, 'alice'); + const conventional = process.platform === 'darwin' ? '/Users' : '/home'; + const existing = fs + .readdirSync(conventional) + .find((entry) => + fs.statSync(path.join(conventional, entry), { throwIfNoEntry: false })?.isDirectory() + ); + + // Skipped rather than asserted blind if the machine has no such user. + if (existing) { + expect(resolveHomeDir(existing)).toBe(path.join(conventional, existing)); + } + }); + }); +}); diff --git a/packages/keyman/tests/keys.test.ts b/packages/keyman/tests/keys.test.ts new file mode 100644 index 0000000..5e4abb6 --- /dev/null +++ b/packages/keyman/tests/keys.test.ts @@ -0,0 +1,128 @@ +/** + * Tests for private key discovery. + * + * Real files, because the classification is a bounded read of a real header. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { reportSkippedKeys, scanPrivateKeys } from '../src/keyman.keys.js'; + +const OPENSSH = '-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXk\n'; +const RSA_PEM = '-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\n'; +const PKCS8 = '-----BEGIN PRIVATE KEY-----\nMIIB\n'; + +describe('scanPrivateKeys', () => { + let dir: string; + + const write = (name: string, contents: string) => + fs.writeFileSync(path.join(dir, name), contents); + + beforeEach(() => { + dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-keys-'))); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('returns nothing for a directory that does not exist', () => { + expect(scanPrivateKeys(path.join(dir, 'nope'))).toEqual({ keys: [], skipped: [] }); + }); + + it('offers the id_ keys and not their public halves', () => { + write('id_prod', OPENSSH); + write('id_prod.pub', 'ssh-ed25519 AAAA'); + + expect(scanPrivateKeys(dir)).toEqual({ keys: ['id_prod'], skipped: [] }); + }); + + it('sorts the keys, so the menu order does not come from the filesystem', () => { + for (const name of ['id_stage', 'id_alpha', 'id_prod']) { + write(name, OPENSSH); + } + + expect(scanPrivateKeys(dir).keys).toEqual(['id_alpha', 'id_prod', 'id_stage']); + }); + + it('offers an id_ file without checking what is in it', () => { + // Unchanged from before the scan existed: whatever was offered still is. + write('id_prod', 'not a key at all'); + + expect(scanPrivateKeys(dir).keys).toEqual(['id_prod']); + }); + + it.each([ + ['an OpenSSH key', OPENSSH], + ['an encrypted PEM key', RSA_PEM], + ['a PKCS#8 key', PKCS8], + ])('reports %s that is not named id_*', (_label, contents) => { + write('deploy_ed25519', contents); + + expect(scanPrivateKeys(dir)).toEqual({ keys: [], skipped: ['deploy_ed25519'] }); + }); + + it('ignores the other files a .ssh directory is full of', () => { + write('known_hosts', 'github.com ssh-ed25519 AAAA'); + write('config', 'Host *\n AddKeysToAgent yes\n'); + write('authorized_keys', 'ssh-ed25519 AAAA'); + fs.mkdirSync(path.join(dir, 'sockets')); + + expect(scanPrivateKeys(dir)).toEqual({ keys: [], skipped: [] }); + }); + + it('ignores a file it cannot read', () => { + write('secret', OPENSSH); + fs.chmodSync(path.join(dir, 'secret'), 0o000); + + // Reported as not-a-key rather than crashing the menu it was building. + expect(scanPrivateKeys(dir).skipped).toEqual([]); + + fs.chmodSync(path.join(dir, 'secret'), 0o600); + }); + + it('does not read past the header', () => { + // The marker is in the first line; a mention further down is not a key. + write('decoy', `${'x'.repeat(200)}\nPRIVATE KEY-----\n`); + + expect(scanPrivateKeys(dir).skipped).toEqual([]); + }); +}); + +describe('reportSkippedKeys', () => { + let logSpy: ReturnType; + + const messages = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('says nothing when nothing was skipped', () => { + reportSkippedKeys([], '/home/alice/.ssh'); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('names the keys, the directory and the reason', () => { + reportSkippedKeys(['deploy_ed25519', 'backup_rsa'], '/home/alice/.ssh'); + + expect(messages()).toContain('deploy_ed25519, backup_rsa'); + expect(messages()).toContain('/home/alice/.ssh'); + expect(messages()).toContain('2 private keys'); + // Without the reason the message is a complaint rather than an instruction. + expect(messages()).toContain('rename'); + }); + + it('says key, singular, for one of them', () => { + reportSkippedKeys(['deploy_ed25519'], '/home/alice/.ssh'); + + expect(messages()).toContain('1 private key '); + }); +}); diff --git a/packages/keyman/tests/main.test.ts b/packages/keyman/tests/main.test.ts index 73be963..babc9ab 100644 --- a/packages/keyman/tests/main.test.ts +++ b/packages/keyman/tests/main.test.ts @@ -136,6 +136,7 @@ describe('keyman', () => { 'generate', 'encrypt', 'decrypt', + 'clear', 'quit', ]); }); @@ -257,21 +258,55 @@ describe('keyman', () => { }); it('targets another user home directory when a user is named', async () => { + // A real sibling of the current HOME, because resolveHomeDir checks that the + // directory exists rather than assuming a layout. + const deployHome = path.join(root, 'deploy'); + fs.mkdirSync(deployHome, { recursive: true }); menu(['list'], 'deploy'); await keyman(); - expect(listKeys).toHaveBeenCalledWith('/home/deploy/.ssh', paths.keysDir, paths.tmpDir); + expect(listKeys).toHaveBeenCalledWith( + path.join(deployHome, '.ssh'), + paths.keysDir, + paths.tmpDir + ); }); - it('aborts when the home directory cannot be determined', async () => { - delete process.env.HOME; + it('aborts when the named user has no home directory', async () => { + menu(['list'], 'nobody-at-all'); const exit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); }); await expect(keyman()).rejects.toThrow('process.exit'); expect(exit).toHaveBeenCalledWith(1); - expect(errorSpy.mock.calls[0][0]).toContain('Unable to determine HOME directory'); + expect(errorSpy.mock.calls[0][0]).toContain('No home directory found'); + }); + + it('writes a .gitignore next to the vault so it cannot be committed', async () => { + await keyman(); + + const contents = fs.readFileSync(path.join(paths.vaultRoot, '.gitignore'), 'utf-8'); + // The README used to ask the user to do this by hand. + expect(contents).toContain('age.key'); + expect(contents).toContain('tmp/'); + }); + + it('clears the decrypted keys on request', async () => { + fs.mkdirSync(paths.tmpDir, { recursive: true }); + fs.writeFileSync(path.join(paths.tmpDir, 'id_prod'), 'PRIVATE'); + // Not the `menu` helper: this one has to answer the confirmation too. + const queue = ['clear', 'quit']; + prompt.mockImplementation(async (questions: { name: string }[]) => { + const { name } = questions[0]; + if (name === 'user') return { user: '@current' }; + if (name === 'confirmed') return { confirmed: true }; + return { category: queue.shift() }; + }); + + await keyman(); + + expect(fs.existsSync(path.join(paths.tmpDir, 'id_prod'))).toBe(false); }); });