diff --git a/packages/keyman/src/keyman.decrypt.ts b/packages/keyman/src/keyman.decrypt.ts index e91f7f2..9da132c 100644 --- a/packages/keyman/src/keyman.decrypt.ts +++ b/packages/keyman/src/keyman.decrypt.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import inquirer from 'inquirer'; import { runTool } from './keyman.utils.js'; +import { listVaultKeys } from './keyman.vault.js'; /** The two decryption targets. Values, so the label can name the real directory. */ const LOCAL_MODE = 'local'; @@ -15,13 +16,7 @@ interface DecryptPlan { } export async function decryptKeys(sshDir: string, keysDir: string, tmpDir: string, ageKey: string) { - // Guarded: nothing creates the keys directory until the first encrypt, so on a - // fresh vault this readdir threw instead of reporting an empty vault. - const vaultKeys = fs.existsSync(keysDir) - ? fs - .readdirSync(keysDir) - .filter((key) => fs.existsSync(path.join(keysDir, key, `id_${key}.age`))) - : []; + const vaultKeys = listVaultKeys(keysDir); if (vaultKeys.length === 0) { console.log('⚠️ No encrypted keys found.'); diff --git a/packages/keyman/src/keyman.generate.ts b/packages/keyman/src/keyman.generate.ts index 90fcdca..431ccb8 100644 --- a/packages/keyman/src/keyman.generate.ts +++ b/packages/keyman/src/keyman.generate.ts @@ -4,7 +4,21 @@ import inquirer from 'inquirer'; import { runTool } from './keyman.utils.js'; import { storeInVault } from './keyman.vault.js'; -export async function generateKey(tmpDir: string, keysDir: string, pubkey: string) { +export interface KeyOptions { + algorithm: string; + identity: string; +} + +/** + * How a new key pair should be made: the algorithm and the comment. + * + * Shared with rotation, which asks the same two questions about a key whose name + * it works out for itself. + * + * @param defaultIdentity offered as the answer — the comment of the key being + * replaced, when there is one + */ +export async function promptKeyOptions(defaultIdentity?: string): Promise { const { algorithm } = await inquirer.prompt<{ algorithm: string }>([ { type: 'list', @@ -15,29 +29,33 @@ export async function generateKey(tmpDir: string, keysDir: string, pubkey: strin }, ]); - const { keyName } = await inquirer.prompt<{ keyName: string }>([ - { - type: 'input', - name: 'keyName', - message: 'Enter key name:', - validate: (input) => (input.trim() !== '' ? true : 'Key name cannot be empty'), - }, - ]); - const { identity } = await inquirer.prompt<{ identity: string }>([ { type: 'input', name: 'identity', message: 'Enter key identity (comment):', + default: defaultIdentity, }, ]); - const fileName = keyName.startsWith('id_') ? keyName : `id_${keyName}`; - const keyPath = path.join(tmpDir, fileName); + return { algorithm, identity }; +} + +/** + * Generates one key pair at `keyPath`, reporting a failure rather than throwing. + * + * @returns whether the key pair was written + */ +export async function createKeyPair( + keyPath: string, + algorithm: string, + identity: string +): Promise { + const fileName = path.basename(keyPath); if (fs.existsSync(keyPath)) { - console.error(`❌ Error: Key file ${fileName} already exists in ${tmpDir}`); - return; + console.error(`❌ Error: Key file ${fileName} already exists in ${path.dirname(keyPath)}`); + return false; } const args = ['-t', algorithm, '-f', keyPath, '-C', identity]; @@ -54,8 +72,29 @@ export async function generateKey(tmpDir: string, keysDir: string, pubkey: strin // before that. A passphrase keyman never learns cannot be leaked by keyman. await runTool('ssh-keygen', args, { stdio: 'inherit' }); console.log(`✅ Key generated: ${keyPath}`); + return true; } catch (error) { console.error(`❌ Error generating key: ${error instanceof Error ? error.message : error}`); + return false; + } +} + +export async function generateKey(tmpDir: string, keysDir: string, pubkey: string) { + const { keyName } = await inquirer.prompt<{ keyName: string }>([ + { + type: 'input', + name: 'keyName', + message: 'Enter key name:', + validate: (input) => (input.trim() !== '' ? true : 'Key name cannot be empty'), + }, + ]); + + const { algorithm, identity } = await promptKeyOptions(); + + const fileName = keyName.startsWith('id_') ? keyName : `id_${keyName}`; + const keyPath = path.join(tmpDir, fileName); + + if (!(await createKeyPair(keyPath, algorithm, identity))) { return; } diff --git a/packages/keyman/src/keyman.main.ts b/packages/keyman/src/keyman.main.ts index b70e4ad..240fff0 100644 --- a/packages/keyman/src/keyman.main.ts +++ b/packages/keyman/src/keyman.main.ts @@ -9,6 +9,7 @@ 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 { retireKey, rotateKey } from './keyman.rotate.js'; import { extractAgePublicKey } from './keyman.utils.js'; // 🔹 Main function to resolve paths and manage flow @@ -75,6 +76,8 @@ export async function keyman() { { name: '🆕 Generate key', value: 'generate' }, { name: '🔒 Encrypt keys', value: 'encrypt' }, { name: '🔓 Decrypt keys', value: 'decrypt' }, + { name: '🔄 Rotate key', value: 'rotate' }, + { name: '🗑️ Retire key', value: 'retire' }, { name: '🧹 Clear decrypted keys', value: 'clear' }, { name: '❌ Quit', value: 'quit' }, ], @@ -105,6 +108,16 @@ export async function keyman() { case 'decrypt': await decryptKeys(sshDir, paths.keysDir, paths.tmpDir, paths.keyPath); break; + case 'rotate': { + const pubkey = await ageRecipient(); + if (pubkey) { + await rotateKey(sshDir, paths.keysDir, paths.tmpDir, pubkey); + } + break; + } + case 'retire': + await retireKey(sshDir, paths.keysDir, paths.tmpDir); + break; case 'clear': await clearDecryptedKeys(paths.tmpDir); break; diff --git a/packages/keyman/src/keyman.rotate.ts b/packages/keyman/src/keyman.rotate.ts new file mode 100644 index 0000000..0f9b084 --- /dev/null +++ b/packages/keyman/src/keyman.rotate.ts @@ -0,0 +1,256 @@ +/** + * Key rotation, in two halves that are deliberately not one operation. + * + * `rotateKey` only ever *adds*: a replacement key generated under the next name in + * the series and encrypted alongside the key it replaces. `retireKey` is what + * finally deletes the old one, once the user says the replacement is deployed. + * + * Rotating in place — overwriting the key, or deleting it in the same breath — + * locks you out of the host you were rotating for: the replacement is not on it + * yet, and the only copy of the key that is has gone. The gap between the two + * operations is where you add the new public key and check that it works. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import inquirer from 'inquirer'; +import { createKeyPair, promptKeyOptions } from './keyman.generate.js'; +import { scanPrivateKeys } from './keyman.keys.js'; +import { listVaultKeys, storeInVault } from './keyman.vault.js'; + +interface Series { + base: string; + version: number; +} + +/** `prod-2` → base `prod`, version 2. An unsuffixed name is version 1. */ +function series(key: string): Series { + const match = /^(.+)-(\d+)$/.exec(key); + return match ? { base: match[1], version: Number(match[2]) } : { base: key, version: 1 }; +} + +/** + * The name for the replacement of `key`: same series, next version up. + * + * The name has to change. The vault layout derives the directory from it, so a + * replacement also called `prod` *is* the `prod` entry — and holding both at once + * is the whole point of rotating this way. + * + * @param taken every name already in use, in the vault or as a plaintext key, so + * the suffix skips a version that was made by hand + */ +export function nextRotationName(key: string, taken: string[]): string { + const { base, version } = series(key); + let next = version + 1; + + for (const name of taken) { + const other = series(name); + if (other.base === base && other.version >= next) { + next = other.version + 1; + } + } + + return `${base}-${next}`; +} + +/** The latest key in the vault that comes after `key` in its series, if any. */ +export function supersededBy(key: string, vaultKeys: string[]): string | null { + const { base, version } = series(key); + let successor: string | null = null; + let highest = version; + + for (const name of vaultKeys) { + const other = series(name); + if (other.base === base && other.version > highest) { + successor = name; + highest = other.version; + } + } + + return successor; +} + +/** The bare names of the plaintext keys in `dir`, matching the vault's naming. */ +function plaintextNames(dir: string): string[] { + return scanPrivateKeys(dir).keys.map((file) => file.replace(/^id_/, '')); +} + +/** The comment on a stored public key, so a rotation can carry it over. */ +function storedComment(publicKeyFile: string): string | undefined { + if (!fs.existsSync(publicKeyFile)) { + return undefined; + } + + // ` `: the comment is optional and may hold spaces. + const comment = fs.readFileSync(publicKeyFile, 'utf-8').trim().split(/\s+/).slice(2).join(' '); + return comment || undefined; +} + +/** Prints a public key for copying, or says why it cannot. */ +function showPublicKey(label: string, file: string): void { + console.log(`\n ${label}`); + if (fs.existsSync(file)) { + console.log(` ${fs.readFileSync(file, 'utf-8').trim()}`); + } else { + console.log(` (none stored at ${file})`); + } +} + +function isFile(file: string): boolean { + return fs.statSync(file, { throwIfNoEntry: false })?.isFile() ?? false; +} + +/** + * Generates a replacement for a vault key and stores it beside the original. + * + * Nothing is deleted or overwritten; `retireKey` is the other half. + */ +export async function rotateKey( + sshDir: string, + keysDir: string, + tmpDir: string, + pubkey: string +): Promise { + const vaultKeys = listVaultKeys(keysDir); + + if (vaultKeys.length === 0) { + console.log('⚠️ No encrypted keys to rotate — generate or encrypt one first.'); + return; + } + + const { key } = await inquirer.prompt<{ key: string }>([ + { + type: 'list', + name: 'key', + message: 'Select the key to rotate:', + choices: vaultKeys, + }, + ]); + + const currentPublicKey = path.join(keysDir, key, `id_${key}.pub`); + const { algorithm, identity } = await promptKeyOptions(storedComment(currentPublicKey)); + + const replacement = nextRotationName(key, [ + ...vaultKeys, + ...plaintextNames(tmpDir), + ...plaintextNames(sshDir), + ]); + const keyPath = path.join(tmpDir, `id_${replacement}`); + + console.log(`\n🔄 Rotating ${key} → ${replacement}`); + console.log(` ${key} is left exactly as it is, in the vault and on its hosts.\n`); + + fs.mkdirSync(tmpDir, { recursive: true, mode: 0o700 }); + if (!(await createKeyPair(keyPath, algorithm, identity))) { + return; + } + + try { + await storeInVault(keyPath, keysDir, pubkey); + } catch (error) { + console.error( + `❌ Error encrypting the replacement: ${error instanceof Error ? error.message : error}` + ); + console.error(` ${keyPath} was generated; encrypt it once the problem is fixed.`); + return; + } + + showPublicKey(`Current — still valid (${key}):`, currentPublicKey); + showPublicKey(`Replacement — deploy this (${replacement}):`, `${keyPath}.pub`); + + console.log('\n Next:'); + console.log(` 1. Add the replacement public key wherever ${key} is authorized.`); + console.log(` 2. Check that you can log in with ${keyPath}.`); + console.log(` 3. Remove ${key} from those hosts, then retire it here.\n`); +} + +/** + * Deletes a vault key and its plaintext copies, after saying exactly what goes. + * + * The second half of a rotation, and the only operation in keyman that destroys an + * encrypted key. + */ +export async function retireKey(sshDir: string, keysDir: string, tmpDir: string): Promise { + const vaultKeys = listVaultKeys(keysDir); + + if (vaultKeys.length === 0) { + console.log('⚠️ No encrypted keys in the vault.'); + return; + } + + const { key } = await inquirer.prompt<{ key: string }>([ + { + type: 'list', + name: 'key', + message: 'Select the key to retire:', + choices: vaultKeys, + }, + ]); + + const vaultPath = path.join(keysDir, key); + const files = [ + ...fs.readdirSync(vaultPath).map((file) => path.join(vaultPath, file)), + path.join(tmpDir, `id_${key}`), + path.join(tmpDir, `id_${key}.pub`), + path.join(sshDir, `id_${key}`), + path.join(sshDir, `id_${key}.pub`), + ].filter(isFile); + + const successor = supersededBy(key, vaultKeys); + + console.log(`\n🗑️ Retiring ${key} deletes:`); + for (const file of files) { + console.log(` ${file}`); + } + if (successor) { + console.log(`\n ${successor} is in the vault and supersedes ${key}.`); + } else { + console.log(`\n⚠️ Nothing in the vault supersedes ${key}: this deletes the only copy.`); + } + + const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ + { + type: 'confirm', + name: 'confirmed', + message: `Delete ${files.length} ${files.length === 1 ? 'file' : 'files'}?`, + default: false, + }, + ]); + + if (!confirmed) { + console.log(' Nothing was deleted.'); + return; + } + + // Typed out when there is no successor, because that is the deletion this tool + // exists to prevent: an encrypted key nothing replaces is the only copy there is, + // and a y/n is one keystroke away from an irreversible one. + if (!successor) { + const { typed } = await inquirer.prompt<{ typed: string }>([ + { + type: 'input', + name: 'typed', + message: `Type ${key} to confirm:`, + }, + ]); + + if (typed.trim() !== key) { + console.log(' Name did not match — nothing was deleted.'); + return; + } + } + + for (const file of files) { + fs.rmSync(file, { force: true }); + console.log(` Removed ${file}`); + } + + try { + // Only while empty: anything left in there was not ours to delete. + fs.rmdirSync(vaultPath); + } catch { + console.log(` Kept ${vaultPath} — it still holds other files.`); + } + + console.log(`✅ Retired ${key}.`); +} diff --git a/packages/keyman/src/keyman.vault.ts b/packages/keyman/src/keyman.vault.ts index a0f8da1..f62f9e6 100644 --- a/packages/keyman/src/keyman.vault.ts +++ b/packages/keyman/src/keyman.vault.ts @@ -2,6 +2,27 @@ import fs from 'node:fs'; import path from 'node:path'; import { runTool } from './keyman.utils.js'; +/** + * The vault entries that hold an encrypted key, sorted. + * + * A directory counts as an entry when it holds `id_.age` — the layout + * `storeInVault` writes and `decrypt` reads back — which is what keeps a stray + * file, or a directory whose encryption failed, out of every menu built from this. + * Sorted because the order otherwise comes from the filesystem. + */ +export function listVaultKeys(keysDir: string): string[] { + // Nothing creates the keys directory until the first encrypt, so on a fresh + // vault this readdir threw instead of reporting an empty one. + if (!fs.existsSync(keysDir)) { + return []; + } + + return fs + .readdirSync(keysDir) + .filter((key) => fs.existsSync(path.join(keysDir, key, `id_${key}.age`))) + .sort(); +} + /** * The public half of a private key, derived if the sibling file is missing. * diff --git a/packages/keyman/tests/main.test.ts b/packages/keyman/tests/main.test.ts index babc9ab..257224a 100644 --- a/packages/keyman/tests/main.test.ts +++ b/packages/keyman/tests/main.test.ts @@ -19,6 +19,8 @@ const { generateKey, encryptKeys, decryptKeys, + rotateKey, + retireKey, extractAgePublicKey, } = vi.hoisted(() => ({ prompt: vi.fn(), @@ -29,6 +31,8 @@ const { generateKey: vi.fn(), encryptKeys: vi.fn(), decryptKeys: vi.fn(), + rotateKey: vi.fn(), + retireKey: vi.fn(), extractAgePublicKey: vi.fn(), })); @@ -39,6 +43,7 @@ vi.mock('../src/keyman.copy.js', () => ({ copyKey })); vi.mock('../src/keyman.generate.js', () => ({ generateKey })); vi.mock('../src/keyman.encrypt.js', () => ({ encryptKeys })); vi.mock('../src/keyman.decrypt.js', () => ({ decryptKeys })); +vi.mock('../src/keyman.rotate.js', () => ({ rotateKey, retireKey })); vi.mock('../src/keyman.utils.js', () => ({ extractAgePublicKey })); import { keyman } from '../src/keyman.main.js'; @@ -136,6 +141,8 @@ describe('keyman', () => { 'generate', 'encrypt', 'decrypt', + 'rotate', + 'retire', 'clear', 'quit', ]); @@ -199,6 +206,33 @@ describe('keyman', () => { ); }); + it('rotates a key with the age recipient, against the same directories', async () => { + menu(['rotate']); + + await keyman(); + + expect(rotateKey).toHaveBeenCalledWith( + path.join(process.env.HOME as string, '.ssh'), + paths.keysDir, + paths.tmpDir, + 'age1recipient' + ); + }); + + it('retires a key without needing a recipient', async () => { + menu(['retire']); + + await keyman(); + + expect(retireKey).toHaveBeenCalledWith( + path.join(process.env.HOME as string, '.ssh'), + paths.keysDir, + paths.tmpDir + ); + // Retiring only deletes, so it works with no age identity at all. + expect(extractAgePublicKey).not.toHaveBeenCalled(); + }); + describe('without an age recipient', () => { beforeEach(() => { extractAgePublicKey.mockResolvedValue(null); @@ -207,6 +241,7 @@ describe('keyman', () => { it.each([ ['generate', generateKey], ['encrypt', encryptKeys], + ['rotate', rotateKey], ])('refuses %s with a remedy instead of passing null to age', async (choice, operation) => { menu([choice]); @@ -220,12 +255,13 @@ describe('keyman', () => { }); it('still allows the operations that need no recipient', async () => { - menu(['list', 'decrypt']); + menu(['list', 'decrypt', 'retire']); await keyman(); expect(listKeys).toHaveBeenCalled(); expect(decryptKeys).toHaveBeenCalled(); + expect(retireKey).toHaveBeenCalled(); }); it('retries the lookup, so creating the identity mid-session works', async () => { diff --git a/packages/keyman/tests/rotate.test.ts b/packages/keyman/tests/rotate.test.ts new file mode 100644 index 0000000..dd2508f --- /dev/null +++ b/packages/keyman/tests/rotate.test.ts @@ -0,0 +1,471 @@ +/** + * Tests for rotation and retirement. + * + * ssh-keygen and age are mocked; the ssh-keygen stand-in writes the pair the real + * binary would, so the vault write is a real one. Everything the operations claim + * about the filesystem is asserted against the filesystem. + */ + +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 { execa, prompt } = vi.hoisted(() => ({ execa: vi.fn(), prompt: vi.fn() })); + +vi.mock('execa', () => ({ execa })); +vi.mock('inquirer', () => ({ default: { prompt } })); + +import { nextRotationName, retireKey, rotateKey, supersededBy } from '../src/keyman.rotate.js'; + +describe('nextRotationName', () => { + it('starts a series at 2, so the first key keeps its plain name', () => { + expect(nextRotationName('prod', ['prod'])).toBe('prod-2'); + }); + + it('continues an existing series', () => { + expect(nextRotationName('prod-2', ['prod', 'prod-2'])).toBe('prod-3'); + }); + + it('skips past a version that already exists', () => { + // Rotating the original again after prod-2 and prod-3 exist: -2 is taken. + expect(nextRotationName('prod', ['prod', 'prod-2', 'prod-3'])).toBe('prod-4'); + }); + + it('ignores other series', () => { + expect(nextRotationName('prod', ['prod', 'stage-7', 'prod-backup'])).toBe('prod-2'); + }); + + it('treats a name that ends in a number as its own series', () => { + // `web2` is a host name, not a version — the separator is what makes a series. + expect(nextRotationName('web2', ['web2'])).toBe('web2-2'); + }); + + it('keeps a hyphenated base intact', () => { + expect(nextRotationName('build-agent', ['build-agent'])).toBe('build-agent-2'); + expect(nextRotationName('build-agent-2', ['build-agent-2'])).toBe('build-agent-3'); + }); +}); + +describe('supersededBy', () => { + it('finds the replacement of a key', () => { + expect(supersededBy('prod', ['prod', 'prod-2'])).toBe('prod-2'); + }); + + it('answers with the latest one', () => { + expect(supersededBy('prod', ['prod', 'prod-2', 'prod-3'])).toBe('prod-3'); + }); + + it('says nothing supersedes the newest key in a series', () => { + expect(supersededBy('prod-3', ['prod', 'prod-2', 'prod-3'])).toBeNull(); + }); + + it('does not count an unrelated key', () => { + expect(supersededBy('prod', ['prod', 'stage-9'])).toBeNull(); + }); +}); + +describe('rotateKey', () => { + let root: string; + let sshDir: string; + let keysDir: string; + let tmpDir: string; + let logSpy: ReturnType; + let errorSpy: ReturnType; + + const PUBKEY = 'age1recipient'; + + /** Creates //id_.{age,pub}. */ + const vaultKey = (name: string, comment = 'me@host') => { + const dir = path.join(keysDir, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `id_${name}.age`), `ENCRYPTED ${name}`); + fs.writeFileSync(path.join(dir, `id_${name}.pub`), `ssh-ed25519 AAAA${name} ${comment}\n`); + }; + + /** Answers each prompt by the name of the question it asks. */ + const answer = (answers: Record) => { + prompt.mockImplementation(async (questions: { name: string }[]) => { + const { name } = questions[0]; + return { [name]: answers[name] }; + }); + }; + + const question = (name: string) => + prompt.mock.calls.map((c) => c[0][0]).find((q) => q.name === name); + + const argsOf = (binary: string) => + execa.mock.calls.find((c) => c[0] === binary)?.[1] as string[] | undefined; + + const messages = (spy: ReturnType) => + spy.mock.calls.map((c) => c.join(' ')).join('\n'); + + beforeEach(() => { + vi.clearAllMocks(); + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-rotate-'))); + sshDir = path.join(root, '.ssh'); + keysDir = path.join(root, 'vault', 'keys'); + tmpDir = path.join(root, 'vault', 'tmp'); + fs.mkdirSync(keysDir, { recursive: true }); + fs.mkdirSync(sshDir, { recursive: true }); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + execa.mockImplementation(async (binary: string, args: string[]) => { + if (binary === 'ssh-keygen') { + const keyPath = args[args.indexOf('-f') + 1]; + fs.writeFileSync(keyPath, 'PRIVATE'); + fs.writeFileSync(`${keyPath}.pub`, `ssh-ed25519 NEWKEY ${args[args.indexOf('-C') + 1]}\n`); + } + if (binary === 'age') { + // Written, not just recorded: what the vault ends up holding is the thing + // under test, and a later listing has to see the new entry. + fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED'); + } + return { exitCode: 0 }; + }); + + answer({ key: 'prod', algorithm: 'ed25519', identity: 'me@host' }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('says there is nothing to rotate on an empty vault', async () => { + await rotateKey(sshDir, keysDir, tmpDir, PUBKEY); + + expect(messages(logSpy)).toContain('No encrypted keys to rotate'); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('offers the vault keys', async () => { + vaultKey('stage'); + vaultKey('prod'); + + await rotateKey(sshDir, keysDir, tmpDir, PUBKEY); + + expect(question('key').choices).toEqual(['prod', 'stage']); + }); + + it('generates the replacement into the tmp directory under the next name', async () => { + vaultKey('prod'); + + await rotateKey(sshDir, keysDir, tmpDir, PUBKEY); + + expect(argsOf('ssh-keygen')).toEqual([ + '-t', + 'ed25519', + '-f', + path.join(tmpDir, 'id_prod-2'), + '-C', + 'me@host', + ]); + }); + + it('leaves the rotated key untouched in the vault', async () => { + vaultKey('prod'); + + await rotateKey(sshDir, keysDir, tmpDir, PUBKEY); + + // The whole point of rotating this way: both keys are in the vault, and the + // one that is deployed is byte for byte what it was. + expect(fs.readFileSync(path.join(keysDir, 'prod', 'id_prod.age'), 'utf-8')).toBe( + 'ENCRYPTED prod' + ); + expect(fs.existsSync(path.join(keysDir, 'prod-2', 'id_prod-2.age'))).toBe(true); + }); + + it('encrypts the replacement to the vault recipient', async () => { + vaultKey('prod'); + + await rotateKey(sshDir, keysDir, tmpDir, PUBKEY); + + expect(argsOf('age')).toEqual([ + '-r', + PUBKEY, + '-o', + path.join(keysDir, 'prod-2', 'id_prod-2.age'), + path.join(tmpDir, 'id_prod-2'), + ]); + }); + + it('offers the comment of the key being replaced', async () => { + vaultKey('prod', 'deploy@prod'); + + await rotateKey(sshDir, keysDir, tmpDir, PUBKEY); + + expect(question('identity').default).toBe('deploy@prod'); + }); + + it('offers no comment when the stored public key has none', async () => { + vaultKey('prod'); + fs.writeFileSync(path.join(keysDir, 'prod', 'id_prod.pub'), 'ssh-ed25519 AAAAprod\n'); + + await rotateKey(sshDir, keysDir, tmpDir, PUBKEY); + + expect(question('identity').default).toBeUndefined(); + }); + + it('rotates a key whose public half was never stored', async () => { + vaultKey('prod'); + fs.rmSync(path.join(keysDir, 'prod', 'id_prod.pub')); + + await rotateKey(sshDir, keysDir, tmpDir, PUBKEY); + + expect(question('identity').default).toBeUndefined(); + // Reported rather than printed as a blank line, since the user needs it to + // know what to remove from the host afterwards. + expect(messages(logSpy)).toContain('none stored at'); + expect(fs.existsSync(path.join(keysDir, 'prod-2', 'id_prod-2.age'))).toBe(true); + }); + + it('prints both public keys and what to do with them', async () => { + vaultKey('prod', 'deploy@prod'); + + await rotateKey(sshDir, keysDir, tmpDir, PUBKEY); + + const output = messages(logSpy); + expect(output).toContain('ssh-ed25519 AAAAprod deploy@prod'); + expect(output).toContain('ssh-ed25519 NEWKEY me@host'); + // Deploy-then-retire, in that order: the reverse locks you out. + expect(output).toContain('Add the replacement public key'); + expect(output).toContain('retire'); + }); + + it('skips a name taken by a plaintext key outside the vault', async () => { + vaultKey('prod'); + fs.writeFileSync(path.join(sshDir, 'id_prod-2'), 'PRIVATE'); + + await rotateKey(sshDir, keysDir, tmpDir, PUBKEY); + + // Generating id_prod-2 would have refused, or worse asked ssh-keygen to + // overwrite a private key that is in use. + expect(argsOf('ssh-keygen')).toContain(path.join(tmpDir, 'id_prod-3')); + expect(fs.readFileSync(path.join(sshDir, 'id_prod-2'), 'utf-8')).toBe('PRIVATE'); + }); + + it('skips a name taken by an earlier rotation still in tmp', async () => { + vaultKey('prod'); + fs.mkdirSync(tmpDir, { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'id_prod-2'), 'PRIVATE'); + + await rotateKey(sshDir, keysDir, tmpDir, PUBKEY); + + expect(argsOf('ssh-keygen')).toContain(path.join(tmpDir, 'id_prod-3')); + }); + + it('requests a 4096 bit key for rsa', async () => { + vaultKey('prod'); + answer({ key: 'prod', algorithm: 'rsa', identity: '' }); + + await rotateKey(sshDir, keysDir, tmpDir, PUBKEY); + + expect(argsOf('ssh-keygen')?.slice(-2)).toEqual(['-b', '4096']); + }); + + it('stops at a failure from ssh-keygen without touching the vault', async () => { + vaultKey('prod'); + execa.mockImplementation(async () => { + throw Object.assign(new Error('ssh-keygen exploded'), { stderr: 'ssh-keygen exploded' }); + }); + + await rotateKey(sshDir, keysDir, tmpDir, PUBKEY); + + expect(messages(errorSpy)).toContain('Error generating key'); + expect(fs.existsSync(path.join(keysDir, 'prod-2'))).toBe(false); + }); + + it('reports a failure from age and says where the replacement is', async () => { + vaultKey('prod'); + execa.mockImplementation(async (binary: string, args: string[]) => { + if (binary === 'age') { + throw Object.assign(new Error('age exploded'), { stderr: 'age exploded' }); + } + const keyPath = args[args.indexOf('-f') + 1]; + fs.writeFileSync(keyPath, 'PRIVATE'); + fs.writeFileSync(`${keyPath}.pub`, 'ssh-ed25519 NEWKEY me@host\n'); + return { exitCode: 0 }; + }); + + await rotateKey(sshDir, keysDir, tmpDir, PUBKEY); + + expect(messages(errorSpy)).toContain('Error encrypting the replacement'); + expect(messages(errorSpy)).toContain(path.join(tmpDir, 'id_prod-2')); + // No summary: nothing was stored, so there is nothing to deploy yet. + expect(messages(logSpy)).not.toContain('Add the replacement public key'); + }); +}); + +describe('retireKey', () => { + let root: string; + let sshDir: string; + let keysDir: string; + let tmpDir: string; + let logSpy: ReturnType; + + const vaultKey = (name: string) => { + const dir = path.join(keysDir, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `id_${name}.age`), `ENCRYPTED ${name}`); + fs.writeFileSync(path.join(dir, `id_${name}.pub`), `PUBLIC ${name}`); + }; + + const answer = (answers: Record) => { + prompt.mockImplementation(async (questions: { name: string }[]) => { + const { name } = questions[0]; + return { [name]: answers[name] }; + }); + }; + + const question = (name: string) => + prompt.mock.calls.map((c) => c[0][0]).find((q) => q.name === name); + + const messages = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + + beforeEach(() => { + vi.clearAllMocks(); + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-retire-'))); + sshDir = path.join(root, '.ssh'); + keysDir = path.join(root, 'vault', 'keys'); + tmpDir = path.join(root, 'vault', 'tmp'); + fs.mkdirSync(keysDir, { recursive: true }); + fs.mkdirSync(sshDir, { recursive: true }); + fs.mkdirSync(tmpDir, { recursive: true }); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + answer({ key: 'prod', confirmed: true, typed: 'prod' }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('says there is nothing to retire on an empty vault', async () => { + await retireKey(sshDir, keysDir, tmpDir); + + expect(messages()).toContain('No encrypted keys in the vault'); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('removes the vault entry and its directory', async () => { + vaultKey('prod'); + vaultKey('prod-2'); + + await retireKey(sshDir, keysDir, tmpDir); + + expect(fs.existsSync(path.join(keysDir, 'prod'))).toBe(false); + // Only the one that was named. + expect(fs.existsSync(path.join(keysDir, 'prod-2', 'id_prod-2.age'))).toBe(true); + }); + + it('removes the plaintext copies as well', async () => { + vaultKey('prod'); + vaultKey('prod-2'); + for (const dir of [sshDir, tmpDir]) { + fs.writeFileSync(path.join(dir, 'id_prod'), 'PRIVATE'); + fs.writeFileSync(path.join(dir, 'id_prod.pub'), 'PUBLIC'); + } + + await retireKey(sshDir, keysDir, tmpDir); + + expect(fs.readdirSync(sshDir)).toEqual([]); + expect(fs.readdirSync(tmpDir)).toEqual([]); + }); + + it('lists every path before asking, and asks with a no default', async () => { + vaultKey('prod'); + vaultKey('prod-2'); + fs.writeFileSync(path.join(sshDir, 'id_prod'), 'PRIVATE'); + + await retireKey(sshDir, keysDir, tmpDir); + + expect(messages()).toContain(path.join(keysDir, 'prod', 'id_prod.age')); + expect(messages()).toContain(path.join(sshDir, 'id_prod')); + expect(question('confirmed')).toMatchObject({ type: 'confirm', default: false }); + expect(question('confirmed').message).toContain('3 files'); + }); + + it('counts one file as one file', async () => { + vaultKey('prod-2'); + fs.mkdirSync(path.join(keysDir, 'prod')); + fs.writeFileSync(path.join(keysDir, 'prod', 'id_prod.age'), 'ENCRYPTED'); + + await retireKey(sshDir, keysDir, tmpDir); + + expect(question('confirmed').message).toContain('1 file?'); + expect(fs.existsSync(path.join(keysDir, 'prod'))).toBe(false); + }); + + it('says what supersedes the key it is about to delete', async () => { + vaultKey('prod'); + vaultKey('prod-2'); + + await retireKey(sshDir, keysDir, tmpDir); + + expect(messages()).toContain('prod-2 is in the vault and supersedes prod'); + expect(question('typed')).toBeUndefined(); + }); + + it('keeps everything when the confirmation is declined', async () => { + vaultKey('prod'); + vaultKey('prod-2'); + answer({ key: 'prod', confirmed: false }); + + await retireKey(sshDir, keysDir, tmpDir); + + expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.age'))).toBe(true); + expect(messages()).toContain('Nothing was deleted'); + }); + + describe('a key nothing replaces', () => { + beforeEach(() => { + vaultKey('prod'); + }); + + it('warns that this is the only copy', async () => { + await retireKey(sshDir, keysDir, tmpDir); + + expect(messages()).toContain('Nothing in the vault supersedes prod'); + }); + + it('asks for the name to be typed out, and deletes when it matches', async () => { + await retireKey(sshDir, keysDir, tmpDir); + + // A y/n is one keystroke from an irreversible deletion; this is not. + expect(question('typed')).toBeDefined(); + expect(fs.existsSync(path.join(keysDir, 'prod'))).toBe(false); + }); + + it('deletes nothing when the typed name does not match', async () => { + answer({ key: 'prod', confirmed: true, typed: 'prodd' }); + + await retireKey(sshDir, keysDir, tmpDir); + + expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.age'))).toBe(true); + expect(messages()).toContain('Name did not match'); + }); + + it('accepts the name with stray whitespace', async () => { + answer({ key: 'prod', confirmed: true, typed: ' prod ' }); + + await retireKey(sshDir, keysDir, tmpDir); + + expect(fs.existsSync(path.join(keysDir, 'prod'))).toBe(false); + }); + }); + + it('keeps a vault directory that holds something else', async () => { + vaultKey('prod'); + vaultKey('prod-2'); + fs.mkdirSync(path.join(keysDir, 'prod', 'notes')); + + await retireKey(sshDir, keysDir, tmpDir); + + // The .age and .pub are gone; the directory stays, and says why. + expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.age'))).toBe(false); + expect(fs.existsSync(path.join(keysDir, 'prod', 'notes'))).toBe(true); + expect(messages()).toContain('it still holds other files'); + }); +}); diff --git a/packages/keyman/tests/vault.test.ts b/packages/keyman/tests/vault.test.ts index 1b2c756..22042db 100644 --- a/packages/keyman/tests/vault.test.ts +++ b/packages/keyman/tests/vault.test.ts @@ -15,7 +15,52 @@ const { execa } = vi.hoisted(() => ({ execa: vi.fn() })); vi.mock('execa', () => ({ execa })); -import { storeInVault } from '../src/keyman.vault.js'; +import { listVaultKeys, storeInVault } from '../src/keyman.vault.js'; + +describe('listVaultKeys', () => { + let keysDir: string; + + const entry = (name: string, file = `id_${name}.age`) => { + fs.mkdirSync(path.join(keysDir, name), { recursive: true }); + fs.writeFileSync(path.join(keysDir, name, file), 'ENCRYPTED'); + }; + + beforeEach(() => { + keysDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-vaultlist-'))); + }); + + afterEach(() => { + fs.rmSync(keysDir, { recursive: true, force: true }); + }); + + it('is empty for a keys directory that was never created', () => { + expect(listVaultKeys(path.join(keysDir, 'nope'))).toEqual([]); + }); + + it('sorts the entries rather than taking the filesystem order', () => { + for (const name of ['stage', 'alpha', 'prod']) { + entry(name); + } + + expect(listVaultKeys(keysDir)).toEqual(['alpha', 'prod', 'stage']); + }); + + it('ignores a directory with no encrypted key in it', () => { + entry('prod'); + // The shape a failed encryption used to leave behind, and a plain mistake. + fs.mkdirSync(path.join(keysDir, 'empty')); + entry('notes', 'README.md'); + + expect(listVaultKeys(keysDir)).toEqual(['prod']); + }); + + it('ignores a loose file', () => { + entry('prod'); + fs.writeFileSync(path.join(keysDir, 'id_stage.age'), 'ENCRYPTED'); + + expect(listVaultKeys(keysDir)).toEqual(['prod']); + }); +}); describe('storeInVault', () => { let root: string;