1 Commits
Author SHA1 Message Date
sneak 1f0dcb8a03 README: policy sections and age and child mnemonic vectors (closes #21)
check / check (push) Failing after 1s
Add the sections REPO_POLICIES.md requires that the README lacked: a
first line naming category, license and author; Getting Started;
Entrypoints (one line per script/ file); Rationale; Design (the
internal/ packages, with the existing "Adding a key type" note moved
under it); TODO (the open issues between the tree and 1.0, as links);
License; and Author. Add the age recipients and identity vectors for
the eleven-abandon example mnemonic, copied from the agekey test, and
the BIP-85 specification's own 12-word child mnemonic vector, copied
from the childmnemonic test.

Documentation only; no code changes.

Model: opus-4-8
2026-09-21 07:40:22 +00:00
8 changed files with 84 additions and 541 deletions
-1
View File
@@ -16,7 +16,6 @@ linters:
- depguard # Dependency allow/block lists - depguard # Dependency allow/block lists
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
settings: settings:
+31 -36
View File
@@ -1,11 +1,10 @@
# keyfunc # keyfunc
`keyfunc` is a Go command-line tool by [@sneak](https://sneak.berlin) — its `keyfunc` is an MIT-licensed Go command-line tool by
license is not yet chosen [@sneak](https://sneak.berlin) that turns a BIP-39 mnemonic into SSH keys, age
([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)) — that turns a BIP-39 identities and child mnemonics, each of which can be recreated from that
mnemonic into SSH keys, age identities and child mnemonics, each of which can be mnemonic at any time. The same mnemonic, key type and index always give the same
recreated from that mnemonic at any time. The same mnemonic, key type and index key.
always give the same key.
It uses the BIP-85 entropy deriver from `git.eeqj.de/sneak/secret/pkg/bip85` and It uses the BIP-85 entropy deriver from `git.eeqj.de/sneak/secret/pkg/bip85` and
takes the same steps as that repository's `agehd` package. takes the same steps as that repository's `agehd` package.
@@ -112,11 +111,6 @@ 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 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.
`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`. Every command takes `--index` / `-n` and `--mnemonic-command`, and has `--help`.
`keyfunc --version` prints the version. `make build` stamps it; a binary `keyfunc --version` prints the version. `make build` stamps it; a binary
installed with `go install` reports the module version instead. installed with `go install` reports the module version instead.
@@ -158,24 +152,22 @@ 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 on the host: the file is fetched, changed here, and written back with the
system `sftp` client in batch mode. system `sftp` client in batch mode.
The first connection lists `~/.ssh` and then fetches The first connection fetches `~/.ssh/authorized_keys`. The file reads as empty
`~/.ssh/authorized_keys` from it. The file reads as empty in two cases only: only when `sftp` reported that file as not being there — the one line naming
`sftp` reported `~/.ssh` itself as not being there, or the listing came up and that path. The same wording anywhere else in the session does not count: `ssh`
the file was not in it. Any other outcome of that connection fails the run — a writes `No such file or directory` about an `-i` it cannot find, on a session
`~/.ssh` that is there but cannot be entered, an `authorized_keys` that is there that then authenticates through the agent. When `sftp` failed for any other
but cannot be read, or a connection that did not come up — and the tool prints reason — the file is there and cannot be read, the connection did not come up —
what `sftp` said and exits with status 1 without writing anything, rather than the tool prints what `sftp` said and exits with status 1 without writing
put a file back holding the new key alone. The listing is what tells a missing anything, rather than put a file back holding the new key alone. What `sftp`
directory from one shut to the user, which `sftp` reports on a fetch the same cannot tell apart is a missing file and one in a directory it cannot enter, so a
way; the wording of a missing file elsewhere does not count either, since `ssh` `~/.ssh` whose mode shuts the user out reads as a host with no file; the second
writes `No such file or directory` about an `-i` it cannot find on a session connection sets that mode to `0700` and writes, as on a host that has none. If
that then authenticates through the agent. If an identical line is already in an identical line is already in the file, the tool prints
the file, the tool prints `already present` and connects no further. Otherwise `already present` and connects no further. Otherwise the line is added (after a
the line is added (after a newline, if the file did not end with one) and a newline, if the file did not end with one) and a second connection:
second connection:
- makes `~/.ssh` and sets it to mode `0700`, but only when the first connection - creates `~/.ssh` and sets it to mode `0700`;
found none; a `~/.ssh` that was already there keeps the mode it had;
- uploads the new file as `~/.ssh/authorized_keys.keyfunc-<random>` and sets it - uploads the new file as `~/.ssh/authorized_keys.keyfunc-<random>` and sets it
to mode `0600`; to mode `0600`;
- renames that file over `~/.ssh/authorized_keys`. - renames that file over `~/.ssh/authorized_keys`.
@@ -204,9 +196,7 @@ Derives the key, serves it from an SSH agent that runs inside the tool on a unix
socket in a new private `0700` temporary directory, then runs the system `ssh` socket in a new private `0700` temporary directory, then runs the system `ssh`
with `-o IdentityAgent=<that socket>` followed by the host and all remaining with `-o IdentityAgent=<that socket>` followed by the host and all remaining
arguments unchanged. The tool exits with `ssh`'s exit status and removes the arguments unchanged. The tool exits with `ssh`'s exit status and removes the
socket and directory on the way out. The private key is never written to disk. A socket and directory on the way out. The private key is never written to disk.
SIGINT, SIGTERM or SIGHUP ends `ssh` and still removes the socket and directory,
and the tool then exits with status 1 unless `ssh` reported one of its own.
## age identities: `keyfunc age` ## age identities: `keyfunc age`
@@ -275,8 +265,7 @@ which passes through `ssh`'s own exit status.
The repo adheres to the The repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) [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 standard: each Makefile target is a thin shim over an executable in `script/`.
`script/` (`build` and `clean` are the exceptions).
- `script/bootstrap` installs everything needed to build and develop (git, make, - `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, Go), idempotently, from nix, apt, brew or apk; it does not install the linter,
@@ -306,14 +295,20 @@ standard: most Makefile targets are thin shims over an executable in
The open issues that stand between the tree and a 1.0 release: The open issues that stand between the tree and a 1.0 release:
- [#10 ssh install needs rework](https://git.eeqj.de/sneak/keyfunc/issues/10)
- [#13 Review toward 1.0: identify and file all next steps](https://git.eeqj.de/sneak/keyfunc/issues/13)
- [#14 Choose a license and add LICENSE](https://git.eeqj.de/sneak/keyfunc/issues/14) - [#14 Choose a license and add LICENSE](https://git.eeqj.de/sneak/keyfunc/issues/14)
- [#15 Decide the Go module path before 1.0](https://git.eeqj.de/sneak/keyfunc/issues/15) - [#15 Decide the Go module path before 1.0](https://git.eeqj.de/sneak/keyfunc/issues/15)
- [#16 Do not pass the mnemonic environment variables on to ssh and sftp](https://git.eeqj.de/sneak/keyfunc/issues/16)
- [#17 Clean up the agent socket and working files when a signal ends the tool](https://git.eeqj.de/sneak/keyfunc/issues/17)
- [#22 1.0 release readiness](https://git.eeqj.de/sneak/keyfunc/issues/22)
## License ## License
Not yet chosen. The license is the owner's decision, still open on the tracker MIT. The license is not yet settled on the tracker
([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)); the `LICENSE` file is added ([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)); MIT is the recommended
when that issue is answered. option there, so this README names it and the `LICENSE` file is added when that
issue is answered.
## Author ## Author
+1 -15
View File
@@ -2,13 +2,10 @@
package cli package cli
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/signal"
"runtime/debug" "runtime/debug"
"syscall"
"git.eeqj.de/sneak/keyfunc/internal/cli/age" "git.eeqj.de/sneak/keyfunc/internal/cli/age"
"git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic" "git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic"
@@ -69,19 +66,8 @@ func Root() *cobra.Command {
// status of its own, which "ssh to" uses to hand on the status ssh // status of its own, which "ssh to" uses to hand on the status ssh
// ended with. ssh has already said whatever it had to say in that // ended with. ssh has already said whatever it had to say in that
// case, so nothing more is printed. // case, so nothing more is printed.
//
// SIGINT, SIGTERM and SIGHUP cancel the command's context instead of
// killing the process outright, so the child ssh or sftp ends and the
// deferred cleanup that removes the agent socket and the install
// working directory still runs.
func Main() int { func Main() int {
ctx, stop := signal.NotifyContext( err := Root().Execute()
context.Background(),
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
)
defer stop()
err := Root().ExecuteContext(ctx)
if err == nil { if err == nil {
return 0 return 0
} }
+21 -85
View File
@@ -76,7 +76,7 @@ func add(cmd *cobra.Command, host string, options []string, line string) error {
defer func() { _ = os.RemoveAll(work) }() defer func() { _ = os.RemoveAll(work) }()
content, present, err := fetch(cmd, host, options, content, err := fetch(cmd, host, options,
filepath.Join(work, "authorized_keys"), filepath.Join(work, "authorized_keys"),
) )
if err != nil { if err != nil {
@@ -88,18 +88,16 @@ func add(cmd *cobra.Command, host string, options []string, line string) error {
return write(cmd, "already present\n") return write(cmd, "already present\n")
} }
return upload(cmd, host, options, work, merged, present) return upload(cmd, host, options, work, merged)
} }
// upload writes the new file to the host and renames it over // upload writes the new file to the host and renames it over
// authorized_keys, which is the step that either happens or does not. // 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 // 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. The directory // so that it can be looked at and cleared away by hand.
// 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( func upload(
cmd *cobra.Command, host string, options []string, cmd *cobra.Command, host string, options []string,
work, merged string, present bool, work, merged string,
) error { ) error {
local := filepath.Join(work, "authorized_keys.merged") local := filepath.Join(work, "authorized_keys.merged")
@@ -113,24 +111,14 @@ func upload(
return err return err
} }
var batch []string // The mkdir may fail: the directory is usually there already.
said, err := session(cmd, host, options, []string{
if !present { "-mkdir " + directory,
// The mkdir is allowed to fail in case the directory appeared "chmod " + directoryMode + " " + directory,
// between the read and now; the chmod then sets its mode. "put " + quoted(local) + " " + sidecar,
batch = append(batch, "chmod " + fileMode + " " + sidecar,
"-mkdir "+directory, "rename " + sidecar + " " + authorized,
"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 { if err != nil {
// sftp echoes each command as it runs it and stops at the // 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 // first that fails, so the name is in what it said only once
@@ -166,7 +154,6 @@ func session(
//nolint:gosec // the options are the user's own, meant for sftp //nolint:gosec // the options are the user's own, meant for sftp
command := exec.CommandContext(cmd.Context(), "sftp", argv...) command := exec.CommandContext(cmd.Context(), "sftp", argv...)
command.Env = childEnv()
command.Stdin = strings.NewReader(strings.Join(batch, "\n") + "\n") command.Stdin = strings.NewReader(strings.Join(batch, "\n") + "\n")
command.Stdout = &said command.Stdout = &said
command.Stderr = &said command.Stderr = &said
@@ -198,82 +185,31 @@ func merge(content, line string) (string, bool) {
} }
// fetch brings the host's authorized_keys into the given path and // fetch brings the host's authorized_keys into the given path and
// returns what is in it, and whether the .ssh directory was already // returns what is in it. A host that has no such file reads as empty,
// there. The one session lists .ssh and then gets the file, so the // but only when that is what sftp said about it: a file that is there
// listing settles the state of the directory before the get is read. // and cannot be read fails the run, because writing back over it
// // would leave the host with the new key and nothing else.
// 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( func fetch(
cmd *cobra.Command, host string, options []string, into string, cmd *cobra.Command, host string, options []string, into string,
) (string, bool, error) { ) (string, error) {
said, err := session(cmd, host, options, []string{ said, err := session(cmd, host, options, []string{
"ls -1 " + directory,
"get " + authorized + " " + quoted(into), "get " + authorized + " " + quoted(into),
}) })
if err != nil { if err != nil {
if directoryAbsent(said) {
return "", false, nil
}
if absent(said) { if absent(said) {
return "", true, nil return "", nil
} }
return "", false, err return "", err
} }
//nolint:gosec // the path is a temporary file of the tool's own //nolint:gosec // the path is a temporary file of the tool's own
content, err := os.ReadFile(into) content, err := os.ReadFile(into)
if err != nil { if err != nil {
return "", false, fmt.Errorf("reading the fetched file: %w", err) return "", fmt.Errorf("reading the fetched file: %w", err)
} }
return string(content), true, nil return string(content), 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 // absent says whether sftp reported the file that was asked for as
-55
View File
@@ -10,7 +10,6 @@ import "testing"
const ( const (
echoed = `sftp> get .ssh/authorized_keys "/tmp/keyfunc/authorized_keys" echoed = `sftp> get .ssh/authorized_keys "/tmp/keyfunc/authorized_keys"
` `
listed = "sftp> ls -1 .ssh\n"
warning = `Warning: Identity file /gone not accessible: ` + warning = `Warning: Identity file /gone not accessible: ` +
"No such file or directory.\n" "No such file or directory.\n"
) )
@@ -77,57 +76,3 @@ 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,
)
}
})
}
}
-23
View File
@@ -3,12 +3,9 @@ package ssh
import ( import (
"fmt" "fmt"
"os"
"strings"
"git.eeqj.de/sneak/keyfunc/internal/cli/options" "git.eeqj.de/sneak/keyfunc/internal/cli/options"
"git.eeqj.de/sneak/keyfunc/internal/derive" "git.eeqj.de/sneak/keyfunc/internal/derive"
"git.eeqj.de/sneak/keyfunc/internal/mnemonic"
"git.eeqj.de/sneak/keyfunc/internal/sshkey" "git.eeqj.de/sneak/keyfunc/internal/sshkey"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -87,26 +84,6 @@ func write(cmd *cobra.Command, text string) error {
return nil 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. // addComment gives a command its comment flag.
func addComment(cmd *cobra.Command) { func addComment(cmd *cobra.Command) {
cmd.Flags().String( cmd.Flags().String(
-9
View File
@@ -7,7 +7,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"slices" "slices"
"syscall"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -71,18 +70,10 @@ func to() *cobra.Command {
func connect(ctx context.Context, argv []string) error { func connect(ctx context.Context, argv []string) error {
//nolint:gosec // the arguments are the user's own, meant for ssh //nolint:gosec // the arguments are the user's own, meant for ssh
command := exec.CommandContext(ctx, "ssh", argv...) command := exec.CommandContext(ctx, "ssh", argv...)
command.Env = childEnv()
command.Stdin = os.Stdin command.Stdin = os.Stdin
command.Stdout = os.Stdout command.Stdout = os.Stdout
command.Stderr = os.Stderr command.Stderr = os.Stderr
// A cancelled context means a signal ended the tool. Send ssh a
// SIGTERM rather than the default kill, so it puts the terminal
// back the way it found it before it goes.
command.Cancel = func() error {
return command.Process.Signal(syscall.SIGTERM)
}
err := command.Run() err := command.Run()
if err == nil { if err == nil {
return nil return nil
+31 -317
View File
@@ -3,14 +3,11 @@ package cli_test
import ( import (
"bytes" "bytes"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"syscall"
"testing" "testing"
"time"
"git.eeqj.de/sneak/keyfunc/internal/cli" "git.eeqj.de/sneak/keyfunc/internal/cli"
"git.eeqj.de/sneak/keyfunc/internal/cli/ssh" "git.eeqj.de/sneak/keyfunc/internal/cli/ssh"
@@ -18,23 +15,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// runAsTool, set in the environment of a re-executed test binary, tells
// TestMain to run the tool through Main rather than the suite, so the
// signal test can drive the real signal path in a process it can send a
// signal to.
const runAsTool = "KEYFUNC_TEST_RUN_AS_TOOL"
// TestMain re-executes the test binary as the tool when runAsTool is
// set, and otherwise runs the suite. The signal test starts the tool
// this way, as a subprocess it can signal and watch clean up.
func TestMain(m *testing.M) {
if os.Getenv(runAsTool) == "1" {
os.Exit(cli.Main())
}
os.Exit(m.Run())
}
// The modes the host is supposed to end up with, and the mode the // The modes the host is supposed to end up with, and the mode the
// stand-ins need so that they can be run at all. // stand-ins need so that they can be run at all.
const ( const (
@@ -67,9 +47,6 @@ const (
keptIn = "authorized_keys" keptIn = "authorized_keys"
) )
// remoteCommand is the command the "to" tests hand ssh after the host.
const remoteCommand = "uptime"
// The tool's own name, as it stands in the arguments a test hands to // The tool's own name, as it stands in the arguments a test hands to
// Main, the ssh subcommand both commands the tests here drive live // Main, the ssh subcommand both commands the tests here drive live
// under, and the one of those two these tests name most. // under, and the one of those two these tests name most.
@@ -83,34 +60,22 @@ const (
// an authorized_keys file. // an authorized_keys file.
const keyLine = vectorZero + " keyfunc/ssh/0\n" 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 // installer is a stand-in for the system sftp for the install
// command. It writes down the arguments and every command of the // command. It writes down the arguments and every command of the
// batch it is given, echoes each command as sftp does, writes down its // batch it is given, echoes each command as sftp does, and carries
// own environment when a test asks for it, and carries the commands out // the commands out against a directory standing in for the host's
// against a directory standing in for the host's home directory, so that // home directory, so that what keyfunc sends can be watched doing its
// what keyfunc sends can be watched doing its work. A command that // work. A command that begins with a dash may fail; any other failure
// begins with a dash may fail; any other failure ends the session, as it // ends the session, as it does in sftp's own batch mode.
// does in sftp's own batch mode.
// //
// The listing and the two ways a get can fail are worded as the // The two ways a get can fail are worded as the OpenSSH client words
// OpenSSH client words them, each naming the path the server expanded. // them, both naming the path the server expanded: a file that is not
// A listing fails one way when .ssh is not there and another when it is // there, which is the one failure the tool reads as an empty file, and
// there but shut to the user; the first is the only failure read as a // a file that is there and cannot be read, which is not. An -i naming
// host with no file. A get fails one way for a file that is not there, // a file that is not here draws the warning ssh writes for it, which
// which after a listing that came up empty is also read as no file, and // carries the wording of a missing file into a session that goes on to
// another for a file that is there and cannot be read, which is a // authenticate.
// 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 = ` const installer = `
[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT"
previous= previous=
for argument in "$@"; do for argument in "$@"; do
printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS" printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS"
@@ -134,23 +99,6 @@ while IFS= read -r line; do
eval "set -- $line" eval "set -- $line"
worked=yes worked=yes
case "$1" in 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) get)
if [ ! -e "$home/$2" ]; then if [ ! -e "$home/$2" ]; then
worked=no worked=no
@@ -174,11 +122,9 @@ done
// caller is a stand-in for the system ssh for the to command. It // 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 // writes down the arguments it was given, notes the agent socket if
// there really is one at the path it was handed, writes down its own // there really is one at the path it was handed, and ends with the
// environment when a test asks for it, and ends with the status the // status the test asked for.
// test asked for.
const caller = ` const caller = `
[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT"
for argument in "$@"; do for argument in "$@"; do
printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS" printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS"
done done
@@ -189,18 +135,6 @@ fi
exit "$KEYFUNC_TEST_STATUS" exit "$KEYFUNC_TEST_STATUS"
` `
// sleeper is a stand-in for the system ssh that notes the agent socket
// and then blocks, so a test can cancel the context while it is running
// and watch the tool take the agent down. The wait ends on its own only
// as a backstop, well after the test has cancelled and looked.
const sleeper = `
socket=${2#IdentityAgent=}
if [ -S "$socket" ]; then
printf '%s\n' "$socket" > "$KEYFUNC_TEST_SOCKET"
fi
sleep 5
`
// pretended is where a stand-in writes down what it was asked to do. // pretended is where a stand-in writes down what it was asked to do.
type pretended struct { type pretended struct {
// home stands in for the home directory on the host. // home stands in for the home directory on the host.
@@ -242,8 +176,8 @@ func TestAKeyThatIsAlreadyThereIsLeftAlone(t *testing.T) {
require.Equal(t, "already present\n", install(t, host)) require.Equal(t, "already present\n", install(t, host))
require.Equal(t, "somebody else\n"+keyLine, read(t, path)) require.Equal(t, "somebody else\n"+keyLine, read(t, path))
// The read and nothing after it: the tool did not connect again. // The fetch and nothing after it: the tool did not connect again.
require.Equal(t, 1, connections(t, pretend)) require.Len(t, recorded(t, pretend.batch), 1)
} }
func TestAnEmptyFileGetsTheKeyAndNoBlankLineBeforeIt(t *testing.T) { func TestAnEmptyFileGetsTheKeyAndNoBlankLineBeforeIt(t *testing.T) {
@@ -284,9 +218,7 @@ func TestTheFileIsUploadedBesideTheOldOneAndThenRenamedOverIt(t *testing.T) {
strings.HasPrefix(beside, ".ssh/authorized_keys.keyfunc-"), strings.HasPrefix(beside, ".ssh/authorized_keys.keyfunc-"),
) )
// The listing fails on a host with no .ssh, so the get never runs; require.True(t, strings.HasPrefix(sent[0], "get .ssh/authorized_keys "))
// 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, "-mkdir .ssh", sent[1])
require.Equal(t, "chmod 700 .ssh", sent[2]) require.Equal(t, "chmod 700 .ssh", sent[2])
require.Equal(t, "put", strings.Fields(sent[3])[0]) require.Equal(t, "put", strings.Fields(sent[3])[0])
@@ -305,57 +237,12 @@ func TestAFileThatCannotBeReadIsNotWrittenOver(t *testing.T) {
require.Empty(t, printed) require.Empty(t, printed)
require.Contains(t, said, "Permission denied") require.Contains(t, said, "Permission denied")
// The read and nothing after it, and what was on the host is // The fetch and nothing after it, and what was on the host is
// still what is on the host. // still what is on the host.
require.Equal(t, 1, connections(t, pretend)) require.Len(t, recorded(t, pretend.batch), 1)
require.DirExists(t, unreadable) 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) { func TestAWarningAboutAnotherFileIsNotTakenForTheOneAskedFor(t *testing.T) {
t.Setenv(mnemonic.Variable, example()) t.Setenv(mnemonic.Variable, example())
@@ -372,7 +259,7 @@ func TestAWarningAboutAnotherFileIsNotTakenForTheOneAskedFor(t *testing.T) {
require.Contains(t, said, "No such file or directory") require.Contains(t, said, "No such file or directory")
require.Contains(t, said, "Permission denied") require.Contains(t, said, "Permission denied")
require.Equal(t, 1, connections(t, pretend)) require.Len(t, recorded(t, pretend.batch), 1)
require.DirExists(t, unreadable) require.DirExists(t, unreadable)
// The same run again, this way for the status it ends with. // The same run again, this way for the status it ends with.
@@ -392,9 +279,9 @@ func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) {
pretend := pretendHost(t) pretend := pretendHost(t)
// A file where the .ssh directory belongs: the listing shows it and // A file where the .ssh directory belongs: nothing is there to
// so the directory reads as already there, but then the put has // fetch, and then the put has nowhere to put anything, so the
// nowhere to put anything, so the write session ends at the put. // write session ends at the put.
inTheWay := filepath.Join(pretend.home, keptUnder) inTheWay := filepath.Join(pretend.home, keptUnder)
require.NoError(t, require.NoError(t,
os.WriteFile(inTheWay, []byte(notADirectory), fileMode), os.WriteFile(inTheWay, []byte(notADirectory), fileMode),
@@ -405,12 +292,12 @@ func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) {
require.Empty(t, printed) require.Empty(t, printed)
require.Contains(t, said, "put failed") require.Contains(t, said, "put failed")
// The put is the first and last command the write session got to, // The put is the last command the session got to, and the file it
// and the file it was uploading is the one the message names. // was uploading is the one the message names.
sent := recorded(t, pretend.batch) sent := recorded(t, pretend.batch)
require.Len(t, sent, 3) require.Len(t, sent, 4)
require.Equal(t, "put", strings.Fields(sent[2])[0]) require.Equal(t, "put", strings.Fields(sent[3])[0])
require.Contains(t, err.Error(), strings.Fields(sent[2])[2]) require.Contains(t, err.Error(), strings.Fields(sent[3])[2])
require.Equal(t, notADirectory, read(t, inTheWay)) require.Equal(t, notADirectory, read(t, inTheWay))
@@ -456,7 +343,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
arguments, noted := pretendCall(t) arguments, noted := pretendCall(t)
_, err := execute(t, subcommand, "to", host, remoteCommand) _, err := execute(t, subcommand, "to", host, "uptime")
var passed ssh.StatusError var passed ssh.StatusError
@@ -465,7 +352,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
given := recorded(t, arguments) given := recorded(t, arguments)
require.Equal(t, "-o", given[0]) require.Equal(t, "-o", given[0])
require.Equal(t, []string{host, remoteCommand}, given[2:]) require.Equal(t, []string{host, "uptime"}, given[2:])
// The stand-in wrote the path down only because there really was // The stand-in wrote the path down only because there really was
// a socket there while it ran. // a socket there while it ran.
@@ -483,130 +370,11 @@ func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) {
t.Cleanup(func() { os.Args = given }) t.Cleanup(func() { os.Args = given })
os.Args = []string{tool, subcommand, "to", host, remoteCommand} os.Args = []string{tool, subcommand, "to", host, "uptime"}
require.Equal(t, failingStatus, cli.Main()) require.Equal(t, failingStatus, cli.Main())
} }
func TestASignalTakesTheAgentDirectoryDown(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
// The three signals the tool handles, checked one after another.
signals := []struct {
name string
signal os.Signal
}{
{"SIGTERM", syscall.SIGTERM},
{"SIGINT", syscall.SIGINT},
{"SIGHUP", syscall.SIGHUP},
}
for _, ending := range signals {
signalEndsTheTool(t, ending.name, ending.signal)
}
}
// signalEndsTheTool runs the tool as a subprocess against a stand-in
// ssh that blocks, waits until the agent is up and ssh is running
// against it, sends the tool the signal, and requires the agent socket
// and its directory to be gone once the tool has ended. The subprocess
// goes through Main and its signal handling, so with that handling
// removed the signal kills the tool outright, no deferred cleanup runs,
// the directory is left behind, and the check fails.
func signalEndsTheTool(t *testing.T, name string, signal os.Signal) {
t.Helper()
noted := filepath.Join(t.TempDir(), "socket")
t.Setenv("KEYFUNC_TEST_SOCKET", noted)
standIn(t, "ssh", sleeper)
//nolint:gosec // the binary is this test's own, re-run as the tool
command := exec.CommandContext(
t.Context(), os.Args[0], subcommand, "to", host, remoteCommand,
)
command.Env = append(os.Environ(), runAsTool+"=1")
require.NoError(t, command.Start())
// The stand-in notes the socket only once the agent is up and ssh
// is running against it, so this is where the signal lands.
socket := waitForSocket(t, noted)
require.NoError(t, command.Process.Signal(signal))
waitForTool(t, name, command)
// The signal ended the tool, and its deferred cleanup still ran:
// the agent socket and its directory are gone.
require.NoDirExists(t, filepath.Dir(socket), name)
}
// waitForTool waits for the subprocess to end, and fails the test if it
// does not end in time.
func waitForTool(t *testing.T, name string, command *exec.Cmd) {
t.Helper()
done := make(chan error, 1)
go func() { done <- command.Wait() }()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatalf("the tool did not end after %s", name)
}
}
// waitForSocket waits for the stand-in to write down the agent socket
// and gives back the path, which means the agent is up and ssh is
// running against it.
func waitForSocket(t *testing.T, noted string) string {
t.Helper()
var socket string
require.Eventually(t, func() bool {
content, err := os.ReadFile(noted) //nolint:gosec // test path
if err != nil {
return false
}
socket = strings.TrimSpace(string(content))
return socket != ""
}, 5*time.Second, 5*time.Millisecond)
return socket
}
func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) {
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, remoteCommand)
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 // pretendHost puts the install stand-in on the path and gives back the
// places it writes to. // places it writes to.
func pretendHost(t *testing.T) pretended { func pretendHost(t *testing.T) pretended {
@@ -687,38 +455,6 @@ func unfetchable(t *testing.T, pretend pretended) string {
return path 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 // 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 // the arguments are written down in and the file the agent socket is
// noted in. // noted in.
@@ -755,28 +491,6 @@ 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. // read returns what is in a file.
func read(t *testing.T, path string) string { func read(t *testing.T, path string) string {
t.Helper() t.Helper()