check / check (push) Failing after 0s
The first sftp session now lists .ssh before fetching authorized_keys. The file reads as empty only when sftp reports .ssh itself as missing, or the listing succeeded and the file is reported missing. A directory or file that is there but cannot be read fails the run and nothing is written, so no existing authorized_keys is replaced by content that was not built from what was read. An .ssh that already exists keeps its mode; the directory is made and set to 0700 only when none was found. The README describes the rule and states batch mode's limit: a key or an agent must authenticate. Model: opus-4-8 (implementation); fable-5-1 (summary)
216 lines
8.9 KiB
Markdown
216 lines
8.9 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. `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.
|
|
|
|
## 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
|
|
```
|