[keyman] phase 4: decrypt stops destroying keys and stops the 0644 window

Verified before the fix: `age -d -o <existing>` overwrites without a word
("PRECIOUS EXISTING KEY" became "secret"), and the old `cp` for the public
key did the same. Decrypting a vault entry on top of a newer working key
in ~/.ssh destroyed it with no prompt, no backup and no mention. It is the
only finding in the audit that loses data the user never asked to touch.

Every collision — private and public, both output modes — is now settled
before anything is written, so the questions are asked about files that
still exist. Default is to keep what is there.

cp and chmod are gone. Three spawns per key become one, it works where
those binaries do not, and the chmod happens in-process immediately after
age returns: age creates its output 0644 regardless of umask, so a
plaintext private key was world-readable for the length of two spawns and
stayed 0644 whenever the chmod itself failed. ~/.ssh is created 0700 when
absent rather than assumed.

decrypt.test.ts stops asserting on which binaries were spawned. The age
stand-in now writes its -o file at 0644 the way age does, and the tests
assert the bytes and the mode on disk — the outcome rather than the
mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Diedrichsen
2026-07-30 14:39:30 +02:00
parent 77bd43818f
commit 653d348ecc
2 changed files with 224 additions and 40 deletions
+71 -18
View File
@@ -1,9 +1,18 @@
import fs from 'node:fs';
import path from 'node:path';
import { execa } from 'execa';
import inquirer from 'inquirer';
import { runTool } from './keyman.utils.js';
const LOCAL_MODE = 'Local (vault/tmp)';
interface DecryptPlan {
key: string;
encryptedKey: string;
publicKey: string;
privateKeyOut: string;
publicKeyOut: string;
}
export async function decryptKeys(sshDir: string, vaultDir: string, ageKey: string) {
const keyDir = path.join(vaultDir, 'keys');
// Guarded: nothing creates the keys directory until the first encrypt, so on a
@@ -28,27 +37,71 @@ export async function decryptKeys(sshDir: string, vaultDir: string, ageKey: stri
type: 'list',
name: 'decryptMode',
message: 'Choose decryption location:',
choices: ['Local (vault/tmp)', 'SSH (~/.ssh)'],
choices: [LOCAL_MODE, 'SSH (~/.ssh)'],
},
]);
for (const key of selectedKeys) {
const encryptedKey = path.join(keyDir, key, `id_${key}.age`);
const publicKey = path.join(keyDir, key, `id_${key}.pub`);
const privateKeyOut =
decryptMode === 'Local (vault/tmp)'
? path.join(vaultDir, 'tmp', `id_${key}`)
: path.join(sshDir, `id_${key}`);
const publicKeyOut =
decryptMode === 'Local (vault/tmp)'
? path.join(vaultDir, 'tmp', `id_${key}.pub`)
: path.join(sshDir, `id_${key}.pub`);
const outDir = decryptMode === LOCAL_MODE ? path.join(vaultDir, 'tmp') : sshDir;
// Decrypt key
await runTool('age', ['-d', '-i', ageKey, '-o', privateKeyOut, encryptedKey]);
const plans: DecryptPlan[] = selectedKeys.map((key: string) => ({
key,
encryptedKey: path.join(keyDir, key, `id_${key}.age`),
publicKey: path.join(keyDir, key, `id_${key}.pub`),
privateKeyOut: path.join(outDir, `id_${key}`),
publicKeyOut: path.join(outDir, `id_${key}.pub`),
}));
await execa('cp', [publicKey, publicKeyOut]);
await execa('chmod', ['600', privateKeyOut]);
console.log(`✅ Decrypted: ${privateKeyOut}`);
// 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}`);
}
}