764f890900
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.
89 lines
3.2 KiB
TypeScript
89 lines
3.2 KiB
TypeScript
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { runTool } from './keyman.utils.js';
|
||
|
||
/**
|
||
* The public half of a private key, derived if the sibling file is missing.
|
||
*
|
||
* `encrypt` builds its selection list from private keys only, so a key whose
|
||
* `.pub` was deleted is offered like any other. Reading the sibling blindly meant
|
||
* finding out it was absent *after* `age` had written the encrypted key — a vault
|
||
* entry with no public key, and an exception that killed the rest of the batch.
|
||
*
|
||
* @returns the public key text, or null with the reason already reported
|
||
*/
|
||
async function publicKeyFor(keyPath: string): Promise<string | null> {
|
||
const sibling = `${keyPath}.pub`;
|
||
if (fs.existsSync(sibling)) {
|
||
return fs.readFileSync(sibling, 'utf-8');
|
||
}
|
||
|
||
const fileName = path.basename(keyPath);
|
||
console.log(`ℹ️ ${fileName} has no .pub file — deriving it with ssh-keygen.`);
|
||
|
||
try {
|
||
// stdin and stderr inherited, stdout piped: verified that `ssh-keygen -y`
|
||
// prompts for the passphrase of an encrypted key, and that it prompts on
|
||
// *stderr*. Capturing everything would hide the prompt and then fail on the
|
||
// passphrase nobody was asked for; inheriting everything would lose the key.
|
||
const { stdout } = await runTool('ssh-keygen', ['-y', '-f', keyPath], {
|
||
stdio: ['inherit', 'pipe', 'inherit'],
|
||
});
|
||
const derived = stdout.trim();
|
||
if (derived) {
|
||
return `${derived}\n`;
|
||
}
|
||
} catch (error) {
|
||
console.warn(`⚠️ ${fileName}: ${error instanceof Error ? error.message : error}`);
|
||
}
|
||
|
||
console.warn(`⚠️ ${fileName}: no public key could be derived; storing the private key alone.`);
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Encrypts one private key into `<keysDir>/<name>/`, alongside its public half.
|
||
*
|
||
* Shared by `encrypt` and `generate`, which were two copies of it.
|
||
*
|
||
* @returns the vault directory the key was stored in
|
||
*/
|
||
export async function storeInVault(
|
||
keyPath: string,
|
||
keysDir: string,
|
||
pubkey: string
|
||
): Promise<string> {
|
||
const fileName = path.basename(keyPath);
|
||
const vaultPath = path.join(keysDir, fileName.replace(/^id_/, ''));
|
||
|
||
// Before the directory exists, so a key that cannot be read does not leave one.
|
||
const publicKey = await publicKeyFor(keyPath);
|
||
|
||
fs.mkdirSync(vaultPath, { recursive: true, mode: 0o700 });
|
||
const encryptedKey = path.join(vaultPath, `${fileName}.age`);
|
||
|
||
try {
|
||
await runTool('age', ['-r', pubkey, '-o', encryptedKey, keyPath]);
|
||
} catch (error) {
|
||
// age writes into a directory that has to exist already, so a failure here
|
||
// leaves one behind — and possibly a truncated .age file, which `list` would
|
||
// count as a vault entry and `decrypt` would offer. Both are ours: the file
|
||
// because we named it, the directory only while it is empty, since one
|
||
// holding an earlier key is not.
|
||
fs.rmSync(encryptedKey, { force: true });
|
||
try {
|
||
fs.rmdirSync(vaultPath);
|
||
} catch {
|
||
// ENOTEMPTY — something else was already stored here.
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
if (publicKey !== null) {
|
||
fs.writeFileSync(path.join(vaultPath, `${fileName}.pub`), publicKey);
|
||
}
|
||
|
||
console.log(`🔒 Encrypted and stored: ${path.join(vaultPath, `${fileName}.age`)}`);
|
||
return vaultPath;
|
||
}
|