Files
ansiblings/packages/keyman
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
..

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 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

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

# 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-2prod-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:

{
  "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:

$ 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:

mv <vaultRoot>/keys/* <vaultRoot>/<keysDir>/

Nobody on the default names is affected — for them the two halves were the same directory all along.