`keyfunc ssh to` and `keyfunc ssh install` started the system `ssh` and `sftp` with the tool's whole environment, so a mnemonic given in `KEYFUNC_MNEMONIC` stayed readable in the child's environment and could be forwarded to the host by a `SendEnv` line. Both children now get the environment with `KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` removed, through one helper, `childEnv`, in the ssh cli package. The mnemonic command still runs with the full environment. Two tests drive the real commands against the stand-in `ssh` and `sftp` and check that a third variable still arrives. Model: opus-4-8 (implementation, review); fable-5-1 (merge message)
9.1 KiB
keyfunc
keyfunc turns a BIP-39 mnemonic into key pairs that 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.
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'. 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.- 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.
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
~/.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.
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.
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.
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.
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.
Errors
Errors go to standard error and the exit status is 1, except for ssh to,
which passes through ssh's own exit status.
Building and running
make build # produces ./keyfunc
make check # fmt-check, lint (golangci-lint) and tests
Examples:
keyfunc ssh pub -n 3 --mnemonic-command 'secret get foo'
keyfunc ssh priv -n 3 > ~/.ssh/id_bip85_3
keyfunc ssh install -n 3 user@example.com
keyfunc ssh to -n 3 user@example.com uptime
keyfunc age pub -n 0
keyfunc age encrypt -n 0 --armor -o notes.age notes.txt
keyfunc age decrypt -n 0 notes.age
keyfunc mnemonic -n 1 --words 24