Commit Graph
5 Commits
Author SHA1 Message Date
Benjamin Diedrichsen 0993a4d3bb [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.
2026-07-30 15:42:35 +02:00
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
Benjamin Diedrichsen da9df57e11 [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.
2026-07-30 14:45:27 +02:00
Benjamin DiedrichsenandClaude Opus 5 11c323b715 [keyman] phase 2: guard the directories nothing creates
encrypt read ~/.ssh and the tmp directory, and decrypt read <vault>/keys,
with no existsSync between them. main.ts created vaultRoot and tmpDir but
never keysDir, so decrypt on a fresh vault threw ENOENT instead of
printing the "no encrypted keys" message it already had — the message was
unreachable until something else created the directory.

Both functions now fall through to their warning. main.ts creates all
three directories, 0700: the vault holds the age identity and tmp holds
plaintext private keys.

age spawns go through runTool, which separates "not installed" (ENOENT,
whose message is `spawn age ENOENT`) from "age refused" (whose reason is
on stderr and nowhere in the thrown message). Tested against real
processes, not a mocked execa — the shape of the failure is the point.

list.ts kept statSync rather than switching to withFileTypes as planned:
withFileTypes reports a symlinked key directory as a link and would have
silently dropped it. `throwIfNoEntry: false` fixes the dangling-symlink
throw and keeps following the good ones. Both cases now have a test.

Also deletes the three debug logs (encrypt.ts printed both key arrays,
decrypt.ts printed every candidate path from inside a filter).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:33:26 +02:00
Benjamin Diedrichsen 736c01216a initial transfer 2026-07-27 13:09:00 +02:00