[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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user