[keyman] phase 3: derive the age recipient, and survive not having one

main.ts asserted the recipient non-null twice — extractAgePublicKey(...)!
— and the type already said null was possible. With no age.key the vault
encrypted to the string "null": execa stringifies it, age exits 1, and on
the generate path that happens *after* ssh-keygen has written a plaintext
private key into tmpDir, so the user is told the operation failed and left
with a key on disk. Now the recipient is resolved once, remembered on
success, and a null prints the remedy (age-keygen -o <path>) and returns
to the menu. list, copy and decrypt still work without one.

extractAgePublicKey now derives the public key with `age-keygen -y`
instead of scraping the `# public key:` comment. The comment is ordinary
text nothing re-checks; verified that rewriting it does not change what
-y reports, so a stale or forged comment silently encrypted the vault to
a recipient nobody holds the private half of.

The comment survives as a fallback for a machine with no age-keygen,
behind a warning that it is unverified — but not when age-keygen runs and
refuses the file. That means age cannot read the identity, and trusting
the comment there would encrypt to a recipient the vault could never
decrypt with.

runTool throws ToolNotFoundError for ENOENT so the two cases can be told
apart. Its own tests move to tool.test.ts, which keeps real processes;
utils.test.ts mocks execa, since the gate cannot require age installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Diedrichsen
2026-07-30 14:37:26 +02:00
parent 11c323b715
commit 77bd43818f
5 changed files with 268 additions and 77 deletions
+24 -9
View File
@@ -44,6 +44,18 @@ export async function keyman() {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
// Resolved on demand, because only generate and encrypt need a recipient, and
// remembered once it succeeds. Retried while it has not: creating the identity
// mid-session should not mean restarting.
let recipient: string | null = null;
const ageRecipient = async () => {
recipient ??= await extractAgePublicKey(paths.keyPath);
if (!recipient) {
console.error(` Create one with: age-keygen -o ${paths.keyPath}`);
}
return recipient;
};
// Main loop - keep showing menu until user quits
let running = true;
while (running) {
@@ -73,17 +85,20 @@ export async function keyman() {
case 'copy':
await copyKey(sshDir, paths.tmpDir);
break;
case 'generate':
await generateKey(paths.tmpDir, paths.keysDir, extractAgePublicKey(paths.keyPath)!);
case 'generate': {
const pubkey = await ageRecipient();
if (pubkey) {
await generateKey(paths.tmpDir, paths.keysDir, pubkey);
}
break;
case 'encrypt':
await encryptKeys(
sshDir,
paths.vaultRoot,
paths.tmpDir,
extractAgePublicKey(paths.keyPath)!
);
}
case 'encrypt': {
const pubkey = await ageRecipient();
if (pubkey) {
await encryptKeys(sshDir, paths.vaultRoot, paths.tmpDir, pubkey);
}
break;
}
case 'decrypt':
await decryptKeys(sshDir, paths.vaultRoot, paths.keyPath);
break;