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