Files
keyfunc/README.md
T
clawbot 1ddc2c747a
check / check (push) Successful in 2m37s
make fmt and make fmt-check cover Markdown with prettier (closes #39)
make fmt and make fmt-check now cover the Markdown files with prettier as well as the Go source: prettier 3.8.1 pinned in package.json and yarn.lock, four-space indents and proseWrap always in .prettierrc, all three copied unchanged from the sneak/prompts templates. script/bootstrap adds pinned node (through nvm from a hash-checked archive when none is installed), yarn and prettier after the pinned Go. README.md is reformatted by make fmt and its Entrypoints section says what each step needs.

Model: opus-5-5
2026-10-04 05:42:46 +02:00

341 lines
15 KiB
Markdown

# keyfunc
`keyfunc` is an MIT-licensed Go command-line tool by
[@sneak](https://sneak.berlin) that turns a BIP-39 mnemonic into SSH keys, age
identities and child mnemonics, each of which can be recreated from that
mnemonic at any time. The same mnemonic, key type and index always give the same
key.
It uses the BIP-85 entropy deriver from `git.eeqj.de/sneak/secret/pkg/bip85` and
takes the same steps as that repository's `agehd` package.
Commands are grouped by what is derived: `keyfunc ssh ...` for ed25519 SSH keys,
`keyfunc age ...` for age identities and for encrypting and decrypting with
them, and `keyfunc mnemonic ...` for child mnemonics derived from the main one.
## Getting Started
Install with Go:
```
go install sneak.berlin/go/keyfunc/cmd/keyfunc@latest
```
Or build from a clone and run the binary:
```
git clone git@git.eeqj.de:sneak/keyfunc.git
cd keyfunc
make build
./keyfunc --version
```
`make build` produces `./keyfunc`. Every deriving command needs a mnemonic; see
[Giving it the mnemonic](#giving-it-the-mnemonic) for where it is read from,
then for example:
```
./keyfunc ssh pub -n 0 --mnemonic-command 'secret get foo'
```
## Rationale
A key you can derive again never has to be backed up. One mnemonic, kept safe
once, stands behind every key this tool produces: lose a laptop and the SSH key,
the age identity and any child mnemonic on it come back from the mnemonic alone,
at the same index, byte for byte. Nothing else has to be written down, copied
between machines, or stored in a secret manager, because it can always be
derived again.
## Design
The entry point is a thin `cmd/keyfunc/main.go` (what `make build` builds) that
calls into `internal/`. The packages there are:
- `internal/derive` turns a mnemonic into the 32 bytes a key is made from: it
walks BIP-39 seed, BIP-32 master key and BIP-85 entropy, and holds the shared
constants (the byte count and the largest key index).
- `internal/mnemonic` finds the mnemonic to work from — a command, an
environment variable, or a terminal prompt — and refuses one that fails the
BIP-39 checksum.
- `internal/sshkey` turns the derived bytes into an ed25519 SSH key
(`sshkey.go`) and serves that key from an in-process SSH agent on a private
unix socket, keeping it out of any file (`agent.go`).
- `internal/agekey` turns the derived bytes into an age identity and encrypts
and decrypts with it.
- `internal/childmnemonic` derives a child mnemonic from the main one using
BIP-85's own mnemonic application.
- `internal/cli` builds the cobra command tree and runs it. Under it,
`cli/options` holds the flags every command shares, and `cli/ssh`, `cli/age`
and `cli/mnemonic` are the command groups.
### Adding a key type
Adding a key type is one package under `internal/` that turns the 32 derived
bytes into that type's key, plus one cobra subcommand under `internal/cli/` that
groups its commands.
## Derivation
The mnemonic is turned into a key like this:
1. mnemonic -> BIP-39 seed (empty passphrase);
2. seed -> BIP-32 master key;
3. master key -> 64 bytes of BIP-85 entropy at the path below;
4. entropy -> BIP-85 DRNG (SHAKE256); read 32 bytes;
5. those 32 bytes become the key in the way the key type needs.
The path is:
```
m/83696968'/<app>'/<n>'
```
- `83696968` is the fixed BIP-85 purpose.
- `app` is the application number of the key type. There is no vendor id: the
path is meant as a standard any implementation can follow, not something tied
to one tool. Each key type's number is spelled the way BIP-85 spells its own
RSA application (`828365` is the ASCII codes of `R`, `S`, `A` written out):
SSH is `838372` (`S` `S` `H`), age is `657169` (`A` `G` `E`).
- `n` is the key index: flag `--index` / `-n`, default `0`.
## Giving it the mnemonic
The mnemonic itself is never a command-line argument. It is looked for in this
order; the first one found wins:
1. `--mnemonic-command <command>`: a shell command, run with `sh -c`, whose
standard output is the mnemonic. Example:
`--mnemonic-command 'secret get foo'`. Whitespace around the output is
dropped. If the command exits with a non-zero status, the tool prints its
standard error and exits with status 1.
2. Environment variable `KEYFUNC_MNEMONIC_COMMAND`: the same, as a shell command
held in the environment.
3. Environment variable `KEYFUNC_MNEMONIC`: the mnemonic itself.
4. A prompt on the terminal with echo turned off.
If none of these is available and standard input is not a terminal, the tool
refuses and exits with status 1. A mnemonic that fails the BIP-39 checksum is
refused with a message saying so.
`KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` are removed from the
environment before the system `ssh` (`keyfunc ssh to`) and `sftp`
(`keyfunc ssh install`) are started, so the mnemonic is never handed on to them.
Every command takes `--index` / `-n` and `--mnemonic-command`, and has `--help`.
`keyfunc --version` prints the version. `make build` stamps it; a binary
installed with `go install` reports the module version instead.
## SSH keys: `keyfunc ssh`
Only ed25519 keys are produced. The application number is `838372`, so the path
is `m/83696968'/838372'/<n>'`. The 32 bytes from step 4 are the ed25519 seed.
Test vector, mnemonic
`abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`:
```
index 0: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJZOtOczrc/7CQytcuFwt7s4r8KjkZWkwjLZWBaFKD+7
index 1: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOEWY8+/gmHYVC4u0Y0I4FKs+eVUulTPHfk9VtXw1tMF
```
### `keyfunc ssh pub`
Prints one `authorized_keys` line to standard output:
```
ssh-ed25519 <key> <comment>
```
The comment defaults to `keyfunc/ssh/<n>`; change it with `--comment`.
### `keyfunc ssh priv`
Prints the unencrypted private key in OpenSSH format (the
`-----BEGIN OPENSSH PRIVATE KEY-----` block that `ssh` reads) to standard output
and nothing else, so it can be redirected into a file. The key's comment is the
same as for `pub`.
### `keyfunc ssh install <[user@]host> [-- sftp options...]`
Adds the `pub` line to `~/.ssh/authorized_keys` on the host. No command is run
on the host: the file is fetched, changed here, and written back with the system
`sftp` client in batch mode.
The first connection lists `~/.ssh` and then fetches `~/.ssh/authorized_keys`
from it. The file reads as empty in two cases only: `sftp` reported `~/.ssh`
itself as not being there, or the listing came up and the file was not in it.
Any other outcome of that connection fails the run — a `~/.ssh` that is there
but cannot be entered, an `authorized_keys` that is there but cannot be read, or
a connection that did not come up — and the tool prints what `sftp` said and
exits with status 1 without writing anything, rather than put a file back
holding the new key alone. The listing is what tells a missing directory from
one shut to the user, which `sftp` reports on a fetch the same way; the wording
of a missing file elsewhere does not count either, since `ssh` writes
`No such file or directory` about an `-i` it cannot find on a session that then
authenticates through the agent. If an identical line is already in the file,
the tool prints `already present` and connects no further. Otherwise the line is
added (after a newline, if the file did not end with one) and a second
connection:
- makes `~/.ssh` and sets it to mode `0700`, but only when the first connection
found none; a `~/.ssh` that was already there keeps the mode it had;
- uploads the new file as `~/.ssh/authorized_keys.keyfunc-<random>` and sets it
to mode `0600`;
- renames that file over `~/.ssh/authorized_keys`.
The tool then prints `added`. So a run that adds a line connects twice. The
rename is the step that either happens or does not: the file on the host is
never half-written. `sftp` does it in one step against servers that offer
OpenSSH's POSIX rename extension, as OpenSSH's own server does; a server without
it may refuse to rename onto a file that is already there.
If a step fails, the tool prints what `sftp` said, removes nothing, and exits
with status 1. It names the uploaded file only when the step that failed was the
upload or one after it, which is where a file of that name can be on the host; a
failure before the upload names none. Everything `sftp` writes goes to standard
error, so the tool's own standard output is only `added` or `already present`.
Anything after `--` is passed to `sftp` unchanged, which is where the port goes
(`-P 2222`, not `-p`). How the connection authenticates is up to the user's
normal `ssh` setup, except that batch mode does not prompt: a key or an agent
has to do it, not a typed password.
### `keyfunc ssh to <host> [ssh arguments...]`
Derives the key, serves it from an SSH agent that runs inside the tool on a unix
socket in a new private `0700` temporary directory, then runs the system `ssh`
with `-o IdentityAgent=<that socket>` followed by the host and all remaining
arguments unchanged. The tool exits with `ssh`'s exit status and removes the
socket and directory on the way out. The private key is never written to disk. A
SIGINT, SIGTERM or SIGHUP ends `ssh` and still removes the socket and directory,
and the tool then exits with status 1 unless `ssh` reported one of its own.
## age identities: `keyfunc age`
The application number is `657169`, path `m/83696968'/657169'/<n>'`. The 32
bytes from step 4 are clamped as X25519 requires and become an age identity, the
same steps `sneak/secret` takes in its `agehd` package. `secret` derives at a
vendor-specific path today; for its keys to equal this tool's it moves to this
path, which is a change in `secret`, not here.
Test vectors, mnemonic
`abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`:
```
recipient index 0: age1xwdy9y6ckyfsgjc8k02e9uhsf3fmjy0ufysewlj68kmx5n67e3nsg2mftq
recipient index 1: age1pmm92sxaf5mazjwvjph7dx2zq9r5p8l3rarfgqm7hmakqhvgyy4q5p3w7j
identity index 0: AGE-SECRET-KEY-19QKK2P38598XLXMQFFU3P7J9PLDD7527T70JDHGDJ7AMNF3XT44S00JFU5
```
### `keyfunc age pub`
Prints the recipient, the `age1...` public key, on one line.
### `keyfunc age priv`
Prints the identity, the `AGE-SECRET-KEY-1...` line, and nothing else.
### `keyfunc age encrypt [-n N] [--to <recipient>...] [-o <file>] [<file>]`
Encrypts the file (or standard input) with age. The recipients are the derived
identity's own recipient, plus any given with `--to`, so the same mnemonic can
always decrypt what it encrypted. Output goes to `-o` or standard output;
`--armor` writes the text form. Nothing is written except the output.
### `keyfunc age decrypt [-n N] [-o <file>] [<file>]`
Decrypts the file (or standard input) with the derived identity. Output goes to
`-o` or standard output. If the identity is not one of the recipients, the tool
says so and exits with status 1.
## Derived mnemonics: `keyfunc mnemonic`
### `keyfunc mnemonic [-n N] [--words 12|18|24]`
Prints a child mnemonic derived from the main one, using BIP-85's own mnemonic
application (number `39`, English, path `m/83696968'/39'/0'/<words>'/<n>'`,
entropy taken as the specification says, not through step 4). Default 12 words.
A child mnemonic is a full mnemonic in its own right: it can seed another
`keyfunc`, another wallet, or `secret`, and it never has to be written down,
since it can be derived again.
Test vector: the child-mnemonic step is checked against BIP-85's own published
vectors, which derive from the specification's master key
`xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb`.
At key index 0 the 12-word English child mnemonic is:
```
girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose
```
## Errors
Errors go to standard error and the exit status is 1, except for `ssh to`, which
passes through `ssh`'s own exit status.
## Entrypoints
The repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: most Makefile targets are thin shims over an executable in `script/`
(`build` and `clean` are the exceptions).
- `script/bootstrap` installs, idempotently, everything needed to build and
develop apart from Docker, which it only warns about when it is missing, and
the linter, which only runs inside Docker. In this order:
- git and make from nix, apt, brew or apk, and from there too curl and bash
when a later step needs them;
- Go at the version the `Dockerfile`'s Go image carries, from the official
release archive at go.dev (checked against a sha256 in the script) into
`~/.local/go`, unless the `go` first on the `PATH` already is that
version; then the Go modules. `script/bootstrap` itself, `script/fmt`,
`script/fmt-check`, `script/precommit` and the `Makefile` put
`~/.local/go/bin` first on their `PATH`, so they use that Go;
- node: an installed one is used as it is, otherwise a pinned one is
installed through nvm, which, when it is missing, comes from its release
archive, checked against a sha256;
- yarn: an installed one is used as it is, otherwise the pinned version
through corepack, or through npm where there is no corepack;
- the pinned prettier, through yarn.
- `script/setup` prepares a fresh clone: it runs `bootstrap`, then installs the
git pre-commit hook.
- `script/projectname` prints the project name; other scripts call it so they
stay identical across repos.
- `script/test` builds the `test` phase of the `Dockerfile` alone, uncached: the
test suite runs with the race detector inside the build, rerunning verbosely
if a test fails.
- `script/lint` builds the `lint` phase of the `Dockerfile` alone, uncached: the
linter, pinned by hash, runs inside the build, so a complaint fails it and
leaves no container behind.
- `script/fmt` formats in place: the Go source with `go fmt`, then every
Markdown file with prettier (four-space indents, prose wrapped at 80 columns).
- `script/fmt-check` checks the same files the same way without writing, failing
if anything is unformatted. Both need the node, yarn and prettier that
`bootstrap` installs.
- `script/check` runs `test`, `lint` and `fmt-check` and changes no files.
- `script/docker` builds the Docker image, uncached, tagged with the project
name and stamped with the version `git describe` gives on the host. The image
cannot be built unless the `lint` and `test` phases pass, so a plain
`docker build .` runs them too.
- `script/cibuild` is the CI build the Gitea workflow calls: it runs
`bootstrap`, then `check`, then builds the image as `script/docker` does.
- `script/precommit` is what the git pre-commit hook runs: `go mod tidy` and
`go fmt`, failing if `go.mod` or `go.sum` changed, then `check`.
- `script/install-precommit` installs the git pre-commit hook that runs
`script/precommit`.
## TODO
The open issues that stand between the tree and a 1.0 release:
- [#42 go-bip39 no longer exists upstream: keep it, or copy it into the repo?](https://git.eeqj.de/sneak/keyfunc/issues/42)
## License
MIT. The full text is in [`LICENSE`](LICENSE).
## Author
[@sneak](https://sneak.berlin).