From 8aeed7b901fe0795b3285f6a674d6ee8802321ff Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 8 Sep 2026 08:20:17 +0200 Subject: [PATCH 01/10] ssh install works over sftp and runs nothing on the host (closes #10) ssh install no longer runs a command on the host. It reads .ssh/authorized_keys over sftp, takes the empty reading only from sftp's own message about that path, appends the derived key locally when it is not already present, uploads the result beside the file with mode 0600 and renames it over the original. Any other failure prints what sftp said, writes nothing and exits 1. sftp batch mode disables password prompts, so a key or agent is required; a directory the owner cannot enter reads as a host with no file, which README.md states. Model: opus-5 (implementation); fable-5-1 (landing) --- README.md | 48 +++- internal/cli/ssh/install.go | 286 +++++++++++++++++++----- internal/cli/ssh/install_test.go | 78 +++++++ internal/cli/ssh_test.go | 367 ++++++++++++++++++++++++++----- 4 files changed, 659 insertions(+), 120 deletions(-) create mode 100644 internal/cli/ssh/install_test.go diff --git a/README.md b/README.md index a5b03d6..7dcb19f 100644 --- a/README.md +++ b/README.md @@ -88,17 +88,49 @@ Prints the unencrypted private key in OpenSSH format (the and nothing else, so it can be redirected into a file. The key's comment is the same as for `pub`. -### `keyfunc ssh install <[user@]host> [-- ssh options...]` +### `keyfunc ssh install <[user@]host> [-- sftp options...]` -Runs the system `ssh` to the host and, on the host: +Adds the `pub` line to `~/.ssh/authorized_keys` on the host. No command is run +on the host: the file is fetched, changed here, and written back with the +system `sftp` client in batch mode. -- creates `~/.ssh` with mode `0700` if it is missing; -- creates `~/.ssh/authorized_keys` with mode `0600` if it is missing; -- appends the `pub` line only if an identical line is not already there. +The first connection fetches `~/.ssh/authorized_keys`. The file reads as empty +only when `sftp` reported that file as not being there — the one line naming +that path. The same wording anywhere else in the session does not count: `ssh` +writes `No such file or directory` about an `-i` it cannot find, on a session +that then authenticates through the agent. When `sftp` failed for any other +reason — the file is there and cannot be read, the connection did not come up — +the tool prints what `sftp` said and exits with status 1 without writing +anything, rather than put a file back holding the new key alone. What `sftp` +cannot tell apart is a missing file and one in a directory it cannot enter, so a +`~/.ssh` whose mode shuts the user out reads as a host with no file; the second +connection sets that mode to `0700` and writes, as on a host that has none. If +an identical line is already in the file, the tool prints +`already present` and connects no further. Otherwise the line is added (after a +newline, if the file did not end with one) and a second connection: -It then prints `added` or `already present`. How this `ssh` connection -authenticates is up to the user's normal `ssh` setup (existing keys, agent, -password). Anything after `--` is passed to `ssh` unchanged. +- creates `~/.ssh` and sets it to mode `0700`; +- uploads the new file as `~/.ssh/authorized_keys.keyfunc-` and sets it + to mode `0600`; +- renames that file over `~/.ssh/authorized_keys`. + +The tool then prints `added`. So a run that adds a line connects twice. The +rename is the step that either happens or does not: the file on the host is +never half-written. `sftp` does it in one step against servers that offer +OpenSSH's POSIX rename extension, as OpenSSH's own server does; a server +without it may refuse to rename onto a file that is already there. + +If a step fails, the tool prints what `sftp` said, removes nothing, and exits +with status 1. It names the uploaded file only when the step that failed was +the upload or one after it, which is where a file of that name can be on the +host; a failure before the upload names none. Everything `sftp` +writes goes to standard error, so the tool's own standard output is only +`added` or `already present`. + +Anything after `--` is passed to `sftp` unchanged, which is where the port goes +(`-P 2222`, not `-p`). How the connection authenticates is up to the user's +normal `ssh` setup, except that batch mode does not prompt: a key or an agent +has to do it, not a typed password. ### `keyfunc ssh to [ssh arguments...]` diff --git a/internal/cli/ssh/install.go b/internal/cli/ssh/install.go index 8133261..5886a69 100644 --- a/internal/cli/ssh/install.go +++ b/internal/cli/ssh/install.go @@ -1,57 +1,48 @@ package ssh import ( + "bytes" + "crypto/rand" + "encoding/hex" "fmt" + "os" "os/exec" + "path/filepath" "slices" "strings" "github.com/spf13/cobra" ) -// script is what runs on the host. It reads the key line from its own -// standard input, so the line never appears on a command line, where -// anyone else on the host could read it out of the process list. It -// contains no single quote, so the whole of it travels through ssh -// inside one pair of them. The umask keeps anything it makes to the -// owner from the start; the modes are then set outright, whatever the -// umask on the host turns out to be. A file whose last line has no -// newline at its end gets one before the key line goes on, so that the -// two do not run into each other. -const script = ` -set -e -umask 077 -directory="$HOME/.ssh" -file="$directory/authorized_keys" -if [ ! -d "$directory" ]; then - mkdir -p "$directory" - chmod 700 "$directory" -fi -if [ ! -f "$file" ]; then - : > "$file" - chmod 600 "$file" -fi -IFS= read -r line -if grep -q -x -F -e "$line" "$file"; then - echo "already present" -else - if [ -s "$file" ] && [ -n "$(tail -c 1 "$file")" ]; then - printf "\n" >> "$file" - fi - printf "%s\n" "$line" >> "$file" - echo "added" -fi -` +// Where the key goes on the host and what the file it arrives in is +// called before it is renamed into place. The random end of that name +// keeps two runs at once from writing to the same file. +const ( + directory = ".ssh" + authorized = ".ssh/authorized_keys" + sidecarPrefix = ".ssh/authorized_keys.keyfunc-" + sidecarBytes = 8 +) + +// The modes the host is left with, as sftp's chmod spells them, and +// the mode of the copy made here on the way. +const ( + directoryMode = "700" + fileMode = "600" + localMode = 0o600 +) // install returns the command that adds the public key to a host. func install() *cobra.Command { cmd := &cobra.Command{ - Use: "install <[user@]host> [-- ssh options...]", + Use: "install <[user@]host> [-- sftp options...]", Short: "add the public key to a host's authorized_keys", - Long: "Runs the system ssh to the host, which makes ~/.ssh and " + - "~/.ssh/authorized_keys there if they are missing and adds " + - "the public key unless the same line is already in the " + - "file. Anything after -- is given to ssh unchanged.", + Long: "Downloads the host's authorized_keys with the system " + + "sftp, adds the public key to it here unless the same " + + "line is already there, and uploads the result as a file " + + "beside it which is then renamed over it. Nothing is run " + + "on the host. Anything after -- is given to sftp " + + "unchanged, which is where the port goes (-P).", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { key, comment, err := derived(cmd) @@ -64,7 +55,7 @@ func install() *cobra.Command { return err } - return send(cmd, args[0], args[1:], line) + return add(cmd, args[0], args[1:], line) }, } @@ -73,24 +64,207 @@ func install() *cobra.Command { return cmd } -// send runs ssh to the host with the user's options, gives it the -// script to run there, and writes the key line to its standard input. -// What the host says, added or already present, is passed straight on. -func send(cmd *cobra.Command, host string, options []string, line string) error { - argv := slices.Concat(options, []string{ - host, "/bin/sh -c '" + script + "'", - }) - - //nolint:gosec // the options are the user's own, meant for ssh - command := exec.CommandContext(cmd.Context(), "ssh", argv...) - command.Stdin = strings.NewReader(line + "\n") - command.Stdout = cmd.OutOrStdout() - command.Stderr = cmd.ErrOrStderr() - - err := command.Run() +// add puts the key line in the host's authorized_keys. The file is +// fetched in one sftp session and written back in another, so a run +// that adds a line connects twice; a run that finds the line already +// there connects once and stops. +func add(cmd *cobra.Command, host string, options []string, line string) error { + work, err := os.MkdirTemp("", "keyfunc-install-") if err != nil { - return fmt.Errorf("running ssh: %w", err) + return fmt.Errorf("making a temporary directory: %w", err) } - return nil + defer func() { _ = os.RemoveAll(work) }() + + content, err := fetch(cmd, host, options, + filepath.Join(work, "authorized_keys"), + ) + if err != nil { + return err + } + + merged, added := merge(content, line) + if !added { + return write(cmd, "already present\n") + } + + return upload(cmd, host, options, work, merged) +} + +// upload writes the new file to the host and renames it over +// authorized_keys, which is the step that either happens or does not. +// Nothing is removed when a step fails: the file left behind is named +// so that it can be looked at and cleared away by hand. +func upload( + cmd *cobra.Command, host string, options []string, + work, merged string, +) error { + local := filepath.Join(work, "authorized_keys.merged") + + err := os.WriteFile(local, []byte(merged), localMode) + if err != nil { + return fmt.Errorf("writing the new file: %w", err) + } + + sidecar, err := sidecarName() + if err != nil { + return err + } + + // The mkdir may fail: the directory is usually there already. + said, err := session(cmd, host, options, []string{ + "-mkdir " + directory, + "chmod " + directoryMode + " " + directory, + "put " + quoted(local) + " " + sidecar, + "chmod " + fileMode + " " + sidecar, + "rename " + sidecar + " " + authorized, + }) + if err != nil { + // sftp echoes each command as it runs it and stops at the + // first that fails, so the name is in what it said only once + // the put was reached, which is where a file of that name + // can be on the host. Before that there is none to name. + if strings.Contains(said, sidecar) { + return fmt.Errorf( + "%w; %s may be left on the host", err, sidecar, + ) + } + + return err + } + + return write(cmd, "added\n") +} + +// session runs one sftp session with the user's own options and the +// batch of commands, which sftp reads from its standard input and +// stops at the first of which that fails, unless it begins with a +// dash. sftp echoes the commands as it runs them, so everything it +// says goes to the error output and the tool's own output stays the +// one word it prints. What it said is also given back: a session that +// failed says there what went wrong, and the status alone does not. +func session( + cmd *cobra.Command, host string, options []string, batch []string, +) (string, error) { + argv := slices.Concat( + []string{"-b", "-"}, options, []string{host}, + ) + + var said bytes.Buffer + + //nolint:gosec // the options are the user's own, meant for sftp + command := exec.CommandContext(cmd.Context(), "sftp", argv...) + command.Stdin = strings.NewReader(strings.Join(batch, "\n") + "\n") + command.Stdout = &said + command.Stderr = &said + + err := command.Run() + + _, _ = cmd.ErrOrStderr().Write(said.Bytes()) + + if err != nil { + return said.String(), fmt.Errorf("running sftp: %w", err) + } + + return said.String(), nil +} + +// merge returns the file with the key line on the end, and whether it +// had to be added. A file whose last line has no newline at its end +// gets one first, so that the two lines do not run into each other. +func merge(content, line string) (string, bool) { + if slices.Contains(strings.Split(content, "\n"), line) { + return content, false + } + + if content != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + + return content + line + "\n", true +} + +// fetch brings the host's authorized_keys into the given path and +// returns what is in it. A host that has no such file reads as empty, +// but only when that is what sftp said about it: a file that is there +// and cannot be read fails the run, because writing back over it +// would leave the host with the new key and nothing else. +func fetch( + cmd *cobra.Command, host string, options []string, into string, +) (string, error) { + said, err := session(cmd, host, options, []string{ + "get " + authorized + " " + quoted(into), + }) + if err != nil { + if absent(said) { + return "", nil + } + + return "", err + } + + //nolint:gosec // the path is a temporary file of the tool's own + content, err := os.ReadFile(into) + if err != nil { + return "", fmt.Errorf("reading the fetched file: %w", err) + } + + return string(content), nil +} + +// absent says whether sftp reported the file that was asked for as +// not being there, which is the one failure of the fetch that is read +// as an empty authorized_keys. The reading is taken only from the +// line in which sftp reports on that file, because ssh writes "no +// such file" into the same output for reasons of its own — a missing +// -i identity file draws that warning on a session that then +// authenticates through the agent — and a real read failure on such a +// session must not pass for an empty file. +func absent(said string) bool { + for line := range strings.Lines(said) { + named, is := reportedNotFound(strings.TrimSpace(line)) + if is && (named == authorized || + strings.HasSuffix(named, "/"+authorized)) { + return true + } + } + + return false +} + +// reportedNotFound returns the path an sftp line reports as not being +// there, and whether the line is such a report. The client writes one +// wording for a remote file it cannot find, naming the path the +// server expanded, which is the absolute one. +func reportedNotFound(line string) (string, bool) { + const ( + before = `File "` + after = `" not found.` + ) + + if !strings.HasPrefix(line, before) || + !strings.HasSuffix(line, after) { + return "", false + } + + return strings.TrimSuffix(strings.TrimPrefix(line, before), after), true +} + +// sidecarName returns the name the new file is uploaded under. +func sidecarName() (string, error) { + random := make([]byte, sidecarBytes) + + _, err := rand.Read(random) + if err != nil { + return "", fmt.Errorf("making a name for the new file: %w", err) + } + + return sidecarPrefix + hex.EncodeToString(random), nil +} + +// quoted puts the double quotes around a path that sftp needs when the +// path has a space in it. Only paths of the tool's own making are +// given to it, and they hold no quote of their own. +func quoted(path string) string { + return `"` + path + `"` } diff --git a/internal/cli/ssh/install_test.go b/internal/cli/ssh/install_test.go new file mode 100644 index 0000000..051014c --- /dev/null +++ b/internal/cli/ssh/install_test.go @@ -0,0 +1,78 @@ +//nolint:testpackage // absent is what these wordings are read by +package ssh + +import "testing" + +// What a session says besides its report on the file that was asked +// for: sftp echoes the command it is running, and ssh warns about an +// identity file it cannot find in the words of a missing file even +// though the session goes on to authenticate. +const ( + echoed = `sftp> get .ssh/authorized_keys "/tmp/keyfunc/authorized_keys" +` + warning = `Warning: Identity file /gone not accessible: ` + + "No such file or directory.\n" +) + +// TestAbsenceIsReadOnlyFromWhatSFTPSaidAboutAuthorizedKeys holds the +// wordings the OpenSSH client was seen to use against a real server: +// a file it cannot find is reported one way, naming the path the +// server expanded, and everything else it says is a failure. +func TestAbsenceIsReadOnlyFromWhatSFTPSaidAboutAuthorizedKeys(t *testing.T) { + t.Parallel() + + sessions := map[string]struct { + said string + want bool + }{ + "the file is not there": { + said: echoed + + `File "/home/someone/.ssh/authorized_keys" not found.` + "\n", + want: true, + }, + "the file is not there, named as it was asked for": { + said: echoed + `File ".ssh/authorized_keys" not found.` + "\n", + want: true, + }, + "the file is not there and an identity file is not either": { + said: warning + echoed + + `File "/home/someone/.ssh/authorized_keys" not found.` + "\n", + want: true, + }, + "the file is there and cannot be read": { + said: echoed + + `remote open "/home/someone/.ssh/authorized_keys": ` + + "Permission denied\n", + want: false, + }, + "only an identity file is not there": { + said: warning + echoed + + `remote open "/home/someone/.ssh/authorized_keys": ` + + "Permission denied\n", + want: false, + }, + "some other file is not there": { + said: echoed + `File "/home/someone/.ssh/known_hosts" not found.` + + "\n", + want: false, + }, + "the connection did not come up": { + said: "ssh: connect to host example.com port 22: " + + "Connection refused\nConnection closed\n", + want: false, + }, + } + + for name, session := range sessions { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if absent(session.said) != session.want { + t.Errorf( + "read as absent: %t, wanted %t, from:\n%s", + !session.want, session.want, session.said, + ) + } + }) + } +} diff --git a/internal/cli/ssh_test.go b/internal/cli/ssh_test.go index 6b787fc..9aafe21 100644 --- a/internal/cli/ssh_test.go +++ b/internal/cli/ssh_test.go @@ -1,8 +1,10 @@ package cli_test import ( + "bytes" "os" "path/filepath" + "slices" "strconv" "strings" "testing" @@ -14,7 +16,7 @@ import ( ) // The modes the host is supposed to end up with, and the mode the -// stand-in ssh needs so that it can be run at all. +// stand-ins need so that they can be run at all. const ( directoryMode = 0o700 fileMode = 0o600 @@ -22,8 +24,21 @@ const ( ) // failingStatus is the status the stand-in ssh ends with when a test -// wants to see a status handed on. -const failingStatus = 7 +// wants to see a status handed on, and failedStatus is the status the +// tool itself ends with when something went wrong. +const ( + failingStatus = 7 + failedStatus = 1 +) + +// notADirectory is what a test puts where the .ssh directory belongs +// to make a step of the write session fail. +const notADirectory = "a file where the directory belongs\n" + +// missingIdentity is a path with no file at it, handed to sftp after +// the dashes so that ssh warns about it in the words of a missing +// file. +const missingIdentity = "/nonexistent/keyfunc-test-identity" // The host, and where on it the key ends up. const ( @@ -32,20 +47,77 @@ const ( keptIn = "authorized_keys" ) -// installer is a stand-in for the system ssh for the install command. -// It writes down what it was given and then runs the command meant for -// the host right here, with the home directory pointed at a directory -// standing in for the host's, so that what keyfunc sends can be -// watched doing its work. +// 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 +// under, and the one of those two these tests name most. +const ( + tool = "keyfunc" + subcommand = "ssh" + installing = "install" +) + +// The key line the example mnemonic gives at index 0, as it stands in +// an authorized_keys file. +const keyLine = vectorZero + " keyfunc/ssh/0\n" + +// installer is a stand-in for the system sftp for the install +// command. It writes down the arguments and every command of the +// batch it is given, echoes each command as sftp does, and carries +// the commands out against a directory standing in for the host's +// home directory, so that what keyfunc sends can be watched doing its +// work. A command that begins with a dash may fail; any other failure +// ends the session, as it does in sftp's own batch mode. +// +// The two ways a get can fail are worded as the OpenSSH client words +// them, both naming the path the server expanded: a file that is not +// there, which is the one failure the tool reads as an empty file, and +// a file that is there and cannot be read, which is not. An -i naming +// a file that is not here draws the warning ssh writes for it, which +// carries the wording of a missing file into a session that goes on to +// authenticate. const installer = ` -while [ $# -gt 1 ]; do - printf '%s\n' "$1" >> "$KEYFUNC_TEST_ARGUMENTS" - shift +previous= +for argument in "$@"; do + printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS" + if [ "$previous" = -i ] && [ ! -e "$argument" ]; then + printf 'Warning: Identity file %s not accessible: %s.\n' \ + "$argument" "No such file or directory" >&2 + fi + previous=$argument +done +home="$KEYFUNC_TEST_HOME" +while IFS= read -r line; do + printf 'sftp> %s\n' "$line" + printf '%s\n' "$line" >> "$KEYFUNC_TEST_BATCH" + allowed=no + case "$line" in + -*) + line=${line#-} + allowed=yes + ;; + esac + eval "set -- $line" + worked=yes + case "$1" in + get) + if [ ! -e "$home/$2" ]; then + worked=no + printf 'File "%s" not found.\n' "$home/$2" >&2 + elif ! cp "$home/$2" "$3" 2>/dev/null; then + worked=no + printf 'remote open "%s": Permission denied\n' "$home/$2" >&2 + fi + ;; + put) cp "$2" "$home/$3" 2>/dev/null || worked=no ;; + mkdir) mkdir "$home/$2" 2>/dev/null || worked=no ;; + chmod) chmod "$2" "$home/$3" 2>/dev/null || worked=no ;; + rename) mv "$home/$2" "$home/$3" 2>/dev/null || worked=no ;; + esac + if [ "$worked" = no ] && [ "$allowed" = no ]; then + printf 'sftp: %s failed\n' "$1" >&2 + exit 1 + fi done -printf '%s' "$1" > "$KEYFUNC_TEST_COMMAND" -HOME="$KEYFUNC_TEST_HOME" -export HOME -eval "$1" ` // caller is a stand-in for the system ssh for the to command. It @@ -63,24 +135,22 @@ fi exit "$KEYFUNC_TEST_STATUS" ` -// pretended is where a stand-in ssh 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 { // home stands in for the home directory on the host. home string - // arguments holds what ssh was given before the command, one per - // line. + // arguments holds the arguments of every session, one per line. arguments string - // command holds what ssh was told to run on the host. - command string + // batch holds the commands of every session, one per line. + batch string } -func TestTheKeyIsAddedToTheHostAndThenLeftAlone(t *testing.T) { +func TestTheKeyIsAddedToAHostThatHasNoFileYet(t *testing.T) { t.Setenv(mnemonic.Variable, example()) pretend := pretendHost(t) - require.Equal(t, "added\n", run(t, "ssh", "install", host)) + require.Equal(t, "added\n", install(t, host)) directory, err := os.Stat(filepath.Join(pretend.home, keptUnder)) require.NoError(t, err) @@ -94,11 +164,30 @@ func TestTheKeyIsAddedToTheHostAndThenLeftAlone(t *testing.T) { require.NoError(t, err) require.Equal(t, os.FileMode(fileMode), file.Mode().Perm()) - added := read(t, path) - require.Equal(t, vectorZero+" keyfunc/ssh/0\n", added) + require.Equal(t, keyLine, read(t, path)) +} - require.Equal(t, "already present\n", run(t, "ssh", "install", host)) - require.Equal(t, added, read(t, path)) +func TestAKeyThatIsAlreadyThereIsLeftAlone(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + path := seed(t, pretend, "somebody else\n"+keyLine) + + require.Equal(t, "already present\n", install(t, host)) + require.Equal(t, "somebody else\n"+keyLine, read(t, path)) + + // The fetch and nothing after it: the tool did not connect again. + require.Len(t, recorded(t, pretend.batch), 1) +} + +func TestAnEmptyFileGetsTheKeyAndNoBlankLineBeforeIt(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + path := seed(t, pretend, "") + + require.Equal(t, "added\n", install(t, host)) + require.Equal(t, keyLine, read(t, path)) } func TestTheKeyDoesNotRunIntoALineWithNoNewlineAtItsEnd(t *testing.T) { @@ -106,41 +195,145 @@ func TestTheKeyDoesNotRunIntoALineWithNoNewlineAtItsEnd(t *testing.T) { pretend := pretendHost(t) already := "ssh-ed25519 AAAAsomebodyelse somebody@else" + path := seed(t, pretend, already) - require.NoError(t, - os.Mkdir(filepath.Join(pretend.home, keptUnder), directoryMode), - ) - - path := filepath.Join(pretend.home, keptUnder, keptIn) - require.NoError(t, os.WriteFile(path, []byte(already), fileMode)) - - require.Equal(t, "added\n", run(t, "ssh", "install", host)) - require.Equal(t, - already+"\n"+vectorZero+" keyfunc/ssh/0\n", - read(t, path), - ) + require.Equal(t, "added\n", install(t, host)) + require.Equal(t, already+"\n"+keyLine, read(t, path)) } -func TestTheKeyLineIsNotOnTheCommandLine(t *testing.T) { +func TestTheFileIsUploadedBesideTheOldOneAndThenRenamedOverIt(t *testing.T) { t.Setenv(mnemonic.Variable, example()) pretend := pretendHost(t) - run(t, "ssh", "install", host) + require.Equal(t, "added\n", install(t, host)) + + sent := recorded(t, pretend.batch) + require.Len(t, sent, 6) + + // The name of the uploaded file is random, so it is read off the + // put and then looked for in the two commands that follow. + beside := strings.Fields(sent[3])[2] + require.True(t, + strings.HasPrefix(beside, ".ssh/authorized_keys.keyfunc-"), + ) + + require.True(t, strings.HasPrefix(sent[0], "get .ssh/authorized_keys ")) + require.Equal(t, "-mkdir .ssh", sent[1]) + require.Equal(t, "chmod 700 .ssh", sent[2]) + require.Equal(t, "put", strings.Fields(sent[3])[0]) + require.Equal(t, "chmod 600 "+beside, sent[4]) + require.Equal(t, "rename "+beside+" .ssh/authorized_keys", sent[5]) +} + +func TestAFileThatCannotBeReadIsNotWrittenOver(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + unreadable := unfetchable(t, pretend) + + printed, said, err := attempt(t, host) + require.Error(t, err) + require.Empty(t, printed) + require.Contains(t, said, "Permission denied") + + // The fetch and nothing after it, and what was on the host is + // still what is on the host. + require.Len(t, recorded(t, pretend.batch), 1) + require.DirExists(t, unreadable) +} + +func TestAWarningAboutAnotherFileIsNotTakenForTheOneAskedFor(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + unreadable := unfetchable(t, pretend) + + // ssh warns about an -i it cannot find in the words of a missing + // file, on a session that then authenticates perfectly well. That + // warning is not sftp reporting on authorized_keys, so the fetch + // failure is still a failure. + printed, said, err := attempt(t, host, "--", "-i", missingIdentity) + require.Error(t, err) + require.Empty(t, printed) + require.Contains(t, said, "No such file or directory") + require.Contains(t, said, "Permission denied") + + require.Len(t, recorded(t, pretend.batch), 1) + require.DirExists(t, unreadable) + + // The same run again, this way for the status it ends with. + given := os.Args + + t.Cleanup(func() { os.Args = given }) + + os.Args = []string{ + tool, subcommand, installing, host, "--", "-i", missingIdentity, + } + + require.Equal(t, failedStatus, cli.Main()) +} + +func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + // A file where the .ssh directory belongs: nothing is there to + // fetch, and then the put has nowhere to put anything, so the + // write session ends at the put. + inTheWay := filepath.Join(pretend.home, keptUnder) + require.NoError(t, + os.WriteFile(inTheWay, []byte(notADirectory), fileMode), + ) + + printed, said, err := attempt(t, host) + require.Error(t, err) + require.Empty(t, printed) + require.Contains(t, said, "put failed") + + // The put is the last command the session got to, and the file it + // was uploading is the one the message names. + sent := recorded(t, pretend.batch) + require.Len(t, sent, 4) + require.Equal(t, "put", strings.Fields(sent[3])[0]) + require.Contains(t, err.Error(), strings.Fields(sent[3])[2]) + + require.Equal(t, notADirectory, read(t, inTheWay)) + + // The same run again, this way for the status it ends with. + given := os.Args + + t.Cleanup(func() { os.Args = given }) + + os.Args = []string{tool, subcommand, installing, host} + + require.Equal(t, failedStatus, cli.Main()) +} + +func TestTheKeyLineIsNotSentAsACommand(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + install(t, host) require.NotContains(t, read(t, pretend.arguments), "ssh-ed25519") - require.NotContains(t, read(t, pretend.command), "ssh-ed25519") + require.NotContains(t, read(t, pretend.batch), "ssh-ed25519") } -func TestWhatComesAfterTheDashesIsGivenToSSH(t *testing.T) { +func TestWhatComesAfterTheDashesIsGivenToSFTP(t *testing.T) { t.Setenv(mnemonic.Variable, example()) pretend := pretendHost(t) - run(t, "ssh", "install", host, "--", "-p", "2222") + install(t, host, "--", "-P", "2222") + // The same arguments twice over: adding a line takes two + // connections, one to fetch the file and one to write it back. + session := []string{"-b", "-", "-P", "2222", host} require.Equal(t, - []string{"-p", "2222", host}, + slices.Concat(session, session), recorded(t, pretend.arguments), ) } @@ -150,7 +343,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) { arguments, noted := pretendCall(t) - _, err := execute(t, "ssh", "to", host, "uptime") + _, err := execute(t, subcommand, "to", host, "uptime") var passed ssh.StatusError @@ -177,7 +370,7 @@ func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) { t.Cleanup(func() { os.Args = given }) - os.Args = []string{"keyfunc", "ssh", "to", host, "uptime"} + os.Args = []string{tool, subcommand, "to", host, "uptime"} require.Equal(t, failingStatus, cli.Main()) } @@ -190,17 +383,78 @@ func pretendHost(t *testing.T) pretended { pretend := pretended{ home: t.TempDir(), arguments: filepath.Join(t.TempDir(), "arguments"), - command: filepath.Join(t.TempDir(), "command"), + batch: filepath.Join(t.TempDir(), "batch"), } t.Setenv("KEYFUNC_TEST_HOME", pretend.home) t.Setenv("KEYFUNC_TEST_ARGUMENTS", pretend.arguments) - t.Setenv("KEYFUNC_TEST_COMMAND", pretend.command) - standIn(t, installer) + t.Setenv("KEYFUNC_TEST_BATCH", pretend.batch) + standIn(t, "sftp", installer) return pretend } +// install runs the install command, requires it to have worked, and +// gives back what the tool itself printed. +func install(t *testing.T, args ...string) string { + t.Helper() + + printed, _, err := attempt(t, args...) + require.NoError(t, err) + + return printed +} + +// attempt runs the install command with the tool's own output kept +// apart from what the stand-in said, since the stand-in echoes its +// batch as sftp does. It gives back what the tool printed, what the +// stand-in said, and how the run ended. +func attempt(t *testing.T, args ...string) (string, string, error) { + t.Helper() + + var printed, said bytes.Buffer + + root := cli.Root() + root.SetOut(&printed) + root.SetErr(&said) + root.SetArgs(slices.Concat([]string{subcommand, installing}, args)) + + err := root.ExecuteContext(t.Context()) + + return printed.String(), said.String(), err +} + +// seed puts an authorized_keys file on the stand-in host before the +// tool runs and gives back its path. +func seed(t *testing.T, pretend pretended, content string) string { + t.Helper() + + directory := filepath.Join(pretend.home, keptUnder) + require.NoError(t, os.Mkdir(directory, directoryMode)) + + path := filepath.Join(directory, keptIn) + require.NoError(t, os.WriteFile(path, []byte(content), fileMode)) + + return path +} + +// unfetchable puts a directory where authorized_keys belongs on the +// stand-in host, which the stand-in can see but cannot fetch: that is +// how a file that is there and cannot be read looks from here. It +// gives back the path. +func unfetchable(t *testing.T, pretend pretended) string { + t.Helper() + + require.NoError(t, + os.Mkdir(filepath.Join(pretend.home, keptUnder), directoryMode), + ) + + path := filepath.Join(pretend.home, keptUnder, keptIn) + require.NoError(t, os.Mkdir(path, directoryMode)) + + return path +} + // pretendCall puts the to stand-in on the path and gives back the file // the arguments are written down in and the file the agent socket is // noted in. @@ -213,20 +467,21 @@ func pretendCall(t *testing.T) (string, string) { t.Setenv("KEYFUNC_TEST_ARGUMENTS", arguments) t.Setenv("KEYFUNC_TEST_SOCKET", noted) t.Setenv("KEYFUNC_TEST_STATUS", strconv.Itoa(failingStatus)) - standIn(t, caller) + standIn(t, "ssh", caller) return arguments, noted } -// standIn writes a stand-in for the system ssh and puts it first on -// the path, so that the tool finds it instead of the real one. -func standIn(t *testing.T, body string) { +// standIn writes a stand-in for one of the system programs and puts it +// first on the path, so that the tool finds it instead of the real +// one. +func standIn(t *testing.T, name, body string) { t.Helper() directory := t.TempDir() err := os.WriteFile( - filepath.Join(directory, "ssh"), + filepath.Join(directory, name), []byte("#!/bin/sh\n"+body), standInMode, ) require.NoError(t, err) @@ -247,7 +502,7 @@ func read(t *testing.T, path string) string { return string(content) } -// recorded returns the arguments a stand-in wrote down, one per line. +// recorded returns the lines a stand-in wrote down. func recorded(t *testing.T, path string) []string { t.Helper() -- 2.54.0 From 63575ce827a736f396b43556521862a950d0c46e Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 21 Sep 2026 09:25:38 +0200 Subject: [PATCH 02/10] Move the entrypoint to cmd/keyfunc (closes #20) main.go moves unchanged to cmd/keyfunc/main.go, where REPO_POLICIES.md puts Go entrypoints, and the Makefile build target builds ./cmd/keyfunc. The binary is still written to ./keyfunc and still carries the stamped version. Model: opus-4-8 (implementation); fable-5-1 (summary) --- Makefile | 2 +- main.go => cmd/keyfunc/main.go | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename main.go => cmd/keyfunc/main.go (100%) diff --git a/Makefile b/Makefile index af82436..b977ebe 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ setup: @script/setup build: - go build -trimpath -ldflags "$(LDFLAGS)" -o keyfunc . + go build -trimpath -ldflags "$(LDFLAGS)" -o keyfunc ./cmd/keyfunc test: @script/test diff --git a/main.go b/cmd/keyfunc/main.go similarity index 100% rename from main.go rename to cmd/keyfunc/main.go -- 2.54.0 From 860e590114d79731e3dd4be5e187edee589baa1e Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 21 Sep 2026 09:34:26 +0200 Subject: [PATCH 03/10] Update dependencies to current releases (closes #19) 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) --- go.mod | 32 ++++++------ go.sum | 152 +++++++++++++-------------------------------------------- 2 files changed, 49 insertions(+), 135 deletions(-) diff --git a/go.mod b/go.mod index 4a2f2ce..092d55c 100644 --- a/go.mod +++ b/go.mod @@ -1,27 +1,27 @@ module git.eeqj.de/sneak/keyfunc -go 1.26 +go 1.26.0 require ( - filippo.io/age v1.2.1 + filippo.io/age v1.3.2 git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd - github.com/btcsuite/btcd v0.24.2 - github.com/btcsuite/btcd/btcutil v1.1.6 - github.com/spf13/cobra v1.9.1 - github.com/stretchr/testify v1.8.4 + github.com/btcsuite/btcd v0.25.0 + github.com/btcsuite/btcd/btcutil v1.2.0 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.12.1 github.com/tyler-smith/go-bip39 v1.1.0 - golang.org/x/crypto v0.38.0 - golang.org/x/term v0.32.0 + golang.org/x/crypto v0.57.0 + golang.org/x/term v0.46.0 ) require ( - github.com/btcsuite/btcd/btcec/v2 v2.1.3 // indirect - github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + filippo.io/hpke v0.4.0 // indirect + github.com/btcsuite/btcd/btcec/v2 v2.5.0 // indirect + github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/spf13/pflag v1.0.6 // indirect - golang.org/x/sys v0.33.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee // indirect + github.com/spf13/pflag v1.0.9 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/sys v0.48.0 // indirect ) diff --git a/go.sum b/go.sum index 4915728..57e7f4b 100644 --- a/go.sum +++ b/go.sum @@ -1,136 +1,50 @@ -c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= -c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= -filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o= -filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= +c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs= +c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= +filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c= +filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd h1:6YFV6horz2wDFPWWhour8qx8gLGyO0qoplwEeOuQ2J4= git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd/go.mod h1:gKCcMZvlBOqusn/BxR8IyFmSJQr6R4vvjJ926iNpOSI= -github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= -github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= -github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= -github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= -github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY= -github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg= -github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= -github.com/btcsuite/btcd/btcec/v2 v2.1.3 h1:xM/n3yIhHAhHy04z4i43C8p4ehixJZMsnrVJkgl+MTE= -github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= -github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= -github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= -github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= -github.com/btcsuite/btcd/btcutil v1.1.6 h1:zFL2+c3Lb9gEgqKNzowKUPQNb8jV7v5Oaodi/AYFd6c= -github.com/btcsuite/btcd/btcutil v1.1.6/go.mod h1:9dFymx8HpuLqBnsPELrImQeTQfKBQqzqGbbV3jK55aE= -github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= -github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= -github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= -github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= -github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= -github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= -github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= -github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= -github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= -github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= -github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= -github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= -github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/btcsuite/btcd v0.25.0 h1:JPbjwvHGpSywBRuorFFqTjaVP4y6Qw69XJ1nQ6MyWJM= +github.com/btcsuite/btcd v0.25.0/go.mod h1:qbPE+pEiR9643E1s1xu57awsRhlCIm1ZIi6FfeRA4KE= +github.com/btcsuite/btcd/btcec/v2 v2.5.0 h1:KioMXOWa76b86sTZZOmbzv/ldaQCmB8KFAyn5PbB8E8= +github.com/btcsuite/btcd/btcec/v2 v2.5.0/go.mod h1:+K/MYXcLBtHEQjRbjHuJChuybk4LCgjdjgRwil+e+Kk= +github.com/btcsuite/btcd/btcutil v1.2.0 h1:p3+S2g3Q+7G5NOh4Ji+2UrBOrg5Z0Q4ykzShWG1Dhgs= +github.com/btcsuite/btcd/btcutil v1.2.0/go.mod h1:/Taflm113pYjUpbWKKQEfa6XOtI/+WS8awxeMZpY75k= +github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0 h1:yMIg99+4aBvqfl/HzJRKfxTX9rGfikoI9uvFzterhc8= +github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0/go.mod h1:Y72Ren9gfhlEvnwnT78BGcSNO2UMphTKLn9AorF+5rg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= -github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= -github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= -github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= -github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee h1:FPP9HDkBbPyniu+u7FHZg+kKFX1WW0gxOGteJ0h3AJk= +github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee/go.mod h1:N6sz6HwJAenJ6d+/xmSl0ikfV05ZrVGmjt1ryy/WOtE= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= -golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= -golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -- 2.54.0 From e6ddf49accf54f24b83493163a4e8aa8a6227ae4 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 21 Sep 2026 09:39:38 +0200 Subject: [PATCH 04/10] Report the module version for a go install build (closes #18) 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) --- README.md | 3 ++- internal/cli/cli.go | 30 +++++++++++++++++++--- internal/cli/version_internal_test.go | 37 +++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 internal/cli/version_internal_test.go diff --git a/README.md b/README.md index 7dcb19f..6312e75 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,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` diff --git a/internal/cli/cli.go b/internal/cli/cli.go index c6d63d0..57b4da1 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -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, } diff --git a/internal/cli/version_internal_test.go b/internal/cli/version_internal_test.go new file mode 100644 index 0000000..9c01c91 --- /dev/null +++ b/internal/cli/version_internal_test.go @@ -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)) + }) +} -- 2.54.0 From 3d90ac87f1a8fcae285541f437f28d33b2195a04 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 21 Sep 2026 09:49:59 +0200 Subject: [PATCH 05/10] ssh install tells a missing .ssh from one it cannot enter (closes #10) 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) --- README.md | 32 +++---- internal/cli/ssh/install.go | 107 ++++++++++++++++++----- internal/cli/ssh/install_test.go | 55 ++++++++++++ internal/cli/ssh_test.go | 143 ++++++++++++++++++++++++++----- 4 files changed, 279 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 6312e75..aa78dc6 100644 --- a/README.md +++ b/README.md @@ -95,22 +95,24 @@ Adds the `pub` line to `~/.ssh/authorized_keys` on the host. No command is run on the host: the file is fetched, changed here, and written back with the system `sftp` client in batch mode. -The first connection fetches `~/.ssh/authorized_keys`. The file reads as empty -only when `sftp` reported that file as not being there — the one line naming -that path. The same wording anywhere else in the session does not count: `ssh` -writes `No such file or directory` about an `-i` it cannot find, on a session -that then authenticates through the agent. When `sftp` failed for any other -reason — the file is there and cannot be read, the connection did not come up — -the tool prints what `sftp` said and exits with status 1 without writing -anything, rather than put a file back holding the new key alone. What `sftp` -cannot tell apart is a missing file and one in a directory it cannot enter, so a -`~/.ssh` whose mode shuts the user out reads as a host with no file; the second -connection sets that mode to `0700` and writes, as on a host that has none. If -an identical line is already in the file, the tool prints -`already present` and connects no further. Otherwise the line is added (after a -newline, if the file did not end with one) and a second connection: +The first connection lists `~/.ssh` and then fetches +`~/.ssh/authorized_keys` from it. The file reads as empty in two cases only: +`sftp` reported `~/.ssh` itself as not being there, or the listing came up and +the file was not in it. Any other outcome of that connection fails the run — a +`~/.ssh` that is there but cannot be entered, an `authorized_keys` that is there +but cannot be read, or a connection that did not come up — and the tool prints +what `sftp` said and exits with status 1 without writing anything, rather than +put a file back holding the new key alone. The listing is what tells a missing +directory from one shut to the user, which `sftp` reports on a fetch the same +way; the wording of a missing file elsewhere does not count either, since `ssh` +writes `No such file or directory` about an `-i` it cannot find on a session +that then authenticates through the agent. If an identical line is already in +the file, the tool prints `already present` and connects no further. Otherwise +the line is added (after a newline, if the file did not end with one) and a +second connection: -- creates `~/.ssh` and sets it to mode `0700`; +- makes `~/.ssh` and sets it to mode `0700`, but only when the first connection + found none; a `~/.ssh` that was already there keeps the mode it had; - uploads the new file as `~/.ssh/authorized_keys.keyfunc-` and sets it to mode `0600`; - renames that file over `~/.ssh/authorized_keys`. diff --git a/internal/cli/ssh/install.go b/internal/cli/ssh/install.go index 5886a69..0a6cb1a 100644 --- a/internal/cli/ssh/install.go +++ b/internal/cli/ssh/install.go @@ -76,7 +76,7 @@ func add(cmd *cobra.Command, host string, options []string, line string) error { defer func() { _ = os.RemoveAll(work) }() - content, err := fetch(cmd, host, options, + content, present, err := fetch(cmd, host, options, filepath.Join(work, "authorized_keys"), ) if err != nil { @@ -88,16 +88,18 @@ func add(cmd *cobra.Command, host string, options []string, line string) error { return write(cmd, "already present\n") } - return upload(cmd, host, options, work, merged) + return upload(cmd, host, options, work, merged, present) } // upload writes the new file to the host and renames it over // authorized_keys, which is the step that either happens or does not. // Nothing is removed when a step fails: the file left behind is named -// so that it can be looked at and cleared away by hand. +// so that it can be looked at and cleared away by hand. The directory +// is made and set to its mode only when the read found none: an .ssh +// that was already there is left with the mode it had. func upload( cmd *cobra.Command, host string, options []string, - work, merged string, + work, merged string, present bool, ) error { local := filepath.Join(work, "authorized_keys.merged") @@ -111,14 +113,24 @@ func upload( return err } - // The mkdir may fail: the directory is usually there already. - said, err := session(cmd, host, options, []string{ - "-mkdir " + directory, - "chmod " + directoryMode + " " + directory, - "put " + quoted(local) + " " + sidecar, - "chmod " + fileMode + " " + sidecar, - "rename " + sidecar + " " + authorized, - }) + var batch []string + + if !present { + // The mkdir is allowed to fail in case the directory appeared + // between the read and now; the chmod then sets its mode. + batch = append(batch, + "-mkdir "+directory, + "chmod "+directoryMode+" "+directory, + ) + } + + batch = append(batch, + "put "+quoted(local)+" "+sidecar, + "chmod "+fileMode+" "+sidecar, + "rename "+sidecar+" "+authorized, + ) + + said, err := session(cmd, host, options, batch) if err != nil { // sftp echoes each command as it runs it and stops at the // first that fails, so the name is in what it said only once @@ -185,31 +197,82 @@ func merge(content, line string) (string, bool) { } // fetch brings the host's authorized_keys into the given path and -// returns what is in it. A host that has no such file reads as empty, -// but only when that is what sftp said about it: a file that is there -// and cannot be read fails the run, because writing back over it -// would leave the host with the new key and nothing else. +// returns what is in it, and whether the .ssh directory was already +// there. The one session lists .ssh and then gets the file, so the +// listing settles the state of the directory before the get is read. +// +// The file reads as empty in just two cases: sftp reported .ssh itself +// as not there, or the listing succeeded and the get then reported the +// file as not there. Anything else — the listing refused, the file +// there but unreadable, the connection down — fails the run and writes +// nothing, because writing back over what was not read would leave the +// host with the new key and nothing else. sftp cannot tell a missing +// file from one in a directory it cannot enter, so the listing does: +// a directory that is there but cannot be read is a failure, not an +// empty file. func fetch( cmd *cobra.Command, host string, options []string, into string, -) (string, error) { +) (string, bool, error) { said, err := session(cmd, host, options, []string{ + "ls -1 " + directory, "get " + authorized + " " + quoted(into), }) if err != nil { - if absent(said) { - return "", nil + if directoryAbsent(said) { + return "", false, nil } - return "", err + if absent(said) { + return "", true, nil + } + + return "", false, err } //nolint:gosec // the path is a temporary file of the tool's own content, err := os.ReadFile(into) if err != nil { - return "", fmt.Errorf("reading the fetched file: %w", err) + return "", false, fmt.Errorf("reading the fetched file: %w", err) } - return string(content), nil + return string(content), true, nil +} + +// directoryAbsent says whether sftp reported .ssh itself as not being +// there, which is the one listing failure read as a host that has no +// authorized_keys yet. The reading is taken only from the line in which +// sftp reports on that directory: any other failure of the listing, in +// particular a directory that is there but cannot be entered, is left +// as a failure, so that no key is written to a host whose keys were +// never read. +func directoryAbsent(said string) bool { + for line := range strings.Lines(said) { + named, is := reportedCannotList(strings.TrimSpace(line)) + if is && (named == directory || + strings.HasSuffix(named, "/"+directory)) { + return true + } + } + + return false +} + +// reportedCannotList returns the path an sftp line reports it cannot +// list for want of the directory, and whether the line is such a +// report. The client writes this one wording when the directory a +// listing names is not there, giving the path the server expanded. +func reportedCannotList(line string) (string, bool) { + const ( + before = `Can't ls: "` + after = `" not found` + ) + + if !strings.HasPrefix(line, before) || + !strings.HasSuffix(line, after) { + return "", false + } + + return strings.TrimSuffix(strings.TrimPrefix(line, before), after), true } // absent says whether sftp reported the file that was asked for as diff --git a/internal/cli/ssh/install_test.go b/internal/cli/ssh/install_test.go index 051014c..09d53de 100644 --- a/internal/cli/ssh/install_test.go +++ b/internal/cli/ssh/install_test.go @@ -10,6 +10,7 @@ import "testing" const ( echoed = `sftp> get .ssh/authorized_keys "/tmp/keyfunc/authorized_keys" ` + listed = "sftp> ls -1 .ssh\n" warning = `Warning: Identity file /gone not accessible: ` + "No such file or directory.\n" ) @@ -76,3 +77,57 @@ func TestAbsenceIsReadOnlyFromWhatSFTPSaidAboutAuthorizedKeys(t *testing.T) { }) } } + +// TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo holds the +// wordings the OpenSSH client was seen to use when a listing fails: a +// directory it cannot find is reported one way, and one it cannot enter +// another, and only the first is read as a host with no .ssh yet. +func TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo(t *testing.T) { + t.Parallel() + + listings := map[string]struct { + said string + want bool + }{ + "the directory is not there": { + said: listed + `Can't ls: "/home/someone/.ssh" not found` + "\n", + want: true, + }, + "the directory is not there, named as it was asked for": { + said: listed + `Can't ls: ".ssh" not found` + "\n", + want: true, + }, + "the directory is not there and an identity file is not either": { + said: warning + listed + + `Can't ls: "/home/someone/.ssh" not found` + "\n", + want: true, + }, + "the directory is there and cannot be entered": { + said: listed + + `remote readdir("/home/someone/.ssh/"): Permission denied` + "\n", + want: false, + }, + "some other directory is not there": { + said: listed + `Can't ls: "/home/someone/.config" not found` + "\n", + want: false, + }, + "the connection did not come up": { + said: "ssh: connect to host example.com port 22: " + + "Connection refused\nConnection closed\n", + want: false, + }, + } + + for name, listing := range listings { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if directoryAbsent(listing.said) != listing.want { + t.Errorf( + "read as absent: %t, wanted %t, from:\n%s", + !listing.want, listing.want, listing.said, + ) + } + }) + } +} diff --git a/internal/cli/ssh_test.go b/internal/cli/ssh_test.go index 9aafe21..9fd4ae9 100644 --- a/internal/cli/ssh_test.go +++ b/internal/cli/ssh_test.go @@ -68,13 +68,18 @@ const keyLine = vectorZero + " keyfunc/ssh/0\n" // work. A command that begins with a dash may fail; any other failure // ends the session, as it does in sftp's own batch mode. // -// The two ways a get can fail are worded as the OpenSSH client words -// them, both naming the path the server expanded: a file that is not -// there, which is the one failure the tool reads as an empty file, and -// a file that is there and cannot be read, which is not. An -i naming -// a file that is not here draws the warning ssh writes for it, which -// carries the wording of a missing file into a session that goes on to -// authenticate. +// The listing and the two ways a get can fail are worded as the +// OpenSSH client words them, each naming the path the server expanded. +// A listing fails one way when .ssh is not there and another when it is +// there but shut to the user; the first is the only failure read as a +// host with no file. A get fails one way for a file that is not there, +// which after a listing that came up empty is also read as no file, and +// another for a file that is there and cannot be read, which is a +// failure. A directory shut to the user is stood in for by mode 000, +// which the listing reads off the mode itself so that the test does not +// turn on the user it runs as. An -i naming a file that is not here +// draws the warning ssh writes for it, which carries the wording of a +// missing file into a session that goes on to authenticate. const installer = ` previous= for argument in "$@"; do @@ -99,6 +104,23 @@ while IFS= read -r line; do eval "set -- $line" worked=yes case "$1" in + ls) + dir=$2 + [ "$dir" = -1 ] && dir=$3 + if [ ! -e "$home/$dir" ]; then + worked=no + printf 'Can'\''t ls: "%s" not found\n' "$home/$dir" >&2 + elif [ -d "$home/$dir" ] && [ "$(stat -c '%a' "$home/$dir")" = 0 ]; then + worked=no + printf 'remote readdir("%s/"): Permission denied\n' \ + "$home/$dir" >&2 + else + for entry in "$home/$dir"/*; do + [ -e "$entry" ] || continue + printf '%s/%s\n' "$dir" "$(basename "$entry")" + done + fi + ;; get) if [ ! -e "$home/$2" ]; then worked=no @@ -176,8 +198,8 @@ func TestAKeyThatIsAlreadyThereIsLeftAlone(t *testing.T) { require.Equal(t, "already present\n", install(t, host)) require.Equal(t, "somebody else\n"+keyLine, read(t, path)) - // The fetch and nothing after it: the tool did not connect again. - require.Len(t, recorded(t, pretend.batch), 1) + // The read and nothing after it: the tool did not connect again. + require.Equal(t, 1, connections(t, pretend)) } func TestAnEmptyFileGetsTheKeyAndNoBlankLineBeforeIt(t *testing.T) { @@ -218,7 +240,9 @@ func TestTheFileIsUploadedBesideTheOldOneAndThenRenamedOverIt(t *testing.T) { strings.HasPrefix(beside, ".ssh/authorized_keys.keyfunc-"), ) - require.True(t, strings.HasPrefix(sent[0], "get .ssh/authorized_keys ")) + // The listing fails on a host with no .ssh, so the get never runs; + // the write session then makes the directory and puts the file. + require.Equal(t, "ls -1 .ssh", sent[0]) require.Equal(t, "-mkdir .ssh", sent[1]) require.Equal(t, "chmod 700 .ssh", sent[2]) require.Equal(t, "put", strings.Fields(sent[3])[0]) @@ -237,12 +261,57 @@ func TestAFileThatCannotBeReadIsNotWrittenOver(t *testing.T) { require.Empty(t, printed) require.Contains(t, said, "Permission denied") - // The fetch and nothing after it, and what was on the host is + // The read and nothing after it, and what was on the host is // still what is on the host. - require.Len(t, recorded(t, pretend.batch), 1) + require.Equal(t, 1, connections(t, pretend)) require.DirExists(t, unreadable) } +func TestAnUnreadableDirectoryIsNotWrittenInto(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + unlistable(t, pretend) + + // The listing is refused, which is not the same as no directory, so + // the tool writes nothing rather than treat a directory it cannot + // enter as a host with no file. + printed, said, err := attempt(t, host) + require.Error(t, err) + require.Empty(t, printed) + require.Contains(t, said, "Permission denied") + + // The read and nothing after it: no second connection wrote a key. + require.Equal(t, 1, connections(t, pretend)) +} + +func TestAnExistingDirectoryKeepsItsModeAndIsNotRemade(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + // A directory that is there but holds no file yet, made with a mode + // of its own so that a stray chmod would show. + const ownMode = 0o755 + + directory := filepath.Join(pretend.home, keptUnder) + require.NoError(t, os.Mkdir(directory, ownMode)) + + require.Equal(t, "added\n", install(t, host)) + + // The key is added and the directory keeps the mode it had: the + // write session neither made it nor set its mode. + require.Equal(t, keyLine, read(t, filepath.Join(directory, keptIn))) + + kept, err := os.Stat(directory) + require.NoError(t, err) + require.Equal(t, os.FileMode(ownMode), kept.Mode().Perm()) + + sent := recorded(t, pretend.batch) + require.NotContains(t, sent, "-mkdir .ssh") + require.NotContains(t, sent, "chmod 700 .ssh") +} + func TestAWarningAboutAnotherFileIsNotTakenForTheOneAskedFor(t *testing.T) { t.Setenv(mnemonic.Variable, example()) @@ -259,7 +328,7 @@ func TestAWarningAboutAnotherFileIsNotTakenForTheOneAskedFor(t *testing.T) { require.Contains(t, said, "No such file or directory") require.Contains(t, said, "Permission denied") - require.Len(t, recorded(t, pretend.batch), 1) + require.Equal(t, 1, connections(t, pretend)) require.DirExists(t, unreadable) // The same run again, this way for the status it ends with. @@ -279,9 +348,9 @@ func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) { pretend := pretendHost(t) - // A file where the .ssh directory belongs: nothing is there to - // fetch, and then the put has nowhere to put anything, so the - // write session ends at the put. + // A file where the .ssh directory belongs: the listing shows it and + // so the directory reads as already there, but then the put has + // nowhere to put anything, so the write session ends at the put. inTheWay := filepath.Join(pretend.home, keptUnder) require.NoError(t, os.WriteFile(inTheWay, []byte(notADirectory), fileMode), @@ -292,12 +361,12 @@ func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) { require.Empty(t, printed) require.Contains(t, said, "put failed") - // The put is the last command the session got to, and the file it - // was uploading is the one the message names. + // The put is the first and last command the write session got to, + // and the file it was uploading is the one the message names. sent := recorded(t, pretend.batch) - require.Len(t, sent, 4) - require.Equal(t, "put", strings.Fields(sent[3])[0]) - require.Contains(t, err.Error(), strings.Fields(sent[3])[2]) + require.Len(t, sent, 3) + require.Equal(t, "put", strings.Fields(sent[2])[0]) + require.Contains(t, err.Error(), strings.Fields(sent[2])[2]) require.Equal(t, notADirectory, read(t, inTheWay)) @@ -455,6 +524,38 @@ func unfetchable(t *testing.T, pretend pretended) string { return path } +// unlistable puts a .ssh on the stand-in host that is there but shut to +// the user, a directory of mode 000, and gives back its path. Its mode +// is put back before the temporary directory is cleared so that it can +// be. +func unlistable(t *testing.T, pretend pretended) string { + t.Helper() + + directory := filepath.Join(pretend.home, keptUnder) + require.NoError(t, os.Mkdir(directory, directoryMode)) + require.NoError(t, os.Chmod(directory, 0)) + + t.Cleanup(func() { _ = os.Chmod(directory, directoryMode) }) + + return directory +} + +// connections returns how many times the tool ran sftp, counted from +// the -b that opens each session's arguments. +func connections(t *testing.T, pretend pretended) int { + t.Helper() + + count := 0 + + for _, argument := range recorded(t, pretend.arguments) { + if argument == "-b" { + count++ + } + } + + return count +} + // pretendCall puts the to stand-in on the path and gives back the file // the arguments are written down in and the file the agent socket is // noted in. -- 2.54.0 From 64dcc7f42b717df47623a1ca96bc56e27392ea3a Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 21 Sep 2026 14:58:26 +0200 Subject: [PATCH 06/10] Keep the mnemonic out of the ssh and sftp children (closes #16) `keyfunc ssh to` and `keyfunc ssh install` started the system `ssh` and `sftp` with the tool's whole environment, so a mnemonic given in `KEYFUNC_MNEMONIC` stayed readable in the child's environment and could be forwarded to the host by a `SendEnv` line. Both children now get the environment with `KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` removed, through one helper, `childEnv`, in the ssh cli package. The mnemonic command still runs with the full environment. Two tests drive the real commands against the stand-in `ssh` and `sftp` and check that a third variable still arrives. Model: opus-4-8 (implementation, review); fable-5-1 (merge message) --- README.md | 5 +++ internal/cli/ssh/install.go | 1 + internal/cli/ssh/ssh.go | 23 ++++++++++++ internal/cli/ssh/to.go | 1 + internal/cli/ssh_test.go | 75 +++++++++++++++++++++++++++++++++---- 5 files changed, 98 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index aa78dc6..975ecd7 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,11 @@ If none of these is available and standard input is not a terminal, the tool refuses and exits with status 1. A mnemonic that fails the BIP-39 checksum is refused with a message saying so. +`KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` are removed from the +environment before the system `ssh` (`keyfunc ssh to`) and `sftp` +(`keyfunc ssh install`) are started, so the mnemonic is never handed on to +them. + Every command takes `--index` / `-n` and `--mnemonic-command`, and has `--help`. `keyfunc --version` prints the version. `make build` stamps it; a binary installed with `go install` reports the module version instead. diff --git a/internal/cli/ssh/install.go b/internal/cli/ssh/install.go index 0a6cb1a..761fe3c 100644 --- a/internal/cli/ssh/install.go +++ b/internal/cli/ssh/install.go @@ -166,6 +166,7 @@ func session( //nolint:gosec // the options are the user's own, meant for sftp command := exec.CommandContext(cmd.Context(), "sftp", argv...) + command.Env = childEnv() command.Stdin = strings.NewReader(strings.Join(batch, "\n") + "\n") command.Stdout = &said command.Stderr = &said diff --git a/internal/cli/ssh/ssh.go b/internal/cli/ssh/ssh.go index b15a08b..3df7344 100644 --- a/internal/cli/ssh/ssh.go +++ b/internal/cli/ssh/ssh.go @@ -3,9 +3,12 @@ package ssh import ( "fmt" + "os" + "strings" "git.eeqj.de/sneak/keyfunc/internal/cli/options" "git.eeqj.de/sneak/keyfunc/internal/derive" + "git.eeqj.de/sneak/keyfunc/internal/mnemonic" "git.eeqj.de/sneak/keyfunc/internal/sshkey" "github.com/spf13/cobra" ) @@ -84,6 +87,26 @@ func write(cmd *cobra.Command, text string) error { return nil } +// childEnv is the tool's environment with the mnemonic variables taken +// out, for the ssh and sftp children it starts. "ssh to" exists so the +// private key never leaves the tool; the mnemonic, from either variable, +// must not leave it either. +func childEnv() []string { + environ := os.Environ() + kept := make([]string, 0, len(environ)) + + for _, entry := range environ { + name, _, _ := strings.Cut(entry, "=") + if name == mnemonic.Variable || name == mnemonic.CommandVariable { + continue + } + + kept = append(kept, entry) + } + + return kept +} + // addComment gives a command its comment flag. func addComment(cmd *cobra.Command) { cmd.Flags().String( diff --git a/internal/cli/ssh/to.go b/internal/cli/ssh/to.go index 8dc5149..1afd7a9 100644 --- a/internal/cli/ssh/to.go +++ b/internal/cli/ssh/to.go @@ -70,6 +70,7 @@ func to() *cobra.Command { func connect(ctx context.Context, argv []string) error { //nolint:gosec // the arguments are the user's own, meant for ssh command := exec.CommandContext(ctx, "ssh", argv...) + command.Env = childEnv() command.Stdin = os.Stdin command.Stdout = os.Stdout command.Stderr = os.Stderr diff --git a/internal/cli/ssh_test.go b/internal/cli/ssh_test.go index 9fd4ae9..775d1e9 100644 --- a/internal/cli/ssh_test.go +++ b/internal/cli/ssh_test.go @@ -60,13 +60,19 @@ const ( // an authorized_keys file. const keyLine = vectorZero + " keyfunc/ssh/0\n" +// marker is a variable set beside the mnemonic ones and expected to +// reach the stand-in, so a scrubbed environment is told apart from an +// empty one. +const marker = "KEYFUNC_TEST_MARKER" + // installer is a stand-in for the system sftp for the install // command. It writes down the arguments and every command of the -// batch it is given, echoes each command as sftp does, and carries -// the commands out against a directory standing in for the host's -// home directory, so that what keyfunc sends can be watched doing its -// work. A command that begins with a dash may fail; any other failure -// ends the session, as it does in sftp's own batch mode. +// batch it is given, echoes each command as sftp does, writes down its +// own environment when a test asks for it, and carries the commands out +// against a directory standing in for the host's home directory, so that +// what keyfunc sends can be watched doing its work. A command that +// begins with a dash may fail; any other failure ends the session, as it +// does in sftp's own batch mode. // // The listing and the two ways a get can fail are worded as the // OpenSSH client words them, each naming the path the server expanded. @@ -81,6 +87,7 @@ const keyLine = vectorZero + " keyfunc/ssh/0\n" // draws the warning ssh writes for it, which carries the wording of a // missing file into a session that goes on to authenticate. const installer = ` +[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT" previous= for argument in "$@"; do printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS" @@ -144,9 +151,11 @@ done // caller is a stand-in for the system ssh for the to command. It // writes down the arguments it was given, notes the agent socket if -// there really is one at the path it was handed, and ends with the -// status the test asked for. +// there really is one at the path it was handed, writes down its own +// environment when a test asks for it, and ends with the status the +// test asked for. const caller = ` +[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT" for argument in "$@"; do printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS" done @@ -444,6 +453,36 @@ func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) { require.Equal(t, failingStatus, cli.Main()) } +func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) { + t.Setenv(mnemonic.CommandVariable, "echo "+example()) + t.Setenv(mnemonic.Variable, example()) + t.Setenv(marker, "reaches the stand-in") + + pretendHost(t) + environment := recordEnvironment(t) + + install(t, host) + + mnemonicWithheld(t, read(t, environment)) +} + +func TestTheMnemonicIsNotHandedToSSH(t *testing.T) { + t.Setenv(mnemonic.CommandVariable, "echo "+example()) + t.Setenv(mnemonic.Variable, example()) + t.Setenv(marker, "reaches the stand-in") + + pretendCall(t) + environment := recordEnvironment(t) + + _, err := execute(t, subcommand, "to", host, "uptime") + + var passed ssh.StatusError + + require.ErrorAs(t, err, &passed) + + mnemonicWithheld(t, read(t, environment)) +} + // pretendHost puts the install stand-in on the path and gives back the // places it writes to. func pretendHost(t *testing.T) pretended { @@ -592,6 +631,28 @@ func standIn(t *testing.T, name, body string) { ) } +// recordEnvironment asks the stand-in to write its environment down and +// gives back the file it writes it to. +func recordEnvironment(t *testing.T) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "environment") + t.Setenv("KEYFUNC_TEST_ENVIRONMENT", path) + + return path +} + +// mnemonicWithheld requires that neither mnemonic variable reached the +// stand-in and that the marker set beside them did, so an empty +// environment does not pass for a scrubbed one. +func mnemonicWithheld(t *testing.T, environment string) { + t.Helper() + + require.NotContains(t, environment, mnemonic.Variable+"=") + require.NotContains(t, environment, mnemonic.CommandVariable+"=") + require.Contains(t, environment, marker+"=") +} + // read returns what is in a file. func read(t *testing.T, path string) string { t.Helper() -- 2.54.0 From 15ebe24f7b171708fba029bd5a7eb21d8e1b9f1c Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 21 Sep 2026 16:24:28 +0200 Subject: [PATCH 07/10] README: policy sections and age and child mnemonic vectors (closes #21) 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) --- README.md | 154 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 127 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 975ecd7..5f6ace2 100644 --- a/README.md +++ b/README.md @@ -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 @@ -156,6 +214,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. @@ -188,33 +255,66 @@ 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) +- [#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 + +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). -- 2.54.0 From 40e9beea8cf99aa7217d9928d84a8b4ef8e6cb8d Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 21 Sep 2026 16:58:24 +0200 Subject: [PATCH 08/10] Clean up the agent socket and working files when a signal ends the tool (closes #17) `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) --- README.md | 5 +- internal/cli/cli.go | 16 ++++- internal/cli/ssh/to.go | 8 +++ internal/cli/ssh_test.go | 132 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 154 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5f6ace2..65d3e65 100644 --- a/README.md +++ b/README.md @@ -204,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` with `-o IdentityAgent=` followed by the host and all remaining 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` @@ -306,7 +308,6 @@ 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) -- [#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 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 57b4da1..2dd443e 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -2,10 +2,13 @@ package cli import ( + "context" "errors" "fmt" "os" + "os/signal" "runtime/debug" + "syscall" "git.eeqj.de/sneak/keyfunc/internal/cli/age" "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 // ended with. ssh has already said whatever it had to say in that // 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 { - err := Root().Execute() + ctx, stop := signal.NotifyContext( + context.Background(), + syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, + ) + defer stop() + + err := Root().ExecuteContext(ctx) if err == nil { return 0 } diff --git a/internal/cli/ssh/to.go b/internal/cli/ssh/to.go index 1afd7a9..6569c5c 100644 --- a/internal/cli/ssh/to.go +++ b/internal/cli/ssh/to.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "slices" + "syscall" "github.com/spf13/cobra" ) @@ -75,6 +76,13 @@ func connect(ctx context.Context, argv []string) error { command.Stdout = os.Stdout 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() if err == nil { return nil diff --git a/internal/cli/ssh_test.go b/internal/cli/ssh_test.go index 775d1e9..1bf8193 100644 --- a/internal/cli/ssh_test.go +++ b/internal/cli/ssh_test.go @@ -3,11 +3,14 @@ package cli_test import ( "bytes" "os" + "os/exec" "path/filepath" "slices" "strconv" "strings" + "syscall" "testing" + "time" "git.eeqj.de/sneak/keyfunc/internal/cli" "git.eeqj.de/sneak/keyfunc/internal/cli/ssh" @@ -15,6 +18,23 @@ import ( "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 // stand-ins need so that they can be run at all. const ( @@ -47,6 +67,9 @@ const ( 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 // Main, the ssh subcommand both commands the tests here drive live // under, and the one of those two these tests name most. @@ -166,6 +189,18 @@ fi 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. type pretended struct { // home stands in for the home directory on the host. @@ -421,7 +456,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) { arguments, noted := pretendCall(t) - _, err := execute(t, subcommand, "to", host, "uptime") + _, err := execute(t, subcommand, "to", host, remoteCommand) var passed ssh.StatusError @@ -430,7 +465,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) { given := recorded(t, arguments) 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 // a socket there while it ran. @@ -448,11 +483,100 @@ func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) { 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()) } +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) { t.Setenv(mnemonic.CommandVariable, "echo "+example()) t.Setenv(mnemonic.Variable, example()) @@ -474,7 +598,7 @@ func TestTheMnemonicIsNotHandedToSSH(t *testing.T) { pretendCall(t) environment := recordEnvironment(t) - _, err := execute(t, subcommand, "to", host, "uptime") + _, err := execute(t, subcommand, "to", host, remoteCommand) var passed ssh.StatusError -- 2.54.0 From ace7846d5796391171e70b007bfafb14398a23ff Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 21 Sep 2026 17:58:20 +0200 Subject: [PATCH 09/10] Use gomodguard_v2 in the linter config (closes #31) 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) --- .golangci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.golangci.yml b/.golangci.yml index 26b1610..8a90995 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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: -- 2.54.0 From dd14677145c7e1f7f1bb3708664252dade5445d5 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 21 Sep 2026 18:07:36 +0200 Subject: [PATCH 10/10] README checked against the tree by running every example (closes #22) 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) --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 65d3e65..2c83234 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,6 @@ 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) -- [#22 1.0 release readiness](https://git.eeqj.de/sneak/keyfunc/issues/22) ## License -- 2.54.0