[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
+50 -1
View File
@@ -81,7 +81,7 @@ describe('keyman', () => {
ageKeyFile: 'age.key',
});
resolveConfigPaths.mockReturnValue(paths);
extractAgePublicKey.mockReturnValue('age1recipient');
extractAgePublicKey.mockResolvedValue('age1recipient');
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -197,6 +197,55 @@ describe('keyman', () => {
);
});
describe('without an age recipient', () => {
beforeEach(() => {
extractAgePublicKey.mockResolvedValue(null);
});
it.each([
['generate', generateKey],
['encrypt', encryptKeys],
])('refuses %s with a remedy instead of passing null to age', async (choice, operation) => {
menu([choice]);
await keyman();
expect(operation).not.toHaveBeenCalled();
const reported = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
expect(reported).toContain(`age-keygen -o ${paths.keyPath}`);
// The whole point: the loop survives and quit is still reached.
expect(output()).toContain('Goodbye!');
});
it('still allows the operations that need no recipient', async () => {
menu(['list', 'decrypt']);
await keyman();
expect(listKeys).toHaveBeenCalled();
expect(decryptKeys).toHaveBeenCalled();
});
it('retries the lookup, so creating the identity mid-session works', async () => {
extractAgePublicKey.mockResolvedValueOnce(null).mockResolvedValueOnce('age1later');
menu(['generate', 'generate']);
await keyman();
expect(extractAgePublicKey).toHaveBeenCalledTimes(2);
expect(generateKey).toHaveBeenCalledTimes(1);
expect(generateKey).toHaveBeenCalledWith(paths.tmpDir, paths.keysDir, 'age1later');
});
});
it('resolves the recipient once for repeated operations', async () => {
menu(['generate', 'encrypt']);
await keyman();
expect(extractAgePublicKey).toHaveBeenCalledTimes(1);
});
it('keeps showing the menu until the user quits', async () => {
menu(['list', 'copy', 'list']);