All checks were successful
check / check (push) Successful in 4s
ssh install no longer runs a command on the host. It reads .ssh/authorized_keys over sftp, takes the empty reading only from sftp's own message about that path, appends the derived key locally when it is not already present, uploads the result beside the file with mode 0600 and renames it over the original. Any other failure prints what sftp said, writes nothing and exits 1. sftp batch mode disables password prompts, so a key or agent is required; a directory the owner cannot enter reads as a host with no file, which README.md states. Model: opus-5 (implementation); fable-5-1 (landing)
213 lines
8.7 KiB
Markdown
213 lines
8.7 KiB
Markdown
# 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:
|
|
|
|
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.
|
|
|
|
Every command takes `--index` / `-n` and `--mnemonic-command`, and has `--help`.
|
|
`keyfunc --version` prints the version set at build time.
|
|
|
|
## 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 fetches `~/.ssh/authorized_keys`. The file reads as empty
|
|
only when `sftp` reported that file as not being there — the one line naming
|
|
that path. The same wording anywhere else in the session does not count: `ssh`
|
|
writes `No such file or directory` about an `-i` it cannot find, on a session
|
|
that then authenticates through the agent. When `sftp` failed for any other
|
|
reason — the file is there and cannot be read, the connection did not come up —
|
|
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. What `sftp`
|
|
cannot tell apart is a missing file and one in a directory it cannot enter, so a
|
|
`~/.ssh` whose mode shuts the user out reads as a host with no file; the second
|
|
connection sets that mode to `0700` and writes, as on a host that has none. 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:
|
|
|
|
- creates `~/.ssh` and sets it to mode `0700`;
|
|
- 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.
|
|
|
|
## 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
|
|
```
|