inquirer v10 removed the legacy 'list' prompt in favour of 'select', so every list-style prompt — starting with the main menu — died with "Prompt type \"list\" is not registered" on any real run against the declared ^14 dependency. The tests never saw it because they all mock inquirer.prompt, which accepts any type string. Choice shapes and the 'default' option are unchanged; 'select' takes them as-is. Bump to 0.7.2 to ship the fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ce5atB2tXDXyz2jd9s1bqE
257 lines
7.9 KiB
TypeScript
257 lines
7.9 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
|
|
// `<type> <base64> <comment...>`: 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<void> {
|
|
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: 'select',
|
|
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<void> {
|
|
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: 'select',
|
|
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}.`);
|
|
}
|