From 8aeed7b901fe0795b3285f6a674d6ee8802321ff Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 8 Sep 2026 08:20:17 +0200 Subject: [PATCH 01/28] 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() 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/28] 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 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/28] 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= 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/28] 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)) + }) +} 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/28] 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. 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/28] 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() 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/28] 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). 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/28] 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 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/28] 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: 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/28] 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 From 7f7fe33cd6a01caa41c6526912e0e903dba428bc Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 06:12:05 +0200 Subject: [PATCH 11/28] Stamp the git tag or short commit in a plain docker build (closes #35) .dockerignore left out .git, so make build inside the image fell back to "dev". The build context now carries .git, without its config, which can hold a credential in the remote URL. The build stage takes the VERSION build argument when one is given, otherwise git describe --tags --always, and fails if the context carries .git and no version comes out. Model: opus-5-5 --- .dockerignore | 3 ++- Dockerfile | 14 +++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index 9d848e1..9aacf28 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,4 @@ -.git +# .git is sent so the build can stamp the version, without its config. +.git/config .gitea /keyfunc diff --git a/Dockerfile b/Dockerfile index e48ab65..fcea34f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,19 @@ COPY . . RUN make fmt-check RUN make test -RUN make build + +# The version stamped into the binary: the VERSION build argument when one +# is given, otherwise `git describe --tags --always` of the .git in the +# build context. A context that carries .git and still yields no version +# fails the build; with neither, as from a source tarball, it is "dev". +ARG VERSION +RUN version="${VERSION:-$(git describe --tags --always || echo dev)}"; \ + if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \ + [ "$version" = unknown ]; }; then \ + echo "no version could be derived although the build context carries .git" >&2; \ + exit 1; \ + fi; \ + make build VERSION="$version" # alpine:3.23, 2026-09-07 FROM alpine@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 From f8c796db9b7c25c02d307675acc771aa86fd5cc5 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sat, 3 Oct 2026 14:42:59 +0200 Subject: [PATCH 12/28] Add the MIT license (closes #14) Adds LICENSE with the standard MIT text and "Copyright (c) 2026 sneak", the license sneak chose. The README now calls keyfunc MIT-licensed in its first paragraph, its License section points at LICENSE, and the license line leaves the TODO list. Model: opus-5-5 --- LICENSE | 21 +++++++++++++++++++++ README.md | 16 ++++++---------- 2 files changed, 27 insertions(+), 10 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..34edefe --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 sneak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 2c83234..182ed23 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # keyfunc -`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. +`keyfunc` is an MIT-licensed Go command-line tool by +[@sneak](https://sneak.berlin) that turns a BIP-39 mnemonic into SSH keys, age +identities and child mnemonics, each of which can be recreated from that +mnemonic at any time. The same mnemonic, key type and index always give the same +key. It uses the BIP-85 entropy deriver from `git.eeqj.de/sneak/secret/pkg/bip85` and takes the same steps as that repository's `agehd` package. @@ -306,14 +305,11 @@ standard: most Makefile targets are thin shims over an executable in 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) ## 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. +MIT. The full text is in [`LICENSE`](LICENSE). ## Author From 8d1c873bb736811635f25de1bb73d9e903ef0444 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 01:42:43 +0200 Subject: [PATCH 13/28] Move the Go module to sneak.berlin/go/keyfunc (closes #15) The module path becomes sneak.berlin/go/keyfunc, as the repo policy sets for Go modules: go.mod, every import and the -X path in the Makefile. The old path gets no alias. The README gives a go install line for the new path, which resolves with @latest only once main carries the move, and its TODO list now names the open 1.0 issues. Model: opus-5-5 --- Makefile | 2 +- README.md | 13 +++++++++++-- cmd/keyfunc/main.go | 2 +- go.mod | 2 +- internal/agekey/agekey_test.go | 4 ++-- internal/childmnemonic/childmnemonic.go | 2 +- internal/childmnemonic/childmnemonic_test.go | 4 ++-- internal/cli/age/age.go | 6 +++--- internal/cli/age_test.go | 4 ++-- internal/cli/cli.go | 8 ++++---- internal/cli/cli_test.go | 8 ++++---- internal/cli/mnemonic/mnemonic.go | 6 +++--- internal/cli/options/options.go | 2 +- internal/cli/ssh/ssh.go | 8 ++++---- internal/cli/ssh_test.go | 6 +++--- internal/derive/derive_test.go | 2 +- internal/mnemonic/mnemonic_test.go | 2 +- internal/sshkey/sshkey_test.go | 4 ++-- 18 files changed, 47 insertions(+), 38 deletions(-) diff --git a/Makefile b/Makefile index b977ebe..eb1d4bd 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # which needs the version stamped into the binary. VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) -LDFLAGS := -s -w -X 'git.eeqj.de/sneak/keyfunc/internal/cli.Version=$(VERSION)' +LDFLAGS := -s -w -X 'sneak.berlin/go/keyfunc/internal/cli.Version=$(VERSION)' .PHONY: default bootstrap setup build test lint fmt fmt-check check \ docker cibuild hooks clean diff --git a/README.md b/README.md index 182ed23..31fd1ce 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,13 @@ them, and `keyfunc mnemonic ...` for child mnemonics derived from the main one. ## Getting Started -Build from a clone and run the binary: +Install with Go: + +``` +go install sneak.berlin/go/keyfunc/cmd/keyfunc@latest +``` + +Or build from a clone and run the binary: ``` git clone git@git.eeqj.de:sneak/keyfunc.git @@ -305,7 +311,10 @@ standard: most Makefile targets are thin shims over an executable in The open issues that stand between the tree and a 1.0 release: -- [#15 Decide the Go module path before 1.0](https://git.eeqj.de/sneak/keyfunc/issues/15) +- [#38 Lint and test as phases of the Dockerfile, as the current repo policy requires](https://git.eeqj.de/sneak/keyfunc/issues/38) +- [#39 make fmt and make fmt-check cover Markdown with prettier](https://git.eeqj.de/sneak/keyfunc/issues/39) +- [#40 The linter config, .gitignore and .dockerignore from the current templates](https://git.eeqj.de/sneak/keyfunc/issues/40) +- [#42 go-bip39 no longer exists upstream: keep it, or copy it into the repo?](https://git.eeqj.de/sneak/keyfunc/issues/42) ## License diff --git a/cmd/keyfunc/main.go b/cmd/keyfunc/main.go index 90d87cc..a3b2c11 100644 --- a/cmd/keyfunc/main.go +++ b/cmd/keyfunc/main.go @@ -4,7 +4,7 @@ package main import ( "os" - "git.eeqj.de/sneak/keyfunc/internal/cli" + "sneak.berlin/go/keyfunc/internal/cli" ) func main() { diff --git a/go.mod b/go.mod index 092d55c..543f9b5 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module git.eeqj.de/sneak/keyfunc +module sneak.berlin/go/keyfunc go 1.26.0 diff --git a/internal/agekey/agekey_test.go b/internal/agekey/agekey_test.go index 376e3ab..fd488d0 100644 --- a/internal/agekey/agekey_test.go +++ b/internal/agekey/agekey_test.go @@ -5,9 +5,9 @@ import ( "strings" "testing" - "git.eeqj.de/sneak/keyfunc/internal/agekey" - "git.eeqj.de/sneak/keyfunc/internal/derive" "github.com/stretchr/testify/require" + "sneak.berlin/go/keyfunc/internal/agekey" + "sneak.berlin/go/keyfunc/internal/derive" ) // The recipients the example mnemonic produces at the first two diff --git a/internal/childmnemonic/childmnemonic.go b/internal/childmnemonic/childmnemonic.go index f795f1a..a651666 100644 --- a/internal/childmnemonic/childmnemonic.go +++ b/internal/childmnemonic/childmnemonic.go @@ -5,10 +5,10 @@ import ( "errors" "fmt" - "git.eeqj.de/sneak/keyfunc/internal/derive" "git.eeqj.de/sneak/secret/pkg/bip85" "github.com/btcsuite/btcd/btcutil/hdkeychain" bip39 "github.com/tyler-smith/go-bip39" + "sneak.berlin/go/keyfunc/internal/derive" ) // english is the number BIP-85 gives the English word list. diff --git a/internal/childmnemonic/childmnemonic_test.go b/internal/childmnemonic/childmnemonic_test.go index ccd8990..0075a24 100644 --- a/internal/childmnemonic/childmnemonic_test.go +++ b/internal/childmnemonic/childmnemonic_test.go @@ -4,11 +4,11 @@ import ( "strings" "testing" - "git.eeqj.de/sneak/keyfunc/internal/childmnemonic" - "git.eeqj.de/sneak/keyfunc/internal/derive" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/stretchr/testify/require" bip39 "github.com/tyler-smith/go-bip39" + "sneak.berlin/go/keyfunc/internal/childmnemonic" + "sneak.berlin/go/keyfunc/internal/derive" ) // The lengths the tool offers, and one it does not. diff --git a/internal/cli/age/age.go b/internal/cli/age/age.go index 4de2b60..700fdf2 100644 --- a/internal/cli/age/age.go +++ b/internal/cli/age/age.go @@ -8,10 +8,10 @@ import ( "os" "path/filepath" - "git.eeqj.de/sneak/keyfunc/internal/agekey" - "git.eeqj.de/sneak/keyfunc/internal/cli/options" - "git.eeqj.de/sneak/keyfunc/internal/derive" "github.com/spf13/cobra" + "sneak.berlin/go/keyfunc/internal/agekey" + "sneak.berlin/go/keyfunc/internal/cli/options" + "sneak.berlin/go/keyfunc/internal/derive" ) // Command returns the age command and everything under it. diff --git a/internal/cli/age_test.go b/internal/cli/age_test.go index 3d452b9..1b37231 100644 --- a/internal/cli/age_test.go +++ b/internal/cli/age_test.go @@ -6,9 +6,9 @@ import ( "strings" "testing" - "git.eeqj.de/sneak/keyfunc/internal/agekey" - "git.eeqj.de/sneak/keyfunc/internal/mnemonic" "github.com/stretchr/testify/require" + "sneak.berlin/go/keyfunc/internal/agekey" + "sneak.berlin/go/keyfunc/internal/mnemonic" ) func TestTheAgeCommandsPrintTheKey(t *testing.T) { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 2dd443e..e49c853 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -10,11 +10,11 @@ import ( "runtime/debug" "syscall" - "git.eeqj.de/sneak/keyfunc/internal/cli/age" - "git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic" - "git.eeqj.de/sneak/keyfunc/internal/cli/options" - "git.eeqj.de/sneak/keyfunc/internal/cli/ssh" "github.com/spf13/cobra" + "sneak.berlin/go/keyfunc/internal/cli/age" + "sneak.berlin/go/keyfunc/internal/cli/mnemonic" + "sneak.berlin/go/keyfunc/internal/cli/options" + "sneak.berlin/go/keyfunc/internal/cli/ssh" ) // devVersion is what Version holds until a build stamps a real one. diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index fa11a40..2f46688 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -5,13 +5,13 @@ import ( "strings" "testing" - "git.eeqj.de/sneak/keyfunc/internal/childmnemonic" - "git.eeqj.de/sneak/keyfunc/internal/cli" - "git.eeqj.de/sneak/keyfunc/internal/derive" - "git.eeqj.de/sneak/keyfunc/internal/mnemonic" "github.com/stretchr/testify/require" bip39 "github.com/tyler-smith/go-bip39" "golang.org/x/crypto/ssh" + "sneak.berlin/go/keyfunc/internal/childmnemonic" + "sneak.berlin/go/keyfunc/internal/cli" + "sneak.berlin/go/keyfunc/internal/derive" + "sneak.berlin/go/keyfunc/internal/mnemonic" ) // The two lines the README says the example mnemonic produces. diff --git a/internal/cli/mnemonic/mnemonic.go b/internal/cli/mnemonic/mnemonic.go index e157e18..8c33332 100644 --- a/internal/cli/mnemonic/mnemonic.go +++ b/internal/cli/mnemonic/mnemonic.go @@ -4,10 +4,10 @@ package mnemonic import ( "fmt" - "git.eeqj.de/sneak/keyfunc/internal/childmnemonic" - "git.eeqj.de/sneak/keyfunc/internal/cli/options" - "git.eeqj.de/sneak/keyfunc/internal/derive" "github.com/spf13/cobra" + "sneak.berlin/go/keyfunc/internal/childmnemonic" + "sneak.berlin/go/keyfunc/internal/cli/options" + "sneak.berlin/go/keyfunc/internal/derive" ) // Command returns the mnemonic command. diff --git a/internal/cli/options/options.go b/internal/cli/options/options.go index cfad875..5d454ec 100644 --- a/internal/cli/options/options.go +++ b/internal/cli/options/options.go @@ -5,8 +5,8 @@ package options import ( "fmt" - "git.eeqj.de/sneak/keyfunc/internal/mnemonic" "github.com/spf13/cobra" + "sneak.berlin/go/keyfunc/internal/mnemonic" ) // Add gives a command the flags that every command has. They are diff --git a/internal/cli/ssh/ssh.go b/internal/cli/ssh/ssh.go index 3df7344..9fa73f6 100644 --- a/internal/cli/ssh/ssh.go +++ b/internal/cli/ssh/ssh.go @@ -6,11 +6,11 @@ import ( "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" + "sneak.berlin/go/keyfunc/internal/cli/options" + "sneak.berlin/go/keyfunc/internal/derive" + "sneak.berlin/go/keyfunc/internal/mnemonic" + "sneak.berlin/go/keyfunc/internal/sshkey" ) // Command returns the ssh command and everything under it. diff --git a/internal/cli/ssh_test.go b/internal/cli/ssh_test.go index 1bf8193..7ab7128 100644 --- a/internal/cli/ssh_test.go +++ b/internal/cli/ssh_test.go @@ -12,10 +12,10 @@ import ( "testing" "time" - "git.eeqj.de/sneak/keyfunc/internal/cli" - "git.eeqj.de/sneak/keyfunc/internal/cli/ssh" - "git.eeqj.de/sneak/keyfunc/internal/mnemonic" "github.com/stretchr/testify/require" + "sneak.berlin/go/keyfunc/internal/cli" + "sneak.berlin/go/keyfunc/internal/cli/ssh" + "sneak.berlin/go/keyfunc/internal/mnemonic" ) // runAsTool, set in the environment of a re-executed test binary, tells diff --git a/internal/derive/derive_test.go b/internal/derive/derive_test.go index 75f8b70..3c35466 100644 --- a/internal/derive/derive_test.go +++ b/internal/derive/derive_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "git.eeqj.de/sneak/keyfunc/internal/derive" "github.com/stretchr/testify/require" + "sneak.berlin/go/keyfunc/internal/derive" ) // application is the number the SSH key type uses. diff --git a/internal/mnemonic/mnemonic_test.go b/internal/mnemonic/mnemonic_test.go index c8f155c..7924f90 100644 --- a/internal/mnemonic/mnemonic_test.go +++ b/internal/mnemonic/mnemonic_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "git.eeqj.de/sneak/keyfunc/internal/mnemonic" "github.com/stretchr/testify/require" + "sneak.berlin/go/keyfunc/internal/mnemonic" ) // Two more mnemonics that pass the checksum, so a test can tell which diff --git a/internal/sshkey/sshkey_test.go b/internal/sshkey/sshkey_test.go index 2ab8cb6..4415054 100644 --- a/internal/sshkey/sshkey_test.go +++ b/internal/sshkey/sshkey_test.go @@ -7,11 +7,11 @@ import ( "strings" "testing" - "git.eeqj.de/sneak/keyfunc/internal/derive" - "git.eeqj.de/sneak/keyfunc/internal/sshkey" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" + "sneak.berlin/go/keyfunc/internal/derive" + "sneak.berlin/go/keyfunc/internal/sshkey" ) // agentDirectoryMode is what the directory holding the agent socket From d6b87f75021ae18f76f8f368ab93688d1efbe70b Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 02:25:50 +0200 Subject: [PATCH 14/28] The linter config, .gitignore and .dockerignore from the current templates (closes #40) .golangci.yml is now a byte-identical copy of the current template: depguard is on with the test-support rule, and the gomodguard_v2 block list is in. .gitignore and .dockerignore are the current templates with this repo's own entries added at the end; the .dockerignore secret-file patterns now keep key files and .env files out of the build context, while .git stays in for the version stamp. No Go source needed changes. Model: opus-5-5 --- .dockerignore | 65 ++++++++++++++++++++++++++++++++++++++++++++++-- .gitignore | 43 ++++++++++++++++++++++++++------ .golangci.yml | 69 ++++++++++++++++++++++++++++++++++++++++++++++++--- README.md | 1 - 4 files changed, 164 insertions(+), 14 deletions(-) diff --git a/.dockerignore b/.dockerignore index 9aacf28..26123bb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,65 @@ -# .git is sent so the build can stamp the version, without its config. +# .dockerignore does NOT use .gitignore semantics. Docker matches with +# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross +# `/` and an unprefixed pattern is anchored at the context root. Every +# depth-independent pattern therefore needs `**/`, or `config/.env` and +# `certs/server.key` still ship while this file reads as solved. Only +# genuinely root-anchored entries go unprefixed. Never transplant these +# into .gitignore, where `**/` is wrong. +# +# Matching is case-sensitive, so secrets use character ranges rather +# than an ALL-CAPS twin, which would still miss `Server.Key`. +# +# Extend with this repo's own host-built artifacts, written anchored: +# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and +# deletes the package directory from the context. + +# .git is sent without its config. Without a VERSION build argument the +# stage that compiles runs `git describe --tags --always` on .git, which +# does not need .git/config; that file can hold a credential, such as a +# password in a remote URL or the token the CI checkout step stores there. .git/config -.gitea + +# Agent scratch: one full checkout of the repo per in-flight agent. +# Anchored because it occurs once where agents run at the repo root. +# KNOWN GAP: a repo running agents in subdirectories still ships +# `services/api/.claude/` and must add its own anchored entry. +.claude + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Re-include a committed template with a negation if the +# build needs one: `!docs/example.env`. +**/*.[eE][nN][vV] +**/.[eE][nN][vV].* +**/.[eE][nN][vV][rR][cC] + +# Private keys and the bundles carrying them. Public certificates +# (*.crt, *.cer) are deliberately absent: they are legitimate inputs. +**/*.[pP][eE][mM] +**/*.[kK][eE][yY] +**/*.[pP]12 +**/*.[pP][fF][xX] +**/[iI][dD]_[rR][sS][aA] +**/[iI][dD]_[dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA] +**/[iI][dD]_[eE][dD]25519 + +# Dependencies: restored inside the image, never copied in. +**/node_modules + +# OS metadata. +**/.DS_Store +**/Thumbs.db + +# Editor state: never a build input, and it churns COPY. +**/*.swp +**/*.swo +**/*~ +**/*.bak +**/.idea +**/.vscode +**/*.sublime-* + +# The binary `make build` writes, and the CI workflow, which is not a +# build input. /keyfunc +.gitea diff --git a/.gitignore b/.gitignore index 7e0d436..0d0698d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ -# The built binary -/keyfunc - # OS .DS_Store Thumbs.db @@ -14,8 +11,38 @@ Thumbs.db .vscode/ *.sublime-* -# Environment / secrets -.env -.env.* -*.pem -*.key +# Agent scratch (worktrees of this repo, created and destroyed by +# in-flight tooling). Unanchored: .gitignore patterns already match at +# every depth, so no prefix is wanted here. This is not a .dockerignore +# entry and must not be given a `**/` prefix on the way into one. +.claude/ + +# Node +node_modules/ + +# Secrets. Unanchored like every entry above, so each matches at every +# depth. Matching is case-sensitive on Linux, so names use character +# ranges rather than a lowercase form that misses `Server.Key`. + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Only the templates `example.env` and `sample.env` are +# re-included below. A repository that commits any other template adds +# its own negation after these lines, for example `!.env.example`. +*.[eE][nN][vV] +.[eE][nN][vV].* +.[eE][nN][vV][rR][cC] +!example.env +!sample.env + +# Private keys and the bundles carrying them. +*.[pP][eE][mM] +*.[kK][eE][yY] +*.[pP]12 +*.[pP][fF][xX] +[iI][dD]_[rR][sS][aA] +[iI][dD]_[dD][sS][aA] +[iI][dD]_[eE][cC][dD][sS][aA] +[iI][dD]_[eE][dD]25519 + +# The binary `make build` writes. +/keyfunc diff --git a/.golangci.yml b/.golangci.yml index 8a90995..a7a74c2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,15 +10,20 @@ run: linters: default: all + enable: + # Successor to the deprecated gomodguard. Named explicitly, rather than + # left to `default: all`, because it carries the module policy below. + - gomodguard_v2 disable: # Genuinely incompatible with project patterns - exhaustruct # Requires all struct fields - - 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 + # Deprecated: the warning is attached to the old name, so it is + # silenced by disabling that name, not by enabling the successor. + - wsl # Deprecated, replaced by wsl_v5 + - gomodguard # Deprecated, replaced by gomodguard_v2 settings: lll: line-length: 88 @@ -29,6 +34,64 @@ linters: max-complexity: 15 dupl: threshold: 100 + depguard: + # Test-support code must not be compiled into the shipped binary. A + # test-support package exists to hand a test privileges the program + # itself must never have, so a file that is not a test must not import + # one. Test files, and the files inside a package whose directory name + # ends in `test`, are where that code belongs, and are exempt. + # + # The deny list below is the one part of this file a repository is + # expected to extend, and the only part it may. depguard matches an + # import path against a list of prefixes, so it cannot be told "any path + # whose last segment ends in test"; a repository's own test-support + # packages have to be named here one at a time, by full import path, + # under a module path that differs from repository to repository. Add + # them; change nothing else. + rules: + test-support: + list-mode: lax + files: + - "$all" + - "!$test" + - "!**/*test/**" + deny: + - pkg: net/http/httptest + desc: >- + Test-support code belongs in test files and in packages whose + directory name ends in test, not in the shipped binary. + # Only decisions already recorded in the Go package defaults are + # listed here. Every entry matches the module path exactly. + gomodguard_v2: + blocked: + - module: github.com/rs/zerolog + recommendations: + - log/slog + reason: "Structured logging is stdlib log/slog." + # One entry per pre-fork module path, because the later releases + # are separate paths. A prefix match would be shorter but would + # also reach github.com/go-redis/redismock, the test double for + # the successor these entries recommend. + - module: github.com/go-redis/redis + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v7 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v8 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/sergi/go-diff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "No unified diff output; use go-udiff." + - module: github.com/hexops/gotextdiff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "Unmaintained fork; use go-udiff." issues: max-issues-per-linter: 0 diff --git a/README.md b/README.md index 31fd1ce..22dd910 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,6 @@ The open issues that stand between the tree and a 1.0 release: - [#38 Lint and test as phases of the Dockerfile, as the current repo policy requires](https://git.eeqj.de/sneak/keyfunc/issues/38) - [#39 make fmt and make fmt-check cover Markdown with prettier](https://git.eeqj.de/sneak/keyfunc/issues/39) -- [#40 The linter config, .gitignore and .dockerignore from the current templates](https://git.eeqj.de/sneak/keyfunc/issues/40) - [#42 go-bip39 no longer exists upstream: keep it, or copy it into the repo?](https://git.eeqj.de/sneak/keyfunc/issues/42) ## License From 90596de901366885205487f9e175257e4b995357 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 02:59:03 +0200 Subject: [PATCH 15/28] Lint and test as phases of the Dockerfile (closes #38) Lint and test are now phases of the one Dockerfile, as the current repo policy requires: a lint phase on the pinned golangci-lint image and a test phase on the pinned Go image, and the build stage depends on both, so a plain docker build . fails when either fails. Dockerfile.lint is gone. REPO_POLICIES.md and script/lint, test, docker and cibuild are byte-identical to the current sneak/prompts copies, so every docker build in script/ is uncached and tagged. make test now needs Docker on the host; formatting is checked on the host only. Judgement calls: the test phase installs gcc and musl-dev unpinned for -race; no -count=1, since a build stage holds no earlier result. Model: opus-5-5 --- Dockerfile | 47 +++++- Dockerfile.lint | 15 -- README.md | 21 +-- REPO_POLICIES.md | 367 ++++++++++++++++++++++++++++++++++++----------- script/bootstrap | 8 +- script/cibuild | 23 ++- script/docker | 12 +- script/lint | 17 ++- script/test | 14 +- 9 files changed, 393 insertions(+), 131 deletions(-) delete mode 100644 Dockerfile.lint diff --git a/Dockerfile b/Dockerfile index fcea34f..e629c25 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,48 @@ -# The formatting check, the tests and the build. Linting is not here: -# it runs in its own pinned image, see Dockerfile.lint and script/lint, -# which script/cibuild runs before this file. +# The lint phase, the test phase and the build. script/lint and +# script/test each build one phase alone; a plain `docker build .` builds +# both, because the build stage copies a file from each. Formatting is +# checked on the host by script/fmt-check, not here. +# Lint phase +# golangci/golangci-lint:v2.12.2, 2026-09-07 +FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN golangci-lint run --config .golangci.yml ./... + +# Test phase +# golang:1.26-alpine, 2026-09-07 +FROM golang@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS test + +# -race needs cgo, and cgo needs a C toolchain. +RUN apk add --no-cache gcc musl-dev + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN go test -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -timeout 90s -race -v ./...; exit 1; } + +# Build stage. Nothing is wanted from either phase above; the copies +# are what make BuildKit build them first, so this stage cannot run +# unless lint and test passed. # golang:1.26-alpine, 2026-09-07 FROM golang@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS builder +COPY --from=lint /src/go.sum /dev/null +COPY --from=test /src/go.sum /dev/null + RUN apk add --no-cache make git WORKDIR /src @@ -14,9 +52,6 @@ RUN go mod download COPY . . -RUN make fmt-check -RUN make test - # The version stamped into the binary: the VERSION build argument when one # is given, otherwise `git describe --tags --always` of the .git in the # build context. A context that carries .git and still yields no version diff --git a/Dockerfile.lint b/Dockerfile.lint deleted file mode 100644 index 635c754..0000000 --- a/Dockerfile.lint +++ /dev/null @@ -1,15 +0,0 @@ -# The linter, pinned by hash, with this repository linted inside it. -# Building this file is how linting happens; see script/lint. Nothing -# lints on the host, so the answer is the same everywhere. - -# golangci/golangci-lint:v2.12.2, 2026-09-07 -FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 - -WORKDIR /src - -COPY go.mod go.sum ./ -RUN go mod download - -COPY . . - -RUN golangci-lint run --timeout 5m diff --git a/README.md b/README.md index 22dd910..8ee721b 100644 --- a/README.md +++ b/README.md @@ -290,18 +290,22 @@ standard: most Makefile targets are thin shims over an executable in 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/test` builds the `test` phase of the `Dockerfile` alone, uncached: the + test suite runs with the race detector inside the build, rerunning verbosely + if a test fails. +- `script/lint` builds the `lint` phase of the `Dockerfile` alone, uncached: the + linter, pinned by hash, runs inside the build, so a complaint fails it 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/docker` builds the Docker image, uncached, tagged with the project + name and stamped with the version `git describe` gives on the host. The image + cannot be built unless the `lint` and `test` phases pass, so a plain + `docker build .` runs them too. +- `script/cibuild` is the CI build the Gitea workflow calls: it runs + `bootstrap`, then `check`, then builds the image as `script/docker` does. - `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 @@ -311,7 +315,6 @@ standard: most Makefile targets are thin shims over an executable in The open issues that stand between the tree and a 1.0 release: -- [#38 Lint and test as phases of the Dockerfile, as the current repo policy requires](https://git.eeqj.de/sneak/keyfunc/issues/38) - [#39 make fmt and make fmt-check cover Markdown with prettier](https://git.eeqj.de/sneak/keyfunc/issues/39) - [#42 go-bip39 no longer exists upstream: keep it, or copy it into the repo?](https://git.eeqj.de/sneak/keyfunc/issues/42) diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md index fad388c..ca05cb4 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-08-19 +last_modified: 2026-10-02 --- This document covers repository structure, tooling, and workflow standards. Code @@ -60,17 +60,28 @@ style conventions are in separate documents: prerequisite since nvm requires bash. yarn is then pinned via `corepack prepare yarn@ --activate`. Never install "latest" or "lts"; always exact versions. `script/cibuild` runs the CI build: it changes to the - repo root and runs `docker build .`; the Gitea workflow calls it. Four further - scripts are our own extensions to the standard: `script/check` runs - `script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is - what the git pre-commit hook runs, and it calls `script/check`; - `script/install-precommit` installs the git pre-commit hook (the `make hooks` - target shims to it); and `script/projectname` (literally that filename) simply - outputs the project's name. Scripts that need the name call - `script/projectname` — e.g. `script/docker` assembles its image tag from it — - so those scripts stay byte-identical across all repos. Repo-type-specific - pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in - `script/precommit`, not in the hook itself. Model scripts are at + repo root, runs `script/bootstrap`, runs `script/check`, and builds the image + with the version; the Gitea workflow calls it. **`script/cibuild` runs + `script/bootstrap` first**, because the workflow checks out the repo and runs + nothing else, while `script/fmt-check` runs the formatter on the host: on a + pristine checkout with nothing installed the run dies there, after the + containerised gates have passed. **The bootstrap alone is not enough**: + `script/bootstrap` installs node and yarn under nvm and leaves neither on the + `PATH` of the shell that called it, so a bare `yarn` still exits 127. The host + entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore + source nvm for the pinned node version before invoking it, exactly as + `script/bootstrap`'s own install step does. A runner carrying nothing but + docker and git then gets through `script/check`. Four further scripts are our + own extensions to the standard: `script/check` runs `script/test`, + `script/lint` and `script/fmt-check`; `script/precommit` is what the git + pre-commit hook runs, and it calls `script/check`; `script/install-precommit` + installs the git pre-commit hook (the `make hooks` target shims to it); and + `script/projectname` (literally that filename) simply outputs the project's + name. Scripts that need the name call `script/projectname` — e.g. + `script/docker` assembles its image tag from it — so those scripts stay + byte-identical across all repos. Repo-type-specific pre-commit extras (e.g. + `go mod tidy` verification in Go repos) belong in `script/precommit`, not in + the hook itself. Model scripts are at `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/`. The README must document the provided scripts in an **Entrypoints** section (see the README requirements below). @@ -89,87 +100,164 @@ style conventions are in separate documents: contributor should be able to understand the entire development workflow by reading the Makefile. -- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` - as a build step so the build fails if the branch is not green. For non-server - repos, the Dockerfile should bring up a development environment and run - `make check`. For server repos, `make check` should run as an early build - stage before the final image is assembled. Dockerfiles install development - prerequisites by running `script/bootstrap` rather than duplicating installs - inline; COPY `script/` and the dependency manifests (`package.json` + - `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap - layer stays cached until dependencies change. +- Every repo should have a `Dockerfile`, and it carries the repo's gates: a + `lint` phase and a `test` phase, with the final stage depending on both so the + image cannot be built unless they pass. For non-server repos the final stage + brings up a development environment; for server repos it is the runtime image. + Dockerfiles install development prerequisites by running `script/bootstrap` + rather than duplicating installs inline; COPY `script/` and the dependency + manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before + running it. -- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go - repos use a multistage build where linting runs in an independent stage based - on the `golangci/golangci-lint` image (pinned by hash). This stage runs - `make fmt-check` and `make lint` before the full build begins. The build stage - then declares an explicit dependency on the lint stage via - `COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete - linting before proceeding to compilation and tests. This ensures lint failures - surface in seconds rather than minutes, without blocking on dependency - download or compilation in the build stage. +- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is + no separate lint file. `script/lint` and `script/test` each build one phase + and nothing else: - The standard pattern for a Go repo Dockerfile is: + ```sh + docker build --no-cache --target lint -t "$(script/projectname)-lint" . + docker build --no-cache --target test -t "$(script/projectname)-test" . + ``` + + **A stage that is not the last one in the file is built only when the final + stage's chain depends on it, or when `--target` names it.** That is why the + two gates are always invoked by name here, and why the final stage carries a + `COPY --from=` of a harmless file from each of them: without that edge a + plain `docker build .` builds the last stage alone and exits 0 having linted + and tested nothing. + + **Every `docker build` in `script/` is tagged**, here and in + `script/cibuild` and `script/docker`. An untagged build leaves a dangling + image behind on every invocation, on every developer host and every CI + runner; a tagged one replaces the previous image. + + Inside a phase the tool is invoked directly — `golangci-lint`, `go test`, + `eslint`, `prettier` — never through `make lint` or `script/test`, which are + themselves a `docker build` and would recurse into a daemon that does not + exist in a build step. Formatting is the exception and stays on the host: + `script/fmt` writes the working tree, and `script/fmt-check` is its + read-only twin. + + **No lint verdict may come from a host invocation of the linter.** On a + shared host golangci-lint reads a result cache keyed on file content rather + than location, so a second checkout of the same content is served the first + one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs + exit non-zero with `parallel golangci-lint is running` — a status a caller + cannot tell from real findings. Both have produced wrong verdicts in this + org, in both directions. A container has its own cache, its own `TMPDIR` and + a digest-pinned binary, so neither is reachable. + +- **Any build that runs checks is built with `--no-cache`.** Docker invalidates + a `COPY` layer only when the copied content changes, so on an unchanged tree + the check `RUN` is served from cache, nothing executes, and the build still + exits 0. Every `docker build` in `script/` therefore passes `--no-cache`: + `script/lint`, `script/test`, `script/cibuild` and `script/docker` are the + four, and there is no fifth — `script/check` runs the two gate phases and + `script/fmt-check`, and builds no image of its own. A bare `docker build .` is + not evidence that anything ran: a sub-second build reporting success is a + cache hit, not a result. Never invalidate by pruning — `docker builder prune` + and friends destroy a build cache shared with every other build on the host. + +- **The gate phases are separate stages, and the build stage depends on both.** + The lint phase is based on the `golangci/golangci-lint` image (pinned by + hash), so lint failures surface in seconds rather than after a full compile, + and the test phase is based on the Go image. The canonical Go repo + `Dockerfile`: ```dockerfile - # Lint stage — fast feedback on formatting and lint issues + # Lint phase # golangci/golangci-lint:v2.x.x, YYYY-MM-DD FROM golangci/golangci-lint@sha256:... AS lint WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . - RUN make fmt-check - RUN make lint + RUN golangci-lint run --config .golangci.yml ./... - # Build stage + # Test phase # golang:1.x-alpine, YYYY-MM-DD - FROM golang@sha256:... AS builder + FROM golang@sha256:... AS test WORKDIR /src - - # Force BuildKit to run the lint stage before proceeding - COPY --from=lint /src/go.sum /dev/null - COPY go.mod go.sum ./ RUN go mod download COPY . . - RUN make test + RUN go test -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -timeout 90s -race -v ./...; exit 1; } - ARG VERSION=dev - RUN CGO_ENABLED=0 go build -trimpath \ - -ldflags="-s -w -X main.Version=${VERSION}" \ - -o /app ./cmd/app/ + # Build stage. Nothing is wanted from either phase above; the copies + # are what make BuildKit build them first, so this stage cannot run + # unless lint and test passed. + # golang:1.x-alpine, YYYY-MM-DD + FROM golang@sha256:... AS builder + COPY --from=lint /src/go.sum /dev/null + COPY --from=test /src/go.sum /dev/null + RUN apk add --no-cache git + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . - # Runtime stage + # The VERSION build arg when one is given, otherwise + # `git describe --tags --always` on the .git in the build context. With + # .git present, a version that is still empty, dev or unknown fails the + # build: git is missing or could not read the checkout. + ARG VERSION + RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ + if [ -e .git ]; then \ + case "$VERSION" in ""|dev|unknown) \ + echo "version is '$VERSION' although .git is present" >&2; \ + exit 1 ;; \ + esac; \ + fi; \ + CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /app ./cmd/app/ + + # Runtime stage, and the last one FROM alpine@sha256:... COPY --from=builder /app /usr/local/bin/app ENTRYPOINT ["app"] ``` Key points: - - The lint stage uses the `golangci/golangci-lint` image directly (it - includes both Go and the linter), so there is no need to install the - linter separately. - - `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates - a stage dependency. BuildKit runs stages in parallel by default; without - this line, the build stage would not wait for lint to finish and a lint - failure might not fail the overall build. + - The lint phase uses the `golangci/golangci-lint` image directly (it has + both Go and the linter), so nothing needs installing. + - `COPY --from= /src/go.sum /dev/null` is a no-op copy whose only + purpose is the ordering edge. BuildKit runs stages in parallel by default, + and a stage nothing depends on is not built at all, so without these two + lines a red gate would not fail the build. + - Keep the runtime stage last, and if you add a stage after it, give it the + same two copies. A plain `docker build .` builds the last stage's chain + and nothing else. - If the project uses `//go:embed` directives that reference build artifacts - (e.g. a web frontend compiled in a separate stage), the lint stage must + (e.g. a web frontend compiled in a separate stage), the lint phase must create placeholder files so the embed directives resolve. Example: `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. - The lint stage should not depend on the actual build output — it exists to - fail fast. - If the project requires CGO or system libraries for linting (e.g. - `vips-dev`), install them in the lint stage with `apk add`. - - The build stage runs `make test` after compilation setup. Tests run in the - build stage, not the lint stage, because they may require compiled - artifacts or heavier dependencies. + `vips-dev`), install them in the lint phase with `apk add`. + - `.dockerignore` lets `.git` into the build context. It keeps out + `.git/config`, which `git describe` does not need and which can hold a + credential: a password in a remote URL, or the token the CI checkout step + stores there. The stage that compiles has `git` (the Debian Go image has + it; an alpine one needs `apk add --no-cache git`) and takes the version + from the `VERSION` build argument when one is given, otherwise from + `git describe --tags --always`. That gives the tag on a tagged commit; on + a later commit, the tag, the number of commits since it and the short + commit (`v1.2.3-4-gabc1234`); and the short commit when no tag is + reachable. `ARG VERSION` has no default, and the build fails if the + context carries `.git` and the version still comes out empty, `dev` or + `unknown`. A plain `docker build .` with no build arguments must succeed; + a Dockerfile that refuses an empty build argument drops that refusal and + keeps the argument. - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - runs `script/cibuild` (which runs `docker build .`) on push. Since the - Dockerfile already runs `make check`, a successful build implies all checks - pass. + runs `script/cibuild` on push, and checks out the repo as its only other step. + That script bootstraps, runs the gate phases, and then builds the image, so a + successful run means every check passed; a bare `docker build .` does not + carry the same guarantee, because its gate phases may come from the cache. The + image build is uncached and so runs the gate phases a second time. That is the + price of the rule above, and it is worth paying: the image that ships is built + from a run of its own gates rather than from a cache entry. - Use platform-standard formatters: `black` for Python, `prettier` for JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with @@ -193,15 +281,17 @@ style conventions are in separate documents: suite that exceeds it fails. Under 20 seconds is the target. A suite between 20 and 60 seconds is still green, but the overage must be filed as an improvement bug against that repo. Add a 90-second timeout to the test - invocation in the Makefile (`go test -timeout 90s`). The backstop deliberately - sits above the hard cap so that it catches a genuinely hung test rather than a - merely slow one. + invocation (`go test -timeout 90s`). The backstop deliberately sits above the + hard cap so that it catches a genuinely hung test rather than a merely slow + one. -- **`make test` should use the conditional verbose rerun pattern.** Run tests - without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to - show full output. This keeps CI logs and `docker build` output clean on - success (just package/suite summaries) while providing full diagnostic detail - on failure (every test case, every assertion). The general shell pattern: +- **The test command should use the conditional verbose rerun pattern.** Run + tests without `-v` (verbose) first. If tests fail, automatically rerun with + `-v` to show full output. This keeps CI logs and `docker build` output clean + on success (just package/suite summaries) while providing full diagnostic + detail on failure (every test case, every assertion). The command lives in the + `test` phase of the `Dockerfile`, since `script/test` builds that phase; the + Makefile form below is the same pattern for any repo-local invocation: ```makefile test: @@ -214,11 +304,24 @@ style conventions are in separate documents: ```makefile test: - @go test -timeout 90s -race -cover ./... || \ + @go test -count=1 -timeout 90s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ - go test -timeout 90s -race -v ./...; exit 1; } + go test -count=1 -timeout 90s -race -v ./...; exit 1; } ``` + `-count=1` is required on both invocations: it defeats Go's test _result_ + cache, so the target cannot report a pass it did not earn, and the rerun + reproduces a failure instead of replaying it. It leaves the build cache + alone, so it costs the runtime of the suite and no recompilation. + + Note that this is a second, independent cache, stacked below the Docker + layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26) + addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes; + it does not guarantee `go test` inside that step does any work, because the + `GOCACHE` baked into earlier image layers survives into the re-executed + step. They are two separate defects requiring two separate fixes, and a fix + for one must not be recorded as covering the other. + Python example: ```makefile @@ -244,10 +347,84 @@ style conventions are in separate documents: must be in `.gitignore`. No exceptions. - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`. - Fetch the standard `.gitignore` from - `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up - a new repo. + editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`), + language build artifacts, and `node_modules/`. Fetch the standard `.gitignore` + from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when + setting up a new repo. These patterns are written to `.gitignore`'s own + semantics, in which an unanchored pattern already matches at every depth; they + are not a `.dockerignore` and must not be transplanted into one unmodified. + +- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns + across unmodified leaves secrets in the build context.** Docker matches with + `moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so + `*` does not cross `/` and a pattern without a leading `**/` is anchored at + the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key` + therefore excludes only the copies at the repository root, while `config/.env` + and `certs/server.key` still reach the context and can land in an image layer + — which is more dangerous than a short file with no secret patterns at all, + because it reads as solved and stops anyone looking. Give every + depth-independent pattern the `**/` prefix and leave only genuinely + root-anchored entries unprefixed: `.claude`, and the repo's own host-built + binary, written `/myapp` and never `**/myapp`, which would also match + `cmd/myapp/` and delete the package directory from the context. Matching is + case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so + secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`, + and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern + also catches something the build needs, re-include it with a negation + (`!docs/example.env`); deleting the pattern reopens the exposure for every + other file it covers. Fetch the standard `.dockerignore` from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend + it with the repo's own artifacts. + +- **In-repo agent scratch belongs in both files, written to each file's own + semantics.** `.claude/` holds one worktree per in-flight agent — an entire + additional checkout of the repo — so under `COPY . .` the build context + inflates by a multiple of the repo and another session's unreviewed work can + be copied into an image layer. In `.gitignore` the entry is `.claude/`, + unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/` + prefix, because the prefixed form would also delete any nested directory of + that name from the build. Anchoring carries a known gap that the canonical + `.dockerignore` states in its own comment, since consuming repos receive the + file and not the tracker: the directory is created in the agent's working + directory, so a repo running agents in subdirectories still ships + `services/api/.claude/` and must add its own anchored entry there. + +- **A plain `docker build .` of a clone stamps the version that + `git describe --tags --always` gives**, derived from the `.git` in the build + context as the canonical `Dockerfile` above shows. Without its failure check, + a missing `git` or an unreadable checkout would leave `-X main.Version=` empty + and the build would still exit 0. `script/docker` and `script/cibuild` pass + the version they compute on the host; it takes precedence. They do this + byte-identically across repos: + + ```sh + # Own line: a failing command substitution inside an argument does not + # trip `set -e`, so the inline form degrades to an empty constant. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$(script/projectname)" . + ``` + + `--always` makes an untagged repo yield an abbreviated commit hash rather + than failing, and the `[ -n "$version" ]` line is the single place the + fallback is applied — a live check that fires on a build from an export with + no `.git` and on a repository with no commits yet. Do not fold it into the + substitution as `|| echo unknown`, which makes the guard unreachable. The + Dockerfile's side is `ARG VERSION` in the stage that compiles, declared + there because `ARG` is stage-scoped; passing `VERSION` to a repo whose + Dockerfile declares no such `ARG` is ignored and costs nothing, which is why + the scripts stay byte-identical. One consequence for CI: the standard + checkout action clones shallow and fetches no tags, so a repo that embeds a + tag-derived version must set `fetch-depth: 0` on its checkout step. + +- **Verify `.dockerignore` by enumerating the image, not by reading the + patterns.** Plant files at the root _and_ at least two directories deep, build + a probe image that does `COPY . .`, and list what actually landed + (`docker run --rm --entrypoint find IMAGE /app`). The `transferring context` + size is not a substitute: a nested secret is a few bytes, and BuildKit + transfers only the delta from the previous build. - **No build artifacts in version control.** Code-derived data (compiled bundles, minified output, generated assets) must never be committed to the @@ -269,9 +446,39 @@ style conventions are in separate documents: byte-identical, so that no repo can quietly loosen its own linting. Linter configuration changes are made to the canonical copy in the `prompts` repo and reach consuming repos by re-vendoring; an agent may open a PR against - canonical, which only the user merges. The canonical golangci-lint version is - v2.12.2 (released 2026-05-06), installed commit-pinned via - `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`. + canonical, which only the user merges. One list is exempt from byte-identity, + because it cannot be written once for every repo: the `deny` list of the + `test-support` depguard rule, where a repo names its own test-support packages + by full import path. A repo adds entries there and changes nothing else, and a + re-vendor carries its entries forward. The canonical golangci-lint version is + v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base + image + (`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, + which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the + only pin, since no repo installs golangci-lint on the host: bumping the + version means changing it and nothing else. + +- **`script/bootstrap` installs a pinned tool by comparing versions, never by + testing presence.** An `if ! command -v ; then install; fi` guard tests + `PATH` only, so on an already-provisioned machine the pin is inert and a + version bump is a silent no-op — while the Dockerfile, installing into a clean + image, gets the pinned version, so a local `make check` and `make docker` can + disagree about what the tool even is. The canonical form: + - compares the installed version against the pin over the **whole** version + token; a parser that stops at the first `-` reports `2.12.2` for a host + running `2.12.2-rc1` and skips the install; + - treats absent, non-zero, empty or unrecognised `--version` output as a + mismatch, so the failure direction is a redundant install and never a + skipped one; + - after installing, re-resolves the binary the way callers do — `hash -r`, + then through `PATH`, not through the directory the installer wrote to — + and fails naming the resolved path, since an install that a shadowing + binary hides succeeds while changing nothing any caller sees; + - is actually called, and prints the version on both success paths: a + function defined and never invoked has the same exit status and the same + empty output as one that worked. + + Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. - When pinning images or packages by hash, add a comment above the reference with the version and date (YYYY-MM-DD). diff --git a/script/bootstrap b/script/bootstrap index 8b1bb0f..4705d2b 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,9 +3,9 @@ # repo. Idempotent: every install is guarded by a check, so tools that # are already there are left alone. Base tooling comes from nix, apt, # brew, or apk, detected in that order, and nothing is assumed to be -# present. The linter is not installed here: it only ever runs inside -# the image built from Dockerfile.lint, so Docker is what is needed for -# it, and that is checked for rather than installed. +# present. The linter is not installed here: linting and testing run +# only as phases of the Dockerfile, so Docker is what is needed for +# them, and that is checked for rather than installed. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -60,7 +60,7 @@ main() { go mod download if missing docker; then - echo "bootstrap: docker is not installed; make lint needs it" >&2 + echo "bootstrap: docker is not installed; make lint and make test need it" >&2 fi echo "bootstrap complete" diff --git a/script/cibuild b/script/cibuild index baad78e..d8d3200 100755 --- a/script/cibuild +++ b/script/cibuild @@ -1,8 +1,10 @@ #!/bin/sh -# script/cibuild: run the CI build. The linter needs an image of its -# own, so it runs first; the Dockerfile then runs the formatting check, -# the tests and the build, so a green run here means make check is -# green. +# script/cibuild: run the CI build. It bootstraps first: a CI runner +# checks out and runs this and nothing else, and script/fmt-check runs +# the formatter on the host, which a pristine checkout cannot do. +# --no-cache for the same reason as script/docker: the gate phases the +# final stage depends on are RUN steps, and a cached one is a check that +# did not run. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -10,8 +12,17 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - "$SCRIPT_DIR/lint" - docker build . + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/check" + # Own line: a failing command substitution inside an argument does + # not trip `set -e`, so the inline form degrades silently to an + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$("$SCRIPT_DIR/projectname")" . } main "$@" diff --git a/script/docker b/script/docker index 9b9ea86..07b626c 100755 --- a/script/docker +++ b/script/docker @@ -1,6 +1,8 @@ #!/bin/sh # script/docker: build the Docker image tagged with the project name. # Identical in all repos; the tag comes from script/projectname. +# --no-cache because the gate phases the final stage depends on are RUN +# steps, and a cached one is a check that did not run. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -8,7 +10,15 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build -t "$("$SCRIPT_DIR/projectname")" . + # Own line: a failing command substitution inside an argument does + # not trip `set -e`, so the inline form degrades silently to an + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$("$SCRIPT_DIR/projectname")" . } main "$@" diff --git a/script/lint b/script/lint index fe75dca..2d8b075 100755 --- a/script/lint +++ b/script/lint @@ -1,9 +1,13 @@ #!/bin/sh -# script/lint: run the linter. Linting only ever happens inside the -# image built from Dockerfile.lint, which pins the linter by hash, so -# the answer is the same on every machine and in CI. The linter runs as -# a build step of that image, so a complaint fails the build and no -# container is left behind. +# script/lint: run the linter. Linting is a phase of the Dockerfile and +# this builds that phase alone; the linter is never installed or run on +# a developer host, where a shared result cache and a host-global lock +# make its answer untrustworthy. +# +# The phase is not the last stage in the file, so it is built only when +# --target names it. --no-cache because a cached lint layer is a lint +# that did not run. The tag makes each build replace the previous image +# instead of leaving a dangling one behind. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" @@ -11,7 +15,8 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - docker build --progress=plain -f Dockerfile.lint \ + docker build --no-cache \ + --target lint \ -t "$("$SCRIPT_DIR/projectname")-lint" . } diff --git a/script/test b/script/test index 25c9896..cd239f2 100755 --- a/script/test +++ b/script/test @@ -1,13 +1,19 @@ #!/bin/sh -# script/test: run the test suite (vet first, verbose rerun on failure). +# script/test: run the test suite. Testing is a phase of the Dockerfile +# and this builds that phase alone, on the same terms as script/lint: +# --target because a phase that is not the last stage is built only when +# named, --no-cache because a cached test layer is a test that did not +# run, and a tag so each build replaces the previous image. set -eu -ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" main() { cd "$ROOT" - go vet ./... - go test -timeout 90s ./... || go test -timeout 90s -v ./... + docker build --no-cache \ + --target test \ + -t "$("$SCRIPT_DIR/projectname")-test" . } main "$@" From 7280ee35f4aa91402b213e74607121eae1e402f8 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 05:06:31 +0200 Subject: [PATCH 16/28] script/bootstrap installs a pinned Go so script/cibuild runs on the Gitea runner (closes #46) script/cibuild runs script/bootstrap on the Gitea runner, which has Docker and git but no Go, and bootstrap could not install it: its apt path never ran apt-get update. Bootstrap now installs Go at the version in the Dockerfile's golang image from go.dev, checked against sha256 values in the script, into ~/.local/go whenever the go first on PATH reports another version; the Makefile and the fmt, fmt-check and precommit scripts put ~/.local/go/bin first on their PATH. apt-get update runs once before the first apt install. Bumping the golang digest now means bumping GO_VERSION and its four hashes. Partially verified: checked in a clean ubuntu:24.04 container, not yet on the Gitea runner. Model: opus-5-5 --- Dockerfile | 3 +- Makefile | 3 ++ README.md | 10 ++++-- script/bootstrap | 93 +++++++++++++++++++++++++++++++++++++++++++++--- script/fmt | 3 ++ script/fmt-check | 3 ++ script/precommit | 3 ++ 7 files changed, 110 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index e629c25..2f33928 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,8 @@ COPY . . RUN golangci-lint run --config .golangci.yml ./... # Test phase -# golang:1.26-alpine, 2026-09-07 +# golang:1.26-alpine, 2026-09-07. It carries Go 1.26.8, the version +# script/bootstrap installs on the host; change both together. FROM golang@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS test # -race needs cgo, and cgo needs a C toolchain. diff --git a/Makefile b/Makefile index eb1d4bd..a4129c9 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,9 @@ VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) LDFLAGS := -s -w -X 'sneak.berlin/go/keyfunc/internal/cli.Version=$(VERSION)' +# Where script/bootstrap installs Go; it cannot put it on our PATH. +export PATH := $(HOME)/.local/go/bin:$(PATH) + .PHONY: default bootstrap setup build test lint fmt fmt-check check \ docker cibuild hooks clean diff --git a/README.md b/README.md index 8ee721b..043ef54 100644 --- a/README.md +++ b/README.md @@ -283,9 +283,13 @@ The repo adheres to the standard: most Makefile targets are thin shims over an executable in `script/` (`build` and `clean` are the exceptions). -- `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/bootstrap` installs everything needed to build and develop, + idempotently: git and make from nix, apt, brew or apk, and Go, at the version + the `Dockerfile`'s Go image carries, from the official release archive + (checked against a sha256 in the script) into `~/.local/go`. `script/fmt`, + `script/fmt-check`, `script/precommit` and the `Makefile` put + `~/.local/go/bin` first on their `PATH`, so they use that Go. 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 diff --git a/script/bootstrap b/script/bootstrap index 4705d2b..33d46f2 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,13 +3,25 @@ # repo. Idempotent: every install is guarded by a check, so tools that # are already there are left alone. Base tooling comes from nix, apt, # brew, or apk, detected in that order, and nothing is assumed to be -# present. The linter is not installed here: linting and testing run -# only as phases of the Dockerfile, so Docker is what is needed for -# them, and that is checked for rather than installed. +# present. Go is installed at the version the Dockerfile's Go image +# carries, from the official release archive, into ~/.local/go. The +# linter is not installed here: linting and testing run only as phases +# of the Dockerfile, so Docker is what is needed for them, and that is +# checked for rather than installed. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# Must match the Go in the Dockerfile's golang image; the sha256 of each +# archive is in install_go. +GO_VERSION="1.26.8" + +# This script cannot change its caller's PATH, so script/fmt, +# script/fmt-check, script/precommit and the Makefile put this directory +# first on their own PATH, as is done here. +GO_DIR="$HOME/.local/go" +PATH="$GO_DIR/bin:$PATH" + PKGMGR="" SUDO="" @@ -32,6 +44,9 @@ detect_pkgmgr() { if [ "$(id -u)" != "0" ]; then SUDO="sudo" fi + # This runs once, before the first install: a fresh host or + # runner image has no package lists yet. + $SUDO apt-get update fi } @@ -50,12 +65,82 @@ missing() { ! command -v "$1" >/dev/null 2>&1 } +# verify_sha256 +verify_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$1" | cut -d' ' -f1)" + else + actual="$(shasum -a 256 "$1" | cut -d' ' -f1)" + fi + if [ "$actual" != "$2" ]; then + echo "bootstrap: sha256 mismatch for $1" >&2 + echo " expected: $2" >&2 + echo " actual: $actual" >&2 + exit 1 + fi +} + +# True when the go first on PATH reports exactly GO_VERSION. No go, a go +# that fails, or any other output is a mismatch. +go_version_matches() { + out="$(go version 2>/dev/null)" || return 1 + case "$out" in + "go version go$GO_VERSION "*) return 0 ;; + *) return 1 ;; + esac +} + +install_go() { + # sha256 of the go1.26.8 archives at https://go.dev/dl/, 2026-10-04 + case "$(uname -s)-$(uname -m)" in + Linux-x86_64) + platform="linux-amd64" + sha256="d0f743b33e8d8945e6b1f432edd15785c70507121d6e2a723b21285eddf8b57b" + ;; + Linux-aarch64) + platform="linux-arm64" + sha256="211ffced9dcb9633a55eac6364816ec0ddd951389a740e88fa8b3337971bdda0" + ;; + Darwin-x86_64) + platform="darwin-amd64" + sha256="186be014105aa6542b767d2c6ed5cca10a0214bdff809ef1724022a8c7894150" + ;; + Darwin-arm64) + platform="darwin-arm64" + sha256="a012b25b571bd0138a03dcd25375ceba866fe5ca822f426d2c66a4de56fd3f4b" + ;; + *) + echo "bootstrap: no Go archive pinned for $(uname -s) $(uname -m)" >&2 + exit 1 + ;; + esac + if missing curl; then pkg_install curl curl curl curl; fi + tmp="$(mktemp -d)" + curl -fsSL -o "$tmp/go.tar.gz" \ + "https://go.dev/dl/go${GO_VERSION}.${platform}.tar.gz" + verify_sha256 "$tmp/go.tar.gz" "$sha256" + # An archive unpacked over an older Go leaves a broken tree. + rm -rf "$GO_DIR" + mkdir -p "$GO_DIR" + tar -xzf "$tmp/go.tar.gz" -C "$GO_DIR" --strip-components=1 + rm -rf "$tmp" +} + main() { cd "$ROOT" if missing git; then pkg_install git git git git; fi if missing make; then pkg_install gnumake make make make; fi - if missing go; then pkg_install go golang go go; fi + + if ! go_version_matches; then + install_go + hash -r + if ! go_version_matches; then + echo "bootstrap: $(command -v go) is not go$GO_VERSION after installing it" >&2 + exit 1 + fi + fi + go version go mod download diff --git a/script/fmt b/script/fmt index e95d111..10a62ff 100755 --- a/script/fmt +++ b/script/fmt @@ -4,6 +4,9 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# Where script/bootstrap installs Go; it cannot put it on our PATH. +PATH="$HOME/.local/go/bin:$PATH" + main() { cd "$ROOT" go fmt ./... diff --git a/script/fmt-check b/script/fmt-check index 2e91588..8170456 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -5,6 +5,9 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# Where script/bootstrap installs Go; it cannot put it on our PATH. +PATH="$HOME/.local/go/bin:$PATH" + main() { cd "$ROOT" if [ -n "$(gofmt -l .)" ]; then diff --git a/script/precommit b/script/precommit index fe9775b..4794d57 100755 --- a/script/precommit +++ b/script/precommit @@ -7,6 +7,9 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" +# Where script/bootstrap installs Go; it cannot put it on our PATH. +PATH="$HOME/.local/go/bin:$PATH" + main() { cd "$ROOT" go mod tidy From 1ddc2c747a379479d319879c2170808325400e26 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 05:42:46 +0200 Subject: [PATCH 17/28] make fmt and make fmt-check cover Markdown with prettier (closes #39) make fmt and make fmt-check now cover the Markdown files with prettier as well as the Go source: prettier 3.8.1 pinned in package.json and yarn.lock, four-space indents and proseWrap always in .prettierrc, all three copied unchanged from the sneak/prompts templates. script/bootstrap adds pinned node (through nvm from a hash-checked archive when none is installed), yarn and prettier after the pinned Go. README.md is reformatted by make fmt and its Entrypoints section says what each step needs. Model: opus-5-5 --- .prettierrc | 4 ++ README.md | 129 +++++++++++++++++++++++++---------------------- package.json | 5 ++ script/bootstrap | 70 +++++++++++++++++++++++-- script/fmt | 23 ++++++++- script/fmt-check | 20 ++++++++ yarn.lock | 8 +++ 7 files changed, 194 insertions(+), 65 deletions(-) create mode 100644 .prettierrc create mode 100644 package.json create mode 100644 yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8af31cd --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "proseWrap": "always" +} diff --git a/README.md b/README.md index 043ef54..fda0e0a 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,8 @@ make build ``` `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: +[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' @@ -105,11 +105,12 @@ The mnemonic itself is never a command-line argument. It is looked for in this order; the first one found wins: 1. `--mnemonic-command `: a shell command, run with `sh -c`, whose - standard output is the mnemonic. Example: `--mnemonic-command 'secret get - foo'`. Whitespace around the output is dropped. If the command exits with a - non-zero status, the tool prints its standard error and exits with status 1. -2. Environment variable `KEYFUNC_MNEMONIC_COMMAND`: the same, as a shell - command held in the environment. + standard output is the mnemonic. Example: + `--mnemonic-command 'secret get foo'`. Whitespace around the output is + dropped. If the command exits with a non-zero status, the tool prints its + standard error and exits with status 1. +2. Environment variable `KEYFUNC_MNEMONIC_COMMAND`: the same, as a shell command + held in the environment. 3. Environment variable `KEYFUNC_MNEMONIC`: the mnemonic itself. 4. A prompt on the terminal with echo turned off. @@ -119,8 +120,7 @@ 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. +(`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 @@ -128,9 +128,8 @@ installed with `go install` reports the module version instead. ## SSH keys: `keyfunc ssh` -Only ed25519 keys are produced. The application number is `838372`, so the -path is `m/83696968'/838372'/'`. The 32 bytes from step 4 are the ed25519 -seed. +Only ed25519 keys are produced. The application number is `838372`, so the path +is `m/83696968'/838372'/'`. The 32 bytes from step 4 are the ed25519 seed. Test vector, mnemonic `abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`: @@ -160,24 +159,24 @@ same as for `pub`. ### `keyfunc ssh install <[user@]host> [-- sftp options...]` 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. +on the host: the file is fetched, changed here, and written back with the system +`sftp` client in batch mode. -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: +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: - 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; @@ -188,15 +187,14 @@ second connection: 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. +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`. +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 @@ -216,10 +214,10 @@ and the tool then exits with status 1 unless `ssh` reported one of its own. ## age identities: `keyfunc age` The application number is `657169`, path `m/83696968'/657169'/'`. The 32 -bytes from step 4 are clamped as X25519 requires and become an age identity, -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. +bytes from step 4 are clamped as X25519 requires and become an age identity, 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`: @@ -256,11 +254,11 @@ says so and exits with status 1. ### `keyfunc mnemonic [-n N] [--words 12|18|24]` Prints a child mnemonic derived from the main one, using BIP-85's own mnemonic -application (number `39`, English, path -`m/83696968'/39'/0'/'/'`, entropy taken as the specification says, -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. +application (number `39`, English, path `m/83696968'/39'/0'/'/'`, +entropy taken as the specification says, 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. Test vector: the child-mnemonic step is checked against BIP-85's own published vectors, which derive from the specification's master key @@ -273,23 +271,33 @@ 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. +Errors go to standard error and the exit status is 1, except for `ssh to`, which +passes through `ssh`'s own exit status. ## Entrypoints 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). +standard: most Makefile targets are thin shims over an executable in `script/` +(`build` and `clean` are the exceptions). -- `script/bootstrap` installs everything needed to build and develop, - idempotently: git and make from nix, apt, brew or apk, and Go, at the version - the `Dockerfile`'s Go image carries, from the official release archive - (checked against a sha256 in the script) into `~/.local/go`. `script/fmt`, - `script/fmt-check`, `script/precommit` and the `Makefile` put - `~/.local/go/bin` first on their `PATH`, so they use that Go. It does not - install the linter, which only runs inside Docker. +- `script/bootstrap` installs, idempotently, everything needed to build and + develop apart from Docker, which it only warns about when it is missing, and + the linter, which only runs inside Docker. In this order: + - git and make from nix, apt, brew or apk, and from there too curl and bash + when a later step needs them; + - Go at the version the `Dockerfile`'s Go image carries, from the official + release archive at go.dev (checked against a sha256 in the script) into + `~/.local/go`, unless the `go` first on the `PATH` already is that + version; then the Go modules. `script/bootstrap` itself, `script/fmt`, + `script/fmt-check`, `script/precommit` and the `Makefile` put + `~/.local/go/bin` first on their `PATH`, so they use that Go; + - node: an installed one is used as it is, otherwise a pinned one is + installed through nvm, which, when it is missing, comes from its release + archive, checked against a sha256; + - yarn: an installed one is used as it is, otherwise the pinned version + through corepack, or through npm where there is no corepack; + - the pinned prettier, through yarn. - `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 @@ -300,9 +308,11 @@ standard: most Makefile targets are thin shims over an executable in - `script/lint` builds the `lint` phase of the `Dockerfile` alone, uncached: the linter, pinned by hash, runs inside the build, so a complaint fails it 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/fmt` formats in place: the Go source with `go fmt`, then every + Markdown file with prettier (four-space indents, prose wrapped at 80 columns). +- `script/fmt-check` checks the same files the same way without writing, failing + if anything is unformatted. Both need the node, yarn and prettier that + `bootstrap` installs. - `script/check` runs `test`, `lint` and `fmt-check` and changes no files. - `script/docker` builds the Docker image, uncached, tagged with the project name and stamped with the version `git describe` gives on the host. The image @@ -319,7 +329,6 @@ standard: most Makefile targets are thin shims over an executable in The open issues that stand between the tree and a 1.0 release: -- [#39 make fmt and make fmt-check cover Markdown with prettier](https://git.eeqj.de/sneak/keyfunc/issues/39) - [#42 go-bip39 no longer exists upstream: keep it, or copy it into the repo?](https://git.eeqj.de/sneak/keyfunc/issues/42) ## License diff --git a/package.json b/package.json new file mode 100644 index 0000000..dc05cde --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "prettier": "3.8.1" + } +} diff --git a/script/bootstrap b/script/bootstrap index 33d46f2..d7d1668 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -4,10 +4,13 @@ # are already there are left alone. Base tooling comes from nix, apt, # brew, or apk, detected in that order, and nothing is assumed to be # present. Go is installed at the version the Dockerfile's Go image -# carries, from the official release archive, into ~/.local/go. The -# linter is not installed here: linting and testing run only as phases -# of the Dockerfile, so Docker is what is needed for them, and that is -# checked for rather than installed. +# carries, from the official release archive, into ~/.local/go. Node is +# used directly if installed; otherwise it is installed at a pinned +# version via nvm (installing nvm itself first, from a hash-verified +# release archive, never curl | sh). The linter is not installed here: +# linting and testing run only as phases of the Dockerfile, so Docker is +# what is needed for them, and that is checked for rather than +# installed. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -22,6 +25,13 @@ GO_VERSION="1.26.8" GO_DIR="$HOME/.local/go" PATH="$GO_DIR/bin:$PATH" +# Pinned versions, 2026-07-06 +NODE_VERSION="22.17.0" +NVM_VERSION="0.40.3" +# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz +NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0" +YARN_VERSION="1.22.22" + PKGMGR="" SUDO="" @@ -126,6 +136,54 @@ install_go() { rm -rf "$tmp" } +# nvm is a bash script; run a command in a bash with nvm loaded +nvm_sh() { + bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*" +} + +ensure_nvm() { + [ -s "$HOME/.nvm/nvm.sh" ] && return 0 + # nvm prerequisites; nvm itself requires bash + if missing bash; then pkg_install bash bash bash bash; fi + if missing curl; then pkg_install curl curl curl curl; fi + if missing git; then pkg_install git git git git; fi + tmp="$(mktemp -d)" + curl -fsSL -o "$tmp/nvm.tar.gz" \ + "https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz" + verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256" + mkdir -p "$HOME/.nvm" + tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1 + rm -rf "$tmp" +} + +ensure_node() { + if ! missing node; then return 0; fi + ensure_nvm + nvm_sh "nvm install $NODE_VERSION" +} + +ensure_yarn() { + if ! missing yarn; then return 0; fi + if ! missing corepack; then + corepack enable + corepack prepare "yarn@$YARN_VERSION" --activate + elif [ -s "$HOME/.nvm/nvm.sh" ]; then + nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \ + corepack prepare yarn@$YARN_VERSION --activate" + else + npm install -g "yarn@$YARN_VERSION" + fi +} + +install_js_deps() { + if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then + nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \ + yarn install --frozen-lockfile" + else + yarn install --frozen-lockfile + fi +} + main() { cd "$ROOT" @@ -144,6 +202,10 @@ main() { go mod download + ensure_node + ensure_yarn + install_js_deps + if missing docker; then echo "bootstrap: docker is not installed; make lint and make test need it" >&2 fi diff --git a/script/fmt b/script/fmt index 10a62ff..c8598b2 100755 --- a/script/fmt +++ b/script/fmt @@ -1,5 +1,6 @@ #!/bin/sh -# script/fmt: format all files (writes). +# script/fmt: format all files (writes): the Go source with go fmt, then +# the Markdown files with prettier. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" @@ -7,9 +8,29 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" # Where script/bootstrap installs Go; it cannot put it on our PATH. PATH="$HOME/.local/go/bin:$PATH" +# Must match the pin in script/bootstrap. +NODE_VERSION="22.17.0" + +# script/bootstrap installs node and yarn under nvm and leaves neither +# on the PATH of the shell that called it, so resolve the pinned +# toolchain here the way bootstrap's own install step does. nvm is a +# bash script, hence the subshell. +run_yarn() { + if command -v yarn >/dev/null 2>&1; then + exec yarn "$@" + fi + if [ ! -s "$HOME/.nvm/nvm.sh" ]; then + echo "fmt: no yarn; run script/bootstrap first" >&2 + exit 1 + fi + exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null && + shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" +} + main() { cd "$ROOT" go fmt ./... + run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always } main "$@" diff --git a/script/fmt-check b/script/fmt-check index 8170456..b66223e 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -8,6 +8,25 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" # Where script/bootstrap installs Go; it cannot put it on our PATH. PATH="$HOME/.local/go/bin:$PATH" +# Must match the pin in script/bootstrap. +NODE_VERSION="22.17.0" + +# script/bootstrap installs node and yarn under nvm and leaves neither +# on the PATH of the shell that called it, so resolve the pinned +# toolchain here the way bootstrap's own install step does. nvm is a +# bash script, hence the subshell. +run_yarn() { + if command -v yarn >/dev/null 2>&1; then + exec yarn "$@" + fi + if [ ! -s "$HOME/.nvm/nvm.sh" ]; then + echo "fmt-check: no yarn; run script/bootstrap first" >&2 + exit 1 + fi + exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null && + shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" +} + main() { cd "$ROOT" if [ -n "$(gofmt -l .)" ]; then @@ -15,6 +34,7 @@ main() { gofmt -l . exit 1 fi + run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always } main "$@" diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..d846639 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +prettier@3.8.1: + version "3.8.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" + integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== From 897b43a20600cca089f88726d5926a57f551e081 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 07:08:46 +0200 Subject: [PATCH 18/28] Derive from the mnemonic's words joined by single spaces (closes #49) The BIP-39 seed was computed over the mnemonic string as given, with only its ends trimmed, so the same words one per line, tab-separated or double-spaced passed the checksum but gave different keys with no warning. The words are now joined by single spaces before the checksum and the seed, for every source: the mnemonic command, both environment variables and the prompt. Single-spaced input gives the same keys as before; a test checks the README vector for each spacing. Model: opus-5-5 --- README.md | 9 +++++---- internal/cli/cli_test.go | 19 +++++++++++++++++++ internal/mnemonic/mnemonic.go | 7 ++++--- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index fda0e0a..464f8ed 100644 --- a/README.md +++ b/README.md @@ -106,9 +106,8 @@ order; the first one found wins: 1. `--mnemonic-command `: a shell command, run with `sh -c`, whose standard output is the mnemonic. Example: - `--mnemonic-command 'secret get foo'`. Whitespace around the output is - dropped. If the command exits with a non-zero status, the tool prints its - standard error and exits with status 1. + `--mnemonic-command 'secret get foo'`. If the command exits with a non-zero + status, the tool prints its standard error and exits with status 1. 2. Environment variable `KEYFUNC_MNEMONIC_COMMAND`: the same, as a shell command held in the environment. 3. Environment variable `KEYFUNC_MNEMONIC`: the mnemonic itself. @@ -116,7 +115,9 @@ order; the first one found wins: 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. +refused with a message saying so. Keys are derived from the mnemonic's words +joined by single spaces, whatever whitespace is around or between them, so one +word per line, tabs or extra spaces give the same keys. `KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` are removed from the environment before the system `ssh` (`keyfunc ssh to`) and `sftp` diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 2f46688..f4146bc 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -47,6 +47,25 @@ func TestTheReadmeTestVectors(t *testing.T) { ) } +func TestTheSpacingBetweenTheWordsDoesNotChangeTheKeys(t *testing.T) { + words := strings.Fields(example()) + + for name, spaced := range map[string]string{ + "one word per line": strings.Join(words, "\n"), + "double spaces": strings.Join(words, " "), + "tabs": strings.Join(words, "\t"), + } { + t.Run(name, func(t *testing.T) { + t.Setenv(mnemonic.Variable, spaced) + + require.Equal(t, + vectorZero+" keyfunc/ssh/0", + strings.TrimSpace(run(t, "ssh", "pub", "-n", "0")), + ) + }) + } +} + func TestTheCommentCanBeChosen(t *testing.T) { t.Setenv(mnemonic.Variable, example()) diff --git a/internal/mnemonic/mnemonic.go b/internal/mnemonic/mnemonic.go index 85b34c0..fdce914 100644 --- a/internal/mnemonic/mnemonic.go +++ b/internal/mnemonic/mnemonic.go @@ -104,10 +104,11 @@ func ask() (string, error) { return checked(string(typed)) } -// checked drops the surrounding whitespace and refuses a mnemonic that -// does not pass the BIP-39 checksum. +// checked joins the words with single spaces, whatever whitespace +// separated them, since the seed is computed over the string itself, +// and refuses a mnemonic that does not pass the BIP-39 checksum. func checked(words string) (string, error) { - words = strings.TrimSpace(words) + words = strings.Join(strings.Fields(words), " ") if !bip39.IsMnemonicValid(words) { return "", ErrChecksum From d4fbcbc83dc6009d3f9d43751ba944690d1b5a23 Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 4 Oct 2026 07:25:49 +0200 Subject: [PATCH 19/28] README child-mnemonic vector and host-key note; ssh install refuses a ~/.ssh it cannot enter (closes #51) The README now gives the child mnemonic keyfunc prints for the abandon ... about test mnemonic at index 0, checked by the README vectors test; the BIP-85 specification vector stays, marked as starting from a master key keyfunc cannot take. It also says ssh install needs the host key in known_hosts already, and how to get round that. ssh install now also lists ~/.ssh/. on its first connection and refuses, before any upload, a ~/.ssh it can read but not enter, which sftp shows as empty; a file where ~/.ssh belongs is refused the same way. Model: opus-5-5 Co-authored-by: clawbot --- README.md | 53 +++++++++++++++--------- internal/cli/cli_test.go | 8 ++++ internal/cli/ssh/install.go | 58 ++++++++++++++++---------- internal/cli/ssh/install_test.go | 16 ++++++-- internal/cli/ssh_test.go | 70 ++++++++++++++++++++++++-------- 5 files changed, 142 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 464f8ed..3f9ff56 100644 --- a/README.md +++ b/README.md @@ -163,21 +163,23 @@ 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 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: +The first connection lists `~/.ssh`, then `~/.ssh/.`, and then fetches +`~/.ssh/authorized_keys`. The file reads as empty in two cases only: `sftp` +reported `~/.ssh` itself as not being there, or both listings came up and the +file was not found. Any other outcome of that connection fails the run — a +`~/.ssh` that is there but cannot be read or 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 listings are what tell a +missing directory from one shut to the user, which `sftp` reports on a fetch the +same way: one that cannot be read fails the first listing, and one that can be +read but not entered fails the second, after which the tool says that `~/.ssh` +cannot be entered. 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: - 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; @@ -200,7 +202,10 @@ 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. +has to do it, not a typed password. Nor does it ask whether to trust a host key +it has not seen, so the host has to be in `known_hosts` already, or the run +fails with `Host key verification failed`. Connect to the host once with `ssh` +first, or pass `-o StrictHostKeyChecking=accept-new` after `--`. ### `keyfunc ssh to [ssh arguments...]` @@ -261,10 +266,18 @@ 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. -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: +Test vector, mnemonic +`abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`: + +``` +index 0: prosper short ramp prepare exchange stove life snack client enough purpose fold +``` + +The child-mnemonic step is also checked against BIP-85's own published vectors. +Those start from the specification's master key +`xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb` +rather than from a mnemonic, so they cannot be given to `keyfunc`; at key index +0 the 12-word English child mnemonic of that key is: ``` girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index f4146bc..d07b08c 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -22,6 +22,11 @@ const ( "0I4FKs+eVUulTPHfk9VtXw1tMF" ) +// The child mnemonic the README says the example mnemonic gives at +// index 0. +const childZero = "prosper short ramp prepare exchange stove life " + + "snack client enough purpose fold" + // The two child mnemonic lengths the tests ask for. const ( twelve = 12 @@ -45,6 +50,9 @@ func TestTheReadmeTestVectors(t *testing.T) { vectorOne+" keyfunc/ssh/1", strings.TrimSpace(run(t, "ssh", "pub", "-n", "1")), ) + require.Equal(t, + childZero, strings.TrimSpace(run(t, "mnemonic", "-n", "0")), + ) } func TestTheSpacingBetweenTheWordsDoesNotChangeTheKeys(t *testing.T) { diff --git a/internal/cli/ssh/install.go b/internal/cli/ssh/install.go index 761fe3c..162bd23 100644 --- a/internal/cli/ssh/install.go +++ b/internal/cli/ssh/install.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/rand" "encoding/hex" + "errors" "fmt" "os" "os/exec" @@ -32,6 +33,12 @@ const ( localMode = 0o600 ) +// ErrCannotEnter is the refusal of a host whose .ssh is there but +// cannot be entered, so that nothing in it can be read or written. +var ErrCannotEnter = errors.New( + "~/.ssh is there on the host but cannot be entered", +) + // install returns the command that adds the public key to a host. func install() *cobra.Command { cmd := &cobra.Command{ @@ -199,30 +206,39 @@ func merge(content, line string) (string, bool) { // fetch brings the host's authorized_keys into the given path and // 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. +// there. The one session lists .ssh, then .ssh/., and then gets the +// file, so the listings settle 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 +// as not there, or both listings succeeded and the get then reported +// the file as not there. Anything else — a 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. +// file from one in a directory it cannot enter, so the listings do: a +// directory that is there but cannot be read fails the first, and one +// that can be read but not entered fails the second, because nothing in +// it can be looked up, not even ".". The first listing of such a +// directory comes up empty, as the server leaves out every name it +// cannot look up. func fetch( cmd *cobra.Command, host string, options []string, into string, ) (string, bool, error) { said, err := session(cmd, host, options, []string{ "ls -1 " + directory, + "ls -1 " + directory + "/.", "get " + authorized + " " + quoted(into), }) if err != nil { - if directoryAbsent(said) { + if listingNotFound(said, directory) { return "", false, nil } + if listingNotFound(said, directory+"/.") { + return "", false, ErrCannotEnter + } + if absent(said) { return "", true, nil } @@ -239,18 +255,18 @@ func fetch( 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 { +// listingNotFound says whether sftp reported the path it was asked to +// list as not being there. For .ssh that is the one listing failure +// read as a host that has no authorized_keys yet; for .ssh/., once .ssh +// itself has been listed, it is a .ssh that is there but cannot be +// entered. The reading is taken only from the line in which sftp +// reports on that path: any other failure of a listing, in particular a +// directory that is there but cannot be read, is left as a failure, so +// that no key is written to a host whose keys were never read. +func listingNotFound(said, path string) bool { for line := range strings.Lines(said) { named, is := reportedCannotList(strings.TrimSpace(line)) - if is && (named == directory || - strings.HasSuffix(named, "/"+directory)) { + if is && (named == path || strings.HasSuffix(named, "/"+path)) { return true } } @@ -259,9 +275,9 @@ func directoryAbsent(said string) bool { } // 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. +// list for want of it, and whether the line is such a report. The +// client writes this one wording when it cannot look up the path a +// listing names, giving the path the server expanded. func reportedCannotList(line string) (string, bool) { const ( before = `Can't ls: "` diff --git a/internal/cli/ssh/install_test.go b/internal/cli/ssh/install_test.go index 09d53de..510404a 100644 --- a/internal/cli/ssh/install_test.go +++ b/internal/cli/ssh/install_test.go @@ -80,8 +80,11 @@ 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. +// directory it cannot find is reported one way, and one it cannot read +// another, and only the first is read as a host with no .ssh yet. A +// .ssh that can be read but not entered lists as empty, and the +// listing of .ssh/. that follows reports that path, not .ssh, as not +// found. func TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo(t *testing.T) { t.Parallel() @@ -102,11 +105,16 @@ func TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo(t *testing.T) { `Can't ls: "/home/someone/.ssh" not found` + "\n", want: true, }, - "the directory is there and cannot be entered": { + "the directory is there and cannot be read": { said: listed + `remote readdir("/home/someone/.ssh/"): Permission denied` + "\n", want: false, }, + "the directory is there and cannot be entered": { + said: listed + "sftp> ls -1 .ssh/.\n" + + `Can't ls: "/home/someone/.ssh/." not found` + "\n", + want: false, + }, "some other directory is not there": { said: listed + `Can't ls: "/home/someone/.config" not found` + "\n", want: false, @@ -122,7 +130,7 @@ func TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - if directoryAbsent(listing.said) != listing.want { + if listingNotFound(listing.said, directory) != 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 7ab7128..04749f4 100644 --- a/internal/cli/ssh_test.go +++ b/internal/cli/ssh_test.go @@ -52,7 +52,7 @@ const ( ) // notADirectory is what a test puts where the .ssh directory belongs -// to make a step of the write session fail. +// to make a .ssh that is listed but cannot be entered. const notADirectory = "a file where the directory belongs\n" // missingIdentity is a path with no file at it, handed to sftp after @@ -106,9 +106,11 @@ const marker = "KEYFUNC_TEST_MARKER" // 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. +// turn on the user it runs as, and one the user can enter but not write +// to by mode 500, which the put reads off the same way. 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 = ` [ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT" previous= @@ -160,7 +162,13 @@ while IFS= read -r line; do printf 'remote open "%s": Permission denied\n' "$home/$2" >&2 fi ;; - put) cp "$2" "$home/$3" 2>/dev/null || worked=no ;; + put) + if [ "$(stat -c '%a' "$(dirname "$home/$3")")" = 500 ]; then + worked=no + else + cp "$2" "$home/$3" 2>/dev/null || worked=no + fi + ;; 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 ;; @@ -329,6 +337,29 @@ func TestAnUnreadableDirectoryIsNotWrittenInto(t *testing.T) { require.Equal(t, 1, connections(t, pretend)) } +func TestADirectoryThatCannotBeEnteredIsRefusedBeforeAnyUpload(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + // A file where .ssh belongs is listed and cannot be entered, which is + // how sftp sees a directory that can be read but not entered: the + // listing of .ssh comes up and the listing of .ssh/. finds nothing. + inTheWay := filepath.Join(pretend.home, keptUnder) + require.NoError(t, + os.WriteFile(inTheWay, []byte(notADirectory), fileMode), + ) + + printed, _, err := attempt(t, host) + require.ErrorIs(t, err, ssh.ErrCannotEnter) + require.Empty(t, printed) + + // The read and nothing after it: no upload was tried, and what was + // on the host is still what is on the host. + require.Equal(t, 1, connections(t, pretend)) + require.Equal(t, notADirectory, read(t, inTheWay)) +} + func TestAnExistingDirectoryKeepsItsModeAndIsNotRemade(t *testing.T) { t.Setenv(mnemonic.Variable, example()) @@ -392,27 +423,30 @@ func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) { pretend := pretendHost(t) - // 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), - ) + // A .ssh that can be listed and entered but not written to: the + // fetch finds no file in it, and then the put has nowhere to put + // anything, so the write session ends at the put. + const unwritable = 0o500 + + directory := filepath.Join(pretend.home, keptUnder) + require.NoError(t, os.Mkdir(directory, unwritable)) printed, said, err := attempt(t, host) require.Error(t, err) require.Empty(t, printed) require.Contains(t, said, "put failed") - // 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. + // The put, after the three commands of the fetch, 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, 3) - require.Equal(t, "put", strings.Fields(sent[2])[0]) - require.Contains(t, err.Error(), strings.Fields(sent[2])[2]) + 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)) + left, err := os.ReadDir(directory) + require.NoError(t, err) + require.Empty(t, left) // The same run again, this way for the status it ends with. given := os.Args From 1d1c8182be09f083daf864bdd287c74648743855 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 08:59:08 +0200 Subject: [PATCH 20/28] Current templates: safe.directory, golangci-lint v2.14.0, fetch-depth 0, the policy's last stage (closes #50) Brings keyfunc to the current sneak/prompts templates: REPO_POLICIES.md and .golangci.yml are the template copies, the lint phase runs golangci-lint v2.14.0 on the template digest (its one new finding fixed), and .dockerignore gains the template line for submodule configs. The stage that compiles keyfunc marks /src safe for git, so a context sent as a tar stream still stamps the tag or short commit. The last stage is now a development environment, as the policy asks of a non-server repo: run the tool as docker run IMAGE keyfunc .... The CI checkout fetches tags. Deviation: .gitea/workflows/check.yml differs from the template copy by fetch-depth: 0, which REPO_POLICIES.md requires. Model: opus-5-5 --- .dockerignore | 3 ++ .gitea/workflows/check.yml | 3 ++ .golangci.yml | 1 + Dockerfile | 56 +++++++++++------------ README.md | 5 ++- REPO_POLICIES.md | 91 +++++++++++++++++++++++--------------- internal/cli/cli.go | 3 +- internal/cli/ssh/to.go | 3 +- 8 files changed, 95 insertions(+), 70 deletions(-) diff --git a/.dockerignore b/.dockerignore index 26123bb..d5bb375 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,10 @@ # stage that compiles runs `git describe --tags --always` on .git, which # does not need .git/config; that file can hold a credential, such as a # password in a remote URL or the token the CI checkout step stores there. +# Each submodule keeps a config with the same exposure in its git directory +# under .git/modules/, nested again for a submodule's own submodules. .git/config +.git/modules/**/config # Agent scratch: one full checkout of the repo per in-flight agent. # Anchored because it occurs once where agents run at the repo root. diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index ee73864..ce6d662 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -6,4 +6,7 @@ jobs: steps: # actions/checkout v4.2.2, 2026-02-22 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + # All history and tags, which `git describe --tags` needs. + fetch-depth: 0 - run: script/cibuild diff --git a/.golangci.yml b/.golangci.yml index a7a74c2..1b73eb9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -17,6 +17,7 @@ linters: disable: # Genuinely incompatible with project patterns - exhaustruct # Requires all struct fields + - exhaustruct_v5 # Requires all struct fields (successor to exhaustruct) - godot # Requires comments to end with periods - wrapcheck # Too verbose for internal packages - varnamelen # Short names like db, id are idiomatic Go diff --git a/Dockerfile b/Dockerfile index 2f33928..812c833 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -# The lint phase, the test phase and the build. script/lint and -# script/test each build one phase alone; a plain `docker build .` builds -# both, because the build stage copies a file from each. Formatting is -# checked on the host by script/fmt-check, not here. +# The lint phase, the test phase and a development environment. +# script/lint and script/test each build one phase alone; a plain +# `docker build .` builds both, because the last stage copies a file from +# each. Formatting is checked on the host by script/fmt-check, not here. # Lint phase -# golangci/golangci-lint:v2.12.2, 2026-09-07 -FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint +# golangci/golangci-lint:v2.14.0, 2026-10-04 +FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint WORKDIR /src @@ -16,13 +16,12 @@ COPY . . RUN golangci-lint run --config .golangci.yml ./... -# Test phase -# golang:1.26-alpine, 2026-09-07. It carries Go 1.26.8, the version -# script/bootstrap installs on the host; change both together. -FROM golang@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS test - -# -race needs cgo, and cgo needs a C toolchain. -RUN apk add --no-cache gcc musl-dev +# Test phase. -race needs cgo and so a C compiler, which the Debian Go +# image ships. +# golang:1.26.8-trixie, 2026-10-04. It carries Go 1.26.8, the version +# script/bootstrap installs on the host; change both together, and the +# same image in the last stage. +FROM golang@sha256:eae2aaa6add2936cbf350dd0d2628b363461542f0c4b3c0b558957e0f2997379 AS test WORKDIR /src @@ -35,21 +34,27 @@ RUN go test -timeout 90s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ go test -timeout 90s -race -v ./...; exit 1; } -# Build stage. Nothing is wanted from either phase above; the copies -# are what make BuildKit build them first, so this stage cannot run -# unless lint and test passed. -# golang:1.26-alpine, 2026-09-07 -FROM golang@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS builder +# Development environment, and the last stage: a plain `docker build .` +# builds this one. It holds the source tree in /src, what +# script/bootstrap installs, and keyfunc built from that tree on the +# PATH. Nothing is wanted from either phase above; the copies are what +# make BuildKit build them first, so this stage cannot run unless lint +# and test passed. +# golang:1.26.8-trixie, 2026-10-04 +FROM golang@sha256:eae2aaa6add2936cbf350dd0d2628b363461542f0c4b3c0b558957e0f2997379 COPY --from=lint /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null -RUN apk add --no-cache make git +# A tar-stream context keeps the sender's file owners, which git refuses. +RUN git config --system --add safe.directory /src WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download +# script/bootstrap needs only script/ and the dependency manifests. +COPY script/ script/ +COPY go.mod go.sum package.json yarn.lock ./ +RUN script/bootstrap COPY . . @@ -64,11 +69,4 @@ RUN version="${VERSION:-$(git describe --tags --always || echo dev)}"; \ echo "no version could be derived although the build context carries .git" >&2; \ exit 1; \ fi; \ - make build VERSION="$version" - -# alpine:3.23, 2026-09-07 -FROM alpine@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 - -COPY --from=builder /src/keyfunc /usr/local/bin/keyfunc - -ENTRYPOINT ["keyfunc"] + make build VERSION="$version" && mv keyfunc /usr/local/bin/keyfunc diff --git a/README.md b/README.md index 3f9ff56..98a53ba 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,10 @@ standard: most Makefile targets are thin shims over an executable in `script/` - `script/docker` builds the Docker image, uncached, tagged with the project name and stamped with the version `git describe` gives on the host. The image cannot be built unless the `lint` and `test` phases pass, so a plain - `docker build .` runs them too. + `docker build .` runs them too. The image is a development environment, not a + runtime image: the Debian Go image with what `script/bootstrap` installs, the + source tree in `/src`, and `keyfunc` built from it on the `PATH`. + `docker run --rm -it keyfunc` opens a shell in it. - `script/cibuild` is the CI build the Gitea workflow calls: it runs `bootstrap`, then `check`, then builds the image as `script/docker` does. - `script/precommit` is what the git pre-commit hook runs: `go mod tidy` and diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md index ca05cb4..af0ea80 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-10-02 +last_modified: 2026-10-04 --- This document covers repository structure, tooling, and workflow standards. Code @@ -160,7 +160,7 @@ style conventions are in separate documents: - **The gate phases are separate stages, and the build stage depends on both.** The lint phase is based on the `golangci/golangci-lint` image (pinned by hash), so lint failures surface in seconds rather than after a full compile, - and the test phase is based on the Go image. The canonical Go repo + and the test phase is based on the Debian Go image. The canonical Go repo `Dockerfile`: ```dockerfile @@ -173,8 +173,9 @@ style conventions are in separate documents: COPY . . RUN golangci-lint run --config .golangci.yml ./... - # Test phase - # golang:1.x-alpine, YYYY-MM-DD + # Test phase. -race needs cgo and so a C compiler, which the Debian Go + # image ships and the alpine one does not. + # golang:1.x, YYYY-MM-DD FROM golang@sha256:... AS test WORKDIR /src COPY go.mod go.sum ./ @@ -192,6 +193,8 @@ style conventions are in separate documents: COPY --from=lint /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null RUN apk add --no-cache git + # A tar-stream context keeps the sender's file owners, which git refuses. + RUN git config --system --add safe.directory /src WORKDIR /src COPY go.mod go.sum ./ RUN go mod download @@ -236,19 +239,23 @@ style conventions are in separate documents: - If the project requires CGO or system libraries for linting (e.g. `vips-dev`), install them in the lint phase with `apk add`. - `.dockerignore` lets `.git` into the build context. It keeps out - `.git/config`, which `git describe` does not need and which can hold a - credential: a password in a remote URL, or the token the CI checkout step - stores there. The stage that compiles has `git` (the Debian Go image has - it; an alpine one needs `apk add --no-cache git`) and takes the version - from the `VERSION` build argument when one is given, otherwise from - `git describe --tags --always`. That gives the tag on a tagged commit; on - a later commit, the tag, the number of commits since it and the short - commit (`v1.2.3-4-gabc1234`); and the short commit when no tag is - reachable. `ARG VERSION` has no default, and the build fails if the - context carries `.git` and the version still comes out empty, `dev` or - `unknown`. A plain `docker build .` with no build arguments must succeed; - a Dockerfile that refuses an empty build argument drops that refusal and - keeps the argument. + `.git/config` and each submodule's `config` under `.git/modules/` at any + depth (`.git/modules/**/config`), which `git describe` does not need and + which can hold a credential: a password in a remote URL, or the token the + CI checkout step stores there. The stage that compiles has `git` (the + Debian Go image has it; an alpine one needs `apk add --no-cache git`) and + takes the version from the `VERSION` build argument when one is given, + otherwise from `git describe --tags --always`. That gives the tag on a + tagged commit; on a later commit, the tag, the number of commits since it + and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no + tag is reachable. The stage that compiles also marks its working directory + safe for git (`git config --system --add safe.directory /src`): a context + sent as a tar stream keeps the sender's file owners, and git refuses a + checkout owned by another user, so the version would come out empty. + `ARG VERSION` has no default, and the build fails if the context carries + `.git` and the version still comes out empty, `dev` or `unknown`. A plain + `docker build .` with no build arguments must succeed; a Dockerfile that + refuses an empty build argument drops that refusal and keeps the argument. - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that runs `script/cibuild` on push, and checks out the repo as its only other step. @@ -310,17 +317,19 @@ style conventions are in separate documents: ``` `-count=1` is required on both invocations: it defeats Go's test _result_ - cache, so the target cannot report a pass it did not earn, and the rerun - reproduces a failure instead of replaying it. It leaves the build cache - alone, so it costs the runtime of the suite and no recompilation. + cache, so neither run can report a stored pass in place of running the + tests. It leaves the build cache alone, so it costs the runtime of the suite + and no recompilation. - Note that this is a second, independent cache, stacked below the Docker - layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26) - addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes; - it does not guarantee `go test` inside that step does any work, because the - `GOCACHE` baked into earlier image layers survives into the re-executed - step. They are two separate defects requiring two separate fixes, and a fix - for one must not be recorded as covering the other. + That cache is Go's own, separate from Docker's layer cache. Go stores a + passing result in its cache directory (`GOCACHE`), and when the same tests + run again on unchanged code it prints that result, marked `(cached)`, + without running them. That matters on a developer's machine, where this + target runs and the directory lasts from one run to the next. The `test` + phase of the `Dockerfile` needs no `-count=1`: its base image holds no + result for this repo's tests and nothing before its `go test` step runs a + test, so there is nothing to replay. `--no-cache` (above) is what makes that + step run on an unchanged tree. Python example: @@ -451,12 +460,18 @@ style conventions are in separate documents: `test-support` depguard rule, where a repo names its own test-support packages by full import path. A repo adds entries there and changes nothing else, and a re-vendor carries its entries forward. The canonical golangci-lint version is - v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base + v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base image - (`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, - which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the - only pin, since no repo installs golangci-lint on the host: bumping the - version means changing it and nothing else. + (`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`, + which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go` + directive must not name a newer Go minor version than the one golangci-lint + was built with, or golangci-lint refuses to lint it: this release lints + `go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo + installs golangci-lint on the host. A repo sets the lint phase digest to the + one named here and re-vendors `.golangci.yml` in the same commit, whichever of + the two prompted the change: the canonical copy can name linters that an older + golangci-lint rejects, and a newer golangci-lint can add linters that + `default: all` switches on until the canonical copy disables them. - **`script/bootstrap` installs a pinned tool by comparing versions, never by testing presence.** An `if ! command -v ; then install; fi` guard tests @@ -592,10 +607,10 @@ style conventions are in separate documents: settings. - Avoid putting files in the repo root unless necessary. Root should contain - only project-level config files (`README.md`, `Makefile`, `Dockerfile`, - `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and - language-specific config). Everything else goes in a subdirectory. Canonical - subdirectory names: + only project-level config files (`README.md`, `AGENTS.md`, `Makefile`, + `Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, + and language-specific config). Everything else goes in a subdirectory. + Canonical subdirectory names: - `bin/` — executable scripts and tools - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose body is a single call into `internal/` or `pkg/`, no project logic in @@ -626,3 +641,7 @@ style conventions are in separate documents: - Go: `go.mod`, `go.sum`, `.golangci.yml` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - Python: `pyproject.toml` + +- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It + is never committed under a file or directory named after one agent tool, such + as `CLAUDE.md` or `.claude/`, and never split into separate memory files. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index e49c853..37ee347 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -86,8 +86,7 @@ func Main() int { return 0 } - var passed ssh.StatusError - if errors.As(err, &passed) { + if passed, ok := errors.AsType[ssh.StatusError](err); ok { return passed.Status } diff --git a/internal/cli/ssh/to.go b/internal/cli/ssh/to.go index 6569c5c..c36ec88 100644 --- a/internal/cli/ssh/to.go +++ b/internal/cli/ssh/to.go @@ -88,8 +88,7 @@ func connect(ctx context.Context, argv []string) error { return nil } - var ended *exec.ExitError - if errors.As(err, &ended) { + if ended, ok := errors.AsType[*exec.ExitError](err); ok { status := ended.ExitCode() if status < 0 { // A signal ended ssh, and a signal has no status of its From c96b77dd672d9c2dc1ffacec6de1a4837fa27186 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 13:42:49 +0200 Subject: [PATCH 21/28] Every command ends on SIGINT, SIGTERM or SIGHUP; an interrupted age -o leaves no file (closes #48) SIGINT, SIGTERM and SIGHUP are no longer caught for the whole run, so they end any command at once, the mnemonic prompt included. One package, internal/cli/signals, catches them only where cleanup is needed, and only those not ignored at start, so nohup still works: ssh to and ssh install while their child runs, and age encrypt -o and age decrypt -o while they write. A signal received by the time the input ends leaves no new file and exits 1; otherwise the whole file is put in place, never an unfinished one. Judgement call: the guarantee is stated for a signal keyfunc has received, as Go cannot promise more; the main goroutine stays on the main thread so a Ctrl-C on a pipeline is seen first on Linux. Unverified on macOS. Model: opus-5-5 (implementation); fable-5-1 (design) --- README.md | 17 ++- internal/cli/age/age.go | 86 +++++++---- internal/cli/age_test.go | 258 ++++++++++++++++++++++++++++++++ internal/cli/cli.go | 34 +++-- internal/cli/signals/signals.go | 53 +++++++ internal/cli/ssh/install.go | 9 ++ internal/cli/ssh/to.go | 14 +- internal/cli/ssh_test.go | 70 ++++++++- 8 files changed, 491 insertions(+), 50 deletions(-) create mode 100644 internal/cli/signals/signals.go diff --git a/README.md b/README.md index 98a53ba..2fecc6c 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,9 @@ calls into `internal/`. The packages there are: - `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. + `cli/options` holds the flags every command shares, `cli/signals` catches + SIGINT, SIGTERM and SIGHUP for the commands that clean up before they end, and + `cli/ssh`, `cli/age` and `cli/mnemonic` are the command groups. ### Adding a key type @@ -288,6 +289,18 @@ girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose Errors go to standard error and the exit status is 1, except for `ssh to`, which passes through `ssh`'s own exit status. +SIGINT, SIGTERM and SIGHUP end any command at once, at the mnemonic prompt too, +with the status a shell gives a program killed by that signal (130 for SIGINT). +While `age encrypt -o` or `age decrypt -o` is writing the file, the signal makes +it remove the unfinished file, leave a file already at the named path as it was, +and exit with status 1. That holds for a signal that has reached `keyfunc` when +its input ends; a later one leaves the whole file in place. Ctrl-C on a pipeline +ends the input at the same moment, and on Linux `keyfunc` sees the signal first, +though no system promises that. While `ssh to` or `ssh install` has `ssh` or +`sftp` running, the signal ends that program instead, the tool removes its agent +socket or working files, and it exits with status 1, or for `ssh to` with +`ssh`'s own status if `ssh` reported one. + ## Entrypoints The repo adheres to the diff --git a/internal/cli/age/age.go b/internal/cli/age/age.go index 700fdf2..b9d4609 100644 --- a/internal/cli/age/age.go +++ b/internal/cli/age/age.go @@ -3,17 +3,27 @@ package age import ( + "context" + "errors" "fmt" "io" "os" + "os/signal" "path/filepath" "github.com/spf13/cobra" "sneak.berlin/go/keyfunc/internal/agekey" "sneak.berlin/go/keyfunc/internal/cli/options" + "sneak.berlin/go/keyfunc/internal/cli/signals" "sneak.berlin/go/keyfunc/internal/derive" ) +// ErrInterrupted is returned when SIGINT, SIGTERM or SIGHUP has been +// received by the time the work writing the file --output names ends. +var ErrInterrupted = errors.New( + "interrupted by a signal; the output file was left as it was", +) + // Command returns the age command and everything under it. func Command() *cobra.Command { group := &cobra.Command{ @@ -128,8 +138,9 @@ func runDecrypt(cmd *cobra.Command, args []string) error { return through(cmd, args, key.Decrypt) } -// through opens the input and the output the arguments ask for, hands -// them to the work, and finishes the output afterwards either way. +// through opens the input the arguments ask for and hands it to the +// work, with the file --output names to write to, or the command's own +// output when it names none. func through( cmd *cobra.Command, args []string, work func(io.Writer, io.Reader) error, @@ -141,14 +152,16 @@ func through( defer closeSrc() - dst, done, err := output(cmd) + name, err := cmd.Flags().GetString("output") if err != nil { - return err + return fmt.Errorf("reading the output file: %w", err) } - err = work(dst, src) + if name == "" { + return work(cmd.OutOrStdout(), src) + } - return done(err) + return output(name, src, work) } // input returns what to read from: the named file, or the command's @@ -167,35 +180,56 @@ func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) { return file, func() { _ = file.Close() }, nil } -// output returns what to write to: a new file beside the one --output -// names, or the command's own output when it names none. The second -// result finishes the write, and is given whatever the work returned: -// the new file takes the named file's place only when the work -// succeeded, so a file that is already there survives a run that -// failed. -func output(cmd *cobra.Command) (io.Writer, func(error) error, error) { - name, err := cmd.Flags().GetString("output") - if err != nil { - return nil, nil, fmt.Errorf("reading the output file: %w", err) - } +// output has the work write a new file beside the named one, and puts +// the new file in the named file's place only when the work succeeded, +// so a file that is already there survives a run that failed. +// +// Meanwhile SIGINT, SIGTERM and SIGHUP are caught, as signals.Context +// does. One the tool has received by the time the work ends wins: the +// new file is removed and ErrInterrupted returned, at once if the work +// is still running, without waiting for it, since it may be blocked +// reading its input. +func output( + name string, src io.Reader, work func(io.Writer, io.Reader) error, +) error { + // received is registered before the context, so it gets every + // signal the context gets. + received := make(chan os.Signal, 1) + signals.Notify(received) - if name == "" { - return cmd.OutOrStdout(), func(failed error) error { - return failed - }, nil - } + defer signal.Stop(received) + + // The context goes on catching the signals until the file is in + // place or removed, so that a later one cannot end the tool with + // the new file left beside the named one. + interrupted, stop := signals.Context(context.Background()) + defer stop() // The file is made in the same directory so that putting it in // place is a rename and never a copy, and it is readable only by // its owner, which is the mode it keeps once renamed. file, err := os.CreateTemp(filepath.Dir(name), filepath.Base(name)+".") if err != nil { - return nil, nil, fmt.Errorf("creating a file beside %s: %w", name, err) + return fmt.Errorf("creating a file beside %s: %w", name, err) } - return file, func(failed error) error { - return finish(file, name, failed) - }, nil + worked := make(chan error, 1) + + go func() { worked <- work(file, src) }() + + select { + case failed := <-worked: + // Stop returns only once every signal the tool has received + // has been handed over, so an empty received means none came. + signal.Stop(received) + + if len(received) == 0 { + return finish(file, name, failed) + } + case <-interrupted.Done(): + } + + return finish(file, name, ErrInterrupted) } // finish closes the new file and puts it in the named file's place, or diff --git a/internal/cli/age_test.go b/internal/cli/age_test.go index 1b37231..2209db9 100644 --- a/internal/cli/age_test.go +++ b/internal/cli/age_test.go @@ -1,13 +1,21 @@ package cli_test import ( + "errors" + "io" "os" + "os/exec" + "os/signal" "path/filepath" "strings" + "syscall" "testing" + "time" "github.com/stretchr/testify/require" "sneak.berlin/go/keyfunc/internal/agekey" + "sneak.berlin/go/keyfunc/internal/cli" + "sneak.berlin/go/keyfunc/internal/cli/age" "sneak.berlin/go/keyfunc/internal/mnemonic" ) @@ -90,6 +98,256 @@ func TestARefusedDecryptionLeavesTheOutputFileAlone(t *testing.T) { require.Equal(t, "what was already there\n", string(kept)) } +func TestASignalStopsAnEncryptionAndLeavesNoFile(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + for _, ending := range []os.Signal{ + syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, + } { + interrupted(t, ending, "encrypt", "the start of the secret\n") + } +} + +func TestASignalStopsADecryptionAndLeavesNoFile(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + // All of an encryption but its last byte, so the tool reads the + // header and then waits for the rest. + sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n")) + cut := sealed[:len(sealed)-1] + + for _, ending := range []os.Signal{ + syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, + } { + interrupted(t, ending, "decrypt", cut) + } +} + +func TestASignalReceivedAsTheInputEndsLeavesTheFileAsItWas(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n")) + + for _, ending := range []syscall.Signal{ + syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, + } { + receivedAtTheEnd(t, ending, "encrypt", "the secret\n") + receivedAtTheEnd(t, ending, "decrypt", sealed) + } +} + +func TestASignalAsTheInputEndsLeavesNoUnfinishedFile(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n")) + + // Ctrl-C on "producer | keyfunc age encrypt -o file" ends the + // producer too, so the input ends just as the signal comes, with + // enough of it in hand for a whole encryption or decryption. Which + // of the two the tool has first varies, so it is tried often, and + // a whole file in place is accepted as well as none. + for range 25 { + named := signalledAsTheInputEnds(t, "encrypt", "the start of the secret\n") + if named != "" { + require.Equal(t, + "the start of the secret\n", run(t, "age", "decrypt", named), + ) + } + + named = signalledAsTheInputEnds(t, "decrypt", sealed) + if named != "" { + require.Equal(t, "the secret\n", read(t, named)) + } + } +} + +func TestAnEncryptionStartedUnderNohupSurvivesAHangup(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + directory := t.TempDir() + named := filepath.Join(directory, "notes") + + // nohup starts the tool with SIGHUP ignored. A tool that caught it + // anyway would turn it back on and be ended by it. + command, producer := writing( + t, directory, "the secret\n", + "nohup", os.Args[0], "age", "encrypt", "-o", named, + ) + + require.NoError(t, command.Process.Signal(syscall.SIGHUP)) + require.NoError(t, producer.Close()) + waitForTool(t, "SIGHUP under nohup", command) + + require.Equal(t, 0, command.ProcessState.ExitCode()) + + left, err := os.ReadDir(directory) + require.NoError(t, err) + require.Len(t, left, 1) + require.Equal(t, "the secret\n", run(t, "age", "decrypt", named)) +} + +// interrupted runs "age encrypt -o" or "age decrypt -o", as the +// operation says, writing into a directory of its own, and once it has +// begun writing sends it the signal and leaves the input open. The tool +// has to end with status 1 and leave the directory empty. A tool that +// went on reading would not end until the input did; one that did not +// remove the file it was writing would leave it there, with what it had +// written so far. +func interrupted(t *testing.T, ending os.Signal, operation, input string) { + t.Helper() + + name := operation + " " + ending.String() + directory := t.TempDir() + + command, _ := writing( + t, directory, input, + os.Args[0], "age", operation, "-o", filepath.Join(directory, "notes"), + ) + + require.NoError(t, command.Process.Signal(ending)) + waitForTool(t, name, command) + + require.Equal(t, 1, command.ProcessState.ExitCode(), name) + + left, err := os.ReadDir(directory) + require.NoError(t, err) + require.Empty(t, left, name) +} + +// signalledAsTheInputEnds runs "age encrypt -o" or "age decrypt -o", as +// the operation says, writing into a directory of its own, and once it +// has begun writing sends it SIGINT and at once ends its input. Either +// the tool ends with status 1 and leaves the directory empty, and "" +// is returned, or it ends otherwise and leaves only the named file, +// whose path is returned for the caller to check that it is whole. +func signalledAsTheInputEnds(t *testing.T, operation, input string) string { + t.Helper() + + directory := t.TempDir() + named := filepath.Join(directory, "notes") + + command, producer := writing( + t, directory, input, os.Args[0], "age", operation, "-o", named, + ) + + require.NoError(t, command.Process.Signal(syscall.SIGINT)) + require.NoError(t, producer.Close()) + waitForTool(t, operation, command) + + left, err := os.ReadDir(directory) + require.NoError(t, err) + + if command.ProcessState.ExitCode() == failedStatus { + require.Empty(t, left, operation) + + return "" + } + + require.Len(t, left, 1, operation) + + return named +} + +// receivedAtTheEnd runs "age encrypt -o" or "age decrypt -o", as the +// operation says, in this process, over a file that is already there, +// with an input that at its end sends this process the signal and waits +// until it has been received. The tool has to return ErrInterrupted and +// leave that file as it was, with nothing beside it. A tool that went +// by the end of the input alone would put its new file in place. +func receivedAtTheEnd( + t *testing.T, ending syscall.Signal, operation, input string, +) { + t.Helper() + + name := operation + " " + ending.String() + existing := written(t, "notes", "what was already there\n") + + // The test catches the signal as well, so that it does not end the + // test binary and so that the input can wait for it. + received := make(chan os.Signal, 1) + signal.Notify(received, ending) + + defer signal.Stop(received) + + root := cli.Root() + root.SetIn(&endingInASignal{ + rest: strings.NewReader(input), ending: ending, received: received, + }) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs([]string{"age", operation, "-o", existing}) + + err := root.ExecuteContext(t.Context()) + require.ErrorIs(t, err, age.ErrInterrupted, name) + + require.Equal(t, "what was already there\n", read(t, existing), name) + + left, err := os.ReadDir(filepath.Dir(existing)) + require.NoError(t, err) + require.Len(t, left, 1, name) +} + +// endingInASignal is an input that, when it runs out, sends this +// process its signal and waits for it on received before it reports its +// end. It sends the signal only once: once nothing catches it, another +// would end the test binary. +type endingInASignal struct { + rest io.Reader + ending syscall.Signal + received chan os.Signal + sent bool +} + +func (input *endingInASignal) Read(buffer []byte) (int, error) { + n, err := input.rest.Read(buffer) + if !errors.Is(err, io.EOF) || input.sent { + return n, err + } + + input.sent = true + + err = syscall.Kill(os.Getpid(), input.ending) + if err != nil { + return n, err + } + + <-input.received + + return n, io.EOF +} + +// writing starts argv, the tool told to write into directory, as a +// subprocess reading the input from a pipe, and returns once the tool +// has begun writing the file beside the one it was named. The pipe is +// left open for the caller to end. +func writing( + t *testing.T, directory, input string, argv ...string, +) (*exec.Cmd, io.WriteCloser) { + t.Helper() + + //nolint:gosec // this test's own binary as the tool, or nohup running it + command := exec.CommandContext(t.Context(), argv[0], argv[1:]...) + + command.Env = append(os.Environ(), runAsTool+"=1") + + producer, err := command.StdinPipe() + require.NoError(t, err) + require.NoError(t, command.Start()) + + _, err = io.WriteString(producer, input) + require.NoError(t, err) + + // The file beside the named one is made once the mnemonic has been + // read, before any input is. + require.Eventually(t, func() bool { + entries, err := os.ReadDir(directory) + + return err == nil && len(entries) > 0 + }, 5*time.Second, 5*time.Millisecond) + + return command, producer +} + // written puts the contents in a file of that name in a directory of // this test's own and returns the path to it. func written(t *testing.T, name, contents string) string { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 37ee347..7824e68 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -2,13 +2,11 @@ package cli import ( - "context" "errors" "fmt" "os" - "os/signal" + "runtime" "runtime/debug" - "syscall" "github.com/spf13/cobra" "sneak.berlin/go/keyfunc/internal/cli/age" @@ -64,24 +62,32 @@ func Root() *cobra.Command { return root } +// init keeps the command on the main thread. Linux hands a signal sent +// to the tool to that thread first, and a thread runs a pending signal +// handler before its own code, so when "age encrypt -o" or "age +// decrypt -o" checks for a signal as its input ends, one sent before +// then, as by Ctrl-C on a pipeline, has been received. +// +//nolint:gochecknoinits // only an init can keep main on the main thread +func init() { + runtime.LockOSThread() +} + // Main runs the tool and returns the status the process should exit // with. An error ends the tool with status 1, except when it carries a // 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. +// SIGINT, SIGTERM and SIGHUP end the tool at once, as they end any Go +// program, so a command waiting at the mnemonic prompt or reading what +// it encrypts or decrypts goes no further. The exceptions catch the +// signals to clean up first: "ssh to" and "ssh install" while they +// have ssh or sftp running, so the child ends and their own cleanup +// still runs, and "age encrypt -o" and "age decrypt -o" while they +// write, so the unfinished file is removed. func Main() int { - ctx, stop := signal.NotifyContext( - context.Background(), - syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, - ) - defer stop() - - err := Root().ExecuteContext(ctx) + err := Root().Execute() if err == nil { return 0 } diff --git a/internal/cli/signals/signals.go b/internal/cli/signals/signals.go new file mode 100644 index 0000000..1657564 --- /dev/null +++ b/internal/cli/signals/signals.go @@ -0,0 +1,53 @@ +// Package signals catches the signals that end the tool, for the +// commands that clean up before they end. +package signals + +import ( + "context" + "os" + "os/signal" + "syscall" +) + +// Context is signal.NotifyContext for SIGINT, SIGTERM and SIGHUP: the +// context it returns is cancelled when one of them arrives, and stop +// stops catching them. It leaves out any of the three the tool was +// started with set to be ignored, as nohup does with SIGHUP, because +// catching a signal turns an ignored one back on and would end a run +// that was meant to survive it. +func Context(parent context.Context) (context.Context, context.CancelFunc) { + endings := caught() + + // Given no signals at all, NotifyContext would catch every one. + if len(endings) == 0 { + return context.WithCancel(parent) + } + + return signal.NotifyContext(parent, endings...) +} + +// Notify is signal.Notify for the signals Context catches: each one +// that arrives is sent to c, until signal.Stop(c). +func Notify(c chan<- os.Signal) { + endings := caught() + + // Given no signals at all, Notify would catch every one. + if len(endings) > 0 { + signal.Notify(c, endings...) + } +} + +// caught returns those of SIGINT, SIGTERM and SIGHUP that the tool was +// not started with set to be ignored. +func caught() []os.Signal { + endings := []os.Signal{syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP} + kept := make([]os.Signal, 0, len(endings)) + + for _, ending := range endings { + if !signal.Ignored(ending) { + kept = append(kept, ending) + } + } + + return kept +} diff --git a/internal/cli/ssh/install.go b/internal/cli/ssh/install.go index 162bd23..263ed45 100644 --- a/internal/cli/ssh/install.go +++ b/internal/cli/ssh/install.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/spf13/cobra" + "sneak.berlin/go/keyfunc/internal/cli/signals" ) // Where the key goes on the host and what the file it arrives in is @@ -62,6 +63,14 @@ func install() *cobra.Command { return err } + // From here on a signal cancels the context, which + // sftp runs under, instead of ending the tool, so sftp + // ends and the working directory is still removed. + ctx, stop := signals.Context(cmd.Context()) + defer stop() + + cmd.SetContext(ctx) + return add(cmd, args[0], args[1:], line) }, } diff --git a/internal/cli/ssh/to.go b/internal/cli/ssh/to.go index c36ec88..c165796 100644 --- a/internal/cli/ssh/to.go +++ b/internal/cli/ssh/to.go @@ -10,6 +10,7 @@ import ( "syscall" "github.com/spf13/cobra" + "sneak.berlin/go/keyfunc/internal/cli/signals" ) // StatusError says the tool should end with the status ssh ended with. @@ -42,7 +43,14 @@ func to() *cobra.Command { return err } - served, err := key.Serve(cmd.Context(), comment) + // From here until the agent is taken down, a signal + // cancels the context instead of ending the tool, so + // ssh ends and the socket and its directory are still + // removed. + ctx, stop := signals.Context(cmd.Context()) + defer stop() + + served, err := key.Serve(ctx, comment) if err != nil { return err } @@ -53,7 +61,7 @@ func to() *cobra.Command { "-o", "IdentityAgent=" + served.Socket(), }, args) - return connect(cmd.Context(), argv) + return connect(ctx, argv) }, } @@ -76,7 +84,7 @@ 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 + // A cancelled context means a signal arrived. 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 { diff --git a/internal/cli/ssh_test.go b/internal/cli/ssh_test.go index 04749f4..4776237 100644 --- a/internal/cli/ssh_test.go +++ b/internal/cli/ssh_test.go @@ -20,13 +20,13 @@ import ( // 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. +// signal tests can drive the real signal path in a process they 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. +// set, and otherwise runs the suite. The signal tests start the tool +// this way, as a subprocess they can signal and watch end. func TestMain(m *testing.M) { if os.Getenv(runAsTool) == "1" { os.Exit(cli.Main()) @@ -209,6 +209,16 @@ fi sleep 5 ` +// stalled is a stand-in for the system sftp that notes it has started +// and then blocks, so a test can signal the tool while sftp is running +// and watch it remove its working directory. The exec keeps the shell +// from leaving a sleep behind that holds the output the tool reads sftp +// through. +const stalled = ` +touch "$KEYFUNC_TEST_STARTED" +exec 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. @@ -544,7 +554,7 @@ func TestASignalTakesTheAgentDirectoryDown(t *testing.T) { // 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 +// goes through Main and the command's 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) { @@ -611,6 +621,56 @@ func waitForSocket(t *testing.T, noted string) string { return socket } +func TestASignalTakesTheInstallWorkingDirectoryDown(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + for _, ending := range []os.Signal{ + syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, + } { + signalEndsTheInstall(t, ending.String(), ending) + } +} + +// signalEndsTheInstall runs "ssh install" as a subprocess against a +// stand-in sftp that blocks, with a temporary directory of the test's +// own, waits until sftp is running, sends the tool the signal, and +// requires the working directory the tool made there to be gone once +// the tool has ended. +func signalEndsTheInstall(t *testing.T, name string, signal os.Signal) { + t.Helper() + + temporary := t.TempDir() + started := filepath.Join(t.TempDir(), "started") + t.Setenv("KEYFUNC_TEST_STARTED", started) + standIn(t, "sftp", stalled) + + //nolint:gosec // the binary is this test's own, re-run as the tool + command := exec.CommandContext( + t.Context(), os.Args[0], subcommand, installing, host, + ) + + command.Env = append(os.Environ(), runAsTool+"=1", "TMPDIR="+temporary) + require.NoError(t, command.Start()) + + // sftp is started only once the working directory has been made. + require.Eventually(t, func() bool { + _, err := os.Stat(started) + + return err == nil + }, 5*time.Second, 5*time.Millisecond) + + working, err := os.ReadDir(temporary) + require.NoError(t, err) + require.Len(t, working, 1, name) + + require.NoError(t, command.Process.Signal(signal)) + waitForTool(t, name, command) + + left, err := os.ReadDir(temporary) + require.NoError(t, err) + require.Empty(t, left, name) +} + func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) { t.Setenv(mnemonic.CommandVariable, "echo "+example()) t.Setenv(mnemonic.Variable, example()) From dad29597bd60f8d2fe3f73299a614807bfa748e3 Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 4 Oct 2026 14:25:47 +0200 Subject: [PATCH 22/28] ssh install ends on a signal while sftp's ssh is still connecting (closes #57) A signal during ssh install now sends sftp a SIGTERM instead of killing it, as ssh to already does for ssh, so sftp stops the ssh it started. The sftp command also has a WaitDelay of a quarter second, so a child that still holds sftp's output cannot keep the tool waiting on a host that does not answer: it ends with status 1 and removes its working directory. The test's sftp stand-in now leaves such a child. Judgement call: exec.ErrWaitDelay counts as success, so a run that used ControlPersist does not report a failure for a key it added. Model: opus-5-5 Co-authored-by: clawbot --- internal/cli/ssh/install.go | 25 +++++++++++++++++++++++ internal/cli/ssh_test.go | 40 +++++++++++++++++++++++++++++-------- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/internal/cli/ssh/install.go b/internal/cli/ssh/install.go index 263ed45..1e89e2a 100644 --- a/internal/cli/ssh/install.go +++ b/internal/cli/ssh/install.go @@ -11,6 +11,8 @@ import ( "path/filepath" "slices" "strings" + "syscall" + "time" "github.com/spf13/cobra" "sneak.berlin/go/keyfunc/internal/cli/signals" @@ -34,6 +36,13 @@ const ( localMode = 0o600 ) +// waitDelay is the WaitDelay sftp runs with: from a signal, or from sftp +// ending, how long the tool waits for sftp to end and its output to +// close before it kills sftp and stops reading. That is ample for sftp +// to stop the ssh it started, and short enough that a signal still ends +// the tool within a second. +const waitDelay = 250 * time.Millisecond + // ErrCannotEnter is the refusal of a host whose .ssh is there but // cannot be entered, so that nothing in it can be read or written. var ErrCannotEnter = errors.New( @@ -187,8 +196,24 @@ func session( command.Stdout = &said command.Stderr = &said + // A cancelled context means a signal arrived. Send sftp a SIGTERM + // rather than the default kill, so it stops the ssh it started + // before it goes. Anything sftp started that still holds its output + // keeps the tool waiting no longer than waitDelay. + command.Cancel = func() error { + return command.Process.Signal(syscall.SIGTERM) + } + command.WaitDelay = waitDelay + err := command.Run() + // sftp ended well and only something it started, such as the + // master ssh leaves running for ControlPersist under -v, still held + // its output: the session worked. + if errors.Is(err, exec.ErrWaitDelay) { + err = nil + } + _, _ = cmd.ErrOrStderr().Write(said.Bytes()) if err != nil { diff --git a/internal/cli/ssh_test.go b/internal/cli/ssh_test.go index 4776237..5342323 100644 --- a/internal/cli/ssh_test.go +++ b/internal/cli/ssh_test.go @@ -209,14 +209,16 @@ fi sleep 5 ` -// stalled is a stand-in for the system sftp that notes it has started -// and then blocks, so a test can signal the tool while sftp is running -// and watch it remove its working directory. The exec keeps the shell -// from leaving a sleep behind that holds the output the tool reads sftp -// through. +// stalled is a stand-in for the system sftp that starts a child, notes +// it has started, and then blocks, so a test can signal the tool while +// sftp is running. The child holds the output the tool reads sftp +// through, as the ssh that sftp starts does, and is started before the +// note so that it is there when the signal ends the shell and still +// holds that output afterwards. const stalled = ` +sleep 5 & touch "$KEYFUNC_TEST_STARTED" -exec sleep 5 +wait ` // pretended is where a stand-in writes down what it was asked to do. @@ -495,6 +497,22 @@ func TestWhatComesAfterTheDashesIsGivenToSFTP(t *testing.T) { ) } +func TestAProcessSFTPLeavesBehindDoesNotFailTheRun(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + // A session that works ends by leaving a child behind that holds + // sftp's output, as the master ssh leaves running for ControlPersist + // does under -v. + standIn(t, "sftp", installer+"sleep 5 &\n") + + require.Equal(t, "added\n", install(t, host)) + require.Equal(t, keyLine, + read(t, filepath.Join(pretend.home, keptUnder, keptIn)), + ) +} + func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) { t.Setenv(mnemonic.Variable, example()) @@ -634,8 +652,8 @@ func TestASignalTakesTheInstallWorkingDirectoryDown(t *testing.T) { // signalEndsTheInstall runs "ssh install" as a subprocess against a // stand-in sftp that blocks, with a temporary directory of the test's // own, waits until sftp is running, sends the tool the signal, and -// requires the working directory the tool made there to be gone once -// the tool has ended. +// requires the tool to end within a second with status 1 and the +// working directory it made there to be gone. func signalEndsTheInstall(t *testing.T, name string, signal os.Signal) { t.Helper() @@ -663,9 +681,15 @@ func signalEndsTheInstall(t *testing.T, name string, signal os.Signal) { require.NoError(t, err) require.Len(t, working, 1, name) + sent := time.Now() + require.NoError(t, command.Process.Signal(signal)) waitForTool(t, name, command) + // The child sftp started would hold sftp's output for seconds yet. + require.Less(t, time.Since(sent), time.Second, name) + require.Equal(t, failedStatus, command.ProcessState.ExitCode(), name) + left, err := os.ReadDir(temporary) require.NoError(t, err) require.Empty(t, left, name) From 5b36e42e4d2fbd0ef9f4994f055b13bc410a3450 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 16:59:02 +0200 Subject: [PATCH 23/28] age -o keeps a symlink, pipe, device or redirected stream at the path (closes #59) age encrypt -o and age decrypt -o now look at the -o path before writing. A path that is the same file as the tool's standard output or standard error, under any name, is written to that stream, so a redirected file keeps its contents, inode and mode. A new path or a regular file is written beside it and renamed over it, as before, with the signal handling of #48. A symlink gets the same rule for what it points at, so the link survives; a dangling one is refused. A named pipe or a device is written directly. The README says a replaced file gets mode 0600. Rule suppressed: gosec G304 on the direct open of the -o path. Model: opus-5-5 --- README.md | 35 +++++++++---- internal/cli/age/age.go | 110 +++++++++++++++++++++++++++++++++++++-- internal/cli/age_test.go | 105 +++++++++++++++++++++++++++++++++++++ internal/cli/cli.go | 3 +- 4 files changed, 236 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 2fecc6c..511f00c 100644 --- a/README.md +++ b/README.md @@ -250,11 +250,21 @@ identity's own recipient, plus any given with `--to`, so the same mnemonic can always decrypt what it encrypted. Output goes to `-o` or standard output; `--armor` writes the text form. Nothing is written except the output. +A `-o` path that is the same file as the tool's own standard output or standard +error, under any name such as `/dev/stdout` or `/dev/fd/2`, is written to that +stream, as leaving out `-o` writes to standard output; the file the stream is +redirected to is written as the redirect says and never replaced, so with `>>` +the output follows what the file already held. Otherwise, a regular file already +at the `-o` path is replaced, and the new file has mode `0600`. A symlink there +is followed, and what it points at is treated the same way, so the link keeps +pointing where it did; a symlink that points at nothing is refused. A named pipe +or a device, such as `/dev/null`, is written to directly. + ### `keyfunc age decrypt [-n N] [-o ] []` Decrypts the file (or standard input) with the derived identity. Output goes to -`-o` or standard output. If the identity is not one of the recipients, the tool -says so and exits with status 1. +`-o`, which is treated as for `encrypt`, or standard output. If the identity is +not one of the recipients, the tool says so and exits with status 1. ## Derived mnemonics: `keyfunc mnemonic` @@ -291,15 +301,18 @@ passes through `ssh`'s own exit status. SIGINT, SIGTERM and SIGHUP end any command at once, at the mnemonic prompt too, with the status a shell gives a program killed by that signal (130 for SIGINT). -While `age encrypt -o` or `age decrypt -o` is writing the file, the signal makes -it remove the unfinished file, leave a file already at the named path as it was, -and exit with status 1. That holds for a signal that has reached `keyfunc` when -its input ends; a later one leaves the whole file in place. Ctrl-C on a pipeline -ends the input at the same moment, and on Linux `keyfunc` sees the signal first, -though no system promises that. While `ssh to` or `ssh install` has `ssh` or -`sftp` running, the signal ends that program instead, the tool removes its agent -socket or working files, and it exits with status 1, or for `ssh to` with -`ssh`'s own status if `ssh` reported one. +While `age encrypt -o` or `age decrypt -o` is writing a new file or replacing a +regular one, the signal makes it remove the unfinished file, leave a file +already at the named path as it was, and exit with status 1. That holds for a +signal that has reached `keyfunc` when its input ends; a later one leaves the +whole file in place. Ctrl-C on a pipeline ends the input at the same moment, and +on Linux `keyfunc` sees the signal first, though no system promises that. A +named pipe or a device at the `-o` path, or a path that is the same file as +standard output or standard error, is written to directly, and the signal ends +the tool there as it ends any other command. While `ssh to` or `ssh install` has +`ssh` or `sftp` running, the signal ends that program instead, the tool removes +its agent socket or working files, and it exits with status 1, or for `ssh to` +with `ssh`'s own status if `ssh` reported one. ## Entrypoints diff --git a/internal/cli/age/age.go b/internal/cli/age/age.go index b9d4609..a1f9f32 100644 --- a/internal/cli/age/age.go +++ b/internal/cli/age/age.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "io/fs" "os" "os/signal" "path/filepath" @@ -140,7 +141,10 @@ func runDecrypt(cmd *cobra.Command, args []string) error { // through opens the input the arguments ask for and hands it to the // work, with the file --output names to write to, or the command's own -// output when it names none. +// output when it names none or names the same file as that output, and +// the command's own error output when it names the same file as that. +// Those two are the streams the tool already has, so whatever they are +// redirected to is written as the redirect says, never replaced. func through( cmd *cobra.Command, args []string, work func(io.Writer, io.Reader) error, @@ -157,11 +161,36 @@ func through( return fmt.Errorf("reading the output file: %w", err) } - if name == "" { + switch { + case name == "", same(name, cmd.OutOrStdout()): return work(cmd.OutOrStdout(), src) + case same(name, cmd.ErrOrStderr()): + return work(cmd.ErrOrStderr(), src) + default: + return output(name, src, work) + } +} + +// same reports whether the named path, followed to the end, is the +// file the stream writes to, whatever name it is reached by, such as +// /dev/stdout or /dev/fd/1 for standard output. +func same(name string, stream io.Writer) bool { + file, ok := stream.(*os.File) + if !ok { + return false } - return output(name, src, work) + streamInfo, err := file.Stat() + if err != nil { + return false + } + + info, err := os.Stat(name) + if err != nil { + return false + } + + return os.SameFile(info, streamInfo) } // input returns what to read from: the named file, or the command's @@ -180,7 +209,78 @@ func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) { return file, func() { _ = file.Close() }, nil } -// output has the work write a new file beside the named one, and puts +// output has the work write to the named path, going by what is there +// without following a final symlink: +// +// - nothing, or a regular file: replace writes a new file beside it +// and renames that over it; +// - a symlink: the same for what it points at, so that the link keeps +// pointing where it did; one that points at nothing is refused; +// - anything else, such as a named pipe or a device like /dev/null: +// direct writes to it, since a rename would put a regular file in +// its place. +func output( + name string, src io.Reader, work func(io.Writer, io.Reader) error, +) error { + info, err := os.Lstat(name) + + switch { + case errors.Is(err, fs.ErrNotExist): + return replace(name, src, work) + case err != nil: + return fmt.Errorf("looking at %s: %w", name, err) + case info.Mode().IsRegular(): + return replace(name, src, work) + case info.Mode().Type() == fs.ModeSymlink: + // os.Stat follows the link as opening it would. /dev/fd/3 + // needs that: it reaches a pipe or a terminal through a link + // that names no path. + info, err = os.Stat(name) + if err != nil { + return fmt.Errorf("following %s: %w", name, err) + } + + if !info.Mode().IsRegular() { + return direct(name, src, work) + } + + target, err := filepath.EvalSymlinks(name) + if err != nil { + return fmt.Errorf("following %s: %w", name, err) + } + + return replace(target, src, work) + default: + return direct(name, src, work) + } +} + +// direct has the work write straight to the named path, which is there +// and is not a regular file. No signal is caught, so one ends the tool +// as it ends any other command. +func direct( + name string, src io.Reader, work func(io.Writer, io.Reader) error, +) error { + file, err := os.OpenFile(name, os.O_WRONLY, 0) //nolint:gosec // the -o path + if err != nil { + return fmt.Errorf("opening %s: %w", name, err) + } + + failed := work(file, src) + closeErr := file.Close() + + if failed != nil { + return failed + } + + if closeErr != nil { + return fmt.Errorf("finishing %s: %w", name, closeErr) + } + + return nil +} + +// replace has the work write a new file beside the named one, and puts // the new file in the named file's place only when the work succeeded, // so a file that is already there survives a run that failed. // @@ -189,7 +289,7 @@ func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) { // new file is removed and ErrInterrupted returned, at once if the work // is still running, without waiting for it, since it may be blocked // reading its input. -func output( +func replace( name string, src io.Reader, work func(io.Writer, io.Reader) error, ) error { // received is registered before the context, so it gets every diff --git a/internal/cli/age_test.go b/internal/cli/age_test.go index 2209db9..2ee31e7 100644 --- a/internal/cli/age_test.go +++ b/internal/cli/age_test.go @@ -3,6 +3,7 @@ package cli_test import ( "errors" "io" + "io/fs" "os" "os/exec" "os/signal" @@ -98,6 +99,110 @@ func TestARefusedDecryptionLeavesTheOutputFileAlone(t *testing.T) { require.Equal(t, "what was already there\n", string(kept)) } +func TestASymlinkAtTheOutputPathStaysAndItsTargetGetsTheOutput(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + plain := written(t, "notes.txt", "the secret\n") + target := written(t, "notes.age", "what was already there\n") + link := filepath.Join(t.TempDir(), "notes.age") + require.NoError(t, os.Symlink(target, link)) + + run(t, "age", "encrypt", "-o", link, plain) + + pointsAt, err := os.Readlink(link) + require.NoError(t, err) + require.Equal(t, target, pointsAt) + require.Equal(t, "the secret\n", run(t, "age", "decrypt", target)) +} + +func TestANamedPipeAtTheOutputPathIsWrittenToAndStaysAPipe(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + plain := written(t, "notes.txt", "the secret\n") + pipe := filepath.Join(t.TempDir(), "notes.age") + require.NoError(t, syscall.Mkfifo(pipe, fileMode)) + + // Opening the pipe to read waits until the tool opens it to write. + var sealed []byte + + finished := make(chan error, 1) + + go func() { + var err error + + sealed, err = os.ReadFile(pipe) //nolint:gosec // the test's own path + finished <- err + }() + + run(t, "age", "encrypt", "-o", pipe, plain) + + select { + case err := <-finished: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("nothing was written to the pipe") + } + + info, err := os.Lstat(pipe) + require.NoError(t, err) + require.Equal(t, fs.ModeNamedPipe, info.Mode().Type()) + + sealedFile := written(t, "notes.age", string(sealed)) + require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealedFile)) +} + +func TestANameForStandardOutputAddsToTheFileItIsAppendedTo(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + sealed := filepath.Join(t.TempDir(), "notes.age") + run(t, "age", "encrypt", "-o", sealed, written(t, "notes.txt", "the secret\n")) + + for _, name := range []string{"/dev/stdout", "/dev/fd/1"} { + appendedThrough(t, name, sealed) + } +} + +// appendedThrough decrypts sealed with -o name while the tool's standard +// output is appended to a file that already has contents, as the shell's +// ">> notes.out" does, and checks that the file is the same one, with +// the same mode, and holds its earlier contents and then the output. +func appendedThrough(t *testing.T, name, sealed string) { + t.Helper() + + // A mode of its own, so that a replaced file would show. + const ownMode = 0o644 + + existing := written(t, "notes.out", "what was already there\n") + require.NoError(t, os.Chmod(existing, ownMode)) + + before, err := os.Stat(existing) + require.NoError(t, err) + + //nolint:gosec // the test made this path itself + appended, err := os.OpenFile(existing, os.O_WRONLY|os.O_APPEND, 0) + require.NoError(t, err) + + defer func() { _ = appended.Close() }() + + //nolint:gosec // this test's own binary as the tool + command := exec.CommandContext( + t.Context(), os.Args[0], "age", "decrypt", "-o", name, sealed, + ) + + command.Env = append(os.Environ(), runAsTool+"=1") + command.Stdout = appended + + require.NoError(t, command.Run(), name) + require.Equal(t, + "what was already there\nthe secret\n", read(t, existing), name, + ) + + after, err := os.Stat(existing) + require.NoError(t, err) + require.True(t, os.SameFile(before, after), name) + require.Equal(t, os.FileMode(ownMode), after.Mode().Perm(), name) +} + func TestASignalStopsAnEncryptionAndLeavesNoFile(t *testing.T) { t.Setenv(mnemonic.Variable, example()) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7824e68..dabad05 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -85,7 +85,8 @@ func init() { // signals to clean up first: "ssh to" and "ssh install" while they // have ssh or sftp running, so the child ends and their own cleanup // still runs, and "age encrypt -o" and "age decrypt -o" while they -// write, so the unfinished file is removed. +// write a new file to rename over the named one, so the unfinished file +// is removed. func Main() int { err := Root().Execute() if err == nil { From 56e20b66e45374105324f4239edc36b3929e329f Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 4 Oct 2026 18:25:45 +0200 Subject: [PATCH 24/28] ssh install refuses stray arguments before -- and a symlinked authorized_keys (closes #61) ssh install now takes the host alone before --: any other word there, or a second argument without --, is refused before the mnemonic is read or sftp runs, so keyfunc ssh install alice@host frank@host no longer installs the key for frank@host. The first listing of ~/.ssh is now ls -n, which shows the file type, so a symlinked authorized_keys is refused before any upload instead of being replaced by a regular file; the README says so. Judgement calls: install -- host is refused; a symlinked authorized_keys is refused even when its target already holds the key. Model: opus-5-5 Co-authored-by: clawbot --- README.md | 11 ++++-- internal/cli/ssh/install.go | 58 ++++++++++++++++++++++++++- internal/cli/ssh/install_test.go | 2 +- internal/cli/ssh_test.go | 68 ++++++++++++++++++++++++++++++-- 4 files changed, 128 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 511f00c..4128af2 100644 --- a/README.md +++ b/README.md @@ -177,10 +177,13 @@ same way: one that cannot be read fails the first listing, and one that can be read but not entered fails the second, after which the tool says that `~/.ssh` cannot be entered. 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: +find on a session that then authenticates through the agent. An +`authorized_keys` that the first listing shows to be a symlink is refused and +left as it is, since the rename below would replace the link itself and the file +it points at would never get the key. 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: - 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; diff --git a/internal/cli/ssh/install.go b/internal/cli/ssh/install.go index 1e89e2a..1837e68 100644 --- a/internal/cli/ssh/install.go +++ b/internal/cli/ssh/install.go @@ -49,6 +49,20 @@ var ErrCannotEnter = errors.New( "~/.ssh is there on the host but cannot be entered", ) +// ErrSymlink is the refusal of a host whose authorized_keys is a +// symlink: the rename that puts the new file in place would replace the +// link itself, and the file it points at would never get the key. +var ErrSymlink = errors.New( + "~/.ssh/authorized_keys on the host is a symlink, which the tool " + + "leaves alone", +) + +// ErrStrayArgument is the refusal of anything but the host before --, +// which would otherwise be handed to sftp in front of the host. +var ErrStrayArgument = errors.New( + "only the host goes before --; options for sftp go after --", +) + // install returns the command that adds the public key to a host. func install() *cobra.Command { cmd := &cobra.Command{ @@ -60,7 +74,22 @@ func install() *cobra.Command { "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), + Args: cobra.MatchAll( + cobra.MinimumNArgs(1), + func(cmd *cobra.Command, args []string) error { + // ArgsLenAtDash is -1 when there is no --. + before := cmd.ArgsLenAtDash() + if before == -1 { + before = len(args) + } + + if before != 1 { + return ErrStrayArgument + } + + return nil + }, + ), RunE: func(cmd *cobra.Command, args []string) error { key, comment, err := derived(cmd) if err != nil { @@ -256,14 +285,23 @@ func merge(content, line string) (string, bool) { // it can be looked up, not even ".". The first listing of such a // directory comes up empty, as the server leaves out every name it // cannot look up. +// +// The first listing is a long one, which shows an authorized_keys that +// is a symlink as one. That is refused before anything else sftp said +// is read, so a link the get could not follow is refused in the same +// words. func fetch( cmd *cobra.Command, host string, options []string, into string, ) (string, bool, error) { said, err := session(cmd, host, options, []string{ - "ls -1 " + directory, + "ls -n " + directory, "ls -1 " + directory + "/.", "get " + authorized + " " + quoted(into), }) + if symlinked(said) { + return "", false, ErrSymlink + } + if err != nil { if listingNotFound(said, directory) { return "", false, nil @@ -326,6 +364,22 @@ func reportedCannotList(line string) (string, bool) { return strings.TrimSuffix(strings.TrimPrefix(line, before), after), true } +// symlinked says whether the long listing of .ssh shows authorized_keys +// as a symlink. With -n the client writes each line itself, as ls -l +// does, whatever the server: the type comes first, "l" for a symlink, +// and the path as the listing named it comes last. +func symlinked(said string) bool { + for line := range strings.Lines(said) { + fields := strings.Fields(line) + if len(fields) > 0 && strings.HasPrefix(fields[0], "l") && + fields[len(fields)-1] == authorized { + return true + } + } + + return false +} + // 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 diff --git a/internal/cli/ssh/install_test.go b/internal/cli/ssh/install_test.go index 510404a..cc25219 100644 --- a/internal/cli/ssh/install_test.go +++ b/internal/cli/ssh/install_test.go @@ -10,7 +10,7 @@ import "testing" const ( echoed = `sftp> get .ssh/authorized_keys "/tmp/keyfunc/authorized_keys" ` - listed = "sftp> ls -1 .ssh\n" + listed = "sftp> ls -n .ssh\n" warning = `Warning: Identity file /gone not accessible: ` + "No such file or directory.\n" ) diff --git a/internal/cli/ssh_test.go b/internal/cli/ssh_test.go index 5342323..3aa8ef8 100644 --- a/internal/cli/ssh_test.go +++ b/internal/cli/ssh_test.go @@ -99,6 +99,8 @@ const marker = "KEYFUNC_TEST_MARKER" // // 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 long listing (-n) writes each entry as the client does, its type +// first, so that a symlink shows as one. // 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, @@ -137,8 +139,7 @@ while IFS= read -r line; do worked=yes case "$1" in ls) - dir=$2 - [ "$dir" = -1 ] && dir=$3 + dir=$3 if [ ! -e "$home/$dir" ]; then worked=no printf 'Can'\''t ls: "%s" not found\n' "$home/$dir" >&2 @@ -149,7 +150,13 @@ while IFS= read -r line; do else for entry in "$home/$dir"/*; do [ -e "$entry" ] || continue - printf '%s/%s\n' "$dir" "$(basename "$entry")" + name="$dir/$(basename "$entry")" + if [ "$2" = -n ]; then + printf '%s ? someone users 0 Oct 4 15:44 %s\n' \ + "$(stat -c '%A' "$entry")" "$name" + else + printf '%s\n' "$name" + fi done fi ;; @@ -306,7 +313,7 @@ func TestTheFileIsUploadedBesideTheOldOneAndThenRenamedOverIt(t *testing.T) { // 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, "ls -n .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]) @@ -372,6 +379,59 @@ func TestADirectoryThatCannotBeEnteredIsRefusedBeforeAnyUpload(t *testing.T) { require.Equal(t, notADirectory, read(t, inTheWay)) } +func TestASymlinkedFileIsRefusedBeforeAnyUpload(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + // The file the link points at, which the key would never reach. + target := filepath.Join(pretend.home, "keys") + require.NoError(t, + os.WriteFile(target, []byte("somebody else\n"), fileMode), + ) + + directory := filepath.Join(pretend.home, keptUnder) + require.NoError(t, os.Mkdir(directory, directoryMode)) + + link := filepath.Join(directory, keptIn) + require.NoError(t, os.Symlink(target, link)) + + printed, _, err := attempt(t, host) + require.ErrorIs(t, err, ssh.ErrSymlink) + require.Empty(t, printed) + + // The read and nothing after it: no upload was tried, the link + // still points where it did, and what it points at is unchanged. + require.Equal(t, 1, connections(t, pretend)) + + pointsAt, err := os.Readlink(link) + require.NoError(t, err) + require.Equal(t, target, pointsAt) + require.Equal(t, "somebody else\n", read(t, target)) +} + +func TestAnArgumentBesideTheHostIsRefusedBeforeAnyConnection(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + runs := [][]string{ + {host, "frank@example.com"}, + {host, "2222"}, + {host, "frank@example.com", "--", "-P", "2222"}, + {"--", host}, + } + + for _, args := range runs { + printed, _, err := attempt(t, args...) + require.ErrorIs(t, err, ssh.ErrStrayArgument) + require.Empty(t, printed) + } + + // sftp was never started, so nothing was uploaded. + require.NoFileExists(t, pretend.arguments) +} + func TestAnExistingDirectoryKeepsItsModeAndIsNotRemade(t *testing.T) { t.Setenv(mnemonic.Variable, example()) From b0e018f0b68b5b1595cdc0311c252497503fc37c Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 19:59:07 +0200 Subject: [PATCH 25/28] The development image's /src is a clean checkout: keep .gitea in the build context (closes #63) .dockerignore no longer keeps .gitea out of the build context. The development image gets .git, so without the workflow directory git status in /src reported the CI workflow as deleted, make build stamped a -dirty version, and git commit -a would have deleted the workflow. Now /src is a clean checkout and make build there stamps the short commit. The comment above /keyfunc now names only the binary. Model: opus-5-5 --- .dockerignore | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.dockerignore b/.dockerignore index d5bb375..946c6c5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -62,7 +62,5 @@ **/.vscode **/*.sublime-* -# The binary `make build` writes, and the CI workflow, which is not a -# build input. +# The binary `make build` writes. /keyfunc -.gitea From e82c91d27c5eeca00c6d18a25be10a1ed32a255e Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 4 Oct 2026 20:59:05 +0200 Subject: [PATCH 26/28] package.json carries the license field, as the template does (closes #65) package.json now carries "license": "MIT", as the sneak/prompts template does since 2026-10-04, so yarn install in script/bootstrap no longer warns about a missing license field on every image build. Model: opus-5-5 Co-authored-by: clawbot --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index dc05cde..d846c06 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,5 @@ { + "license": "MIT", "devDependencies": { "prettier": "3.8.1" } From 44714205d4b594c023c156fb0604ef1e11d901ca Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 22:25:47 +0200 Subject: [PATCH 27/28] Bring every copied template file to sneak/prompts next at dd4027b (closes #67) Every file keyfunc copies from sneak/prompts now matches its next branch at commit dd4027b907ef99cdc3187c215cc4d610b7a11efc. REPO_POLICIES.md is the current copy; .gitignore and .dockerignore now ignore id_ecdsa_sk and id_ed25519_sk, the private key files ssh-keygen writes for hardware-backed keys, and .dockerignore keeps out a .git/config at any depth. The other copied files already matched; no adapted file needed a change. Model: opus-5-5 --- .dockerignore | 15 ++++++++++--- .gitignore | 2 ++ REPO_POLICIES.md | 56 +++++++++++++++++++++++++++++++++++++----------- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/.dockerignore b/.dockerignore index 946c6c5..1d67707 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,9 +18,16 @@ # does not need .git/config; that file can hold a credential, such as a # password in a remote URL or the token the CI checkout step stores there. # Each submodule keeps a config with the same exposure in its git directory -# under .git/modules/, nested again for a submodule's own submodules. -.git/config -.git/modules/**/config +# under .git/modules/, nested again for a submodule's own submodules, or in +# its own .git directory when it keeps one. +# KNOWN GAP: a submodule whose name has a `config` segment (`config`, +# `deploy/config`, `config/lib`) loses its whole git directory, because +# `**/.git/modules/**/config` also matches that segment's directory +# under .git/modules/. Go's version stamping then fails the build; +# nothing leaks. Name such a submodule without that segment: +# `git submodule add --name`. +**/.git/config +**/.git/modules/**/config # Agent scratch: one full checkout of the repo per in-flight agent. # Anchored because it occurs once where agents run at the repo root. @@ -44,7 +51,9 @@ **/[iI][dD]_[rR][sS][aA] **/[iI][dD]_[dD][sS][aA] **/[iI][dD]_[eE][cC][dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK] **/[iI][dD]_[eE][dD]25519 +**/[iI][dD]_[eE][dD]25519_[sS][kK] # Dependencies: restored inside the image, never copied in. **/node_modules diff --git a/.gitignore b/.gitignore index 0d0698d..e078f30 100644 --- a/.gitignore +++ b/.gitignore @@ -42,7 +42,9 @@ node_modules/ [iI][dD]_[rR][sS][aA] [iI][dD]_[dD][sS][aA] [iI][dD]_[eE][cC][dD][sS][aA] +[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK] [iI][dD]_[eE][dD]25519 +[iI][dD]_[eE][dD]25519_[sS][kK] # The binary `make build` writes. /keyfunc diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md index af0ea80..20382d1 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -104,10 +104,14 @@ style conventions are in separate documents: `lint` phase and a `test` phase, with the final stage depending on both so the image cannot be built unless they pass. For non-server repos the final stage brings up a development environment; for server repos it is the runtime image. - Dockerfiles install development prerequisites by running `script/bootstrap` - rather than duplicating installs inline; COPY `script/` and the dependency - manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before - running it. + The gate phases and the build stage start from their pinned base images and + install what those images lack either inline, as the canonical Go `Dockerfile` + below does for `git`, or by running `script/bootstrap`, as the `prompts` + repo's own `Dockerfile` does for its yarn packages. The development + environment stage installs development prerequisites by running + `script/bootstrap` rather than duplicating its installs inline. A stage that + runs `script/bootstrap` COPYs `script/` and the dependency manifests + (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it. - **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is no separate lint file. `script/lint` and `script/test` each build one phase @@ -156,6 +160,9 @@ style conventions are in separate documents: not evidence that anything ran: a sub-second build reporting success is a cache hit, not a result. Never invalidate by pruning — `docker builder prune` and friends destroy a build cache shared with every other build on the host. + When a check is added or changed, prove it works by planting a defect it must + catch and watching the run fail on it, then revert the defect. A green run + alone shows neither that the check ran nor that it covers what it should. - **The gate phases are separate stages, and the build stage depends on both.** The lint phase is based on the `golangci/golangci-lint` image (pinned by @@ -236,13 +243,28 @@ style conventions are in separate documents: (e.g. a web frontend compiled in a separate stage), the lint phase must create placeholder files so the embed directives resolve. Example: `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. - - If the project requires CGO or system libraries for linting (e.g. - `vips-dev`), install them in the lint phase with `apk add`. - - `.dockerignore` lets `.git` into the build context. It keeps out - `.git/config` and each submodule's `config` under `.git/modules/` at any - depth (`.git/modules/**/config`), which `git describe` does not need and - which can hold a credential: a password in a remote URL, or the token the - CI checkout step stores there. The stage that compiles has `git` (the + - If the project requires CGO or system libraries for linting, install them + in the lint phase. The `golangci/golangci-lint` image is Debian-based and + has no `apk`, so install with `apt-get` under the Debian package name + (`libvips-dev`, where alpine says `vips-dev`), and delete the package + lists in the same `RUN`, so the layer does not keep them: + + ```dockerfile + RUN apt-get update \ + && apt-get install -y --no-install-recommends libvips-dev \ + && rm -rf /var/lib/apt/lists/* + ``` + + - `.dockerignore` lets `.git` into the build context. It keeps out every git + `config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the + repository's own, each submodule's under `.git/modules/`, and that of a + submodule keeping its own `.git` directory. `git describe` does not need + them, and each can hold a credential: a password in a remote URL, or the + token the CI checkout step stores there. A submodule whose name has a + `config` segment (`config`, `deploy/config`, `config/lib`) loses its whole + git directory to `**/.git/modules/**/config`, and Go's version stamping + then fails the build: give it a name without that segment + (`git submodule add --name`). The stage that compiles has `git` (the Debian Go image has it; an alpine one needs `apk add --no-cache git`) and takes the version from the `VERSION` build argument when one is given, otherwise from `git describe --tags --always`. That gives the tag on a @@ -264,7 +286,12 @@ style conventions are in separate documents: carry the same guarantee, because its gate phases may come from the cache. The image build is uncached and so runs the gate phases a second time. That is the price of the rule above, and it is worth paying: the image that ships is built - from a run of its own gates rather than from a cache entry. + from a run of its own gates rather than from a cache entry. A separate + workflow limited to `main` by a `branches` list under `on: push` cannot be + checked by review: to try a change to it, add the feature branch to that list + and push, then remove the branch from the list again before merging. Keep any + job in it that publishes behind `if: github.ref_name == 'main'`, so the run + from the feature branch publishes nothing. - Use platform-standard formatters: `black` for Python, `prettier` for JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with @@ -495,6 +522,11 @@ style conventions are in separate documents: Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. + A Go tool a repo needs on the host is installed with `go install` pinned to + a commit hash (`go install @`). It is never tracked as + a `go.mod` tool dependency or through a `tools.go` file, either of which + pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`. + - When pinning images or packages by hash, add a comment above the reference with the version and date (YYYY-MM-DD). From ccdc576cd33f47afe8a7efbc3a5065331d1417c7 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 6 Oct 2026 03:27:15 +0200 Subject: [PATCH 28/28] Copy go-bip39 into internal/bip39 (closes #42) go-bip39's repository no longer exists, so keyfunc now carries the part of v1.1.0 it uses, with the upstream LICENSE beside it, and imports it from internal/bip39. Upstream's tests and test vectors for the kept code come along unchanged; where they called a removed function, crypto/rand stands in for NewEntropy and EntropyFromMnemonic for MnemonicToByteArray. Changes beyond the trimming are what the linter asked for: the package-level variables moved into the functions that use them, three error strings were lower-cased, and the unknown-word error now wraps ErrInvalidMnemonic. go.mod no longer requires go-bip39. go mod tidy keeps its two go.sum lines, because a test in sneak/secret's bip85 package imports it, and drops six lines that only go-bip39's own requirements needed. Model: opus-5-5 Co-authored-by: clawbot --- README.md | 6 +- go.mod | 1 - go.sum | 6 - internal/bip39/LICENSE | 21 + internal/bip39/bip39.go | 268 +++ internal/bip39/bip39_internal_test.go | 442 ++++ internal/bip39/english.go | 2061 ++++++++++++++++++ internal/bip39/english_internal_test.go | 19 + internal/bip39/example_test.go | 30 + internal/childmnemonic/childmnemonic.go | 2 +- internal/childmnemonic/childmnemonic_test.go | 2 +- internal/cli/cli_test.go | 2 +- internal/derive/derive.go | 2 +- internal/mnemonic/mnemonic.go | 2 +- 14 files changed, 2849 insertions(+), 15 deletions(-) create mode 100644 internal/bip39/LICENSE create mode 100644 internal/bip39/bip39.go create mode 100644 internal/bip39/bip39_internal_test.go create mode 100644 internal/bip39/english.go create mode 100644 internal/bip39/english_internal_test.go create mode 100644 internal/bip39/example_test.go diff --git a/README.md b/README.md index 4128af2..4903928 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ calls into `internal/`. The packages there are: `cli/options` holds the flags every command shares, `cli/signals` catches SIGINT, SIGTERM and SIGHUP for the commands that clean up before they end, and `cli/ssh`, `cli/age` and `cli/mnemonic` are the command groups. +- `internal/bip39` is a copy of `github.com/tyler-smith/go-bip39` v1.1.0, + trimmed to what keyfunc uses. ### Adding a key type @@ -373,9 +375,7 @@ standard: most Makefile targets are thin shims over an executable in `script/` ## TODO -The open issues that stand between the tree and a 1.0 release: - -- [#42 go-bip39 no longer exists upstream: keep it, or copy it into the repo?](https://git.eeqj.de/sneak/keyfunc/issues/42) +No issues are open. ## License diff --git a/go.mod b/go.mod index 543f9b5..107494e 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ require ( 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.57.0 golang.org/x/term v0.46.0 ) diff --git a/go.sum b/go.sum index 57e7f4b..5c2cbff 100644 --- a/go.sum +++ b/go.sum @@ -35,16 +35,10 @@ github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3C 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.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/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.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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/bip39/LICENSE b/internal/bip39/LICENSE new file mode 100644 index 0000000..4dae82d --- /dev/null +++ b/internal/bip39/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2018 Tyler Smith and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/internal/bip39/bip39.go b/internal/bip39/bip39.go new file mode 100644 index 0000000..dc748b5 --- /dev/null +++ b/internal/bip39/bip39.go @@ -0,0 +1,268 @@ +// Package bip39 is the Golang implementation of the BIP39 spec. +// +// The official BIP39 spec can be found at +// https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki +// +// It is a copy of github.com/tyler-smith/go-bip39 v1.1.0, trimmed to what +// keyfunc uses. +// +//nolint:mnd // the numbers are BIP-39's own, written as upstream writes them +package bip39 + +import ( + "crypto/sha256" + "crypto/sha512" + "encoding/binary" + "errors" + "fmt" + "math/big" + "strings" + + "golang.org/x/crypto/pbkdf2" +) + +var ( + // ErrInvalidMnemonic is returned when trying to use a malformed mnemonic. + ErrInvalidMnemonic = errors.New("invalid mnenomic") + + // ErrEntropyLengthInvalid is returned when trying to use an entropy set with + // an invalid size. + ErrEntropyLengthInvalid = errors.New( + "entropy length must be [128, 256] and a multiple of 32", + ) + + // ErrChecksumIncorrect is returned when entropy has the incorrect checksum. + ErrChecksumIncorrect = errors.New("checksum incorrect") +) + +// EntropyFromMnemonic takes a mnemonic generated by this library, +// and returns the input entropy used to generate the given mnemonic. +// An error is returned if the given mnemonic is invalid. +func EntropyFromMnemonic(mnemonic string) ([]byte, error) { + mnemonicSlice, isValid := splitMnemonicWords(mnemonic) + if !isValid { + return nil, ErrInvalidMnemonic + } + + // Some bitwise operands for working with big.Ints + shift11BitsMask := big.NewInt(2048) + bigOne := big.NewInt(1) + + // used to isolate the checksum bits from the entropy+checksum byte array + wordLengthChecksumMasksMapping := map[int]*big.Int{ + 12: big.NewInt(15), + 15: big.NewInt(31), + 18: big.NewInt(63), + 21: big.NewInt(127), + 24: big.NewInt(255), + } + // used to use only the desired x of 8 available checksum bits. + // 256 bit (word length 24) requires all 8 bits of the checksum, + // and thus no shifting is needed for it (we would get a divByZero crash if we did) + wordLengthChecksumShiftMapping := map[int]*big.Int{ + 12: big.NewInt(16), + 15: big.NewInt(8), + 18: big.NewInt(4), + 21: big.NewInt(2), + } + + // wordMap is a reverse lookup map for the word list + wordMap := map[string]int{} + for i, v := range English() { + wordMap[v] = i + } + + // Decode the words into a big.Int. + b := big.NewInt(0) + + for _, v := range mnemonicSlice { + index, found := wordMap[v] + if !found { + return nil, fmt.Errorf( + "%w: word `%v` not found in reverse map", ErrInvalidMnemonic, v, + ) + } + + var wordBytes [2]byte + + //nolint:gosec // the index of a word in the list is below 2048 + binary.BigEndian.PutUint16(wordBytes[:], uint16(index)) + + b = b.Mul(b, shift11BitsMask) + b = b.Or(b, big.NewInt(0).SetBytes(wordBytes[:])) + } + + // Build and add the checksum to the big.Int. + checksum := big.NewInt(0) + checksumMask := wordLengthChecksumMasksMapping[len(mnemonicSlice)] + checksum = checksum.And(b, checksumMask) + + b.Div(b, big.NewInt(0).Add(checksumMask, bigOne)) + + // The entropy is the underlying bytes of the big.Int. Any upper bytes of + // all 0's are not returned so we pad the beginning of the slice with empty + // bytes if necessary. + entropy := b.Bytes() + entropy = padByteSlice(entropy, len(mnemonicSlice)/3*4) + + // Generate the checksum and compare with the one we got from the mneomnic. + entropyChecksumBytes := computeChecksum(entropy) + entropyChecksum := big.NewInt(int64(entropyChecksumBytes[0])) + + if l := len(mnemonicSlice); l != 24 { + checksumShift := wordLengthChecksumShiftMapping[l] + entropyChecksum.Div(entropyChecksum, checksumShift) + } + + if checksum.Cmp(entropyChecksum) != 0 { + return nil, ErrChecksumIncorrect + } + + return entropy, nil +} + +// NewMnemonic will return a string consisting of the mnemonic words for +// the given entropy. +// If the provide entropy is invalid, an error will be returned. +func NewMnemonic(entropy []byte) (string, error) { + // Compute some lengths for convenience. + entropyBitLength := len(entropy) * 8 + checksumBitLength := entropyBitLength / 32 + sentenceLength := (entropyBitLength + checksumBitLength) / 11 + + // Validate that the requested size is supported. + err := validateEntropyBitSize(entropyBitLength) + if err != nil { + return "", err + } + + // Some bitwise operands for working with big.Ints + last11BitsMask := big.NewInt(2047) + shift11BitsMask := big.NewInt(2048) + + // wordList is the set of words to use + wordList := English() + + // Add checksum to entropy. + entropy = addChecksum(entropy) + + // Break entropy up into sentenceLength chunks of 11 bits. + // For each word AND mask the rightmost 11 bits and find the word at that index. + // Then bitshift entropy 11 bits right and repeat. + // Add to the last empty slot so we can work with LSBs instead of MSB. + + // Entropy as an int so we can bitmask without worrying about bytes slices. + entropyInt := new(big.Int).SetBytes(entropy) + + // Slice to hold words in. + words := make([]string, sentenceLength) + + // Throw away big.Int for AND masking. + word := big.NewInt(0) + + for i := sentenceLength - 1; i >= 0; i-- { + // Get 11 right most bits and bitshift 11 to the right for next time. + word.And(entropyInt, last11BitsMask) + entropyInt.Div(entropyInt, shift11BitsMask) + + // Get the bytes representing the 11 bits as a 2 byte slice. + wordBytes := padByteSlice(word.Bytes(), 2) + + // Convert bytes to an index and add that word to the list. + words[i] = wordList[binary.BigEndian.Uint16(wordBytes)] + } + + return strings.Join(words, " "), nil +} + +// NewSeed creates a hashed seed output given a provided string and password. +// No checking is performed to validate that the string provided is a valid mnemonic. +func NewSeed(mnemonic string, password string) []byte { + return pbkdf2.Key([]byte(mnemonic), []byte("mnemonic"+password), 2048, 64, sha512.New) +} + +// IsMnemonicValid attempts to verify that the provided mnemonic is valid. +// Validity is determined by both the number of words being appropriate, +// and that all the words in the mnemonic are present in the word list. +func IsMnemonicValid(mnemonic string) bool { + _, err := EntropyFromMnemonic(mnemonic) + + return err == nil +} + +// Appends to data the first (len(data) / 32)bits of the result of sha256(data) +// Currently only supports data up to 32 bytes +func addChecksum(data []byte) []byte { + // Some bitwise operands for working with big.Ints + bigOne := big.NewInt(1) + bigTwo := big.NewInt(2) + + // Get first byte of sha256 + hash := computeChecksum(data) + firstChecksumByte := hash[0] + + // len() is in bytes so we divide by 4 + checksumBitLength := uint(len(data) / 4) + + // For each bit of check sum we want we shift the data one the left + // and then set the (new) right most bit equal to checksum bit at that index + // staring from the left + dataBigInt := new(big.Int).SetBytes(data) + for i := range checksumBitLength { + // Bitshift 1 left + dataBigInt.Mul(dataBigInt, bigTwo) + + // Set rightmost bit if leftmost checksum bit is set + if firstChecksumByte&(1<<(7-i)) > 0 { + dataBigInt.Or(dataBigInt, bigOne) + } + } + + return dataBigInt.Bytes() +} + +func computeChecksum(data []byte) []byte { + hasher := sha256.New() + hasher.Write(data) + + return hasher.Sum(nil) +} + +// validateEntropyBitSize ensures that entropy is the correct size for being a +// mnemonic. +func validateEntropyBitSize(bitSize int) error { + if (bitSize%32) != 0 || bitSize < 128 || bitSize > 256 { + return ErrEntropyLengthInvalid + } + + return nil +} + +// padByteSlice returns a byte slice of the given size with contents of the +// given slice left padded and any empty spaces filled with 0's. +func padByteSlice(slice []byte, length int) []byte { + offset := length - len(slice) + if offset <= 0 { + return slice + } + + newSlice := make([]byte, length) + copy(newSlice[offset:], slice) + + return newSlice +} + +func splitMnemonicWords(mnemonic string) ([]string, bool) { + // Create a list of all the words in the mnemonic sentence + words := strings.Fields(mnemonic) + + // Get num of words + numOfWords := len(words) + + // The number of words should be 12, 15, 18, 21 or 24 + if numOfWords%3 != 0 || numOfWords < 12 || numOfWords > 24 { + return nil, false + } + + return words, true +} diff --git a/internal/bip39/bip39_internal_test.go b/internal/bip39/bip39_internal_test.go new file mode 100644 index 0000000..a4a1e19 --- /dev/null +++ b/internal/bip39/bip39_internal_test.go @@ -0,0 +1,442 @@ +package bip39 + +import ( + "crypto/rand" + "encoding/hex" + "testing" +) + +type vector struct { + entropy string + mnemonic string + seed string +} + +func TestNewMnemonic(t *testing.T) { + t.Parallel() + + for _, vector := range testVectors() { + entropy, err := hex.DecodeString(vector.entropy) + assertNil(t, err) + + mnemonic, err := NewMnemonic(entropy) + assertNil(t, err) + assertEqualString(t, vector.mnemonic, mnemonic) + + seed := NewSeed(mnemonic, "TREZOR") + assertEqualString(t, vector.seed, hex.EncodeToString(seed)) + } +} + +func TestNewMnemonicInvalidEntropy(t *testing.T) { + t.Parallel() + + _, err := NewMnemonic([]byte{}) + assertNotNil(t, err) +} + +func TestIsMnemonicValid(t *testing.T) { + t.Parallel() + + for _, vector := range badMnemonicSentences() { + assertFalse(t, IsMnemonicValid(vector.mnemonic)) + } + + for _, vector := range testVectors() { + assertTrue(t, IsMnemonicValid(vector.mnemonic)) + } +} + +func TestPadByteSlice(t *testing.T) { + t.Parallel() + + assertEqualByteSlices(t, []byte{0}, padByteSlice([]byte{}, 1)) + assertEqualByteSlices(t, []byte{0, 1}, padByteSlice([]byte{1}, 2)) + assertEqualByteSlices(t, []byte{1, 1}, padByteSlice([]byte{1, 1}, 2)) + assertEqualByteSlices(t, []byte{1, 1, 1}, padByteSlice([]byte{1, 1, 1}, 2)) +} + +//nolint:funlen // the test vectors, kept as upstream wrote them +func TestMnemonicToByteArrayForZeroLeadingSeeds(t *testing.T) { + t.Parallel() + + ms := []string{ + "00000000000000000000000000000000", + "00a84c51041d49acca66e6160c1fa999", + "00ca45df1673c76537a2020bfed1dafd", + "0019d5871c7b81fd83d474ef1c1e1dae", + "00dcb021afb35ffcdd1d032d2056fc86", + "0062be7bd09a27288b6cf0eb565ec739", + "00dc705b5efa0adf25b9734226ba60d4", + "0017747418d54c6003fa64fade83374b", + "000d44d3ee7c3dfa45e608c65384431b", + "008241c1ef976b0323061affe5bf24b9", + "00a6aec77e4d16bea80b50a34991aaba", + "0011527b8c6ddecb9d0c20beccdeb58d", + "001c938c503c8f5a2bba2248ff621546", + "0002f90aaf7a8327698f0031b6317c36", + "00bff43071ed7e07f77b14f615993bac", + "00da143e00ef17fc63b6fb22dcc2c326", + "00ffc6764fb32a354cab1a3ddefb015d", + "0062ef47e0985e8953f24760b7598cdd", + "003bf9765064f71d304908d906c065f5", + "00993851503471439d154b3613947474", + "007ad0ffe9eae753a483a76af06dfa67", + "00091824db9ec19e663bee51d64c83cc", + "00f48ac621f7e3cb39b2012ac3121543", + "0072917415cdca24dfa66c4a92c885b4", + "0027ced2b279ea8a91d29364487cdbf4", + "00b9c0d37fb10ba272e55842ad812583", + "004b3d0d2b9285946c687a5350479c8c", + "00c7c12a37d3a7f8c1532b17c89b724c", + "00f400c5545f06ae17ad00f3041e4e26", + "001e290be10df4d209f247ac5878662b", + "00bf0f74568e582a7dd1ee64f792ec8b", + "00d2e43ecde6b72b847db1539ed89e23", + "00cecba6678505bb7bfec8ed307251f6", + "000aeed1a9edcbb4bc88f610d3ce84eb", + "00d06206aadfc25c2b21805d283f15ae", + "00a31789a2ab2d54f8fadd5331010287", + "003493c5f520e8d5c0483e895a121dc9", + "004706112800b76001ece2e268bc830e", + "00ab31e28bb5305be56e38337dbfa486", + "006872fe85df6b0fa945248e6f9379d1", + "00717e5e375da6934e3cfdf57edaf3bd", + "007f1b46e7b9c4c76e77c434b9bccd6b", + "00dc93735aa35def3b9a2ff676560205", + "002cd5dcd881a49c7b87714c6a570a76", + "0013b5af9e13fac87e0c505686cfb6bf", + "007ab1ec9526b0bc04b64ae65fd42631", + "00abb4e11d8385c1cca905a6a65e9144", + "00574fc62a0501ad8afada2e246708c3", + "005207e0a815bb2da6b4c35ec1f2bf52", + "00f3460f136fb9700080099cbd62bc18", + "007a591f204c03ca7b93981237112526", + "00cfe0befd428f8e5f83a5bfc801472e", + "00987551ac7a879bf0c09b8bc474d9af", + "00cadd3ce3d78e49fbc933a85682df3f", + "00bfbf2e346c855ccc360d03281455a1", + "004cdf55d429d028f715544ce22d4f31", + "0075c84a7d15e0ac85e1e41025eed23b", + "00807dddd61f71725d336cab844d2cb5", + "00422f21b77fe20e367467ed98c18410", + "00b44d0ac622907119c626c850a462fd", + "00363f5e7f22fc49f3cd662a28956563", + "000fe5837e68397bbf58db9f221bdc4e", + "0056af33835c888ef0c22599686445d3", + "00790a8647fd3dfb38b7e2b6f578f2c6", + "00da8d9009675cb7beec930e263014fb", + "00d4b384540a5bb54aa760edaa4fb2fe", + "00be9b1479ed680fdd5d91a41eb926d0", + "009182347502af97077c40a6e74b4b5c", + "00f5c90ee1c67fa77fd821f8e9fab4f1", + "005568f9a2dd6b0c0cc2f5ba3d9cac38", + "008b481f8678577d9cf6aa3f6cd6056b", + "00c4323ece5e4fe3b6cd4c5c932931af", + "009791f7550c3798c5a214cb2d0ea773", + "008a7baab22481f0ad8167dd9f90d55c", + "00f0e601519aafdc8ff94975e64c946d", + "0083b61e0daa9219df59d697c270cd31", + } + + for _, m := range ms { + seed, _ := hex.DecodeString(m) + + mnemonic, err := NewMnemonic(seed) + if err != nil { + t.Errorf("%v", err) + } + + _, err = EntropyFromMnemonic(mnemonic) + if err != nil { + t.Errorf("Failed for %x - %v", seed, mnemonic) + } + } +} + +func TestEntropyFromMnemonic128(t *testing.T) { + t.Parallel() + + testEntropyFromMnemonic(t, 128) +} + +func TestEntropyFromMnemonic160(t *testing.T) { + t.Parallel() + + testEntropyFromMnemonic(t, 160) +} + +func TestEntropyFromMnemonic192(t *testing.T) { + t.Parallel() + + testEntropyFromMnemonic(t, 192) +} + +func TestEntropyFromMnemonic224(t *testing.T) { + t.Parallel() + + testEntropyFromMnemonic(t, 224) +} + +func TestEntropyFromMnemonic256(t *testing.T) { + t.Parallel() + + testEntropyFromMnemonic(t, 256) +} + +//nolint:dupword,lll // the test vector, kept as upstream wrote it +func TestEntropyFromMnemonicInvalidChecksum(t *testing.T) { + t.Parallel() + + _, err := EntropyFromMnemonic("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon yellow") + assertEqual(t, ErrChecksumIncorrect, err) +} + +//nolint:dupword // the test vectors, kept as upstream wrote them +func TestEntropyFromMnemonicInvalidMnemonicSize(t *testing.T) { + t.Parallel() + + for _, mnemonic := range []string{ + "a a a a a a a a a a a a a a a a a a a a a a a a a", // Too many words + "a", // Too few + "a a a a a a a a a a a a a a", // Not multiple of 3 + } { + _, err := EntropyFromMnemonic(mnemonic) + assertEqual(t, ErrInvalidMnemonic, err) + } +} + +func testEntropyFromMnemonic(t *testing.T, bitSize int) { + t.Helper() + + for range 512 { + expectedEntropy := make([]byte, bitSize/8) + _, err := rand.Read(expectedEntropy) + assertNil(t, err) + assertTrue(t, len(expectedEntropy) != 0) + + mnemonic, err := NewMnemonic(expectedEntropy) + assertNil(t, err) + assertTrue(t, len(mnemonic) != 0) + + actualEntropy, err := EntropyFromMnemonic(mnemonic) + assertNil(t, err) + assertEqualByteSlices(t, expectedEntropy, actualEntropy) + } +} + +//nolint:dupword,funlen,lll // the BIP-39 test vectors, kept as upstream wrote them +func testVectors() []vector { + return []vector{ + { + entropy: "00000000000000000000000000000000", + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + seed: "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04", + }, + { + entropy: "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f", + mnemonic: "legal winner thank year wave sausage worth useful legal winner thank yellow", + seed: "2e8905819b8723fe2c1d161860e5ee1830318dbf49a83bd451cfb8440c28bd6fa457fe1296106559a3c80937a1c1069be3a3a5bd381ee6260e8d9739fce1f607", + }, + { + entropy: "80808080808080808080808080808080", + mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage above", + seed: "d71de856f81a8acc65e6fc851a38d4d7ec216fd0796d0a6827a3ad6ed5511a30fa280f12eb2e47ed2ac03b5c462a0358d18d69fe4f985ec81778c1b370b652a8", + }, + { + entropy: "ffffffffffffffffffffffffffffffff", + mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong", + seed: "ac27495480225222079d7be181583751e86f571027b0497b5b5d11218e0a8a13332572917f0f8e5a589620c6f15b11c61dee327651a14c34e18231052e48c069", + }, + { + entropy: "000000000000000000000000000000000000000000000000", + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent", + seed: "035895f2f481b1b0f01fcf8c289c794660b289981a78f8106447707fdd9666ca06da5a9a565181599b79f53b844d8a71dd9f439c52a3d7b3e8a79c906ac845fa", + }, + { + entropy: "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f", + mnemonic: "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will", + seed: "f2b94508732bcbacbcc020faefecfc89feafa6649a5491b8c952cede496c214a0c7b3c392d168748f2d4a612bada0753b52a1c7ac53c1e93abd5c6320b9e95dd", + }, + { + entropy: "808080808080808080808080808080808080808080808080", + mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter always", + seed: "107d7c02a5aa6f38c58083ff74f04c607c2d2c0ecc55501dadd72d025b751bc27fe913ffb796f841c49b1d33b610cf0e91d3aa239027f5e99fe4ce9e5088cd65", + }, + { + entropy: "ffffffffffffffffffffffffffffffffffffffffffffffff", + mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo when", + seed: "0cd6e5d827bb62eb8fc1e262254223817fd068a74b5b449cc2f667c3f1f985a76379b43348d952e2265b4cd129090758b3e3c2c49103b5051aac2eaeb890a528", + }, + { + entropy: "0000000000000000000000000000000000000000000000000000000000000000", + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + seed: "bda85446c68413707090a52022edd26a1c9462295029f2e60cd7c4f2bbd3097170af7a4d73245cafa9c3cca8d561a7c3de6f5d4a10be8ed2a5e608d68f92fcc8", + }, + { + entropy: "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f", + mnemonic: "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title", + seed: "bc09fca1804f7e69da93c2f2028eb238c227f2e9dda30cd63699232578480a4021b146ad717fbb7e451ce9eb835f43620bf5c514db0f8add49f5d121449d3e87", + }, + { + entropy: "8080808080808080808080808080808080808080808080808080808080808080", + mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless", + seed: "c0c519bd0e91a2ed54357d9d1ebef6f5af218a153624cf4f2da911a0ed8f7a09e2ef61af0aca007096df430022f7a2b6fb91661a9589097069720d015e4e982f", + }, + { + entropy: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote", + seed: "dd48c104698c30cfe2b6142103248622fb7bb0ff692eebb00089b32d22484e1613912f0a5b694407be899ffd31ed3992c456cdf60f5d4564b8ba3f05a69890ad", + }, + { + entropy: "77c2b00716cec7213839159e404db50d", + mnemonic: "jelly better achieve collect unaware mountain thought cargo oxygen act hood bridge", + seed: "b5b6d0127db1a9d2226af0c3346031d77af31e918dba64287a1b44b8ebf63cdd52676f672a290aae502472cf2d602c051f3e6f18055e84e4c43897fc4e51a6ff", + }, + { + entropy: "b63a9c59a6e641f288ebc103017f1da9f8290b3da6bdef7b", + mnemonic: "renew stay biology evidence goat welcome casual join adapt armor shuffle fault little machine walk stumble urge swap", + seed: "9248d83e06f4cd98debf5b6f010542760df925ce46cf38a1bdb4e4de7d21f5c39366941c69e1bdbf2966e0f6e6dbece898a0e2f0a4c2b3e640953dfe8b7bbdc5", + }, + { + entropy: "3e141609b97933b66a060dcddc71fad1d91677db872031e85f4c015c5e7e8982", + mnemonic: "dignity pass list indicate nasty swamp pool script soccer toe leaf photo multiply desk host tomato cradle drill spread actor shine dismiss champion exotic", + seed: "ff7f3184df8696d8bef94b6c03114dbee0ef89ff938712301d27ed8336ca89ef9635da20af07d4175f2bf5f3de130f39c9d9e8dd0472489c19b1a020a940da67", + }, + { + entropy: "0460ef47585604c5660618db2e6a7e7f", + mnemonic: "afford alter spike radar gate glance object seek swamp infant panel yellow", + seed: "65f93a9f36b6c85cbe634ffc1f99f2b82cbb10b31edc7f087b4f6cb9e976e9faf76ff41f8f27c99afdf38f7a303ba1136ee48a4c1e7fcd3dba7aa876113a36e4", + }, + { + entropy: "72f60ebac5dd8add8d2a25a797102c3ce21bc029c200076f", + mnemonic: "indicate race push merry suffer human cruise dwarf pole review arch keep canvas theme poem divorce alter left", + seed: "3bbf9daa0dfad8229786ace5ddb4e00fa98a044ae4c4975ffd5e094dba9e0bb289349dbe2091761f30f382d4e35c4a670ee8ab50758d2c55881be69e327117ba", + }, + { + entropy: "2c85efc7f24ee4573d2b81a6ec66cee209b2dcbd09d8eddc51e0215b0b68e416", + mnemonic: "clutch control vehicle tonight unusual clog visa ice plunge glimpse recipe series open hour vintage deposit universe tip job dress radar refuse motion taste", + seed: "fe908f96f46668b2d5b37d82f558c77ed0d69dd0e7e043a5b0511c48c2f1064694a956f86360c93dd04052a8899497ce9e985ebe0c8c52b955e6ae86d4ff4449", + }, + { + entropy: "eaebabb2383351fd31d703840b32e9e2", + mnemonic: "turtle front uncle idea crush write shrug there lottery flower risk shell", + seed: "bdfb76a0759f301b0b899a1e3985227e53b3f51e67e3f2a65363caedf3e32fde42a66c404f18d7b05818c95ef3ca1e5146646856c461c073169467511680876c", + }, + { + entropy: "7ac45cfe7722ee6c7ba84fbc2d5bd61b45cb2fe5eb65aa78", + mnemonic: "kiss carry display unusual confirm curtain upgrade antique rotate hello void custom frequent obey nut hole price segment", + seed: "ed56ff6c833c07982eb7119a8f48fd363c4a9b1601cd2de736b01045c5eb8ab4f57b079403485d1c4924f0790dc10a971763337cb9f9c62226f64fff26397c79", + }, + { + entropy: "4fa1a8bc3e6d80ee1316050e862c1812031493212b7ec3f3bb1b08f168cabeef", + mnemonic: "exile ask congress lamp submit jacket era scheme attend cousin alcohol catch course end lucky hurt sentence oven short ball bird grab wing top", + seed: "095ee6f817b4c2cb30a5a797360a81a40ab0f9a4e25ecd672a3f58a0b5ba0687c096a6b14d2c0deb3bdefce4f61d01ae07417d502429352e27695163f7447a8c", + }, + { + entropy: "18ab19a9f54a9274f03e5209a2ac8a91", + mnemonic: "board flee heavy tunnel powder denial science ski answer betray cargo cat", + seed: "6eff1bb21562918509c73cb990260db07c0ce34ff0e3cc4a8cb3276129fbcb300bddfe005831350efd633909f476c45c88253276d9fd0df6ef48609e8bb7dca8", + }, + { + entropy: "18a2e1d81b8ecfb2a333adcb0c17a5b9eb76cc5d05db91a4", + mnemonic: "board blade invite damage undo sun mimic interest slam gaze truly inherit resist great inject rocket museum chief", + seed: "f84521c777a13b61564234bf8f8b62b3afce27fc4062b51bb5e62bdfecb23864ee6ecf07c1d5a97c0834307c5c852d8ceb88e7c97923c0a3b496bedd4e5f88a9", + }, + { + entropy: "15da872c95a13dd738fbf50e427583ad61f18fd99f628c417a61cf8343c90419", + mnemonic: "beyond stage sleep clip because twist token leaf atom beauty genius food business side grid unable middle armed observe pair crouch tonight away coconut", + seed: "b15509eaa2d09d3efd3e006ef42151b30367dc6e3aa5e44caba3fe4d3e352e65101fbdb86a96776b91946ff06f8eac594dc6ee1d3e82a42dfe1b40fef6bcc3fd", + }, + } +} + +//nolint:dupword,lll // the test vectors, kept as upstream wrote them +func badMnemonicSentences() []vector { + return []vector{ + {mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"}, + {mnemonic: "legal winner thank year wave sausage worth useful legal winner thank yellow yellow"}, + {mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice caged above"}, + {mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo, wrong"}, + {mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"}, + {mnemonic: "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will will will"}, + {mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter always."}, + {mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo why"}, + {mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art art"}, + {mnemonic: "legal winner thank year wave sausage worth useful legal winner thanks year wave worth useful legal winner thank year wave sausage worth title"}, + {mnemonic: "letter advice cage absurd amount doctor acoustic avoid letters advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless"}, + {mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo voted"}, + {mnemonic: "jello better achieve collect unaware mountain thought cargo oxygen act hood bridge"}, + {mnemonic: "renew, stay, biology, evidence, goat, welcome, casual, join, adapt, armor, shuffle, fault, little, machine, walk, stumble, urge, swap"}, + {mnemonic: "dignity pass list indicate nasty"}, + + // From issue 32 + {mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon letter"}, + } +} + +func assertNil(t *testing.T, object any) { + t.Helper() + + if object != nil { + t.Errorf("Expected nil, got %v", object) + } +} + +func assertNotNil(t *testing.T, object any) { + t.Helper() + + if object == nil { + t.Error("Expected not nil") + } +} + +func assertTrue(t *testing.T, a bool) { + t.Helper() + + if !a { + t.Error("Expected true, got false") + } +} + +func assertFalse(t *testing.T, a bool) { + t.Helper() + + if a { + t.Error("Expected false, got true") + } +} + +func assertEqual(t *testing.T, a, b any) { + t.Helper() + + if a != b { + t.Errorf("Objects not equal, expected `%s` and got `%s`", a, b) + } +} + +func assertEqualString(t *testing.T, a, b string) { + t.Helper() + + if a != b { + t.Errorf("Strings not equal, expected `%s` and got `%s`", a, b) + } +} + +func assertEqualByteSlices(t *testing.T, a, b []byte) { + t.Helper() + + if len(a) != len(b) { + t.Errorf("Byte slices not equal, expected %v and got %v", a, b) + + return + } + + for i := range a { + if a[i] != b[i] { + t.Errorf("Byte slices not equal, expected %v and got %v", a, b) + + return + } + } +} diff --git a/internal/bip39/english.go b/internal/bip39/english.go new file mode 100644 index 0000000..ea2d3b1 --- /dev/null +++ b/internal/bip39/english.go @@ -0,0 +1,2061 @@ +package bip39 + +import ( + "strings" +) + +// English returns a slice of mnemonic words taken from the bip39 specification +// https://raw.githubusercontent.com/bitcoin/bips/master/bip-0039/english.txt +func English() []string { + return strings.Split(strings.TrimSpace(english), "\n") +} + +const english = `abandon +ability +able +about +above +absent +absorb +abstract +absurd +abuse +access +accident +account +accuse +achieve +acid +acoustic +acquire +across +act +action +actor +actress +actual +adapt +add +addict +address +adjust +admit +adult +advance +advice +aerobic +affair +afford +afraid +again +age +agent +agree +ahead +aim +air +airport +aisle +alarm +album +alcohol +alert +alien +all +alley +allow +almost +alone +alpha +already +also +alter +always +amateur +amazing +among +amount +amused +analyst +anchor +ancient +anger +angle +angry +animal +ankle +announce +annual +another +answer +antenna +antique +anxiety +any +apart +apology +appear +apple +approve +april +arch +arctic +area +arena +argue +arm +armed +armor +army +around +arrange +arrest +arrive +arrow +art +artefact +artist +artwork +ask +aspect +assault +asset +assist +assume +asthma +athlete +atom +attack +attend +attitude +attract +auction +audit +august +aunt +author +auto +autumn +average +avocado +avoid +awake +aware +away +awesome +awful +awkward +axis +baby +bachelor +bacon +badge +bag +balance +balcony +ball +bamboo +banana +banner +bar +barely +bargain +barrel +base +basic +basket +battle +beach +bean +beauty +because +become +beef +before +begin +behave +behind +believe +below +belt +bench +benefit +best +betray +better +between +beyond +bicycle +bid +bike +bind +biology +bird +birth +bitter +black +blade +blame +blanket +blast +bleak +bless +blind +blood +blossom +blouse +blue +blur +blush +board +boat +body +boil +bomb +bone +bonus +book +boost +border +boring +borrow +boss +bottom +bounce +box +boy +bracket +brain +brand +brass +brave +bread +breeze +brick +bridge +brief +bright +bring +brisk +broccoli +broken +bronze +broom +brother +brown +brush +bubble +buddy +budget +buffalo +build +bulb +bulk +bullet +bundle +bunker +burden +burger +burst +bus +business +busy +butter +buyer +buzz +cabbage +cabin +cable +cactus +cage +cake +call +calm +camera +camp +can +canal +cancel +candy +cannon +canoe +canvas +canyon +capable +capital +captain +car +carbon +card +cargo +carpet +carry +cart +case +cash +casino +castle +casual +cat +catalog +catch +category +cattle +caught +cause +caution +cave +ceiling +celery +cement +census +century +cereal +certain +chair +chalk +champion +change +chaos +chapter +charge +chase +chat +cheap +check +cheese +chef +cherry +chest +chicken +chief +child +chimney +choice +choose +chronic +chuckle +chunk +churn +cigar +cinnamon +circle +citizen +city +civil +claim +clap +clarify +claw +clay +clean +clerk +clever +click +client +cliff +climb +clinic +clip +clock +clog +close +cloth +cloud +clown +club +clump +cluster +clutch +coach +coast +coconut +code +coffee +coil +coin +collect +color +column +combine +come +comfort +comic +common +company +concert +conduct +confirm +congress +connect +consider +control +convince +cook +cool +copper +copy +coral +core +corn +correct +cost +cotton +couch +country +couple +course +cousin +cover +coyote +crack +cradle +craft +cram +crane +crash +crater +crawl +crazy +cream +credit +creek +crew +cricket +crime +crisp +critic +crop +cross +crouch +crowd +crucial +cruel +cruise +crumble +crunch +crush +cry +crystal +cube +culture +cup +cupboard +curious +current +curtain +curve +cushion +custom +cute +cycle +dad +damage +damp +dance +danger +daring +dash +daughter +dawn +day +deal +debate +debris +decade +december +decide +decline +decorate +decrease +deer +defense +define +defy +degree +delay +deliver +demand +demise +denial +dentist +deny +depart +depend +deposit +depth +deputy +derive +describe +desert +design +desk +despair +destroy +detail +detect +develop +device +devote +diagram +dial +diamond +diary +dice +diesel +diet +differ +digital +dignity +dilemma +dinner +dinosaur +direct +dirt +disagree +discover +disease +dish +dismiss +disorder +display +distance +divert +divide +divorce +dizzy +doctor +document +dog +doll +dolphin +domain +donate +donkey +donor +door +dose +double +dove +draft +dragon +drama +drastic +draw +dream +dress +drift +drill +drink +drip +drive +drop +drum +dry +duck +dumb +dune +during +dust +dutch +duty +dwarf +dynamic +eager +eagle +early +earn +earth +easily +east +easy +echo +ecology +economy +edge +edit +educate +effort +egg +eight +either +elbow +elder +electric +elegant +element +elephant +elevator +elite +else +embark +embody +embrace +emerge +emotion +employ +empower +empty +enable +enact +end +endless +endorse +enemy +energy +enforce +engage +engine +enhance +enjoy +enlist +enough +enrich +enroll +ensure +enter +entire +entry +envelope +episode +equal +equip +era +erase +erode +erosion +error +erupt +escape +essay +essence +estate +eternal +ethics +evidence +evil +evoke +evolve +exact +example +excess +exchange +excite +exclude +excuse +execute +exercise +exhaust +exhibit +exile +exist +exit +exotic +expand +expect +expire +explain +expose +express +extend +extra +eye +eyebrow +fabric +face +faculty +fade +faint +faith +fall +false +fame +family +famous +fan +fancy +fantasy +farm +fashion +fat +fatal +father +fatigue +fault +favorite +feature +february +federal +fee +feed +feel +female +fence +festival +fetch +fever +few +fiber +fiction +field +figure +file +film +filter +final +find +fine +finger +finish +fire +firm +first +fiscal +fish +fit +fitness +fix +flag +flame +flash +flat +flavor +flee +flight +flip +float +flock +floor +flower +fluid +flush +fly +foam +focus +fog +foil +fold +follow +food +foot +force +forest +forget +fork +fortune +forum +forward +fossil +foster +found +fox +fragile +frame +frequent +fresh +friend +fringe +frog +front +frost +frown +frozen +fruit +fuel +fun +funny +furnace +fury +future +gadget +gain +galaxy +gallery +game +gap +garage +garbage +garden +garlic +garment +gas +gasp +gate +gather +gauge +gaze +general +genius +genre +gentle +genuine +gesture +ghost +giant +gift +giggle +ginger +giraffe +girl +give +glad +glance +glare +glass +glide +glimpse +globe +gloom +glory +glove +glow +glue +goat +goddess +gold +good +goose +gorilla +gospel +gossip +govern +gown +grab +grace +grain +grant +grape +grass +gravity +great +green +grid +grief +grit +grocery +group +grow +grunt +guard +guess +guide +guilt +guitar +gun +gym +habit +hair +half +hammer +hamster +hand +happy +harbor +hard +harsh +harvest +hat +have +hawk +hazard +head +health +heart +heavy +hedgehog +height +hello +helmet +help +hen +hero +hidden +high +hill +hint +hip +hire +history +hobby +hockey +hold +hole +holiday +hollow +home +honey +hood +hope +horn +horror +horse +hospital +host +hotel +hour +hover +hub +huge +human +humble +humor +hundred +hungry +hunt +hurdle +hurry +hurt +husband +hybrid +ice +icon +idea +identify +idle +ignore +ill +illegal +illness +image +imitate +immense +immune +impact +impose +improve +impulse +inch +include +income +increase +index +indicate +indoor +industry +infant +inflict +inform +inhale +inherit +initial +inject +injury +inmate +inner +innocent +input +inquiry +insane +insect +inside +inspire +install +intact +interest +into +invest +invite +involve +iron +island +isolate +issue +item +ivory +jacket +jaguar +jar +jazz +jealous +jeans +jelly +jewel +job +join +joke +journey +joy +judge +juice +jump +jungle +junior +junk +just +kangaroo +keen +keep +ketchup +key +kick +kid +kidney +kind +kingdom +kiss +kit +kitchen +kite +kitten +kiwi +knee +knife +knock +know +lab +label +labor +ladder +lady +lake +lamp +language +laptop +large +later +latin +laugh +laundry +lava +law +lawn +lawsuit +layer +lazy +leader +leaf +learn +leave +lecture +left +leg +legal +legend +leisure +lemon +lend +length +lens +leopard +lesson +letter +level +liar +liberty +library +license +life +lift +light +like +limb +limit +link +lion +liquid +list +little +live +lizard +load +loan +lobster +local +lock +logic +lonely +long +loop +lottery +loud +lounge +love +loyal +lucky +luggage +lumber +lunar +lunch +luxury +lyrics +machine +mad +magic +magnet +maid +mail +main +major +make +mammal +man +manage +mandate +mango +mansion +manual +maple +marble +march +margin +marine +market +marriage +mask +mass +master +match +material +math +matrix +matter +maximum +maze +meadow +mean +measure +meat +mechanic +medal +media +melody +melt +member +memory +mention +menu +mercy +merge +merit +merry +mesh +message +metal +method +middle +midnight +milk +million +mimic +mind +minimum +minor +minute +miracle +mirror +misery +miss +mistake +mix +mixed +mixture +mobile +model +modify +mom +moment +monitor +monkey +monster +month +moon +moral +more +morning +mosquito +mother +motion +motor +mountain +mouse +move +movie +much +muffin +mule +multiply +muscle +museum +mushroom +music +must +mutual +myself +mystery +myth +naive +name +napkin +narrow +nasty +nation +nature +near +neck +need +negative +neglect +neither +nephew +nerve +nest +net +network +neutral +never +news +next +nice +night +noble +noise +nominee +noodle +normal +north +nose +notable +note +nothing +notice +novel +now +nuclear +number +nurse +nut +oak +obey +object +oblige +obscure +observe +obtain +obvious +occur +ocean +october +odor +off +offer +office +often +oil +okay +old +olive +olympic +omit +once +one +onion +online +only +open +opera +opinion +oppose +option +orange +orbit +orchard +order +ordinary +organ +orient +original +orphan +ostrich +other +outdoor +outer +output +outside +oval +oven +over +own +owner +oxygen +oyster +ozone +pact +paddle +page +pair +palace +palm +panda +panel +panic +panther +paper +parade +parent +park +parrot +party +pass +patch +path +patient +patrol +pattern +pause +pave +payment +peace +peanut +pear +peasant +pelican +pen +penalty +pencil +people +pepper +perfect +permit +person +pet +phone +photo +phrase +physical +piano +picnic +picture +piece +pig +pigeon +pill +pilot +pink +pioneer +pipe +pistol +pitch +pizza +place +planet +plastic +plate +play +please +pledge +pluck +plug +plunge +poem +poet +point +polar +pole +police +pond +pony +pool +popular +portion +position +possible +post +potato +pottery +poverty +powder +power +practice +praise +predict +prefer +prepare +present +pretty +prevent +price +pride +primary +print +priority +prison +private +prize +problem +process +produce +profit +program +project +promote +proof +property +prosper +protect +proud +provide +public +pudding +pull +pulp +pulse +pumpkin +punch +pupil +puppy +purchase +purity +purpose +purse +push +put +puzzle +pyramid +quality +quantum +quarter +question +quick +quit +quiz +quote +rabbit +raccoon +race +rack +radar +radio +rail +rain +raise +rally +ramp +ranch +random +range +rapid +rare +rate +rather +raven +raw +razor +ready +real +reason +rebel +rebuild +recall +receive +recipe +record +recycle +reduce +reflect +reform +refuse +region +regret +regular +reject +relax +release +relief +rely +remain +remember +remind +remove +render +renew +rent +reopen +repair +repeat +replace +report +require +rescue +resemble +resist +resource +response +result +retire +retreat +return +reunion +reveal +review +reward +rhythm +rib +ribbon +rice +rich +ride +ridge +rifle +right +rigid +ring +riot +ripple +risk +ritual +rival +river +road +roast +robot +robust +rocket +romance +roof +rookie +room +rose +rotate +rough +round +route +royal +rubber +rude +rug +rule +run +runway +rural +sad +saddle +sadness +safe +sail +salad +salmon +salon +salt +salute +same +sample +sand +satisfy +satoshi +sauce +sausage +save +say +scale +scan +scare +scatter +scene +scheme +school +science +scissors +scorpion +scout +scrap +screen +script +scrub +sea +search +season +seat +second +secret +section +security +seed +seek +segment +select +sell +seminar +senior +sense +sentence +series +service +session +settle +setup +seven +shadow +shaft +shallow +share +shed +shell +sheriff +shield +shift +shine +ship +shiver +shock +shoe +shoot +shop +short +shoulder +shove +shrimp +shrug +shuffle +shy +sibling +sick +side +siege +sight +sign +silent +silk +silly +silver +similar +simple +since +sing +siren +sister +situate +six +size +skate +sketch +ski +skill +skin +skirt +skull +slab +slam +sleep +slender +slice +slide +slight +slim +slogan +slot +slow +slush +small +smart +smile +smoke +smooth +snack +snake +snap +sniff +snow +soap +soccer +social +sock +soda +soft +solar +soldier +solid +solution +solve +someone +song +soon +sorry +sort +soul +sound +soup +source +south +space +spare +spatial +spawn +speak +special +speed +spell +spend +sphere +spice +spider +spike +spin +spirit +split +spoil +sponsor +spoon +sport +spot +spray +spread +spring +spy +square +squeeze +squirrel +stable +stadium +staff +stage +stairs +stamp +stand +start +state +stay +steak +steel +stem +step +stereo +stick +still +sting +stock +stomach +stone +stool +story +stove +strategy +street +strike +strong +struggle +student +stuff +stumble +style +subject +submit +subway +success +such +sudden +suffer +sugar +suggest +suit +summer +sun +sunny +sunset +super +supply +supreme +sure +surface +surge +surprise +surround +survey +suspect +sustain +swallow +swamp +swap +swarm +swear +sweet +swift +swim +swing +switch +sword +symbol +symptom +syrup +system +table +tackle +tag +tail +talent +talk +tank +tape +target +task +taste +tattoo +taxi +teach +team +tell +ten +tenant +tennis +tent +term +test +text +thank +that +theme +then +theory +there +they +thing +this +thought +three +thrive +throw +thumb +thunder +ticket +tide +tiger +tilt +timber +time +tiny +tip +tired +tissue +title +toast +tobacco +today +toddler +toe +together +toilet +token +tomato +tomorrow +tone +tongue +tonight +tool +tooth +top +topic +topple +torch +tornado +tortoise +toss +total +tourist +toward +tower +town +toy +track +trade +traffic +tragic +train +transfer +trap +trash +travel +tray +treat +tree +trend +trial +tribe +trick +trigger +trim +trip +trophy +trouble +truck +true +truly +trumpet +trust +truth +try +tube +tuition +tumble +tuna +tunnel +turkey +turn +turtle +twelve +twenty +twice +twin +twist +two +type +typical +ugly +umbrella +unable +unaware +uncle +uncover +under +undo +unfair +unfold +unhappy +uniform +unique +unit +universe +unknown +unlock +until +unusual +unveil +update +upgrade +uphold +upon +upper +upset +urban +urge +usage +use +used +useful +useless +usual +utility +vacant +vacuum +vague +valid +valley +valve +van +vanish +vapor +various +vast +vault +vehicle +velvet +vendor +venture +venue +verb +verify +version +very +vessel +veteran +viable +vibrant +vicious +victory +video +view +village +vintage +violin +virtual +virus +visa +visit +visual +vital +vivid +vocal +voice +void +volcano +volume +vote +voyage +wage +wagon +wait +walk +wall +walnut +want +warfare +warm +warrior +wash +wasp +waste +water +wave +way +wealth +weapon +wear +weasel +weather +web +wedding +weekend +weird +welcome +west +wet +whale +what +wheat +wheel +when +where +whip +whisper +wide +width +wife +wild +will +win +window +wine +wing +wink +winner +winter +wire +wisdom +wise +wish +witness +wolf +woman +wonder +wood +wool +word +work +world +worry +worth +wrap +wreck +wrestle +wrist +write +wrong +yard +year +yellow +you +young +youth +zebra +zero +zone +zoo +` diff --git a/internal/bip39/english_internal_test.go b/internal/bip39/english_internal_test.go new file mode 100644 index 0000000..c2652bd --- /dev/null +++ b/internal/bip39/english_internal_test.go @@ -0,0 +1,19 @@ +package bip39 + +import ( + "hash/crc32" + "testing" +) + +func TestEnglishChecksum(t *testing.T) { + t.Parallel() + + // Ensure word list is correct + // $ wget https://raw.githubusercontent.com/bitcoin/bips/master/bip-0039/english.txt + // $ crc32 english.txt + // c1dbd296 + checksum := crc32.ChecksumIEEE([]byte(english)) + if checksum != 0xc1dbd296 { + t.Error("english checksum invalid") + } +} diff --git a/internal/bip39/example_test.go b/internal/bip39/example_test.go new file mode 100644 index 0000000..5048cf9 --- /dev/null +++ b/internal/bip39/example_test.go @@ -0,0 +1,30 @@ +package bip39_test + +import ( + "encoding/hex" + "fmt" + + "sneak.berlin/go/keyfunc/internal/bip39" +) + +//nolint:lll // the test vector and its output, kept as upstream wrote them +func ExampleNewMnemonic() { + // the entropy can be any byte slice, generated how pleased, + // as long its bit size is a multiple of 32 and is within + // the inclusive range of {128,256} + entropy, _ := hex.DecodeString("066dca1a2bb7e8a1db2832148ce9933eea0f3ac9548d793112d9a95c9407efad") + + // generate a mnemomic + mnemomic, _ := bip39.NewMnemonic(entropy) + fmt.Println(mnemomic) + // output: + // all hour make first leader extend hole alien behind guard gospel lava path output census museum junior mass reopen famous sing advance salt reform +} + +//nolint:lll // the test vector and its output, kept as upstream wrote them +func ExampleNewSeed() { + seed := bip39.NewSeed("all hour make first leader extend hole alien behind guard gospel lava path output census museum junior mass reopen famous sing advance salt reform", "TREZOR") + fmt.Println(hex.EncodeToString(seed)) + // output: + // 26e975ec644423f4a4c4f4215ef09b4bd7ef924e85d1d17c4cf3f136c2863cf6df0a475045652c57eb5fb41513ca2a2d67722b77e954b4b3fc11f7590449191d +} diff --git a/internal/childmnemonic/childmnemonic.go b/internal/childmnemonic/childmnemonic.go index a651666..c4d6b89 100644 --- a/internal/childmnemonic/childmnemonic.go +++ b/internal/childmnemonic/childmnemonic.go @@ -7,7 +7,7 @@ import ( "git.eeqj.de/sneak/secret/pkg/bip85" "github.com/btcsuite/btcd/btcutil/hdkeychain" - bip39 "github.com/tyler-smith/go-bip39" + "sneak.berlin/go/keyfunc/internal/bip39" "sneak.berlin/go/keyfunc/internal/derive" ) diff --git a/internal/childmnemonic/childmnemonic_test.go b/internal/childmnemonic/childmnemonic_test.go index 0075a24..7c216d6 100644 --- a/internal/childmnemonic/childmnemonic_test.go +++ b/internal/childmnemonic/childmnemonic_test.go @@ -6,7 +6,7 @@ import ( "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/stretchr/testify/require" - bip39 "github.com/tyler-smith/go-bip39" + "sneak.berlin/go/keyfunc/internal/bip39" "sneak.berlin/go/keyfunc/internal/childmnemonic" "sneak.berlin/go/keyfunc/internal/derive" ) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index d07b08c..0b29adc 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -6,8 +6,8 @@ import ( "testing" "github.com/stretchr/testify/require" - bip39 "github.com/tyler-smith/go-bip39" "golang.org/x/crypto/ssh" + "sneak.berlin/go/keyfunc/internal/bip39" "sneak.berlin/go/keyfunc/internal/childmnemonic" "sneak.berlin/go/keyfunc/internal/cli" "sneak.berlin/go/keyfunc/internal/derive" diff --git a/internal/derive/derive.go b/internal/derive/derive.go index bd95906..cc98fa3 100644 --- a/internal/derive/derive.go +++ b/internal/derive/derive.go @@ -8,7 +8,7 @@ import ( "git.eeqj.de/sneak/secret/pkg/bip85" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" - bip39 "github.com/tyler-smith/go-bip39" + "sneak.berlin/go/keyfunc/internal/bip39" ) const ( diff --git a/internal/mnemonic/mnemonic.go b/internal/mnemonic/mnemonic.go index fdce914..c276074 100644 --- a/internal/mnemonic/mnemonic.go +++ b/internal/mnemonic/mnemonic.go @@ -10,8 +10,8 @@ import ( "os/exec" "strings" - bip39 "github.com/tyler-smith/go-bip39" "golang.org/x/term" + "sneak.berlin/go/keyfunc/internal/bip39" ) const (