[keyman] thread the configured keys and tmp directories through encrypt/decrypt

encryptKeys and decryptKeys each took `vaultDir` and rebuilt `<vault>/keys`
and `<vault>/tmp` from it, so `keysDir` and `tmpDir` in .keymanrc.json were
honoured by main and list and silently ignored by the two operations that
write. main was also passing vaultRoot where encrypt expected the keys
directory, which put encrypted keys one level above where list looks for
them: with any config at all, a key encrypted a second ago was invisible.

Both now take keysDir and tmpDir explicitly. The decrypt location prompt
names the real directories instead of the hardcoded `vault/tmp` and
`~/.ssh`, which meant its labels were also its values — hence LOCAL_MODE.

tests/vault-layout.test.ts is the regression: encrypt then list, driven
through keyman() with only age and the prompts mocked, against a config
using keysDir `encrypted` and tmpDir `plain`. Every unit suite passed
through this bug because each was told which directory to use; the seam
between them was untested. Verified it fails when main is reverted to pass
vaultRoot.
This commit is contained in:
Benjamin Diedrichsen
2026-07-30 14:45:27 +02:00
parent 653d348ecc
commit da9df57e11
7 changed files with 196 additions and 69 deletions
+15 -9
View File
@@ -3,7 +3,8 @@ import path from 'node:path';
import inquirer from 'inquirer'; import inquirer from 'inquirer';
import { runTool } from './keyman.utils.js'; import { runTool } from './keyman.utils.js';
const LOCAL_MODE = 'Local (vault/tmp)'; /** The two decryption targets. Values, so the label can name the real directory. */
const LOCAL_MODE = 'local';
interface DecryptPlan { interface DecryptPlan {
key: string; key: string;
@@ -13,12 +14,13 @@ interface DecryptPlan {
publicKeyOut: string; publicKeyOut: string;
} }
export async function decryptKeys(sshDir: string, vaultDir: string, ageKey: string) { export async function decryptKeys(sshDir: string, keysDir: string, tmpDir: string, ageKey: string) {
const keyDir = path.join(vaultDir, 'keys');
// Guarded: nothing creates the keys directory until the first encrypt, so on a // Guarded: nothing creates the keys directory until the first encrypt, so on a
// fresh vault this readdir threw instead of reporting an empty vault. // fresh vault this readdir threw instead of reporting an empty vault.
const vaultKeys = fs.existsSync(keyDir) const vaultKeys = fs.existsSync(keysDir)
? fs.readdirSync(keyDir).filter((key) => fs.existsSync(path.join(keyDir, key, `id_${key}.age`))) ? fs
.readdirSync(keysDir)
.filter((key) => fs.existsSync(path.join(keysDir, key, `id_${key}.age`)))
: []; : [];
if (vaultKeys.length === 0) { if (vaultKeys.length === 0) {
@@ -37,16 +39,20 @@ export async function decryptKeys(sshDir: string, vaultDir: string, ageKey: stri
type: 'list', type: 'list',
name: 'decryptMode', name: 'decryptMode',
message: 'Choose decryption location:', message: 'Choose decryption location:',
choices: [LOCAL_MODE, 'SSH (~/.ssh)'], // Named after the directories actually in use, which are configurable.
choices: [
{ name: `Local (${tmpDir})`, value: LOCAL_MODE },
{ name: `SSH (${sshDir})`, value: 'ssh' },
],
}, },
]); ]);
const outDir = decryptMode === LOCAL_MODE ? path.join(vaultDir, 'tmp') : sshDir; const outDir = decryptMode === LOCAL_MODE ? tmpDir : sshDir;
const plans: DecryptPlan[] = selectedKeys.map((key: string) => ({ const plans: DecryptPlan[] = selectedKeys.map((key: string) => ({
key, key,
encryptedKey: path.join(keyDir, key, `id_${key}.age`), encryptedKey: path.join(keysDir, key, `id_${key}.age`),
publicKey: path.join(keyDir, key, `id_${key}.pub`), publicKey: path.join(keysDir, key, `id_${key}.pub`),
privateKeyOut: path.join(outDir, `id_${key}`), privateKeyOut: path.join(outDir, `id_${key}`),
publicKeyOut: path.join(outDir, `id_${key}.pub`), publicKeyOut: path.join(outDir, `id_${key}.pub`),
})); }));
+2 -7
View File
@@ -16,12 +16,7 @@ function privateKeysIn(dir: string): string[] {
return fs.readdirSync(dir).filter((key) => key.startsWith('id_') && !key.endsWith('.pub')); return fs.readdirSync(dir).filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
} }
export async function encryptKeys( export async function encryptKeys(sshDir: string, keysDir: string, tmpDir: string, pubkey: string) {
sshDir: string,
vaultDir: string,
tmpDir: string,
pubkey: string
) {
const sshKeys = privateKeysIn(sshDir); const sshKeys = privateKeysIn(sshDir);
const tmpKeys = privateKeysIn(tmpDir); const tmpKeys = privateKeysIn(tmpDir);
const keys = [...new Set([...sshKeys, ...tmpKeys])]; const keys = [...new Set([...sshKeys, ...tmpKeys])];
@@ -42,7 +37,7 @@ export async function encryptKeys(
for (const key of selectedKeys) { for (const key of selectedKeys) {
const keyPath = path.join(tmpKeys.includes(key) ? tmpDir : sshDir, key); const keyPath = path.join(tmpKeys.includes(key) ? tmpDir : sshDir, key);
const vaultPath = path.join(vaultDir, 'keys', key.replace('id_', '')); const vaultPath = path.join(keysDir, key.replace('id_', ''));
fs.mkdirSync(vaultPath, { recursive: true, mode: 0o700 }); fs.mkdirSync(vaultPath, { recursive: true, mode: 0o700 });
// Encrypt key using `age` // Encrypt key using `age`
+2 -2
View File
@@ -95,12 +95,12 @@ export async function keyman() {
case 'encrypt': { case 'encrypt': {
const pubkey = await ageRecipient(); const pubkey = await ageRecipient();
if (pubkey) { if (pubkey) {
await encryptKeys(sshDir, paths.vaultRoot, paths.tmpDir, pubkey); await encryptKeys(sshDir, paths.keysDir, paths.tmpDir, pubkey);
} }
break; break;
} }
case 'decrypt': case 'decrypt':
await decryptKeys(sshDir, paths.vaultRoot, paths.keyPath); await decryptKeys(sshDir, paths.keysDir, paths.tmpDir, paths.keyPath);
break; break;
case 'quit': case 'quit':
console.log('\n👋 Goodbye!\n'); console.log('\n👋 Goodbye!\n');
+28 -28
View File
@@ -19,14 +19,14 @@ vi.mock('inquirer', () => ({ default: { prompt } }));
import { decryptKeys } from '../src/keyman.decrypt.js'; import { decryptKeys } from '../src/keyman.decrypt.js';
const LOCAL = 'Local (vault/tmp)'; const LOCAL = 'local';
const SSH = 'SSH (~/.ssh)'; const SSH = 'ssh';
describe('decryptKeys', () => { describe('decryptKeys', () => {
let root: string; let root: string;
let sshDir: string; let sshDir: string;
let vaultDir: string; let vaultDir: string;
let keyDir: string; let keysDir: string;
let tmpDir: string; let tmpDir: string;
let logSpy: ReturnType<typeof vi.spyOn>; let logSpy: ReturnType<typeof vi.spyOn>;
@@ -34,7 +34,7 @@ describe('decryptKeys', () => {
/** Creates <vault>/keys/<name>/id_<name>.{age,pub}. */ /** Creates <vault>/keys/<name>/id_<name>.{age,pub}. */
const vaultKey = (name: string) => { const vaultKey = (name: string) => {
const dir = path.join(keyDir, name); const dir = path.join(keysDir, name);
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, `id_${name}.age`), `ENCRYPTED ${name}`); fs.writeFileSync(path.join(dir, `id_${name}.age`), `ENCRYPTED ${name}`);
fs.writeFileSync(path.join(dir, `id_${name}.pub`), `PUBLIC ${name}`); fs.writeFileSync(path.join(dir, `id_${name}.pub`), `PUBLIC ${name}`);
@@ -62,9 +62,9 @@ describe('decryptKeys', () => {
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-decrypt-'))); root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-decrypt-')));
sshDir = path.join(root, '.ssh'); sshDir = path.join(root, '.ssh');
vaultDir = path.join(root, 'vault'); vaultDir = path.join(root, 'vault');
keyDir = path.join(vaultDir, 'keys'); keysDir = path.join(vaultDir, 'keys');
tmpDir = path.join(vaultDir, 'tmp'); tmpDir = path.join(vaultDir, 'tmp');
fs.mkdirSync(keyDir, { recursive: true }); fs.mkdirSync(keysDir, { recursive: true });
fs.mkdirSync(sshDir, { recursive: true }); fs.mkdirSync(sshDir, { recursive: true });
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
@@ -83,16 +83,16 @@ describe('decryptKeys', () => {
}); });
it('warns when the vault holds no encrypted keys', async () => { it('warns when the vault holds no encrypted keys', async () => {
await decryptKeys(sshDir, vaultDir, AGE_KEY); await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
expect(messages(logSpy)).toContain('No encrypted keys found.'); expect(messages(logSpy)).toContain('No encrypted keys found.');
expect(prompt).not.toHaveBeenCalled(); expect(prompt).not.toHaveBeenCalled();
}); });
it('warns instead of throwing when the vault has no keys directory', async () => { it('warns instead of throwing when the vault has no keys directory', async () => {
fs.rmSync(keyDir, { recursive: true }); fs.rmSync(keysDir, { recursive: true });
await expect(decryptKeys(sshDir, vaultDir, AGE_KEY)).resolves.toBeUndefined(); await expect(decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY)).resolves.toBeUndefined();
expect(messages(logSpy)).toContain('No encrypted keys found.'); expect(messages(logSpy)).toContain('No encrypted keys found.');
}); });
@@ -103,18 +103,18 @@ describe('decryptKeys', () => {
throw Object.assign(new Error('spawn age ENOENT'), { code: 'ENOENT' }); throw Object.assign(new Error('spawn age ENOENT'), { code: 'ENOENT' });
}); });
await expect(decryptKeys(sshDir, vaultDir, AGE_KEY)).rejects.toThrow( await expect(decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY)).rejects.toThrow(
'`age` was not found on PATH' '`age` was not found on PATH'
); );
}); });
it('offers only directories that actually contain an encrypted key', async () => { it('offers only directories that actually contain an encrypted key', async () => {
vaultKey('prod'); vaultKey('prod');
fs.mkdirSync(path.join(keyDir, 'empty'), { recursive: true }); fs.mkdirSync(path.join(keysDir, 'empty'), { recursive: true });
fs.writeFileSync(path.join(keyDir, 'README.md'), ''); fs.writeFileSync(path.join(keysDir, 'README.md'), '');
answers([]); answers([]);
await decryptKeys(sshDir, vaultDir, AGE_KEY); await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
expect(choices()).toEqual(['prod']); expect(choices()).toEqual(['prod']);
}); });
@@ -123,7 +123,7 @@ describe('decryptKeys', () => {
vaultKey('prod'); vaultKey('prod');
answers(['prod']); answers(['prod']);
await decryptKeys(sshDir, vaultDir, AGE_KEY); await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
const out = path.join(tmpDir, 'id_prod'); const out = path.join(tmpDir, 'id_prod');
expect(argsOf('age')).toEqual([ expect(argsOf('age')).toEqual([
@@ -132,7 +132,7 @@ describe('decryptKeys', () => {
AGE_KEY, AGE_KEY,
'-o', '-o',
out, out,
path.join(keyDir, 'prod', 'id_prod.age'), path.join(keysDir, 'prod', 'id_prod.age'),
]); ]);
expect(fs.readFileSync(out, 'utf-8')).toBe('PLAINTEXT'); expect(fs.readFileSync(out, 'utf-8')).toBe('PLAINTEXT');
expect(fs.readFileSync(`${out}.pub`, 'utf-8')).toBe('PUBLIC prod'); expect(fs.readFileSync(`${out}.pub`, 'utf-8')).toBe('PUBLIC prod');
@@ -143,7 +143,7 @@ describe('decryptKeys', () => {
vaultKey('prod'); vaultKey('prod');
answers(['prod'], SSH); answers(['prod'], SSH);
await decryptKeys(sshDir, vaultDir, AGE_KEY); await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
const out = path.join(sshDir, 'id_prod'); const out = path.join(sshDir, 'id_prod');
expect(argsOf('age')?.[4]).toBe(out); expect(argsOf('age')?.[4]).toBe(out);
@@ -155,7 +155,7 @@ describe('decryptKeys', () => {
vaultKey('prod'); vaultKey('prod');
answers(['prod'], SSH); answers(['prod'], SSH);
await decryptKeys(sshDir, vaultDir, AGE_KEY); await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
expect(modeOf(sshDir)).toBe(0o700); expect(modeOf(sshDir)).toBe(0o700);
expect(fs.existsSync(path.join(sshDir, 'id_prod'))).toBe(true); expect(fs.existsSync(path.join(sshDir, 'id_prod'))).toBe(true);
@@ -165,7 +165,7 @@ describe('decryptKeys', () => {
vaultKey('prod'); vaultKey('prod');
answers(['prod']); answers(['prod']);
await decryptKeys(sshDir, vaultDir, AGE_KEY); await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
expect(modeOf(path.join(tmpDir, 'id_prod'))).toBe(0o600); expect(modeOf(path.join(tmpDir, 'id_prod'))).toBe(0o600);
}); });
@@ -175,7 +175,7 @@ describe('decryptKeys', () => {
vaultKey('stage'); vaultKey('stage');
answers(['prod', 'stage']); answers(['prod', 'stage']);
await decryptKeys(sshDir, vaultDir, AGE_KEY); await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
// One age per key: the cp and chmod spawns are gone. // One age per key: the cp and chmod spawns are gone.
expect(execa).toHaveBeenCalledTimes(2); expect(execa).toHaveBeenCalledTimes(2);
@@ -186,17 +186,17 @@ describe('decryptKeys', () => {
vaultKey('prod'); vaultKey('prod');
answers([]); answers([]);
await decryptKeys(sshDir, vaultDir, AGE_KEY); await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
expect(execa).not.toHaveBeenCalled(); expect(execa).not.toHaveBeenCalled();
}); });
it('writes the private key even when the vault entry has no public key', async () => { it('writes the private key even when the vault entry has no public key', async () => {
vaultKey('prod'); vaultKey('prod');
fs.rmSync(path.join(keyDir, 'prod', 'id_prod.pub')); fs.rmSync(path.join(keysDir, 'prod', 'id_prod.pub'));
answers(['prod']); answers(['prod']);
await decryptKeys(sshDir, vaultDir, AGE_KEY); await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
expect(fs.existsSync(path.join(tmpDir, 'id_prod'))).toBe(true); expect(fs.existsSync(path.join(tmpDir, 'id_prod'))).toBe(true);
expect(messages(logSpy)).toContain('has no public key in the vault'); expect(messages(logSpy)).toContain('has no public key in the vault');
@@ -214,7 +214,7 @@ describe('decryptKeys', () => {
const target = existing(tmpDir); const target = existing(tmpDir);
answers(['prod']); answers(['prod']);
await decryptKeys(sshDir, vaultDir, AGE_KEY); await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
expect(fs.readFileSync(target, 'utf-8')).toBe('PRECIOUS EXISTING KEY'); expect(fs.readFileSync(target, 'utf-8')).toBe('PRECIOUS EXISTING KEY');
expect(execa).not.toHaveBeenCalled(); expect(execa).not.toHaveBeenCalled();
@@ -226,7 +226,7 @@ describe('decryptKeys', () => {
existing(tmpDir); existing(tmpDir);
answers(['prod']); answers(['prod']);
await decryptKeys(sshDir, vaultDir, AGE_KEY); await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
const confirm = prompt.mock.calls.at(-1)?.[0][0]; const confirm = prompt.mock.calls.at(-1)?.[0][0];
expect(confirm).toMatchObject({ type: 'confirm', default: false }); expect(confirm).toMatchObject({ type: 'confirm', default: false });
@@ -238,7 +238,7 @@ describe('decryptKeys', () => {
const target = existing(tmpDir); const target = existing(tmpDir);
answers(['prod'], LOCAL, true); answers(['prod'], LOCAL, true);
await decryptKeys(sshDir, vaultDir, AGE_KEY); await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
expect(fs.readFileSync(target, 'utf-8')).toBe('PLAINTEXT'); expect(fs.readFileSync(target, 'utf-8')).toBe('PLAINTEXT');
}); });
@@ -248,7 +248,7 @@ describe('decryptKeys', () => {
existing(tmpDir, 'id_prod.pub'); existing(tmpDir, 'id_prod.pub');
answers(['prod']); answers(['prod']);
await decryptKeys(sshDir, vaultDir, AGE_KEY); await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
expect(execa).not.toHaveBeenCalled(); expect(execa).not.toHaveBeenCalled();
expect(prompt.mock.calls.at(-1)?.[0][0].message).toContain('id_prod.pub'); expect(prompt.mock.calls.at(-1)?.[0][0].message).toContain('id_prod.pub');
@@ -259,7 +259,7 @@ describe('decryptKeys', () => {
const target = existing(sshDir); const target = existing(sshDir);
answers(['prod'], SSH); answers(['prod'], SSH);
await decryptKeys(sshDir, vaultDir, AGE_KEY); await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
expect(fs.readFileSync(target, 'utf-8')).toBe('PRECIOUS EXISTING KEY'); expect(fs.readFileSync(target, 'utf-8')).toBe('PRECIOUS EXISTING KEY');
}); });
@@ -270,7 +270,7 @@ describe('decryptKeys', () => {
existing(tmpDir); existing(tmpDir);
answers(['prod', 'stage']); answers(['prod', 'stage']);
await decryptKeys(sshDir, vaultDir, AGE_KEY); await decryptKeys(sshDir, keysDir, tmpDir, AGE_KEY);
// stage is written, prod is kept — and the question about prod was asked // stage is written, prod is kept — and the question about prod was asked
// before either was touched. // before either was touched.
+17 -19
View File
@@ -20,7 +20,7 @@ import { encryptKeys } from '../src/keyman.encrypt.js';
describe('encryptKeys', () => { describe('encryptKeys', () => {
let root: string; let root: string;
let sshDir: string; let sshDir: string;
let vaultDir: string; let keysDir: string;
let tmpDir: string; let tmpDir: string;
let logSpy: ReturnType<typeof vi.spyOn>; let logSpy: ReturnType<typeof vi.spyOn>;
@@ -41,7 +41,7 @@ describe('encryptKeys', () => {
vi.clearAllMocks(); vi.clearAllMocks();
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-encrypt-'))); root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-encrypt-')));
sshDir = path.join(root, '.ssh'); sshDir = path.join(root, '.ssh');
vaultDir = path.join(root, 'vault'); keysDir = path.join(root, 'vault', 'keys');
tmpDir = path.join(root, 'vault', 'tmp'); tmpDir = path.join(root, 'vault', 'tmp');
fs.mkdirSync(sshDir, { recursive: true }); fs.mkdirSync(sshDir, { recursive: true });
fs.mkdirSync(tmpDir, { recursive: true }); fs.mkdirSync(tmpDir, { recursive: true });
@@ -60,7 +60,7 @@ describe('encryptKeys', () => {
}); });
it('warns when there is nothing to encrypt', async () => { it('warns when there is nothing to encrypt', async () => {
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.'); expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.');
expect(prompt).not.toHaveBeenCalled(); expect(prompt).not.toHaveBeenCalled();
@@ -69,7 +69,7 @@ describe('encryptKeys', () => {
it('warns instead of throwing when the .ssh directory does not exist', async () => { it('warns instead of throwing when the .ssh directory does not exist', async () => {
fs.rmSync(sshDir, { recursive: true }); fs.rmSync(sshDir, { recursive: true });
await expect(encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY)).resolves.toBeUndefined(); await expect(encryptKeys(sshDir, keysDir, tmpDir, PUBKEY)).resolves.toBeUndefined();
expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.'); expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.');
}); });
@@ -78,7 +78,7 @@ describe('encryptKeys', () => {
key(sshDir, 'id_prod', 'ssh'); key(sshDir, 'id_prod', 'ssh');
prompt.mockResolvedValue({ selectedKeys: [] }); prompt.mockResolvedValue({ selectedKeys: [] });
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
expect(choices()).toEqual(['id_prod']); expect(choices()).toEqual(['id_prod']);
}); });
@@ -88,7 +88,7 @@ describe('encryptKeys', () => {
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] }); prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
execa.mockRejectedValue(Object.assign(new Error('spawn age ENOENT'), { code: 'ENOENT' })); execa.mockRejectedValue(Object.assign(new Error('spawn age ENOENT'), { code: 'ENOENT' }));
await expect(encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY)).rejects.toThrow( await expect(encryptKeys(sshDir, keysDir, tmpDir, PUBKEY)).rejects.toThrow(
'`age` was not found on PATH' '`age` was not found on PATH'
); );
}); });
@@ -97,7 +97,7 @@ describe('encryptKeys', () => {
fs.writeFileSync(path.join(sshDir, 'known_hosts'), ''); fs.writeFileSync(path.join(sshDir, 'known_hosts'), '');
fs.writeFileSync(path.join(sshDir, 'id_orphan.pub'), 'PUBLIC'); fs.writeFileSync(path.join(sshDir, 'id_orphan.pub'), 'PUBLIC');
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.'); expect(messages(logSpy)).toContain('No private SSH keys found to encrypt.');
}); });
@@ -108,7 +108,7 @@ describe('encryptKeys', () => {
key(tmpDir, 'id_stage', 'tmp'); key(tmpDir, 'id_stage', 'tmp');
prompt.mockResolvedValue({ selectedKeys: [] }); prompt.mockResolvedValue({ selectedKeys: [] });
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
expect(choices()).toEqual(['id_prod', 'id_stage']); expect(choices()).toEqual(['id_prod', 'id_stage']);
}); });
@@ -117,9 +117,9 @@ describe('encryptKeys', () => {
key(sshDir, 'id_prod', 'ssh'); key(sshDir, 'id_prod', 'ssh');
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] }); prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
const vaultPath = path.join(vaultDir, 'keys', 'prod'); const vaultPath = path.join(keysDir, 'prod');
expect(execa).toHaveBeenCalledWith('age', [ expect(execa).toHaveBeenCalledWith('age', [
'-r', '-r',
PUBKEY, PUBKEY,
@@ -136,12 +136,10 @@ describe('encryptKeys', () => {
key(tmpDir, 'id_prod', 'tmp'); key(tmpDir, 'id_prod', 'tmp');
prompt.mockResolvedValue({ selectedKeys: ['id_prod'] }); prompt.mockResolvedValue({ selectedKeys: ['id_prod'] });
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
expect(execa.mock.calls[0][1]).toContain(path.join(tmpDir, 'id_prod')); expect(execa.mock.calls[0][1]).toContain(path.join(tmpDir, 'id_prod'));
expect(fs.readFileSync(path.join(vaultDir, 'keys', 'prod', 'id_prod.pub'), 'utf-8')).toBe( expect(fs.readFileSync(path.join(keysDir, 'prod', 'id_prod.pub'), 'utf-8')).toBe('PUBLIC tmp');
'PUBLIC tmp'
);
}); });
it('encrypts every selected key', async () => { it('encrypts every selected key', async () => {
@@ -149,20 +147,20 @@ describe('encryptKeys', () => {
key(sshDir, 'id_stage', 'ssh'); key(sshDir, 'id_stage', 'ssh');
prompt.mockResolvedValue({ selectedKeys: ['id_prod', 'id_stage'] }); prompt.mockResolvedValue({ selectedKeys: ['id_prod', 'id_stage'] });
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
expect(execa).toHaveBeenCalledTimes(2); expect(execa).toHaveBeenCalledTimes(2);
expect(fs.existsSync(path.join(vaultDir, 'keys', 'prod', 'id_prod.age'))).toBe(true); expect(fs.existsSync(path.join(keysDir, 'prod', 'id_prod.age'))).toBe(true);
expect(fs.existsSync(path.join(vaultDir, 'keys', 'stage', 'id_stage.age'))).toBe(true); expect(fs.existsSync(path.join(keysDir, 'stage', 'id_stage.age'))).toBe(true);
}); });
it('does nothing when the selection is empty', async () => { it('does nothing when the selection is empty', async () => {
key(sshDir, 'id_prod', 'ssh'); key(sshDir, 'id_prod', 'ssh');
prompt.mockResolvedValue({ selectedKeys: [] }); prompt.mockResolvedValue({ selectedKeys: [] });
await encryptKeys(sshDir, vaultDir, tmpDir, PUBKEY); await encryptKeys(sshDir, keysDir, tmpDir, PUBKEY);
expect(execa).not.toHaveBeenCalled(); expect(execa).not.toHaveBeenCalled();
expect(fs.existsSync(path.join(vaultDir, 'keys'))).toBe(false); expect(fs.existsSync(keysDir)).toBe(false);
}); });
}); });
+5 -4
View File
@@ -172,27 +172,28 @@ describe('keyman', () => {
expect(generateKey).toHaveBeenCalledWith(paths.tmpDir, paths.keysDir, 'age1recipient'); expect(generateKey).toHaveBeenCalledWith(paths.tmpDir, paths.keysDir, 'age1recipient');
}); });
it('encrypts keys into the vault root', async () => { it('encrypts keys into the configured keys directory', async () => {
menu(['encrypt']); menu(['encrypt']);
await keyman(); await keyman();
expect(encryptKeys).toHaveBeenCalledWith( expect(encryptKeys).toHaveBeenCalledWith(
path.join(process.env.HOME as string, '.ssh'), path.join(process.env.HOME as string, '.ssh'),
paths.vaultRoot, paths.keysDir,
paths.tmpDir, paths.tmpDir,
'age1recipient' 'age1recipient'
); );
}); });
it('decrypts keys using the age identity file', async () => { it('decrypts from the configured keys directory using the age identity file', async () => {
menu(['decrypt']); menu(['decrypt']);
await keyman(); await keyman();
expect(decryptKeys).toHaveBeenCalledWith( expect(decryptKeys).toHaveBeenCalledWith(
path.join(process.env.HOME as string, '.ssh'), path.join(process.env.HOME as string, '.ssh'),
paths.vaultRoot, paths.keysDir,
paths.tmpDir,
paths.keyPath paths.keyPath
); );
}); });
+127
View File
@@ -0,0 +1,127 @@
/**
* End-to-end over the configured vault layout.
*
* Everything except `age` and the prompts is real here — the config loader, the
* path resolution, encrypt and list all run — because the bug this covers lived
* in the seam between them: encrypt wrote to `vaultRoot` while list read from
* `keysDir`, so with the defaults (`keysDir: 'keys'`) an encrypted key was
* invisible to the very next listing. Every unit suite passed throughout, since
* each was told which directory to use.
*
* Non-default names on purpose: `keys`/`tmp` would also pass against a function
* that ignored the config entirely.
*/
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 { keyman } from '../src/keyman.main.js';
describe('the configured vault layout', () => {
let root: string;
let project: string;
let home: string;
let sshDir: string;
let vaultRoot: string;
let cwd: string;
let env: NodeJS.ProcessEnv;
let logSpy: ReturnType<typeof vi.spyOn>;
const output = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
/** The one line of the listing table describing `name`. */
const listingRow = (name: string) =>
output()
.split('\n')
.find((line) => line.includes(name) && line.includes('['));
beforeEach(() => {
vi.clearAllMocks();
cwd = process.cwd();
env = { ...process.env };
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-layout-')));
project = path.join(root, 'project');
home = path.join(root, 'home');
sshDir = path.join(home, '.ssh');
vaultRoot = path.join(project, 'vault');
fs.mkdirSync(project, { recursive: true });
fs.mkdirSync(sshDir, { recursive: true });
fs.writeFileSync(path.join(sshDir, 'id_prod'), 'PRIVATE');
fs.writeFileSync(path.join(sshDir, 'id_prod.pub'), 'PUBLIC');
fs.writeFileSync(
path.join(project, '.keymanrc.json'),
JSON.stringify({ vaultRoot: 'vault', keysDir: 'encrypted', tmpDir: 'plain' })
);
// HOME also redirects os.homedir(), so the real ~/.keymanrc.json cannot
// reach the loader and make this test depend on the machine it runs on.
process.env.HOME = home;
delete process.env.VAULT_ROOT;
process.chdir(project);
// The age identity has to exist before extractAgePublicKey will shell out.
fs.mkdirSync(vaultRoot, { recursive: true });
fs.writeFileSync(path.join(vaultRoot, 'age.key'), 'AGE-SECRET-KEY-1');
execa.mockImplementation(async (binary: string, args: string[]) => {
if (binary === 'age-keygen') {
return { stdout: 'age1recipient' };
}
fs.writeFileSync(args[args.indexOf('-o') + 1], 'ENCRYPTED');
return { stdout: '' };
});
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
process.chdir(cwd);
process.env = env;
fs.rmSync(root, { recursive: true, force: true });
});
/** Walks the menu, answering encrypt's key selection along the way. */
const run = (categories: string[]) => {
const queue = [...categories, 'quit'];
prompt.mockImplementation(async (questions: { name: string }[]) => {
switch (questions[0].name) {
case 'user':
return { user: '@current' };
case 'selectedKeys':
return { selectedKeys: ['id_prod'] };
default:
return { category: queue.shift() };
}
});
return keyman();
};
it('encrypts into the configured keys directory, where the listing looks', async () => {
await run(['encrypt', 'list']);
expect(fs.existsSync(path.join(vaultRoot, 'encrypted', 'prod', 'id_prod.age'))).toBe(true);
// ✅ is reachable only via inVault && inSsh, and the columns are
// [vault] [tmp] [.ssh] — either alone would pass on a blank vault column.
expect(listingRow('id_prod')).toContain('✅');
expect(listingRow('id_prod')).toMatch(/\[✓]\s+\[ ]\s+\[✓]/);
});
it('honours the configured directory names for every path it prints', async () => {
await run([]);
expect(output()).toContain(path.join(vaultRoot, 'encrypted'));
expect(output()).toContain(path.join(vaultRoot, 'plain'));
});
});