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>
18 KiB
keyfunc
keyfunc is an MIT-licensed Go command-line tool by
@sneak 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 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/deriveturns 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/mnemonicfinds the mnemonic to work from — a command, an environment variable, or a terminal prompt — and refuses one that fails the BIP-39 checksum.internal/sshkeyturns 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/agekeyturns the derived bytes into an age identity and encrypts and decrypts with it.internal/childmnemonicderives a child mnemonic from the main one using BIP-85's own mnemonic application.internal/clibuilds the cobra command tree and runs it. Under it,cli/optionsholds the flags every command shares,cli/signalscatches SIGINT, SIGTERM and SIGHUP for the commands that clean up before they end, andcli/ssh,cli/ageandcli/mnemonicare the command groups.internal/bip39is a copy ofgithub.com/tyler-smith/go-bip39v1.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:
- mnemonic -> BIP-39 seed (empty passphrase);
- seed -> BIP-32 master key;
- master key -> 64 bytes of BIP-85 entropy at the path below;
- entropy -> BIP-85 DRNG (SHAKE256); read 32 bytes;
- those 32 bytes become the key in the way the key type needs.
The path is:
m/83696968'/<app>'/<n>'
83696968is the fixed BIP-85 purpose.appis 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 (828365is the ASCII codes ofR,S,Awritten out): SSH is838372(SSH), age is657169(AGE).nis the key index: flag--index/-n, default0.
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:
--mnemonic-command <command>: a shell command, run withsh -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.- Environment variable
KEYFUNC_MNEMONIC_COMMAND: the same, as a shell command held in the environment. - Environment variable
KEYFUNC_MNEMONIC: the mnemonic itself. - 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
~/.sshand sets it to mode0700, but only when the first connection found none; a~/.sshthat was already there keeps the mode it had; - uploads the new file as
~/.ssh/authorized_keys.keyfunc-<random>and sets it to mode0600; - 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
standard: most Makefile targets are thin shims over an executable in script/
(build and clean are the exceptions).
script/bootstrapinstalls, 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 thegofirst on thePATHalready is that version; then the Go modules.script/bootstrapitself,script/fmt,script/fmt-check,script/precommitand theMakefileput~/.local/go/binfirst on theirPATH, 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/setupprepares a fresh clone: it runsbootstrap, then installs the git pre-commit hook.script/projectnameprints the project name; other scripts call it so they stay identical across repos.script/testbuilds thetestphase of theDockerfilealone, uncached: the test suite runs with the race detector inside the build, rerunning verbosely if a test fails.script/lintbuilds thelintphase of theDockerfilealone, uncached: the linter, pinned by hash, runs inside the build, so a complaint fails it and leaves no container behind.script/fmtformats in place: the Go source withgo fmt, then every Markdown file with prettier (four-space indents, prose wrapped at 80 columns).script/fmt-checkchecks the same files the same way without writing, failing if anything is unformatted. Both need the node, yarn and prettier thatbootstrapinstalls.script/checkrunstest,lintandfmt-checkand changes no files.script/dockerbuilds the Docker image, uncached, tagged with the project name and stamped with the versiongit describegives on the host. The image cannot be built unless thelintandtestphases pass, so a plaindocker build .runs them too. The image is a development environment, not a runtime image: the Debian Go image with whatscript/bootstrapinstalls, the source tree in/src, andkeyfuncbuilt from it on thePATH.docker run --rm -it keyfuncopens a shell in it.script/cibuildis the CI build the Gitea workflow calls: it runsbootstrap, thencheck, then builds the image asscript/dockerdoes.script/precommitis what the git pre-commit hook runs:go mod tidyandgo fmt, failing ifgo.modorgo.sumchanged, thencheck.script/install-precommitinstalls the git pre-commit hook that runsscript/precommit.
TODO
No issues are open.
License
MIT. The full text is in LICENSE.