da9df57e11
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.
112 lines
3.6 KiB
TypeScript
112 lines
3.6 KiB
TypeScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import inquirer from 'inquirer';
|
|
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
|
import { copyKey } from './keyman.copy.js';
|
|
import { decryptKeys } from './keyman.decrypt.js';
|
|
import { encryptKeys } from './keyman.encrypt.js';
|
|
import { generateKey } from './keyman.generate.js';
|
|
import { listKeys } from './keyman.list.js';
|
|
import { extractAgePublicKey } from './keyman.utils.js';
|
|
|
|
// 🔹 Main function to resolve paths and manage flow
|
|
export async function keyman() {
|
|
// Load configuration from .keymanrc.json or use defaults
|
|
const config = loadConfig();
|
|
const paths = resolveConfigPaths(config);
|
|
|
|
console.log(`\n📁 Vault Root: ${paths.vaultRoot}`);
|
|
console.log(`🔑 Keys Directory: ${paths.keysDir}`);
|
|
console.log(`📂 Temp Directory: ${paths.tmpDir}`);
|
|
console.log(`🔐 Age Key: ${paths.keyPath}\n`);
|
|
|
|
// Get USER input
|
|
const { user } = await inquirer.prompt<{ user: string }>([
|
|
{
|
|
type: 'input',
|
|
name: 'user',
|
|
message: 'Specify USER (default: @current):',
|
|
default: '@current',
|
|
},
|
|
]);
|
|
|
|
const homeDir = user === '@current' ? process.env.HOME || '' : `/home/${user}`;
|
|
if (!homeDir) {
|
|
console.error('Error: Unable to determine HOME directory.');
|
|
process.exit(1);
|
|
}
|
|
|
|
const sshDir = path.join(homeDir, '.ssh');
|
|
// 0700 because the vault holds the age identity and, in tmp, plaintext private
|
|
// keys. keysDir is created here too: decrypt used to read it before anything
|
|
// created it.
|
|
for (const dir of [paths.vaultRoot, paths.keysDir, paths.tmpDir]) {
|
|
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) {
|
|
console.log(`\n${'='.repeat(50)}`);
|
|
|
|
// 🔹 Show category selection
|
|
const { category } = await inquirer.prompt<{ category: string }>([
|
|
{
|
|
type: 'list',
|
|
name: 'category',
|
|
message: 'Select operation:',
|
|
choices: [
|
|
{ name: '📋 List keys', value: 'list' },
|
|
{ name: '📝 Copy public key', value: 'copy' },
|
|
{ name: '🆕 Generate key', value: 'generate' },
|
|
{ name: '🔒 Encrypt keys', value: 'encrypt' },
|
|
{ name: '🔓 Decrypt keys', value: 'decrypt' },
|
|
{ name: '❌ Quit', value: 'quit' },
|
|
],
|
|
},
|
|
]);
|
|
|
|
switch (category) {
|
|
case 'list':
|
|
await listKeys(sshDir, paths.keysDir, paths.tmpDir);
|
|
break;
|
|
case 'copy':
|
|
await copyKey(sshDir, paths.tmpDir);
|
|
break;
|
|
case 'generate': {
|
|
const pubkey = await ageRecipient();
|
|
if (pubkey) {
|
|
await generateKey(paths.tmpDir, paths.keysDir, pubkey);
|
|
}
|
|
break;
|
|
}
|
|
case 'encrypt': {
|
|
const pubkey = await ageRecipient();
|
|
if (pubkey) {
|
|
await encryptKeys(sshDir, paths.keysDir, paths.tmpDir, pubkey);
|
|
}
|
|
break;
|
|
}
|
|
case 'decrypt':
|
|
await decryptKeys(sshDir, paths.keysDir, paths.tmpDir, paths.keyPath);
|
|
break;
|
|
case 'quit':
|
|
console.log('\n👋 Goodbye!\n');
|
|
running = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|