Author SHA1 Message Date
sneak 1f0dcb8a03 README: policy sections and age and child mnemonic vectors (closes #21)
check / check (push) Failing after 1s
Add the sections REPO_POLICIES.md requires that the README lacked: a
first line naming category, license and author; Getting Started;
Entrypoints (one line per script/ file); Rationale; Design (the
internal/ packages, with the existing "Adding a key type" note moved
under it); TODO (the open issues between the tree and 1.0, as links);
License; and Author. Add the age recipients and identity vectors for
the eleven-abandon example mnemonic, copied from the agekey test, and
the BIP-85 specification's own 12-word child mnemonic vector, copied
from the childmnemonic test.

Documentation only; no code changes.

Model: opus-4-8
2026-09-21 07:40:22 +00:00
clawbot e6ddf49acc Report the module version for a go install build (closes #18)
check / check (push) Successful in 1m41s
keyfunc --version printed dev for any binary not built with make build. When no version was stamped at build time, the tool now reports the module version recorded in the binary's build info, which go install fills in. A stamped version still wins, and a local build with neither still prints dev.

Model: opus-4-8 (implementation); fable-5-1 (summary)
2026-09-21 09:39:38 +02:00
clawbot 860e590114 Update dependencies to current releases (closes #19)
check / check (push) Failing after 1s
Every direct dependency moves to its current release, golang.org/x/crypto first: keyfunc ssh to serves keys through its ssh/agent package, which has had security fixes since the pinned 2025-05 version. go.mod and go.sum only; no code changed and the SSH, age and child mnemonic test vectors pass unedited, so no derived key moves.

Model: opus-4-8 (implementation); fable-5-1 (summary)
2026-09-21 09:34:26 +02:00
3 changed files with 195 additions and 31 deletions
+131 -28
View File
@@ -1,16 +1,73 @@
# 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 an MIT-licensed Go command-line tool by
[@sneak](https://sneak.berlin) 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
@@ -55,7 +112,8 @@ 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.
`keyfunc --version` prints the version. `make build` stamps it; a binary
installed with `go install` reports the module version instead.
## SSH keys: `keyfunc ssh`
@@ -148,6 +206,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.
@@ -180,33 +247,69 @@ 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: each Makefile target is a thin shim over an executable in `script/`.
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:
- [#10 ssh install needs rework](https://git.eeqj.de/sneak/keyfunc/issues/10)
- [#13 Review toward 1.0: identify and file all next steps](https://git.eeqj.de/sneak/keyfunc/issues/13)
- [#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)
- [#16 Do not pass the mnemonic environment variables on to ssh and sftp](https://git.eeqj.de/sneak/keyfunc/issues/16)
- [#17 Clean up the agent socket and working files when a signal ends the tool](https://git.eeqj.de/sneak/keyfunc/issues/17)
- [#22 1.0 release readiness](https://git.eeqj.de/sneak/keyfunc/issues/22)
## License
MIT. The license is not yet settled on the tracker
([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)); MIT is the recommended
option there, so this README names it and the `LICENSE` file is added when that
issue is answered.
## Author
[@sneak](https://sneak.berlin).
+27 -3
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"runtime/debug"
"git.eeqj.de/sneak/keyfunc/internal/cli/age"
"git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic"
@@ -13,20 +14,43 @@ import (
"github.com/spf13/cobra"
)
// Version is what --version prints. The build sets it.
// devVersion is what Version holds until a build stamps a real one.
const devVersion = "dev"
// Version is what --version prints. make build stamps it with -ldflags.
//
//nolint:gochecknoglobals // set at build time with -ldflags
var Version = "dev"
var Version = devVersion
// resolveVersion chooses what --version reports. A value stamped at
// build time wins. Otherwise, for a binary from go install, the module
// version recorded in the build info is used, unless that is empty or
// the "(devel)" of a local build. When neither names a version, the
// "dev" fallback stays.
func resolveVersion(stamped string, info *debug.BuildInfo) string {
if stamped != devVersion {
return stamped
}
if info != nil && info.Main.Version != "" &&
info.Main.Version != "(devel)" {
return info.Main.Version
}
return devVersion
}
// Root returns the whole command tree.
func Root() *cobra.Command {
info, _ := debug.ReadBuildInfo()
root := &cobra.Command{
Use: "keyfunc",
Short: "derive key pairs from a BIP-39 mnemonic",
Long: "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.",
Version: Version,
Version: resolveVersion(Version, info),
SilenceUsage: true,
SilenceErrors: true,
}
+37
View File
@@ -0,0 +1,37 @@
package cli
import (
"runtime/debug"
"testing"
"github.com/stretchr/testify/require"
)
func TestResolveVersion(t *testing.T) {
t.Parallel()
release := &debug.BuildInfo{Main: debug.Module{Version: "v1.2.3"}}
local := &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}
empty := &debug.BuildInfo{}
t.Run("stamped value wins over build info", func(t *testing.T) {
t.Parallel()
require.Equal(t, "v0.1.0", resolveVersion("v0.1.0", release))
})
t.Run("go install reports the module version", func(t *testing.T) {
t.Parallel()
require.Equal(t, "v1.2.3", resolveVersion(devVersion, release))
})
t.Run("a local build stays dev", func(t *testing.T) {
t.Parallel()
require.Equal(t, devVersion, resolveVersion(devVersion, local))
})
t.Run("no version anywhere stays dev", func(t *testing.T) {
t.Parallel()
require.Equal(t, devVersion, resolveVersion(devVersion, empty))
require.Equal(t, devVersion, resolveVersion(devVersion, nil))
})
}