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
5 changed files with 164 additions and 17 deletions
+1
View File
@@ -16,6 +16,7 @@ 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:
+12 -12
View File
@@ -1,10 +1,11 @@
# keyfunc # keyfunc
`keyfunc` is an MIT-licensed Go command-line tool by `keyfunc` is a Go command-line tool by [@sneak](https://sneak.berlin) — its
[@sneak](https://sneak.berlin) that turns a BIP-39 mnemonic into SSH keys, age license is not yet chosen
identities and child mnemonics, each of which can be recreated from that ([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)) — that turns a BIP-39
mnemonic at any time. The same mnemonic, key type and index always give the same mnemonic into SSH keys, age identities and child mnemonics, each of which can be
key. 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.
@@ -203,7 +204,9 @@ 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` 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 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 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. socket and directory on the way out. The private key is never written to disk. A
SIGINT, SIGTERM or SIGHUP ends `ssh` and still removes the socket and directory,
and the tool then exits with status 1 unless `ssh` reported one of its own.
## age identities: `keyfunc age` ## age identities: `keyfunc age`
@@ -305,15 +308,12 @@ 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) - [#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) - [#15 Decide the Go module path before 1.0](https://git.eeqj.de/sneak/keyfunc/issues/15)
- [#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 ## License
MIT. The license is not yet settled on the tracker Not yet chosen. The license is the owner's decision, still open on the tracker
([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)); MIT is the recommended ([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)); the `LICENSE` file is added
option there, so this README names it and the `LICENSE` file is added when that when that issue is answered.
issue is answered.
## Author ## Author
+15 -1
View File
@@ -2,10 +2,13 @@
package cli package cli
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/signal"
"runtime/debug" "runtime/debug"
"syscall"
"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"
@@ -66,8 +69,19 @@ func Root() *cobra.Command {
// status of its own, which "ssh to" uses to hand on the status ssh // status of its own, which "ssh to" uses to hand on the status ssh
// ended with. ssh has already said whatever it had to say in that // ended with. ssh has already said whatever it had to say in that
// case, so nothing more is printed. // case, so nothing more is printed.
//
// SIGINT, SIGTERM and SIGHUP cancel the command's context instead of
// killing the process outright, so the child ssh or sftp ends and the
// deferred cleanup that removes the agent socket and the install
// working directory still runs.
func Main() int { func Main() int {
err := Root().Execute() ctx, stop := signal.NotifyContext(
context.Background(),
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
)
defer stop()
err := Root().ExecuteContext(ctx)
if err == nil { if err == nil {
return 0 return 0
} }
+8
View File
@@ -7,6 +7,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"slices" "slices"
"syscall"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -75,6 +76,13 @@ func connect(ctx context.Context, argv []string) error {
command.Stdout = os.Stdout command.Stdout = os.Stdout
command.Stderr = os.Stderr command.Stderr = os.Stderr
// A cancelled context means a signal ended the tool. Send ssh a
// SIGTERM rather than the default kill, so it puts the terminal
// back the way it found it before it goes.
command.Cancel = func() error {
return command.Process.Signal(syscall.SIGTERM)
}
err := command.Run() err := command.Run()
if err == nil { if err == nil {
return nil return nil
+128 -4
View File
@@ -3,11 +3,14 @@ package cli_test
import ( import (
"bytes" "bytes"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"syscall"
"testing" "testing"
"time"
"git.eeqj.de/sneak/keyfunc/internal/cli" "git.eeqj.de/sneak/keyfunc/internal/cli"
"git.eeqj.de/sneak/keyfunc/internal/cli/ssh" "git.eeqj.de/sneak/keyfunc/internal/cli/ssh"
@@ -15,6 +18,23 @@ 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 (
@@ -47,6 +67,9 @@ const (
keptIn = "authorized_keys" keptIn = "authorized_keys"
) )
// remoteCommand is the command the "to" tests hand ssh after the host.
const remoteCommand = "uptime"
// The tool's own name, as it stands in the arguments a test hands to // The tool's own name, as it stands in the arguments a test hands to
// Main, the ssh subcommand both commands the tests here drive live // Main, the ssh subcommand both commands the tests here drive live
// under, and the one of those two these tests name most. // under, and the one of those two these tests name most.
@@ -166,6 +189,18 @@ fi
exit "$KEYFUNC_TEST_STATUS" exit "$KEYFUNC_TEST_STATUS"
` `
// sleeper is a stand-in for the system ssh that notes the agent socket
// and then blocks, so a test can cancel the context while it is running
// and watch the tool take the agent down. The wait ends on its own only
// as a backstop, well after the test has cancelled and looked.
const sleeper = `
socket=${2#IdentityAgent=}
if [ -S "$socket" ]; then
printf '%s\n' "$socket" > "$KEYFUNC_TEST_SOCKET"
fi
sleep 5
`
// pretended is where a stand-in writes down what it was asked to do. // pretended is where a stand-in writes down what it was asked to do.
type pretended struct { type pretended struct {
// home stands in for the home directory on the host. // home stands in for the home directory on the host.
@@ -421,7 +456,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
arguments, noted := pretendCall(t) arguments, noted := pretendCall(t)
_, err := execute(t, subcommand, "to", host, "uptime") _, err := execute(t, subcommand, "to", host, remoteCommand)
var passed ssh.StatusError var passed ssh.StatusError
@@ -430,7 +465,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
given := recorded(t, arguments) given := recorded(t, arguments)
require.Equal(t, "-o", given[0]) require.Equal(t, "-o", given[0])
require.Equal(t, []string{host, "uptime"}, given[2:]) require.Equal(t, []string{host, remoteCommand}, given[2:])
// The stand-in wrote the path down only because there really was // The stand-in wrote the path down only because there really was
// a socket there while it ran. // a socket there while it ran.
@@ -448,11 +483,100 @@ func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) {
t.Cleanup(func() { os.Args = given }) t.Cleanup(func() { os.Args = given })
os.Args = []string{tool, subcommand, "to", host, "uptime"} os.Args = []string{tool, subcommand, "to", host, remoteCommand}
require.Equal(t, failingStatus, cli.Main()) require.Equal(t, failingStatus, cli.Main())
} }
func TestASignalTakesTheAgentDirectoryDown(t *testing.T) {
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")
t.Setenv("KEYFUNC_TEST_SOCKET", noted)
standIn(t, "ssh", sleeper)
//nolint:gosec // the binary is this test's own, re-run as the tool
command := exec.CommandContext(
t.Context(), os.Args[0], subcommand, "to", host, remoteCommand,
)
command.Env = append(os.Environ(), runAsTool+"=1")
require.NoError(t, command.Start())
// The stand-in notes the socket only once the agent is up and ssh
// 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)
go func() { done <- command.Wait() }()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatalf("the tool did not end after %s", name)
}
}
// waitForSocket waits for the stand-in to write down the agent socket
// and gives back the path, which means the agent is up and ssh is
// running against it.
func waitForSocket(t *testing.T, noted string) string {
t.Helper()
var socket string
require.Eventually(t, func() bool {
content, err := os.ReadFile(noted) //nolint:gosec // test path
if err != nil {
return false
}
socket = strings.TrimSpace(string(content))
return socket != ""
}, 5*time.Second, 5*time.Millisecond)
return socket
}
func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) { func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, "echo "+example()) t.Setenv(mnemonic.CommandVariable, "echo "+example())
t.Setenv(mnemonic.Variable, example()) t.Setenv(mnemonic.Variable, example())
@@ -474,7 +598,7 @@ func TestTheMnemonicIsNotHandedToSSH(t *testing.T) {
pretendCall(t) pretendCall(t)
environment := recordEnvironment(t) environment := recordEnvironment(t)
_, err := execute(t, subcommand, "to", host, "uptime") _, err := execute(t, subcommand, "to", host, remoteCommand)
var passed ssh.StatusError var passed ssh.StatusError