Files
Benjamin Diedrichsen 7862fab809 [docs] keyman: rewrite the README, close the audit
Phase 9 of packages/keyman/docs/PLAN.md; closes AUDIT §5.2, §5.3, §5.4, §5.5,
and the §4 one-liners no phase had claimed (§4.1–§4.4, §3.7).

The README is the only document that ships (package.json files: dist,
README.md, LICENSE), and it described four of nine menu entries, invented key
rotation, told the user to run ssh-keygen by hand, asked them to write a
.gitignore keyman now writes, and mentioned none of the command line. It is
rewritten against the code: every operation, the rotate/retire sequence, the
id_ prefix and what happens to keys without it, installation with the scope
mapping (never a bare --registry, which would send 55 transitive dependencies
to a registry that has never heard of them), the configuration semantics
including which relative path resolves against what, and the Phase 5 migration
for a split vault.

The CLI section is helpText() verbatim, with tests/readme.test.ts asserting the
two are identical and that every menu label appears — so a flag or an operation
added later fails the gate instead of shipping undocumented. That is the part
that keeps this from drifting again.

Also: index.ts loses the bin's shebang (it is only ever imported), exports the
config types so a consumer can name what loadConfig returns, and re-exports the
update module wholesale rather than half of it by name — verified by importing
the built dist/index.js and reading its keys. The narrow surface is now a
comment stating the rule rather than an accident.

AUDIT.md marks all 30 findings closed except the second half of §1.8, keeping
each finding's text as the record with what closed it quoted underneath, the
way DOCS-AUDIT.md does. PLAN.md gains a status section naming the three
deviations. Root CLAUDE.md records the keyman architecture as it now is,
including the deliberate `resolution` divergence from nopy.

DOCS-AUDIT.md §2.10, §6.4 and the §7 keyman-config entry are amended in the
working tree but left unstaged, since that file carries unrelated WIP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:57:00 +02:00

256 lines
11 KiB
Markdown

# keyman — SSH key management with an age-encrypted vault
keyman keeps SSH private keys in a vault you can commit. Each key is encrypted
with [age](https://github.com/FiloSottile/age) to a single recipient — the vault's
identity file — which is the one thing that has to stay out of the repository.
It is an interactive menu rather than a set of subcommands: point it at a vault,
pick an operation, repeat until you quit.
## Requirements
`age`, `age-keygen` and `ssh-keygen` on `PATH`. keyman shells out to all three and
names the missing one instead of failing obscurely.
## Installing
```sh
npm install -g @bitsquare/keyman@main \
--@bitsquare:registry=https://gitea.bitsquare.dev/api/packages/BitSquare/npm/
```
Point the **scope** at that registry rather than setting a bare `--registry`: it
serves `@bitsquare` packages only and does not proxy npmjs, so every other
dependency has to keep resolving from npmjs. Reading needs no token while the
repository is public, and the same line works with `pnpm`.
`@main` is a snapshot of the default branch, published on every push. Name a tag —
there is no `latest` on that registry yet, so an untagged install resolves to
nothing, and keyman has not been released to npmjs. `keyman self-update` keeps you
on whichever channel you installed from.
## Quick start
```sh
# The vault identity. The only secret in the vault, and the only thing here you
# cannot regenerate — back it up somewhere that is not this repository.
mkdir -p vault
age-keygen -o vault/age.key
# Run keyman against it.
VAULT_ROOT=./vault keyman
```
On startup keyman creates `keys/` and `tmp/` under the vault at `0700` and writes
a `.gitignore` beside them covering the identity and `tmp/`, so a fresh vault
cannot be committed by accident.
Then pick **🆕 Generate key**: it makes the key pair and encrypts it into the vault
in one step. `ssh-keygen` collects the passphrase itself — keyman never sees it, so
it can never put it on a command line.
## Operations
Every operation returns to the menu, so a session can run several.
- **📋 List keys** — every key it can see and where it is: encrypted in the vault,
decrypted in `tmp/`, live in `~/.ssh`, or some combination.
- **📝 Copy public key** — the public half of a key in `~/.ssh` or `tmp/`, to the
clipboard via whichever of `pbcopy`, `clip`, `wl-copy`, `xclip` or `xsel` exists.
With none of them, it prints the key instead.
- **🆕 Generate key** — an `ed25519` or 4096-bit `rsa` pair into `tmp/`, encrypted
into the vault straight away.
- **🔒 Encrypt keys** — pick from the private keys in `~/.ssh` *and* `tmp/`; each
goes to `<keysDir>/<name>/` with its public half beside it. A key that has no
`.pub` file gets one derived with `ssh-keygen -y`. One key failing costs only
that key.
- **🔓 Decrypt keys** — pick from the vault and decrypt to `tmp/` or `~/.ssh`.
Never overwrites a file without asking first, and the plaintext key is `0600`
from the moment it exists.
- **🔄 Rotate key** — a replacement for a vault key, encrypted *alongside* the
original. See below.
- **🗑️ Retire key** — the other half of a rotation: delete a vault key and its
plaintext copies, after listing every path that goes.
- **🧹 Clear decrypted keys** — remove the plaintext keys from `tmp/`.
- **❌ Quit**
### Listing
```
🔑 SSH Keys:
Key Name [Vault] [Tmp] [.ssh]
──────────────────────────────────────────────────────────
✅ id_deploy (.pub) [✓] [ ] [✓]
🔓 id_github (.pub) [✓] [✓] [ ]
🔒 id_backup (.pub) [✓] [ ] [ ]
⚠️ id_local (.pub) [ ] [ ] [✓]
Legend:
✅ = Managed (encrypted in vault + active in .ssh)
🔓 = Decrypted (in vault + decrypted to tmp)
🔒 = Encrypted only (in vault, not decrypted)
⚠️ = Unmanaged (in .ssh or tmp, not encrypted in vault)
```
`(.pub)` means a public key was found next to the private one, in either location.
The rows are sorted by name.
### Rotating a key
Rotation is deliberately two operations, because both keys have to exist at once:
1. **🔄 Rotate key**, and pick `prod`. keyman generates `id_prod-2` in `tmp/`,
encrypts it to `keys/prod-2/`, and prints both public keys. `prod` is untouched.
2. Add the `prod-2` public key wherever `prod` is authorized.
3. Check that you can log in with `tmp/id_prod-2`.
4. Remove the `prod` public key from those hosts.
5. **🗑️ Retire key**, and pick `prod`.
The name has to change: the vault directory is derived from it, so a replacement
also called `prod` *is* the `prod` entry. Rotating again continues the series
(`prod-2``prod-3`), and a version already taken — in the vault, in `tmp/` or in
`~/.ssh` — is skipped rather than overwritten.
Doing it in one step instead is what this shape avoids: replace the key in the
vault and you have locked yourself out of the host you were rotating for, because
the replacement is not on it yet and the only copy of the key that is has gone.
Retiring warns when nothing in the vault supersedes the key, and then asks you to
type its name.
### The `id_` prefix
keyman manages keys named `id_*`; the vault directory for `id_prod` is `prod`.
A private key named anything else is not offered by any operation — but List, Copy
and Encrypt report the ones they found, with a count and the reason, so it is
never silently invisible. Rename it to `id_<name>` to bring it in.
## Command line
```
keyman — SSH key management and an age-encrypted key vault
Usage
keyman start the interactive menu
keyman self-update update keyman itself (alias: upgrade)
Flags
-h, --help print this help and exit
-V, --version print the version and exit
--print-config print the resolved paths and the config files
they came from, as JSON, and exit
--self-update same as the self-update subcommand
Flags for self-update
--channel <latest|next|main> channel to update from
(default: derived from the running version)
--registry <url> registry to query instead of the configured one
-n, --dry-run print the install command without running it
-f, --force reinstall even when already up to date
Environment
VAULT_ROOT overrides vaultRoot from .keymanrc.json
KEYMAN_REGISTRY registry for the update check and self-update
KEYMAN_REGISTRY_TOKEN bearer token for a private registry
KEYMAN_NO_UPDATE_CHECK set to 1 to skip the once-a-day update check
(also skipped whenever CI is set)
KEYMAN_PACKAGE_MANAGER npm | pnpm | yarn | bun for the install command
Configuration is read from .keymanrc.json, merged from the current directory
upwards and then from ~/.keymanrc.json.
```
The update channel is derived from the version you are running — a `-main.` build
checks `main`, any other prerelease checks `next`, a clean version checks `latest`
— so an update cannot quietly move you to a different channel. The check runs at
most once a day and prints its hint to **stderr**, which keeps `--print-config`
machine-readable.
## Configuration
`.keymanrc.json`, with every key optional:
```json
{
"vaultRoot": "vault",
"keysDir": "keys",
"tmpDir": "tmp",
"ageKeyFile": "age.key"
}
```
| key | default | meaning |
| ------------ | ---------- | ---------------------------------------------------- |
| `vaultRoot` | `vault` | the vault directory; everything else lives inside it |
| `keysDir` | `keys` | the encrypted keys — the part that is safe to commit |
| `tmpDir` | `tmp` | decrypted keys, in plaintext |
| `ageKeyFile` | `age.key` | the age identity the vault encrypts to |
The last three are resolved against `vaultRoot` unless they are absolute. A
relative `vaultRoot` **in a config file** is resolved against that file's
directory, so a repository config keeps meaning the same vault from any
subdirectory; the built-in default is resolved against the current directory.
Files are read from `~/.keymanrc.json` first, then from the filesystem root down
to the current directory, so the nearest file wins key by key. `VAULT_ROOT` in the
environment beats all of them. A file that is not valid JSON is skipped with a
warning rather than taken as fatal, and a key keyman does not know is reported
instead of silently dropped — `{"vaultroot": "…"}` used to be indistinguishable
from an empty file.
`keyman --print-config` answers what all of that resolved to, and which files it
came from:
```sh
$ keyman --print-config
{"vaultRoot":"/srv/infra/vault","keysDir":"/srv/infra/vault/keys","tmpDir":"/srv/infra/vault/tmp","keyPath":"/srv/infra/vault/age.key","configFiles":["/srv/infra/.keymanrc.json"]}
```
## Vault layout
```
project/
├── vault/
│ ├── .gitignore # written by keyman: the identity and tmp/, not keys/
│ ├── age.key # the vault identity (NEVER commit)
│ ├── keys/ # encrypted keys (safe to commit)
│ │ └── deploy/ # one directory per key, named without the id_ prefix
│ │ ├── id_deploy.age # the private key, encrypted to the vault recipient
│ │ └── id_deploy.pub # the public key
│ └── tmp/ # decrypted keys (NEVER commit)
│ ├── id_deploy
│ └── id_deploy.pub
└── .keymanrc.json # optional
```
With a custom `keysDir` or `tmpDir`, those two names change and nothing else does.
## Practices this tool assumes
1. **Back up `age.key`** somewhere outside the repository. It is the only thing
that can decrypt the vault, and nothing in the vault can reconstruct it.
2. **Commit `keys/`.** Encrypted keys are the point; a vault nobody shares is a
directory.
3. **Do not commit the identity or `tmp/`.** keyman writes a `.gitignore` for
this, and never overwrites one you wrote yourself — check it if you brought
your own.
4. **Clear `tmp/` when you are done with it** (🧹), so plaintext keys do not
outlive the reason they were decrypted.
5. **Keep `.keymanrc.json` in the project root** so everyone resolves the same
vault, and use `VAULT_ROOT` for the exceptions.
## Upgrading from a version before 0.7.0
`keysDir` and `tmpDir` used to be honoured by some operations and ignored by
others, which left anyone with custom names holding a **split vault**: `generate`
and `list` used the configured directories while `encrypt` and `decrypt` used
`<vaultRoot>/keys` and `<vaultRoot>/tmp`. All of them agree now, so anything
written by the old `encrypt` needs moving once:
```sh
mv <vaultRoot>/keys/* <vaultRoot>/<keysDir>/
```
Nobody on the default names is affected — for them the two halves were the same
directory all along.