check / check (push) Successful in 4m30s
go-bip39's repository no longer exists, so keyfunc now carries the part of v1.1.0 it uses, with the upstream LICENSE beside it, and imports it from internal/bip39. Upstream's tests and test vectors for the kept code come along unchanged; where they called a removed function, crypto/rand stands in for NewEntropy and EntropyFromMnemonic for MnemonicToByteArray. Changes beyond the trimming are what the linter asked for: the package-level variables moved into the functions that use them, three error strings were lower-cased, and the unknown-word error now wraps ErrInvalidMnemonic. go.mod no longer requires go-bip39. go mod tidy keeps its two go.sum lines, because a test in sneak/secret's bip85 package imports it, and drops six lines that only go-bip39's own requirements needed. Model: opus-5-5 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
387 lines
18 KiB
Markdown
387 lines
18 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, `cli/signals` catches
|
|
SIGINT, SIGTERM and SIGHUP for the commands that clean up before they end, and
|
|
`cli/ssh`, `cli/age` and `cli/mnemonic` are the command groups.
|
|
- `internal/bip39` is a copy of `github.com/tyler-smith/go-bip39` v1.1.0,
|
|
trimmed to what keyfunc uses.
|
|
|
|
### 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'`. 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. Keys are derived from the mnemonic's words
|
|
joined by single spaces, whatever whitespace is around or between them, so one
|
|
word per line, tabs or extra spaces give the same keys.
|
|
|
|
`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`, then `~/.ssh/.`, and then fetches
|
|
`~/.ssh/authorized_keys`. The file reads as empty in two cases only: `sftp`
|
|
reported `~/.ssh` itself as not being there, or both listings came up and the
|
|
file was not found. Any other outcome of that connection fails the run — a
|
|
`~/.ssh` that is there but cannot be read or 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 listings are what tell a
|
|
missing directory from one shut to the user, which `sftp` reports on a fetch the
|
|
same way: one that cannot be read fails the first listing, and one that can be
|
|
read but not entered fails the second, after which the tool says that `~/.ssh`
|
|
cannot be entered. 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. An
|
|
`authorized_keys` that the first listing shows to be a symlink is refused and
|
|
left as it is, since the rename below would replace the link itself and the file
|
|
it points at would never get the key. 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. Nor does it ask whether to trust a host key
|
|
it has not seen, so the host has to be in `known_hosts` already, or the run
|
|
fails with `Host key verification failed`. Connect to the host once with `ssh`
|
|
first, or pass `-o StrictHostKeyChecking=accept-new` after `--`.
|
|
|
|
### `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.
|
|
|
|
A `-o` path that is the same file as the tool's own standard output or standard
|
|
error, under any name such as `/dev/stdout` or `/dev/fd/2`, is written to that
|
|
stream, as leaving out `-o` writes to standard output; the file the stream is
|
|
redirected to is written as the redirect says and never replaced, so with `>>`
|
|
the output follows what the file already held. Otherwise, a regular file already
|
|
at the `-o` path is replaced, and the new file has mode `0600`. A symlink there
|
|
is followed, and what it points at is treated the same way, so the link keeps
|
|
pointing where it did; a symlink that points at nothing is refused. A named pipe
|
|
or a device, such as `/dev/null`, is written to directly.
|
|
|
|
### `keyfunc age decrypt [-n N] [-o <file>] [<file>]`
|
|
|
|
Decrypts the file (or standard input) with the derived identity. Output goes to
|
|
`-o`, which is treated as for `encrypt`, 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, mnemonic
|
|
`abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`:
|
|
|
|
```
|
|
index 0: prosper short ramp prepare exchange stove life snack client enough purpose fold
|
|
```
|
|
|
|
The child-mnemonic step is also checked against BIP-85's own published vectors.
|
|
Those start from the specification's master key
|
|
`xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb`
|
|
rather than from a mnemonic, so they cannot be given to `keyfunc`; at key index
|
|
0 the 12-word English child mnemonic of that key 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.
|
|
|
|
SIGINT, SIGTERM and SIGHUP end any command at once, at the mnemonic prompt too,
|
|
with the status a shell gives a program killed by that signal (130 for SIGINT).
|
|
While `age encrypt -o` or `age decrypt -o` is writing a new file or replacing a
|
|
regular one, the signal makes it remove the unfinished file, leave a file
|
|
already at the named path as it was, and exit with status 1. That holds for a
|
|
signal that has reached `keyfunc` when its input ends; a later one leaves the
|
|
whole file in place. Ctrl-C on a pipeline ends the input at the same moment, and
|
|
on Linux `keyfunc` sees the signal first, though no system promises that. A
|
|
named pipe or a device at the `-o` path, or a path that is the same file as
|
|
standard output or standard error, is written to directly, and the signal ends
|
|
the tool there as it ends any other command. While `ssh to` or `ssh install` has
|
|
`ssh` or `sftp` running, the signal ends that program instead, the tool removes
|
|
its agent socket or working files, and it exits with status 1, or for `ssh to`
|
|
with `ssh`'s own status if `ssh` reported one.
|
|
|
|
## 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. The image is a development environment, not a
|
|
runtime image: the Debian Go image with what `script/bootstrap` installs, the
|
|
source tree in `/src`, and `keyfunc` built from it on the `PATH`.
|
|
`docker run --rm -it keyfunc` opens a shell in it.
|
|
- `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
|
|
|
|
No issues are open.
|
|
|
|
## License
|
|
|
|
MIT. The full text is in [`LICENSE`](LICENSE).
|
|
|
|
## Author
|
|
|
|
[@sneak](https://sneak.berlin).
|