[keyman] portability, and stop keys from being silently invisible

Four things that each made keyman quietly less useful than it looked.

**Clipboard.** `pbcopy` was spawned unconditionally, with a comment admitting
it. Copy is now a list of commands per platform — pbcopy, clip, and wl-copy /
xclip / xsel tried in order on everything else, because there is no single
answer under Linux and trying them beats detecting the session type. Only an
absent tool advances to the next candidate: one that ran and refused has an
opinion. And if nothing is installed the key is printed, since "give me this
public key" is answerable without a clipboard and used to be a dead end
everywhere but macOS. Verified the round trip through real pbcopy/pbpaste.

**Home directories.** `/home/<user>` was hardcoded — wrong on the platform
this was written on. A named user is now looked for beside the current user's
home first, which is right wherever homes live together whatever that
directory is called, then in /home and /Users, and the failure names every
path tried instead of feeding a nonexistent one to readdir. For the current
user, `HOME` still wins, with `os.userInfo()` behind it: `process.env.HOME ||
''` made an unset HOME fatal, which it is not in a cron job or a container.

**Keys that are not named id_*.** A key called `deploy_ed25519` was absent
from every menu with nothing said. It still is — the vault stores
`<name minus id_>/id_<name>.age` and decrypt rebuilds the filename from the
directory, so relaxing discovery means changing the on-disk layout, which the
plan sizes as its largest single item and is not folded in here. What it does
do is say so: any file whose first line carries a private key header and whose
name lacks the prefix is now reported, per directory, with the reason. A
bounded 64-byte read, because classifying a key is no reason to load one.

**Plaintext hygiene.** A "Clear decrypted keys" entry, defaulting to no and
listing what it would delete first, and a vault `.gitignore` written on first
run covering the age identity and the tmp directory — which the README asked
the user to do by hand. Never overwritten, and silent about a configured
directory that sits outside the vault, since a .gitignore cannot speak for a
path above itself and pretending otherwise reads as protection that is absent.
This commit is contained in:
Benjamin Diedrichsen
2026-07-30 15:42:35 +02:00
parent 270cbe628a
commit 0993a4d3bb
14 changed files with 923 additions and 57 deletions
+86
View File
@@ -0,0 +1,86 @@
import fs from 'node:fs';
import path from 'node:path';
import inquirer from 'inquirer';
import { scanPrivateKeys } from './keyman.keys.js';
/**
* Writes a `.gitignore` beside the vault, once.
*
* The README told the user to do this by hand. A vault holds the age identity and,
* whenever anything has been decrypted, plaintext private keys — committing it is
* the exact failure the tool exists to prevent, and it is one file to prevent it.
*
* Never overwritten: an existing file may say more than this one does.
*/
export function writeVaultGitignore(vaultRoot: string, tmpDir: string, keyPath: string) {
const gitignore = path.join(vaultRoot, '.gitignore');
if (fs.existsSync(gitignore)) {
return;
}
// Both are configurable and may be absolute, so either can sit outside the vault.
// A .gitignore cannot speak about a path above itself, and claiming to would be
// worse than saying nothing.
const inside = (target: string) => {
const relative = path.relative(vaultRoot, target);
return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : null;
};
const tmp = inside(tmpDir);
const key = inside(keyPath);
const lines = [
'# Written by keyman. The encrypted keys under the keys directory are safe to',
'# commit; nothing else here is.',
...(key ? [key, `${key}.pub`] : []),
...(tmp ? [`${tmp}/`] : []),
'',
];
fs.writeFileSync(gitignore, lines.join('\n'), { mode: 0o600 });
}
/**
* Deletes the decrypted keys in the vault's tmp directory.
*
* The counterpart to `decrypt`, which had none: a plaintext private key stayed
* there until someone remembered it, and "someone remembered" is not a security
* control. Only the key pairs are removed — anything else in the directory is not
* keyman's to delete.
*/
export async function clearDecryptedKeys(tmpDir: string) {
const { keys } = scanPrivateKeys(tmpDir);
if (keys.length === 0) {
console.log(`✅ Nothing decrypted in ${tmpDir}.`);
return;
}
console.log(`\n🔓 Decrypted keys in ${tmpDir}:`);
for (const key of keys) {
console.log(` ${key}`);
}
const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([
{
type: 'confirm',
name: 'confirmed',
message: `Delete ${keys.length === 1 ? 'this key' : `these ${keys.length} keys`}?`,
// A key that exists only here — generated and not yet deployed — is gone for
// good, so this is not a question to answer by pressing return.
default: false,
},
]);
if (!confirmed) {
console.log('⏭️ Nothing was deleted.');
return;
}
for (const key of keys) {
for (const file of [key, `${key}.pub`]) {
fs.rmSync(path.join(tmpDir, file), { force: true });
}
console.log(`🧹 Removed ${key}`);
}
}
+56
View File
@@ -0,0 +1,56 @@
import { runTool, ToolNotFoundError } from './keyman.utils.js';
/** A clipboard command and the argv it wants, in the order they are tried. */
interface ClipboardTool {
binary: string;
args: string[];
}
/**
* The clipboard commands worth trying on a platform, best first.
*
* Linux is a list rather than a choice because there is no single answer:
* `wl-copy` under Wayland, `xclip`/`xsel` under X11, and a user may have any
* subset installed. Trying them in order and moving on from an absent one costs a
* failed spawn and removes the need to detect the session type.
*/
export function clipboardTools(platform: string = process.platform): ClipboardTool[] {
switch (platform) {
case 'darwin':
return [{ binary: 'pbcopy', args: [] }];
case 'win32':
return [{ binary: 'clip', args: [] }];
default:
return [
{ binary: 'wl-copy', args: [] },
{ binary: 'xclip', args: ['-selection', 'clipboard'] },
{ binary: 'xsel', args: ['--clipboard', '--input'] },
];
}
}
/**
* Puts `text` on the system clipboard.
*
* keyman used to spawn `pbcopy` unconditionally, with a comment saying so — which
* made "copy public key" a dead end on every platform but macOS, and reported it
* as a clipboard failure rather than as a missing tool.
*
* @returns the command that took it, or null if none was available
*/
export async function copyToClipboard(text: string, platform?: string): Promise<string | null> {
for (const { binary, args } of clipboardTools(platform)) {
try {
await runTool(binary, args, { input: text });
return binary;
} catch (error) {
// Only an absent tool is worth trying the next candidate for. One that ran
// and refused has an opinion, and repeating the paste elsewhere is not it.
if (!(error instanceof ToolNotFoundError)) {
throw error;
}
}
}
return null;
}
+23 -18
View File
@@ -1,18 +1,19 @@
import fs from 'node:fs';
import path from 'node:path';
import { execa } from 'execa';
import inquirer from 'inquirer';
import { copyToClipboard } from './keyman.clipboard.js';
import { reportSkippedKeys, scanPrivateKeys } from './keyman.keys.js';
export async function copyKey(sshDir: string, tmpDir: string) {
const getKeys = (dir: string) => {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir).filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
};
const ssh = scanPrivateKeys(sshDir);
const tmp = scanPrivateKeys(tmpDir);
const sshKeys = getKeys(sshDir);
const tmpKeys = getKeys(tmpDir);
const keys = [...new Set([...ssh.keys, ...tmp.keys])];
const keys = [...new Set([...sshKeys, ...tmpKeys])];
// Before the empty check: "no SSH keys found" next to four unmanageable ones is
// the case the report exists for.
reportSkippedKeys(ssh.skipped, sshDir);
reportSkippedKeys(tmp.skipped, tmpDir);
if (keys.length === 0) {
console.log('⚠️ No SSH keys found.');
@@ -40,19 +41,23 @@ export async function copyKey(sshDir: string, tmpDir: string) {
return;
}
try {
const pubKeyContent = fs.readFileSync(pubKeyPath, 'utf-8').trim();
// Detect OS and use appropriate clipboard command
// Since the environment is Darwin, we prioritize pbcopy, but we can add others for completeness or use a simple check.
// For this specific request on Darwin:
const proc = execa('pbcopy');
proc.stdin?.write(pubKeyContent);
proc.stdin?.end();
await proc;
try {
const tool = await copyToClipboard(pubKeyContent);
console.log(`✅ Public key for ${selectedKey} copied to clipboard!`);
if (tool) {
console.log(`✅ Public key for ${selectedKey} copied to clipboard via ${tool}!`);
return;
}
console.warn('⚠️ No clipboard command found.');
} catch (error) {
console.error(`❌ Failed to copy to clipboard: ${error}`);
console.error(
`❌ Failed to copy to clipboard: ${error instanceof Error ? error.message : error}`
);
}
// Printing it is the point of the operation; the clipboard was only the
// convenient way to deliver it. A public key is not a secret.
console.log(`\n${pubKeyContent}\n`);
}
+8 -16
View File
@@ -1,27 +1,19 @@
import fs from 'node:fs';
import path from 'node:path';
import inquirer from 'inquirer';
import { reportSkippedKeys, scanPrivateKeys } from './keyman.keys.js';
import { ToolNotFoundError } from './keyman.utils.js';
import { storeInVault } from './keyman.vault.js';
/**
* Private keys in a directory that may not exist.
*
* A first run has neither `~/.ssh` nor the tmp directory, and an unguarded
* readdir there threw before the "nothing to encrypt" message could be reached.
*/
function privateKeysIn(dir: string): string[] {
if (!fs.existsSync(dir)) {
return [];
}
return fs.readdirSync(dir).filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
}
export async function encryptKeys(sshDir: string, keysDir: string, tmpDir: string, pubkey: string) {
const sshKeys = privateKeysIn(sshDir);
const tmpKeys = privateKeysIn(tmpDir);
const ssh = scanPrivateKeys(sshDir);
const tmp = scanPrivateKeys(tmpDir);
const sshKeys = ssh.keys;
const tmpKeys = tmp.keys;
const keys = [...new Set([...sshKeys, ...tmpKeys])];
reportSkippedKeys(ssh.skipped, sshDir);
reportSkippedKeys(tmp.skipped, tmpDir);
if (keys.length === 0) {
console.log('⚠️ No private SSH keys found to encrypt.');
return;
+70
View File
@@ -0,0 +1,70 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
/** The answer the USER prompt defaults to: whoever is running keyman. */
export const CURRENT_USER = '@current';
/**
* The home directory of the current user.
*
* `HOME` first, because a user who set it meant it, and `os.userInfo()` after,
* which reads the passwd database and so still answers when `HOME` is unset — a
* cron job, a `su` without `-l`, a container entrypoint. `process.env.HOME || ''`
* treated all of those as a fatal error.
*/
function currentHome(): string | null {
if (process.env.HOME) {
return process.env.HOME;
}
try {
return os.userInfo().homedir || null;
} catch {
// uv_os_get_passwd can fail outright when there is no passwd entry for the uid.
return null;
}
}
/**
* Where another user's home directory is, without asking the system.
*
* The sibling of the current user's home comes first because it is right wherever
* homes live together, whatever that directory is called — `/Users` on macOS,
* `/home` on Linux, `/export/home` on the odd installation. keyman previously
* hardcoded `/home/<user>`, which is wrong on the one platform it was written on.
*
* The candidates are checked for existence rather than guessed at, so a wrong one
* produces an error naming what was tried instead of an empty `readdir`.
*/
function candidateHomes(user: string): string[] {
const home = currentHome();
const siblings = home ? [path.join(path.dirname(home), user)] : [];
return [...new Set([...siblings, path.join('/home', user), path.join('/Users', user)])];
}
/**
* Resolves the home directory for an answer to the USER prompt.
*
* @returns the directory, or null with the reason already reported
*/
export function resolveHomeDir(user: string): string | null {
if (user === CURRENT_USER) {
const home = currentHome();
if (!home) {
console.error('❌ Unable to determine HOME directory for the current user.');
return null;
}
return home;
}
const candidates = candidateHomes(user);
const found = candidates.find((candidate) => fs.existsSync(candidate));
if (!found) {
console.error(`❌ No home directory found for ${user}. Tried: ${candidates.join(', ')}`);
return null;
}
return found;
}
+93
View File
@@ -0,0 +1,93 @@
import fs from 'node:fs';
import path from 'node:path';
/** Present in the first line of every private key format ssh-keygen writes. */
const PRIVATE_KEY_MARKER = 'PRIVATE KEY-----';
/** Enough for `-----BEGIN OPENSSH PRIVATE KEY-----`, and no more of a key than needed. */
const HEADER_BYTES = 64;
/**
* Whether a file opens with a private key header.
*
* A bounded read of the first line, not the file: classifying a key is no reason
* to pull one into memory.
*/
function looksLikePrivateKey(file: string): boolean {
let handle: number | undefined;
try {
handle = fs.openSync(file, 'r');
const buffer = Buffer.alloc(HEADER_BYTES);
const read = fs.readSync(handle, buffer, 0, HEADER_BYTES, 0);
return buffer.subarray(0, read).toString('latin1').includes(PRIVATE_KEY_MARKER);
} catch {
// A directory, a socket, a file with no read permission — none of them a key.
return false;
} finally {
if (handle !== undefined) {
fs.closeSync(handle);
}
}
}
export interface PrivateKeyScan {
/** Keys keyman can manage: named `id_*`, which is what the vault layout assumes. */
keys: string[];
/** Private keys it found and cannot manage, because they are named otherwise. */
skipped: string[];
}
/**
* The private keys in a directory that may not exist.
*
* A first run has neither `~/.ssh` nor the tmp directory, and an unguarded readdir
* there threw before the "nothing to encrypt" message could be reached.
*
* `skipped` exists because the `id_*` filter is silent: a key named
* `deploy_ed25519` was simply absent from every menu, and pre-existing keys are
* the population a key manager gets adopted to take over. Reporting them is not
* managing them — see `reportSkippedKeys`.
*/
export function scanPrivateKeys(dir: string): PrivateKeyScan {
if (!fs.existsSync(dir)) {
return { keys: [], skipped: [] };
}
const keys: string[] = [];
const skipped: string[] = [];
// Sorted, because readdir order is the filesystem's business and a menu's order
// should not depend on it.
for (const file of fs.readdirSync(dir).sort()) {
if (file.endsWith('.pub')) {
continue;
}
if (file.startsWith('id_')) {
// Not content-checked: what the menus offered has not changed.
keys.push(file);
} else if (looksLikePrivateKey(path.join(dir, file))) {
skipped.push(file);
}
}
return { keys, skipped };
}
/**
* Says which private keys were found and left alone, and why.
*
* The vault stores a key as `<name minus id_>/id_<name>.age` and `decrypt`
* reconstructs the filename from the directory, so the prefix is baked into the
* on-disk layout — which is why this is a report and not a fix.
*/
export function reportSkippedKeys(skipped: string[], dir: string): void {
if (skipped.length === 0) {
return;
}
const plural = skipped.length === 1 ? 'key' : 'keys';
console.log(
`️ Skipped ${skipped.length} private ${plural} in ${dir} not named id_*: ${skipped.join(', ')}`
);
console.log(' The vault layout requires the id_ prefix; rename to manage them here.');
}
+7
View File
@@ -1,5 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { reportSkippedKeys, scanPrivateKeys } from './keyman.keys.js';
interface KeyInfo {
name: string;
@@ -105,6 +106,12 @@ export async function listKeys(sshDir: string, vaultDir: string, tmpDir: string)
}
}
// A listing that omits keys without saying so is the worst place for the id_
// assumption to be invisible: this is the screen a user checks it against.
for (const dir of [sshDir, tmpDir]) {
reportSkippedKeys(scanPrivateKeys(dir).skipped, dir);
}
// Display results
if (keyMap.size === 0) {
console.log('⚠️ No SSH keys found.\n');
+10 -4
View File
@@ -1,11 +1,13 @@
import fs from 'node:fs';
import path from 'node:path';
import inquirer from 'inquirer';
import { clearDecryptedKeys, writeVaultGitignore } from './keyman.clear.js';
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 { CURRENT_USER, resolveHomeDir } from './keyman.home.js';
import { listKeys } from './keyman.list.js';
import { extractAgePublicKey } from './keyman.utils.js';
@@ -25,14 +27,13 @@ export async function keyman() {
{
type: 'input',
name: 'user',
message: 'Specify USER (default: @current):',
default: '@current',
message: `Specify USER (default: ${CURRENT_USER}):`,
default: CURRENT_USER,
},
]);
const homeDir = user === '@current' ? process.env.HOME || '' : `/home/${user}`;
const homeDir = resolveHomeDir(user);
if (!homeDir) {
console.error('Error: Unable to determine HOME directory.');
process.exit(1);
}
@@ -43,6 +44,7 @@ export async function keyman() {
for (const dir of [paths.vaultRoot, paths.keysDir, paths.tmpDir]) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
writeVaultGitignore(paths.vaultRoot, paths.tmpDir, paths.keyPath);
// Resolved on demand, because only generate and encrypt need a recipient, and
// remembered once it succeeds. Retried while it has not: creating the identity
@@ -73,6 +75,7 @@ export async function keyman() {
{ name: '🆕 Generate key', value: 'generate' },
{ name: '🔒 Encrypt keys', value: 'encrypt' },
{ name: '🔓 Decrypt keys', value: 'decrypt' },
{ name: '🧹 Clear decrypted keys', value: 'clear' },
{ name: '❌ Quit', value: 'quit' },
],
},
@@ -102,6 +105,9 @@ export async function keyman() {
case 'decrypt':
await decryptKeys(sshDir, paths.keysDir, paths.tmpDir, paths.keyPath);
break;
case 'clear':
await clearDecryptedKeys(paths.tmpDir);
break;
case 'quit':
console.log('\n👋 Goodbye!\n');
running = false;
+158
View File
@@ -0,0 +1,158 @@
/**
* Tests for the plaintext hygiene helpers: the vault .gitignore and the
* clear-decrypted-keys operation.
*/
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 { prompt } = vi.hoisted(() => ({ prompt: vi.fn() }));
vi.mock('inquirer', () => ({ default: { prompt } }));
import { clearDecryptedKeys, writeVaultGitignore } from '../src/keyman.clear.js';
describe('writeVaultGitignore', () => {
let vaultRoot: string;
const read = () => fs.readFileSync(path.join(vaultRoot, '.gitignore'), 'utf-8');
beforeEach(() => {
vaultRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-ignore-')));
});
afterEach(() => {
fs.rmSync(vaultRoot, { recursive: true, force: true });
});
it('ignores the identity and the decrypted keys, not the encrypted ones', () => {
writeVaultGitignore(vaultRoot, path.join(vaultRoot, 'tmp'), path.join(vaultRoot, 'age.key'));
const contents = read();
expect(contents).toContain('age.key\n');
expect(contents).toContain('age.key.pub');
expect(contents).toContain('tmp/');
// The encrypted keys are the thing worth committing.
expect(contents).not.toContain('keys/');
});
it('uses the configured names', () => {
writeVaultGitignore(
vaultRoot,
path.join(vaultRoot, 'plain'),
path.join(vaultRoot, 'identity.age')
);
expect(read()).toContain('plain/');
expect(read()).toContain('identity.age');
});
it('says nothing about a directory outside the vault', () => {
writeVaultGitignore(vaultRoot, '/elsewhere/tmp', path.join(vaultRoot, 'age.key'));
// A .gitignore cannot speak for a path above itself, and pretending otherwise
// would read as protection that is not there.
expect(read()).not.toContain('elsewhere');
expect(read()).toContain('age.key');
});
it('never overwrites an existing file', () => {
fs.writeFileSync(path.join(vaultRoot, '.gitignore'), 'mine\n');
writeVaultGitignore(vaultRoot, path.join(vaultRoot, 'tmp'), path.join(vaultRoot, 'age.key'));
expect(read()).toBe('mine\n');
});
it('creates it private to the owner', () => {
writeVaultGitignore(vaultRoot, path.join(vaultRoot, 'tmp'), path.join(vaultRoot, 'age.key'));
expect(fs.statSync(path.join(vaultRoot, '.gitignore')).mode & 0o777).toBe(0o600);
});
});
describe('clearDecryptedKeys', () => {
let tmpDir: string;
let logSpy: ReturnType<typeof vi.spyOn>;
const messages = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
const decrypted = (name: string) => {
fs.writeFileSync(path.join(tmpDir, name), 'PRIVATE');
fs.writeFileSync(path.join(tmpDir, `${name}.pub`), 'PUBLIC');
};
beforeEach(() => {
vi.clearAllMocks();
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-clear-')));
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
prompt.mockResolvedValue({ confirmed: true });
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('says so when there is nothing to clear', async () => {
await clearDecryptedKeys(tmpDir);
expect(messages()).toContain('Nothing decrypted');
expect(prompt).not.toHaveBeenCalled();
});
it('does not mind a tmp directory that was never created', async () => {
fs.rmSync(tmpDir, { recursive: true });
await expect(clearDecryptedKeys(tmpDir)).resolves.toBeUndefined();
});
it('removes each key and its public half', async () => {
decrypted('id_prod');
decrypted('id_stage');
await clearDecryptedKeys(tmpDir);
expect(fs.readdirSync(tmpDir)).toEqual([]);
expect(messages()).toContain('Removed id_prod');
});
it('lists what it is about to delete before asking', async () => {
decrypted('id_prod');
await clearDecryptedKeys(tmpDir);
const askedAt = messages().indexOf('id_prod');
expect(askedAt).toBeGreaterThanOrEqual(0);
expect(prompt.mock.calls[0][0][0]).toMatchObject({ type: 'confirm', default: false });
});
it('keeps everything when the confirmation is declined', async () => {
decrypted('id_prod');
prompt.mockResolvedValue({ confirmed: false });
await clearDecryptedKeys(tmpDir);
expect(fs.existsSync(path.join(tmpDir, 'id_prod'))).toBe(true);
expect(messages()).toContain('Nothing was deleted');
});
it('leaves files that are not keys alone', async () => {
decrypted('id_prod');
fs.writeFileSync(path.join(tmpDir, 'notes.md'), 'mine');
await clearDecryptedKeys(tmpDir);
expect(fs.readdirSync(tmpDir)).toEqual(['notes.md']);
});
it('does not fail on a key whose public half is missing', async () => {
fs.writeFileSync(path.join(tmpDir, 'id_prod'), 'PRIVATE');
await clearDecryptedKeys(tmpDir);
expect(fs.readdirSync(tmpDir)).toEqual([]);
});
});
+85
View File
@@ -0,0 +1,85 @@
/**
* Tests for the clipboard layer.
*
* The platform is passed in rather than stubbed, so every branch is reachable from
* the one machine the suite runs on.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { execa } = vi.hoisted(() => ({ execa: vi.fn() }));
vi.mock('execa', () => ({ execa }));
import { clipboardTools, copyToClipboard } from '../src/keyman.clipboard.js';
describe('clipboardTools', () => {
it.each([
['darwin', ['pbcopy']],
['win32', ['clip']],
['linux', ['wl-copy', 'xclip', 'xsel']],
// Anything unrecognised gets the X11/Wayland list rather than nothing: a BSD
// running the same desktop stack is closer to linux than to no answer.
['freebsd', ['wl-copy', 'xclip', 'xsel']],
])('offers the right commands on %s', (platform, expected) => {
expect(clipboardTools(platform).map((t) => t.binary)).toEqual(expected);
});
it('passes the clipboard selection to the X11 tools', () => {
const byBinary = new Map(clipboardTools('linux').map((t) => [t.binary, t.args]));
// Without these, xclip and xsel write to the primary selection, which is not
// the clipboard a paste reads from.
expect(byBinary.get('xclip')).toEqual(['-selection', 'clipboard']);
expect(byBinary.get('xsel')).toEqual(['--clipboard', '--input']);
});
});
describe('copyToClipboard', () => {
const notFound = () =>
execa.mockImplementation(async () => {
throw Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' });
});
beforeEach(() => {
vi.clearAllMocks();
execa.mockResolvedValue({ stdout: '' });
});
it('pipes the text to the first available command', async () => {
const tool = await copyToClipboard('ssh-ed25519 AAAA', 'darwin');
expect(tool).toBe('pbcopy');
expect(execa).toHaveBeenCalledWith('pbcopy', [], { input: 'ssh-ed25519 AAAA' });
});
it('moves on from a command that is not installed', async () => {
execa.mockImplementation(async (binary: string) => {
if (binary !== 'xclip') {
throw Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' });
}
return { stdout: '' };
});
expect(await copyToClipboard('key', 'linux')).toBe('xclip');
expect(execa.mock.calls.map((c) => c[0])).toEqual(['wl-copy', 'xclip']);
});
it('reports that nothing was available rather than throwing', async () => {
notFound();
expect(await copyToClipboard('key', 'linux')).toBeNull();
expect(execa).toHaveBeenCalledTimes(3);
});
it('surfaces a command that ran and refused', async () => {
execa.mockImplementation(async () => {
throw Object.assign(new Error('failed'), { stderr: 'Error: No protocol specified' });
});
// A tool with an opinion is not an absent tool: trying the next one would
// hide a real problem behind a second failure.
await expect(copyToClipboard('key', 'linux')).rejects.toThrow('No protocol specified');
expect(execa).toHaveBeenCalledTimes(1);
});
});
+39 -14
View File
@@ -10,11 +10,7 @@ import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { execa, prompt, stdin } = vi.hoisted(() => ({
execa: vi.fn(),
prompt: vi.fn(),
stdin: { write: vi.fn(), end: vi.fn() },
}));
const { execa, prompt } = vi.hoisted(() => ({ execa: vi.fn(), prompt: vi.fn() }));
vi.mock('execa', () => ({ execa }));
vi.mock('inquirer', () => ({ default: { prompt } }));
@@ -27,6 +23,7 @@ describe('copyKey', () => {
let tmpDir: string;
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let warnSpy: ReturnType<typeof vi.spyOn>;
const touch = (dir: string, file: string, contents = '') => {
fs.mkdirSync(dir, { recursive: true });
@@ -39,6 +36,9 @@ describe('copyKey', () => {
/** The choices offered by the last inquirer.prompt call. */
const choices = () => prompt.mock.calls.at(-1)?.[0][0].choices as string[];
/** What was piped into the clipboard command. */
const piped = () => (execa.mock.calls.at(-1)?.[2] as { input?: string } | undefined)?.input;
beforeEach(() => {
vi.clearAllMocks();
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-copy-')));
@@ -46,9 +46,9 @@ describe('copyKey', () => {
tmpDir = path.join(root, 'tmp');
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const proc = Object.assign(Promise.resolve({ exitCode: 0 }), { stdin });
execa.mockReturnValue(proc);
execa.mockResolvedValue({ stdout: '' });
});
afterEach(() => {
@@ -93,9 +93,7 @@ describe('copyKey', () => {
await copyKey(sshDir, tmpDir);
expect(execa).toHaveBeenCalledWith('pbcopy');
expect(stdin.write).toHaveBeenCalledWith('ssh-ed25519 AAAA tmp');
expect(stdin.end).toHaveBeenCalled();
expect(piped()).toBe('ssh-ed25519 AAAA tmp');
expect(messages(logSpy)).toContain('copied to clipboard');
});
@@ -106,7 +104,7 @@ describe('copyKey', () => {
await copyKey(sshDir, tmpDir);
expect(stdin.write).toHaveBeenCalledWith('ssh-ed25519 AAAA ssh');
expect(piped()).toBe('ssh-ed25519 AAAA ssh');
});
it('reports a missing public key without invoking the clipboard', async () => {
@@ -123,11 +121,38 @@ describe('copyKey', () => {
touch(sshDir, 'id_prod');
touch(sshDir, 'id_prod.pub', 'ssh-ed25519 AAAA ssh');
prompt.mockResolvedValue({ selectedKey: 'id_prod' });
execa.mockImplementation(() => {
throw new Error('pbcopy missing');
});
execa.mockRejectedValue(Object.assign(new Error('refused'), { stderr: 'no display' }));
await expect(copyKey(sshDir, tmpDir)).resolves.toBeUndefined();
expect(messages(errorSpy)).toContain('Failed to copy to clipboard');
});
it('prints the key when no clipboard command exists at all', async () => {
touch(sshDir, 'id_prod');
touch(sshDir, 'id_prod.pub', 'ssh-ed25519 AAAA ssh');
prompt.mockResolvedValue({ selectedKey: 'id_prod' });
execa.mockImplementation(async () => {
throw Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' });
});
await copyKey(sshDir, tmpDir);
// The operation is "give me this public key". Without a clipboard it is still
// answerable, and it used to be a dead end on every platform but macOS.
expect(messages(logSpy)).toContain('ssh-ed25519 AAAA ssh');
expect(messages(warnSpy)).toContain('No clipboard command found');
});
it('names the private keys it cannot manage', async () => {
touch(sshDir, 'id_prod');
touch(sshDir, 'id_prod.pub', 'PUBLIC');
touch(sshDir, 'deploy_ed25519', '-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n');
prompt.mockResolvedValue({ selectedKey: 'id_prod' });
await copyKey(sshDir, tmpDir);
expect(choices()).toEqual(['id_prod']);
expect(messages(logSpy)).toContain('deploy_ed25519');
expect(messages(logSpy)).toContain('not named id_*');
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* Tests for home directory resolution.
*
* Real directories under os.tmpdir() stand in for home directories, since the
* whole point of the module is that it checks whether a path exists rather than
* assuming a layout.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CURRENT_USER, resolveHomeDir } from '../src/keyman.home.js';
describe('resolveHomeDir', () => {
let homes: string;
let originalHome: string | undefined;
let errorSpy: ReturnType<typeof vi.spyOn>;
const messages = () => errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
beforeEach(() => {
originalHome = process.env.HOME;
homes = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-homes-')));
process.env.HOME = path.join(homes, 'alice');
fs.mkdirSync(process.env.HOME, { recursive: true });
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
fs.rmSync(homes, { recursive: true, force: true });
});
describe('the current user', () => {
it('uses HOME when it is set', () => {
expect(resolveHomeDir(CURRENT_USER)).toBe(path.join(homes, 'alice'));
});
it('falls back to the passwd entry when HOME is unset', () => {
delete process.env.HOME;
// Not asserted as a literal: what matters is that an unset HOME is no longer
// a fatal error, which is what `process.env.HOME || ''` made it.
expect(resolveHomeDir(CURRENT_USER)).toBe(os.userInfo().homedir);
});
it('reports the failure when neither is available', () => {
delete process.env.HOME;
vi.spyOn(os, 'userInfo').mockImplementation(() => {
throw new Error('no passwd entry for uid');
});
expect(resolveHomeDir(CURRENT_USER)).toBeNull();
expect(messages()).toContain('Unable to determine HOME directory');
});
it('treats an empty passwd home as no answer', () => {
delete process.env.HOME;
vi.spyOn(os, 'userInfo').mockReturnValue({
...os.userInfo(),
homedir: '',
});
expect(resolveHomeDir(CURRENT_USER)).toBeNull();
});
});
describe('another user', () => {
it('looks beside the current home, whatever that directory is called', () => {
const bob = path.join(homes, 'bob');
fs.mkdirSync(bob);
// The old code hardcoded /home/<user>, which is wrong on macOS — where homes
// live in /Users — and on any host that puts them anywhere else.
expect(resolveHomeDir('bob')).toBe(bob);
});
it('still tries the conventional locations with no current home to go by', () => {
delete process.env.HOME;
vi.spyOn(os, 'userInfo').mockImplementation(() => {
throw new Error('no passwd entry for uid');
});
// Not knowing where *this* user lives is no reason to give up on another.
expect(resolveHomeDir('nobody')).toBeNull();
expect(messages()).toContain('/home/nobody, /Users/nobody');
expect(messages()).not.toContain('Unable to determine HOME');
});
it('reports every path it tried when there is no such home', () => {
expect(resolveHomeDir('nobody')).toBeNull();
expect(messages()).toContain('No home directory found for nobody');
expect(messages()).toContain(path.join(homes, 'nobody'));
expect(messages()).toContain('/home/nobody');
expect(messages()).toContain('/Users/nobody');
});
it('still checks the conventional locations when HOME is somewhere odd', () => {
process.env.HOME = path.join(homes, 'alice');
const conventional = process.platform === 'darwin' ? '/Users' : '/home';
const existing = fs
.readdirSync(conventional)
.find((entry) =>
fs.statSync(path.join(conventional, entry), { throwIfNoEntry: false })?.isDirectory()
);
// Skipped rather than asserted blind if the machine has no such user.
if (existing) {
expect(resolveHomeDir(existing)).toBe(path.join(conventional, existing));
}
});
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* Tests for private key discovery.
*
* Real files, because the classification is a bounded read of a real header.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { reportSkippedKeys, scanPrivateKeys } from '../src/keyman.keys.js';
const OPENSSH = '-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXk\n';
const RSA_PEM = '-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\n';
const PKCS8 = '-----BEGIN PRIVATE KEY-----\nMIIB\n';
describe('scanPrivateKeys', () => {
let dir: string;
const write = (name: string, contents: string) =>
fs.writeFileSync(path.join(dir, name), contents);
beforeEach(() => {
dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'keyman-keys-')));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
it('returns nothing for a directory that does not exist', () => {
expect(scanPrivateKeys(path.join(dir, 'nope'))).toEqual({ keys: [], skipped: [] });
});
it('offers the id_ keys and not their public halves', () => {
write('id_prod', OPENSSH);
write('id_prod.pub', 'ssh-ed25519 AAAA');
expect(scanPrivateKeys(dir)).toEqual({ keys: ['id_prod'], skipped: [] });
});
it('sorts the keys, so the menu order does not come from the filesystem', () => {
for (const name of ['id_stage', 'id_alpha', 'id_prod']) {
write(name, OPENSSH);
}
expect(scanPrivateKeys(dir).keys).toEqual(['id_alpha', 'id_prod', 'id_stage']);
});
it('offers an id_ file without checking what is in it', () => {
// Unchanged from before the scan existed: whatever was offered still is.
write('id_prod', 'not a key at all');
expect(scanPrivateKeys(dir).keys).toEqual(['id_prod']);
});
it.each([
['an OpenSSH key', OPENSSH],
['an encrypted PEM key', RSA_PEM],
['a PKCS#8 key', PKCS8],
])('reports %s that is not named id_*', (_label, contents) => {
write('deploy_ed25519', contents);
expect(scanPrivateKeys(dir)).toEqual({ keys: [], skipped: ['deploy_ed25519'] });
});
it('ignores the other files a .ssh directory is full of', () => {
write('known_hosts', 'github.com ssh-ed25519 AAAA');
write('config', 'Host *\n AddKeysToAgent yes\n');
write('authorized_keys', 'ssh-ed25519 AAAA');
fs.mkdirSync(path.join(dir, 'sockets'));
expect(scanPrivateKeys(dir)).toEqual({ keys: [], skipped: [] });
});
it('ignores a file it cannot read', () => {
write('secret', OPENSSH);
fs.chmodSync(path.join(dir, 'secret'), 0o000);
// Reported as not-a-key rather than crashing the menu it was building.
expect(scanPrivateKeys(dir).skipped).toEqual([]);
fs.chmodSync(path.join(dir, 'secret'), 0o600);
});
it('does not read past the header', () => {
// The marker is in the first line; a mention further down is not a key.
write('decoy', `${'x'.repeat(200)}\nPRIVATE KEY-----\n`);
expect(scanPrivateKeys(dir).skipped).toEqual([]);
});
});
describe('reportSkippedKeys', () => {
let logSpy: ReturnType<typeof vi.spyOn>;
const messages = () => logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
beforeEach(() => {
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('says nothing when nothing was skipped', () => {
reportSkippedKeys([], '/home/alice/.ssh');
expect(logSpy).not.toHaveBeenCalled();
});
it('names the keys, the directory and the reason', () => {
reportSkippedKeys(['deploy_ed25519', 'backup_rsa'], '/home/alice/.ssh');
expect(messages()).toContain('deploy_ed25519, backup_rsa');
expect(messages()).toContain('/home/alice/.ssh');
expect(messages()).toContain('2 private keys');
// Without the reason the message is a complaint rather than an instruction.
expect(messages()).toContain('rename');
});
it('says key, singular, for one of them', () => {
reportSkippedKeys(['deploy_ed25519'], '/home/alice/.ssh');
expect(messages()).toContain('1 private key ');
});
});
+39 -4
View File
@@ -136,6 +136,7 @@ describe('keyman', () => {
'generate',
'encrypt',
'decrypt',
'clear',
'quit',
]);
});
@@ -257,21 +258,55 @@ describe('keyman', () => {
});
it('targets another user home directory when a user is named', async () => {
// A real sibling of the current HOME, because resolveHomeDir checks that the
// directory exists rather than assuming a layout.
const deployHome = path.join(root, 'deploy');
fs.mkdirSync(deployHome, { recursive: true });
menu(['list'], 'deploy');
await keyman();
expect(listKeys).toHaveBeenCalledWith('/home/deploy/.ssh', paths.keysDir, paths.tmpDir);
expect(listKeys).toHaveBeenCalledWith(
path.join(deployHome, '.ssh'),
paths.keysDir,
paths.tmpDir
);
});
it('aborts when the home directory cannot be determined', async () => {
delete process.env.HOME;
it('aborts when the named user has no home directory', async () => {
menu(['list'], 'nobody-at-all');
const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit');
});
await expect(keyman()).rejects.toThrow('process.exit');
expect(exit).toHaveBeenCalledWith(1);
expect(errorSpy.mock.calls[0][0]).toContain('Unable to determine HOME directory');
expect(errorSpy.mock.calls[0][0]).toContain('No home directory found');
});
it('writes a .gitignore next to the vault so it cannot be committed', async () => {
await keyman();
const contents = fs.readFileSync(path.join(paths.vaultRoot, '.gitignore'), 'utf-8');
// The README used to ask the user to do this by hand.
expect(contents).toContain('age.key');
expect(contents).toContain('tmp/');
});
it('clears the decrypted keys on request', async () => {
fs.mkdirSync(paths.tmpDir, { recursive: true });
fs.writeFileSync(path.join(paths.tmpDir, 'id_prod'), 'PRIVATE');
// Not the `menu` helper: this one has to answer the confirmation too.
const queue = ['clear', 'quit'];
prompt.mockImplementation(async (questions: { name: string }[]) => {
const { name } = questions[0];
if (name === 'user') return { user: '@current' };
if (name === 'confirmed') return { confirmed: true };
return { category: queue.shift() };
});
await keyman();
expect(fs.existsSync(path.join(paths.tmpDir, 'id_prod'))).toBe(false);
});
});