1 Commits
Author SHA1 Message Date
sneak bd00e4bc11 Clean up the agent socket and working files when a signal ends the tool (closes #17)
check / check (push) Failing after 1s
Main ran the command tree on a background context, so SIGINT, SIGTERM or
SIGHUP killed the process before the deferred cleanup ran: the agent's
temporary directory and socket, and the install working directory, were
left behind.

Main now runs the tree on a signal.NotifyContext for those three signals.
A signal cancels the context, which ends the child ssh or sftp started
with exec.CommandContext, and the deferred cleanup then runs. The exit
status after a signal stays 1 unless ssh reported one of its own.

For "ssh to" the child is cancelled with SIGTERM rather than the default
kill, so ssh restores the terminal before it goes.

Model: opus-4-8
2026-09-21 13:30:25 +00:00
3 changed files with 46 additions and 195 deletions
-1
View File
@@ -16,7 +16,6 @@ linters:
- depguard # Dependency allow/block lists - depguard # Dependency allow/block lists
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
settings: settings:
+27 -125
View File
@@ -1,74 +1,16 @@
# keyfunc # keyfunc
`keyfunc` is a Go command-line tool by [@sneak](https://sneak.berlin) — its `keyfunc` turns a BIP-39 mnemonic into key pairs that can be recreated from
license is not yet chosen that mnemonic at any time. The same mnemonic, key type and index always give the
([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)) — that turns a BIP-39 same key.
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 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 keys, Commands are grouped by what is derived: `keyfunc ssh ...` for ed25519 SSH
`keyfunc age ...` for age identities and for encrypting and decrypting with keys, `keyfunc age ...` for age identities and for encrypting and decrypting
them, and `keyfunc mnemonic ...` for child mnemonics derived from the main one. 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 ## Derivation
@@ -216,15 +158,6 @@ 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.
@@ -257,64 +190,33 @@ 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.
Test vector: the child-mnemonic step is checked against BIP-85's own published ## Adding a key type
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
girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose bytes into that type's key, plus one cobra subcommand under `internal/cli/` that
``` 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.
## Entrypoints ## Building and running
The repo adheres to the ```
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) make build # produces ./keyfunc
standard: most Makefile targets are thin shims over an executable in make check # fmt-check, lint (golangci-lint) and tests
`script/` (`build` and `clean` are the exceptions). ```
- `script/bootstrap` installs everything needed to build and develop (git, make, Examples:
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'
The open issues that stand between the tree and a 1.0 release: keyfunc ssh priv -n 3 > ~/.ssh/id_bip85_3
keyfunc ssh install -n 3 user@example.com
- [#14 Choose a license and add LICENSE](https://git.eeqj.de/sneak/keyfunc/issues/14) keyfunc ssh to -n 3 user@example.com uptime
- [#15 Decide the Go module path before 1.0](https://git.eeqj.de/sneak/keyfunc/issues/15) keyfunc age pub -n 0
keyfunc age encrypt -n 0 --armor -o notes.age notes.txt
## License keyfunc age decrypt -n 0 notes.age
keyfunc mnemonic -n 1 --words 24
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).
+19 -69
View File
@@ -2,13 +2,12 @@ package cli_test
import ( import (
"bytes" "bytes"
"context"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"syscall"
"testing" "testing"
"time" "time"
@@ -18,23 +17,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// runAsTool, set in the environment of a re-executed test binary, tells
// TestMain to run the tool through Main rather than the suite, so the
// signal test can drive the real signal path in a process it can send a
// signal to.
const runAsTool = "KEYFUNC_TEST_RUN_AS_TOOL"
// TestMain re-executes the test binary as the tool when runAsTool is
// set, and otherwise runs the suite. The signal test starts the tool
// this way, as a subprocess it can signal and watch clean up.
func TestMain(m *testing.M) {
if os.Getenv(runAsTool) == "1" {
os.Exit(cli.Main())
}
os.Exit(m.Run())
}
// The modes the host is supposed to end up with, and the mode the // The modes the host is supposed to end up with, and the mode the
// stand-ins need so that they can be run at all. // stand-ins need so that they can be run at all.
const ( const (
@@ -491,68 +473,36 @@ func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) {
func TestASignalTakesTheAgentDirectoryDown(t *testing.T) { func TestASignalTakesTheAgentDirectoryDown(t *testing.T) {
t.Setenv(mnemonic.Variable, example()) t.Setenv(mnemonic.Variable, example())
// The three signals the tool handles, checked one after another.
signals := []struct {
name string
signal os.Signal
}{
{"SIGTERM", syscall.SIGTERM},
{"SIGINT", syscall.SIGINT},
{"SIGHUP", syscall.SIGHUP},
}
for _, ending := range signals {
signalEndsTheTool(t, ending.name, ending.signal)
}
}
// signalEndsTheTool runs the tool as a subprocess against a stand-in
// ssh that blocks, waits until the agent is up and ssh is running
// against it, sends the tool the signal, and requires the agent socket
// and its directory to be gone once the tool has ended. The subprocess
// goes through Main and its signal handling, so with that handling
// removed the signal kills the tool outright, no deferred cleanup runs,
// the directory is left behind, and the check fails.
func signalEndsTheTool(t *testing.T, name string, signal os.Signal) {
t.Helper()
noted := filepath.Join(t.TempDir(), "socket") noted := filepath.Join(t.TempDir(), "socket")
t.Setenv("KEYFUNC_TEST_SOCKET", noted) t.Setenv("KEYFUNC_TEST_SOCKET", noted)
standIn(t, "ssh", sleeper) standIn(t, "ssh", sleeper)
//nolint:gosec // the binary is this test's own, re-run as the tool ctx, cancel := context.WithCancel(context.Background())
command := exec.CommandContext( t.Cleanup(cancel)
t.Context(), os.Args[0], subcommand, "to", host, remoteCommand,
)
command.Env = append(os.Environ(), runAsTool+"=1") root := cli.Root()
require.NoError(t, command.Start()) root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
// The stand-in notes the socket only once the agent is up and ssh root.SetArgs([]string{subcommand, "to", host, remoteCommand})
// is running against it, so this is where the signal lands.
socket := waitForSocket(t, noted)
require.NoError(t, command.Process.Signal(signal))
waitForTool(t, name, command)
// The signal ended the tool, and its deferred cleanup still ran:
// the agent socket and its directory are gone.
require.NoDirExists(t, filepath.Dir(socket), name)
}
// waitForTool waits for the subprocess to end, and fails the test if it
// does not end in time.
func waitForTool(t *testing.T, name string, command *exec.Cmd) {
t.Helper()
done := make(chan error, 1) done := make(chan error, 1)
go func() { done <- command.Wait() }() go func() { done <- root.ExecuteContext(ctx) }()
// The stand-in notes the socket only once it is up and ssh is
// running against it, so this is where a signal would land.
socket := waitForSocket(t, noted)
cancel()
select { select {
case <-done: case <-done:
case <-time.After(10 * time.Second): case <-time.After(10 * time.Second):
t.Fatalf("the tool did not end after %s", name) t.Fatal("the tool did not end after the context was cancelled")
} }
// The deferred cleanup ran even though a cancellation, not a clean
// exit, ended ssh: the agent socket and its directory are gone.
require.NoDirExists(t, filepath.Dir(socket))
} }
// waitForSocket waits for the stand-in to write down the agent socket // waitForSocket waits for the stand-in to write down the agent socket