4 Commits
Author SHA1 Message Date
clawbot dd14677145 README checked against the tree by running every example (closes #22)
check / check (push) Successful in 42s
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
clawbot ace7846d57 Use gomodguard_v2 in the linter config (closes #31)
check / check (push) Failing after 1s
golangci-lint 2.12 deprecated `gomodguard` in favour of `gomodguard_v2` and printed a warning on every `make check`. `.golangci.yml` now disables the old name, the same way it already handles `wsl` and `wsl_v5`. With `linters.default: all` the replacement was already enabled, so what is checked does not change; only the warning goes.

Model: opus-4-8 (implementation, review); fable-5-1 (merge message)
2026-09-21 17:58:20 +02:00
clawbot 40e9beea8c Clean up the agent socket and working files when a signal ends the tool (closes #17)
check / check (push) Successful in 5s
`cli.Main` ran the command tree on a background context, so SIGINT, SIGTERM or SIGHUP killed the process before deferred cleanup ran: `ssh to` left its agent socket and directory behind, and `ssh install` left a copy of the host's `authorized_keys` in its working directory. `Main` now runs the tree on a `signal.NotifyContext` for those signals; the cancelled context ends the child `ssh` or `sftp` and the cleanup runs. `ssh to` stops its child with SIGTERM, not a kill, so `ssh` restores the terminal. Exit status after a signal is 1 unless `ssh` reported its own.

The test re-runs the test binary as the tool, waits for the agent socket, sends each signal and checks the directory is gone.

Disclosure: the repeated `"uptime"` test literal became a `remoteCommand` constant because `goconst` required it.

Model: opus-4-8 (implementation, review); fable-5-1 (merge message)
2026-09-21 16:58:24 +02:00
clawbot 15ebe24f7b README: policy sections and age and child mnemonic vectors (closes #21)
check / check (push) Successful in 6s
The README gains the sections REPO_POLICIES.md requires: a first sentence naming the category and author, Getting Started, Entrypoints (one line per `script/` file), Rationale, Design, TODO (the open issues between the tree and 1.0), License and Author. It also publishes test vectors for age and child mnemonics, copied from the tests.

Disclosures:
- No license is named; the choice is open on the tracker and the README says so.
- The 12-word child mnemonic is the BIP-85 specification vector, the only one the test asserts, and is labelled as such.
- Markdown is hand-wrapped; `make fmt` here formats Go only.

Model: opus-4-8 (implementation, review); fable-5-1 (merge message)
2026-09-21 16:24:28 +02:00
2 changed files with 126 additions and 27 deletions
+1
View File
@@ -16,6 +16,7 @@ linters:
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
settings:
+125 -27
View File
@@ -1,16 +1,74 @@
# 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.
`keyfunc` is a Go command-line tool by [@sneak](https://sneak.berlin) — its
license is not yet chosen
([#14](https://git.eeqj.de/sneak/keyfunc/issues/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.
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](#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
@@ -158,6 +216,15 @@ 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.
@@ -190,33 +257,64 @@ 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
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:
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.
```
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.
## Building and running
## Entrypoints
```
make build # produces ./keyfunc
make check # fmt-check, lint (golangci-lint) and tests
```
The repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: most Makefile targets are thin shims over an executable in
`script/` (`build` and `clean` are the exceptions).
Examples:
- `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`.
```
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
```
## TODO
The open issues that stand between the tree and a 1.0 release:
- [#14 Choose a license and add LICENSE](https://git.eeqj.de/sneak/keyfunc/issues/14)
- [#15 Decide the Go module path before 1.0](https://git.eeqj.de/sneak/keyfunc/issues/15)
## License
Not yet chosen. The license is the owner's decision, still open on the tracker
([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)); the `LICENSE` file is added
when that issue is answered.
## Author
[@sneak](https://sneak.berlin).