[feat] keyman: key rotation, in two halves
Phase 10 of docs/PLAN.md; closes AUDIT §3.6, the README's oldest lie
("Support for key rotation", with no occurrence of "rotat" in src/).
Rotation only ever adds. `rotateKey` generates a replacement under the next
name in the series — prod → prod-2 → prod-3 — and encrypts it *alongside*
the key it replaces, so both are in the vault at once. `retireKey` is a
separate operation, and the only one in keyman that destroys an encrypted
key. The gap between the two is where the new public key gets deployed and
tested: a rotation that replaces the key in one step locks you out of the
host you were rotating for, because the replacement is not on it yet and
the only copy of the one that is has gone.
The name has to change — the vault layout derives the directory from it, so
a replacement also called `prod` *is* the `prod` entry. `nextRotationName`
skips any version already taken in the vault, in tmp or in .ssh, so it
never asks ssh-keygen to overwrite a private key in use. Retirement warns
when nothing in the vault supersedes the key and then makes the user type
its name, since that deletion is unrecoverable.
Three things extracted rather than copied: `listVaultKeys` (vault.ts) now
backs decrypt, rotate and retire; `createKeyPair` and `promptKeyOptions`
(generate.ts) are shared with rotation, which also carries the old key's
comment over as the default. Verified against the real binaries that a
hyphen-suffixed name survives ssh-keygen and age, that the vault entry
round-trips byte-identically, and that ssh-keygen writes the replacement
0600 without help.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import inquirer from 'inquirer';
|
import inquirer from 'inquirer';
|
||||||
import { runTool } from './keyman.utils.js';
|
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. */
|
/** The two decryption targets. Values, so the label can name the real directory. */
|
||||||
const LOCAL_MODE = 'local';
|
const LOCAL_MODE = 'local';
|
||||||
@@ -15,13 +16,7 @@ interface DecryptPlan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function decryptKeys(sshDir: string, keysDir: string, tmpDir: string, ageKey: string) {
|
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
|
const vaultKeys = listVaultKeys(keysDir);
|
||||||
// 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`)))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (vaultKeys.length === 0) {
|
if (vaultKeys.length === 0) {
|
||||||
console.log('⚠️ No encrypted keys found.');
|
console.log('⚠️ No encrypted keys found.');
|
||||||
|
|||||||
@@ -4,7 +4,21 @@ import inquirer from 'inquirer';
|
|||||||
import { runTool } from './keyman.utils.js';
|
import { runTool } from './keyman.utils.js';
|
||||||
import { storeInVault } from './keyman.vault.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<KeyOptions> {
|
||||||
const { algorithm } = await inquirer.prompt<{ algorithm: string }>([
|
const { algorithm } = await inquirer.prompt<{ algorithm: string }>([
|
||||||
{
|
{
|
||||||
type: 'list',
|
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 }>([
|
const { identity } = await inquirer.prompt<{ identity: string }>([
|
||||||
{
|
{
|
||||||
type: 'input',
|
type: 'input',
|
||||||
name: 'identity',
|
name: 'identity',
|
||||||
message: 'Enter key identity (comment):',
|
message: 'Enter key identity (comment):',
|
||||||
|
default: defaultIdentity,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const fileName = keyName.startsWith('id_') ? keyName : `id_${keyName}`;
|
return { algorithm, identity };
|
||||||
const keyPath = path.join(tmpDir, fileName);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<boolean> {
|
||||||
|
const fileName = path.basename(keyPath);
|
||||||
|
|
||||||
if (fs.existsSync(keyPath)) {
|
if (fs.existsSync(keyPath)) {
|
||||||
console.error(`❌ Error: Key file ${fileName} already exists in ${tmpDir}`);
|
console.error(`❌ Error: Key file ${fileName} already exists in ${path.dirname(keyPath)}`);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = ['-t', algorithm, '-f', keyPath, '-C', identity];
|
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.
|
// before that. A passphrase keyman never learns cannot be leaked by keyman.
|
||||||
await runTool('ssh-keygen', args, { stdio: 'inherit' });
|
await runTool('ssh-keygen', args, { stdio: 'inherit' });
|
||||||
console.log(`✅ Key generated: ${keyPath}`);
|
console.log(`✅ Key generated: ${keyPath}`);
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ Error generating key: ${error instanceof Error ? error.message : 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { encryptKeys } from './keyman.encrypt.js';
|
|||||||
import { generateKey } from './keyman.generate.js';
|
import { generateKey } from './keyman.generate.js';
|
||||||
import { CURRENT_USER, resolveHomeDir } from './keyman.home.js';
|
import { CURRENT_USER, resolveHomeDir } from './keyman.home.js';
|
||||||
import { listKeys } from './keyman.list.js';
|
import { listKeys } from './keyman.list.js';
|
||||||
|
import { retireKey, rotateKey } from './keyman.rotate.js';
|
||||||
import { extractAgePublicKey } from './keyman.utils.js';
|
import { extractAgePublicKey } from './keyman.utils.js';
|
||||||
|
|
||||||
// 🔹 Main function to resolve paths and manage flow
|
// 🔹 Main function to resolve paths and manage flow
|
||||||
@@ -75,6 +76,8 @@ export async function keyman() {
|
|||||||
{ name: '🆕 Generate key', value: 'generate' },
|
{ name: '🆕 Generate key', value: 'generate' },
|
||||||
{ name: '🔒 Encrypt keys', value: 'encrypt' },
|
{ name: '🔒 Encrypt keys', value: 'encrypt' },
|
||||||
{ name: '🔓 Decrypt keys', value: 'decrypt' },
|
{ name: '🔓 Decrypt keys', value: 'decrypt' },
|
||||||
|
{ name: '🔄 Rotate key', value: 'rotate' },
|
||||||
|
{ name: '🗑️ Retire key', value: 'retire' },
|
||||||
{ name: '🧹 Clear decrypted keys', value: 'clear' },
|
{ name: '🧹 Clear decrypted keys', value: 'clear' },
|
||||||
{ name: '❌ Quit', value: 'quit' },
|
{ name: '❌ Quit', value: 'quit' },
|
||||||
],
|
],
|
||||||
@@ -105,6 +108,16 @@ export async function keyman() {
|
|||||||
case 'decrypt':
|
case 'decrypt':
|
||||||
await decryptKeys(sshDir, paths.keysDir, paths.tmpDir, paths.keyPath);
|
await decryptKeys(sshDir, paths.keysDir, paths.tmpDir, paths.keyPath);
|
||||||
break;
|
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':
|
case 'clear':
|
||||||
await clearDecryptedKeys(paths.tmpDir);
|
await clearDecryptedKeys(paths.tmpDir);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `<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: '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<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: '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}.`);
|
||||||
|
}
|
||||||
@@ -2,6 +2,27 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { runTool } from './keyman.utils.js';
|
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_<dir>.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.
|
* The public half of a private key, derived if the sibling file is missing.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const {
|
|||||||
generateKey,
|
generateKey,
|
||||||
encryptKeys,
|
encryptKeys,
|
||||||
decryptKeys,
|
decryptKeys,
|
||||||
|
rotateKey,
|
||||||
|
retireKey,
|
||||||
extractAgePublicKey,
|
extractAgePublicKey,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
prompt: vi.fn(),
|
prompt: vi.fn(),
|
||||||
@@ -29,6 +31,8 @@ const {
|
|||||||
generateKey: vi.fn(),
|
generateKey: vi.fn(),
|
||||||
encryptKeys: vi.fn(),
|
encryptKeys: vi.fn(),
|
||||||
decryptKeys: vi.fn(),
|
decryptKeys: vi.fn(),
|
||||||
|
rotateKey: vi.fn(),
|
||||||
|
retireKey: vi.fn(),
|
||||||
extractAgePublicKey: 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.generate.js', () => ({ generateKey }));
|
||||||
vi.mock('../src/keyman.encrypt.js', () => ({ encryptKeys }));
|
vi.mock('../src/keyman.encrypt.js', () => ({ encryptKeys }));
|
||||||
vi.mock('../src/keyman.decrypt.js', () => ({ decryptKeys }));
|
vi.mock('../src/keyman.decrypt.js', () => ({ decryptKeys }));
|
||||||
|
vi.mock('../src/keyman.rotate.js', () => ({ rotateKey, retireKey }));
|
||||||
vi.mock('../src/keyman.utils.js', () => ({ extractAgePublicKey }));
|
vi.mock('../src/keyman.utils.js', () => ({ extractAgePublicKey }));
|
||||||
|
|
||||||
import { keyman } from '../src/keyman.main.js';
|
import { keyman } from '../src/keyman.main.js';
|
||||||
@@ -136,6 +141,8 @@ describe('keyman', () => {
|
|||||||
'generate',
|
'generate',
|
||||||
'encrypt',
|
'encrypt',
|
||||||
'decrypt',
|
'decrypt',
|
||||||
|
'rotate',
|
||||||
|
'retire',
|
||||||
'clear',
|
'clear',
|
||||||
'quit',
|
'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', () => {
|
describe('without an age recipient', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
extractAgePublicKey.mockResolvedValue(null);
|
extractAgePublicKey.mockResolvedValue(null);
|
||||||
@@ -207,6 +241,7 @@ describe('keyman', () => {
|
|||||||
it.each([
|
it.each([
|
||||||
['generate', generateKey],
|
['generate', generateKey],
|
||||||
['encrypt', encryptKeys],
|
['encrypt', encryptKeys],
|
||||||
|
['rotate', rotateKey],
|
||||||
])('refuses %s with a remedy instead of passing null to age', async (choice, operation) => {
|
])('refuses %s with a remedy instead of passing null to age', async (choice, operation) => {
|
||||||
menu([choice]);
|
menu([choice]);
|
||||||
|
|
||||||
@@ -220,12 +255,13 @@ describe('keyman', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('still allows the operations that need no recipient', async () => {
|
it('still allows the operations that need no recipient', async () => {
|
||||||
menu(['list', 'decrypt']);
|
menu(['list', 'decrypt', 'retire']);
|
||||||
|
|
||||||
await keyman();
|
await keyman();
|
||||||
|
|
||||||
expect(listKeys).toHaveBeenCalled();
|
expect(listKeys).toHaveBeenCalled();
|
||||||
expect(decryptKeys).toHaveBeenCalled();
|
expect(decryptKeys).toHaveBeenCalled();
|
||||||
|
expect(retireKey).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retries the lookup, so creating the identity mid-session works', async () => {
|
it('retries the lookup, so creating the identity mid-session works', async () => {
|
||||||
|
|||||||
@@ -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<typeof vi.spyOn>;
|
||||||
|
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
const PUBKEY = 'age1recipient';
|
||||||
|
|
||||||
|
/** Creates <keysDir>/<name>/id_<name>.{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<string, unknown>) => {
|
||||||
|
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<typeof vi.spyOn>) =>
|
||||||
|
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<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
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<string, unknown>) => {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,52 @@ const { execa } = vi.hoisted(() => ({ execa: vi.fn() }));
|
|||||||
|
|
||||||
vi.mock('execa', () => ({ execa }));
|
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', () => {
|
describe('storeInVault', () => {
|
||||||
let root: string;
|
let root: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user