Author SHA1 Message Date
sneak 0d412f6e98 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 08:04:17 +00:00
clawbot 3d90ac87f1 ssh install tells a missing .ssh from one it cannot enter (closes #10)
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)
2026-09-21 09:49:59 +02: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
3 changed files with 194 additions and 31 deletions
+130 -28
View File
@@ -1,16 +1,73 @@
# keyfunc # keyfunc
`keyfunc` turns a BIP-39 mnemonic into key pairs that can be recreated from `keyfunc` is an MIT-licensed Go command-line tool by
that mnemonic at any time. The same mnemonic, key type and index always give the [@sneak](https://sneak.berlin) that turns a BIP-39 mnemonic into SSH keys, age
same key. 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 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. takes the same steps as that repository's `agehd` package.
Commands are grouped by what is derived: `keyfunc ssh ...` for ed25519 SSH Commands are grouped by what is derived: `keyfunc ssh ...` for ed25519 SSH keys,
keys, `keyfunc age ...` for age identities and for encrypting and decrypting `keyfunc age ...` for age identities and for encrypting and decrypting with
with them, and `keyfunc mnemonic ...` for child mnemonics derived from the them, and `keyfunc mnemonic ...` for child mnemonics derived from the main one.
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 ## 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. refused with a message saying so.
Every command takes `--index` / `-n` and `--mnemonic-command`, and has `--help`. 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` ## SSH keys: `keyfunc ssh`
@@ -150,6 +208,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 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. 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` ### `keyfunc age pub`
Prints the recipient, the `age1...` public key, on one line. Prints the recipient, the `age1...` public key, on one line.
@@ -182,33 +249,68 @@ 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 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. 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 girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose
groups its commands. ```
## Errors ## Errors
Errors go to standard error and the exit status is 1, except for `ssh to`, Errors go to standard error and the exit status is 1, except for `ssh to`,
which passes through `ssh`'s own exit status. which passes through `ssh`'s own exit status.
## Building and running ## Entrypoints
``` The repo adheres to the
make build # produces ./keyfunc [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
make check # fmt-check, lint (golangci-lint) and tests 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`.
``` ## TODO
keyfunc ssh pub -n 3 --mnemonic-command 'secret get foo'
keyfunc ssh priv -n 3 > ~/.ssh/id_bip85_3 The open issues that stand between the tree and a 1.0 release:
keyfunc ssh install -n 3 user@example.com
keyfunc ssh to -n 3 user@example.com uptime - [#14 Choose a license and add LICENSE](https://git.eeqj.de/sneak/keyfunc/issues/14)
keyfunc age pub -n 0 - [#15 Decide the Go module path before 1.0](https://git.eeqj.de/sneak/keyfunc/issues/15)
keyfunc age encrypt -n 0 --armor -o notes.age notes.txt - [#16 Do not pass the mnemonic environment variables on to ssh and sftp](https://git.eeqj.de/sneak/keyfunc/issues/16)
keyfunc age decrypt -n 0 notes.age - [#17 Clean up the agent socket and working files when a signal ends the tool](https://git.eeqj.de/sneak/keyfunc/issues/17)
keyfunc mnemonic -n 1 --words 24 - [#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" "errors"
"fmt" "fmt"
"os" "os"
"runtime/debug"
"git.eeqj.de/sneak/keyfunc/internal/cli/age" "git.eeqj.de/sneak/keyfunc/internal/cli/age"
"git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic" "git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic"
@@ -13,20 +14,43 @@ import (
"github.com/spf13/cobra" "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 //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. // Root returns the whole command tree.
func Root() *cobra.Command { func Root() *cobra.Command {
info, _ := debug.ReadBuildInfo()
root := &cobra.Command{ root := &cobra.Command{
Use: "keyfunc", Use: "keyfunc",
Short: "derive key pairs from a BIP-39 mnemonic", Short: "derive key pairs from a BIP-39 mnemonic",
Long: "keyfunc turns a BIP-39 mnemonic into key pairs that can " + Long: "keyfunc turns a BIP-39 mnemonic into key pairs that can " +
"be recreated from that mnemonic at any time. The same " + "be recreated from that mnemonic at any time. The same " +
"mnemonic, key type and index always give the same key.", "mnemonic, key type and index always give the same key.",
Version: Version, Version: resolveVersion(Version, info),
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: 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))
})
}