clawbot dd14677145
check / check (push) Successful in 42s
README checked against the tree by running every example (closes #22)
Every example in the README was run as written with the published test mnemonic and behaved as the README says, so no sentence changed. The only edit removes the landed work from the TODO section, which now lists the two open owner decisions.

Disclosures:
- `ssh install` and `ssh to` were run by the implementer against a throwaway local `sshd`; the reviewer could not repeat that run and checked those sections by reading the code.
- The child mnemonic vector is reachable only through the test suite and was confirmed there.

Model: opus-4-8 (implementation, review); fable-5-1 (merge message)
2026-09-21 18:07:36 +02:00

keyfunc

keyfunc is a Go command-line tool by @sneak — its license is not yet chosen (#14) — 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

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/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 standard: most Makefile targets are thin shims over an executable in script/ (build and clean are the exceptions).

  • script/bootstrap installs everything needed to build and develop (git, make, Go), idempotently, from nix, apt, brew or apk; it does not install the linter, which only runs inside Docker.
  • 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 runs go vet and then the test suite, rerunning verbosely if a test fails.
  • script/lint runs the linter inside the image built from Dockerfile.lint (which pins the linter by hash), so a complaint fails the build and leaves no container behind.
  • script/fmt formats the Go source in place.
  • script/fmt-check checks that formatting without writing, failing if anything is unformatted.
  • script/check runs test, lint and fmt-check and changes no files.
  • script/docker builds the Docker image tagged with the project name.
  • script/cibuild is the CI build the Gitea workflow calls: it runs the linter, then docker build.
  • 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:

License

Not yet chosen. The license is the owner's decision, still open on the tracker (#14); the LICENSE file is added when that issue is answered.

Author

@sneak.

S
Description
No description provided
Readme
148 KiB
Languages
Go 89.6%
Shell 8.3%
Makefile 1.2%
Dockerfile 0.9%