[keyman] keep the passphrase off argv, and recover a missing .pub

Generate prompted for the passphrase itself and passed it as `-N <value>`,
so it sat in this process's argv — readable by any user on the box through
`ps` for the length of the spawn — and in keyman's memory before that.
Verified that omitting `-N` makes ssh-keygen prompt *and* confirm, so the
prompt and the flag are both gone and the spawn inherits stdio. keyman no
longer learns the passphrase, which is strictly better than handling it more
carefully, and it deletes code.

The other half is the missing `.pub`. The selection list is built from
private keys, so an orphan is offered like any other, and copyFileSync
discovered the absent sibling only *after* age had written the encrypted
key: a vault entry with no public key, and an exception that took the rest
of the batch with it. It is now derived with `ssh-keygen -y -f`, before the
vault directory is created. Verified against real binaries that the derived
key matches the original byte for byte, that an encrypted key prompts (on
stderr — hence stdout piped, stdin and stderr inherited), and that a refused
derivation degrades to storing the private key alone rather than failing.

encrypt's loop now isolates per key and reports which ones did not make it,
except for ToolNotFoundError: age missing is not a per-key problem and nine
more identical errors help nobody.

storeInVault is the shared write path both callers had a copy of. It also
undoes its own mess: age has to write into a directory that already exists,
so a failure could leave an empty directory or a truncated .age — which list
counts as a vault entry and decrypt offers. The .age is removed because we
named it, the directory only while empty, since one holding an earlier key
is not ours to delete.
This commit is contained in:
Benjamin Diedrichsen
2026-07-30 15:01:22 +02:00
parent da9df57e11
commit 764f890900
6 changed files with 395 additions and 52 deletions
+24 -31
View File
@@ -1,7 +1,8 @@
import fs from 'node:fs';
import path from 'node:path';
import { execa } from 'execa';
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) {
const { algorithm } = await inquirer.prompt<{ algorithm: string }>([
@@ -23,15 +24,6 @@ export async function generateKey(tmpDir: string, keysDir: string, pubkey: strin
},
]);
const { password } = await inquirer.prompt<{ password: string }>([
{
type: 'password',
name: 'password',
message: 'Enter passphrase (leave empty for no passphrase):',
mask: '*',
},
]);
const { identity } = await inquirer.prompt<{ identity: string }>([
{
type: 'input',
@@ -48,30 +40,31 @@ export async function generateKey(tmpDir: string, keysDir: string, pubkey: strin
return;
}
const args = ['-t', algorithm, '-f', keyPath, '-C', identity];
if (algorithm === 'rsa') {
args.push('-b', '4096');
}
try {
console.log(`Generating ${algorithm} key pair...`);
const args = ['-t', algorithm, '-f', keyPath, '-N', password, '-C', identity];
if (algorithm === 'rsa') {
args.push('-b', '4096');
}
await execa('ssh-keygen', args);
// No `-N`, and stdio inherited: ssh-keygen asks for the passphrase itself and
// confirms it. keyman used to prompt for it and pass it as `-N <value>`,
// which put the passphrase in this process's argv — readable by any user on
// the box via `ps` for as long as the spawn lived, and in keyman's memory
// before that. A passphrase keyman never learns cannot be leaked by keyman.
await runTool('ssh-keygen', args, { stdio: 'inherit' });
console.log(`✅ Key generated: ${keyPath}`);
// Encrypt the key
const folderName = fileName.replace('id_', '');
const vaultPath = path.join(keysDir, folderName);
fs.mkdirSync(vaultPath, { recursive: true });
// Encrypt key using `age`
await execa('age', ['-r', pubkey, '-o', path.join(vaultPath, `${fileName}.age`), keyPath]);
// Copy public key
fs.copyFileSync(`${keyPath}.pub`, path.join(vaultPath, `${fileName}.pub`));
console.log(`🔒 Encrypted and stored: ${vaultPath}`);
} catch (error) {
console.error(`❌ Error generating/encrypting key: ${error}`);
console.error(`❌ Error generating key: ${error instanceof Error ? error.message : error}`);
return;
}
try {
await storeInVault(keyPath, keysDir, pubkey);
} catch (error) {
// The private key is still in tmpDir, so this is recoverable by encrypting it
// — which is why it does not read as having lost the key.
console.error(`❌ Error encrypting key: ${error instanceof Error ? error.message : error}`);
console.error(` ${keyPath} was generated; encrypt it once the problem is fixed.`);
}
}