Files
ansiblings/packages/keyman/src/keyman.decrypt.ts
T
Benjamin DiedrichsenandClaude Opus 5 568d4c83ff keyman: replace removed inquirer prompt type 'list' with 'select'
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
2026-09-02 13:50:59 +02:00

109 lines
3.4 KiB
TypeScript

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';
interface DecryptPlan {
key: string;
encryptedKey: string;
publicKey: string;
privateKeyOut: string;
publicKeyOut: string;
}
export async function decryptKeys(sshDir: string, keysDir: string, tmpDir: string, ageKey: string) {
const vaultKeys = listVaultKeys(keysDir);
if (vaultKeys.length === 0) {
console.log('⚠️ No encrypted keys found.');
return;
}
const { selectedKeys, decryptMode } = await inquirer.prompt([
{
type: 'checkbox',
name: 'selectedKeys',
message: 'Select keys to decrypt:',
choices: vaultKeys,
},
{
type: 'select',
name: 'decryptMode',
message: 'Choose decryption location:',
// Named after the directories actually in use, which are configurable.
choices: [
{ name: `Local (${tmpDir})`, value: LOCAL_MODE },
{ name: `SSH (${sshDir})`, value: 'ssh' },
],
},
]);
const outDir = decryptMode === LOCAL_MODE ? tmpDir : sshDir;
const plans: DecryptPlan[] = selectedKeys.map((key: string) => ({
key,
encryptedKey: path.join(keysDir, key, `id_${key}.age`),
publicKey: path.join(keysDir, key, `id_${key}.pub`),
privateKeyOut: path.join(outDir, `id_${key}`),
publicKeyOut: path.join(outDir, `id_${key}.pub`),
}));
// Every collision is settled before anything is written. `age -d -o` and the
// old `cp` both overwrote silently, so decrypting a vault key on top of a
// newer working key destroyed it with no prompt and no copy — and the user is
// answering these questions about files that still exist.
const approved: DecryptPlan[] = [];
for (const plan of plans) {
const existing = [plan.privateKeyOut, plan.publicKeyOut].filter((file) => fs.existsSync(file));
if (existing.length === 0) {
approved.push(plan);
continue;
}
const { overwrite } = await inquirer.prompt<{ overwrite: boolean }>([
{
type: 'confirm',
name: 'overwrite',
message: `${existing.join(', ')} already present. Overwrite?`,
default: false,
},
]);
if (overwrite) {
approved.push(plan);
} else {
console.log(`⏭️ Skipped ${plan.key} — kept what was already there.`);
}
}
if (approved.length === 0) {
return;
}
// 0700: ~/.ssh may not exist yet, and it is about to hold a private key.
fs.mkdirSync(outDir, { recursive: true, mode: 0o700 });
for (const plan of approved) {
await runTool('age', ['-d', '-i', ageKey, '-o', plan.privateKeyOut, plan.encryptedKey]);
// Immediately, and in-process: age creates its output 0644 regardless of
// umask, so this used to be a world-readable private key for the length of
// two process spawns — and stayed 0644 whenever the chmod itself failed.
fs.chmodSync(plan.privateKeyOut, 0o600);
if (fs.existsSync(plan.publicKey)) {
fs.copyFileSync(plan.publicKey, plan.publicKeyOut);
} else {
console.log(
`⚠️ ${plan.key} has no public key in the vault; only the private key was written.`
);
}
console.log(`✅ Decrypted: ${plan.privateKeyOut}`);
}
}