Files
ansiblings/packages/keyman/tests/encrypt.test.ts
T
Benjamin Diedrichsen 764f890900 [keyman] keep the passphrase off argv, and recover a missing .pub
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.
2026-07-30 15:01:22 +02:00

272 lines
9.9 KiB
TypeScript

/**
* Tests for encryptKeys.
*
* `age` is mocked out; everything the function does to the filesystem itself
* (creating the vault layout, copying public keys) is asserted for real.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { execa, prompt } = vi.hoisted(() => ({ execa: vi.fn(), prompt: vi.fn() }));
vi.mock('execa', () => ({ execa }));
vi.mock('inquirer', () => ({ default: { prompt } }));
import { encryptKeys } from '../src/keyman.encrypt.js';
describe('encryptKeys', () => {
let root: string;
let sshDir: string;
let keysDir: string;
let tmpDir: string;
let logSpy: ReturnType<typeof vi.spyOn>;
const PUBKEY = 'age1recipient';
const key = (dir: string, name: string, marker: string) => {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, name), `PRIVATE ${marker}`);
fs.writeFileSync(path.join(dir, `${name}.pub`), `PUBLIC ${marker}`);
};
const choices = () => prompt.mock.calls.at(-1)?.[0][0].choices as string[];
const messages = (spy: ReturnType<typeof vi.spyOn>) =>
spy.mock.calls.map((c) => c.join(' ')).join('\n');
beforeEach(() => {
vi.clearAllMocks();
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-encrypt-')));
sshDir = path.join(root, '.ssh');
keysDir = path.join(root, 'vault', 'keys');
tmpDir = path.join(root, 'vault', 'tmp');
fs.mkdirSync(sshDir, { recursive: true });
fs.mkdirSync(tmpDir, { recursive: true });
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// Stand in for `age`: record the call and write the output file.
execa.mockImplementation(async (_binary: string, args: string[]) => {
fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED');
return { exitCode: 0 };
});
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(root, { recursive: true, force: true });
});
it('warns when there is nothing to encrypt', async () => {
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.');
expect(prompt).not.toHaveBeenCalled();
});
it('warns instead of throwing when the .ssh directory does not exist', async () => {
fs.rmSync(sshDir, { recursive: true });
await expect(encryptKeys(sshDir, keysDir, tmpDir, PUBKEY)).resolves.toBeUndefined();
expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.');
});
it('still offers the .ssh keys when the tmp directory does not exist', async () => {
fs.rmSync(tmpDir, { recursive: true });
key(sshDir, 'id_prod', 'ssh');
prompt.mockResolvedValue({ selectedKeys: [] });
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
expect(choices()).toEqual(['id_prod']);
});
it('reports a missing age binary rather than an ENOENT', async () => {
key(sshDir, 'id_prod', 'ssh');
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
execa.mockRejectedValue(Object.assign(new Error('spawn age ENOENT'), { code: 'ENOENT' }));
await expect(encryptKeys(sshDir, keysDir, tmpDir, PUBKEY)).rejects.toThrow(
'`age` was not found on PATH'
);
});
it('ignores public keys and unrelated files when building the list', async () => {
fs.writeFileSync(path.join(sshDir, 'known_hosts'), '');
fs.writeFileSync(path.join(sshDir, 'id_orphan.pub'), 'PUBLIC');
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.');
});
it('offers the keys from .ssh and tmp without duplicates', async () => {
key(sshDir, 'id_prod', 'ssh');
key(tmpDir, 'id_prod', 'tmp');
key(tmpDir, 'id_stage', 'tmp');
prompt.mockResolvedValue({ selectedKeys: [] });
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
expect(choices()).toEqual(['id_prod', 'id_stage']);
});
it('encrypts a key from .ssh into the vault', async () => {
key(sshDir, 'id_prod', 'ssh');
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
const vaultPath = path.join(keysDir, 'prod');
expect(execa).toHaveBeenCalledWith('age', [
'-r',
PUBKEY,
'-o',
path.join(vaultPath, 'id_prod.age'),
path.join(sshDir, 'id_prod'),
]);
expect(fs.readFileSync(path.join(vaultPath, 'id_prod.pub'), 'utf-8')).toBe('PUBLIC ssh');
expect(messages(logSpy)).toContain('Encrypted and stored');
});
it('prefers the tmp copy when a key exists in both directories', async () => {
key(sshDir, 'id_prod', 'ssh');
key(tmpDir, 'id_prod', 'tmp');
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
expect(execa.mock.calls[0][1]).toContain(path.join(tmpDir, 'id_prod'));
expect(fs.readFileSync(path.join(keysDir, 'prod', 'id_prod.pub'), 'utf-8')).toBe('PUBLIC tmp');
});
it('encrypts every selected key', async () => {
key(sshDir, 'id_prod', 'ssh');
key(sshDir, 'id_stage', 'ssh');
prompt.mockResolvedValue({ selectedKeys: ['id_prod', 'id_stage'] });
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
expect(execa).toHaveBeenCalledTimes(2);
expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.age'))).toBe(true);
expect(fs.existsSync(path.join(keysDir, 'stage', 'id_stage.age'))).toBe(true);
});
it('does nothing when the selection is empty', async () => {
key(sshDir, 'id_prod', 'ssh');
prompt.mockResolvedValue({ selectedKeys: [] });
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
expect(execa).not.toHaveBeenCalled();
expect(fs.existsSync(keysDir)).toBe(false);
});
describe('a key with no .pub file', () => {
/** A private key without its sibling — what the selection list offers anyway. */
const orphan = (dir: string, name: string) => {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, name), `PRIVATE ${name}`);
};
it('derives the public key with ssh-keygen', async () => {
orphan(sshDir, 'id_prod');
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
execa.mockImplementation(async (binary: string, args: string[]) => {
if (binary === 'ssh-keygen') return { stdout: 'ssh-ed25519 AAAA derived' };
fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED');
return { stdout: '' };
});
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
expect(execa).toHaveBeenCalledWith(
'ssh-keygen',
['-y', '-f', path.join(sshDir, 'id_prod')],
// stderr inherited so the passphrase prompt is visible, stdout piped so
// the derived key can be captured.
{ stdio: ['inherit', 'pipe', 'inherit'] }
);
expect(fs.readFileSync(path.join(keysDir, 'prod', 'id_prod.pub'), 'utf-8')).toBe(
'ssh-ed25519 AAAA derived\n'
);
});
it('stores the private key alone when the derivation fails', async () => {
orphan(sshDir, 'id_prod');
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
execa.mockImplementation(async (binary: string, args: string[]) => {
if (binary === 'ssh-keygen') {
throw Object.assign(new Error('bad passphrase'), { stderr: 'incorrect passphrase' });
}
fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED');
return { stdout: '' };
});
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
// The encrypted key is what matters; the .pub is recoverable from it later.
expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.age'))).toBe(true);
expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.pub'))).toBe(false);
expect(messages(warnSpy)).toContain('no public key could be derived');
});
});
describe('when one key of several fails', () => {
beforeEach(() => {
key(sshDir, 'id_prod', 'ssh');
key(sshDir, 'id_stage', 'ssh');
prompt.mockResolvedValue({ selectedKeys: ['id_prod', 'id_stage'] });
// age refuses the first key only.
execa.mockImplementation(async (_binary: string, args: string[]) => {
if (args.some((arg) => arg.endsWith('id_prod'))) {
throw Object.assign(new Error('age refused'), { stderr: 'no identity' });
}
fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED');
return { stdout: '' };
});
});
it('still encrypts the rest', async () => {
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
expect(fs.existsSync(path.join(keysDir, 'stage', 'id_stage.age'))).toBe(true);
});
it('reports which keys were not stored', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
expect(messages(errorSpy)).toContain('id_prod');
expect(messages(logSpy)).toContain('1 of 2 selected keys were not stored: id_prod');
});
it('leaves no vault entry for the key that failed', async () => {
await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
// Not even an empty directory: list counts a directory with an .age in it,
// and a truncated .age would be offered for decryption.
expect(fs.existsSync(path.join(keysDir, 'prod'))).toBe(false);
});
});
it('gives up immediately when age is not installed', async () => {
key(sshDir, 'id_prod', 'ssh');
key(sshDir, 'id_stage', 'ssh');
prompt.mockResolvedValue({ selectedKeys: ['id_prod', 'id_stage'] });
execa.mockImplementation(async () => {
throw Object.assign(new Error('spawn age ENOENT'), { code: 'ENOENT' });
});
// Not a per-key failure: nine more identical errors help nobody.
await expect(encryptKeys(sshDir, keysDir, tmpDir, PUBKEY)).rejects.toThrow(
'`age` was not found on PATH'
);
expect(execa).toHaveBeenCalledTimes(1);
});
});