diff --git a/internal/cli/cli.go b/internal/cli/cli.go index e06d916..c6d63d0 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -2,6 +2,7 @@ package cli import ( + "errors" "fmt" "os" @@ -37,14 +38,22 @@ func Root() *cobra.Command { } // Main runs the tool and returns the status the process should exit -// with. +// 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. func Main() int { err := Root().Execute() - if err != nil { - fmt.Fprintln(os.Stderr, "keyfunc: "+err.Error()) - - return 1 + if err == nil { + return 0 } - return 0 + var passed ssh.StatusError + if errors.As(err, &passed) { + return passed.Status + } + + fmt.Fprintln(os.Stderr, "keyfunc: "+err.Error()) + + return 1 } diff --git a/internal/cli/ssh/install.go b/internal/cli/ssh/install.go new file mode 100644 index 0000000..8133261 --- /dev/null +++ b/internal/cli/ssh/install.go @@ -0,0 +1,96 @@ +package ssh + +import ( + "fmt" + "os/exec" + "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 +` + +// 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...]", + 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.", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + key, comment, err := derived(cmd) + if err != nil { + return err + } + + line, err := key.Line(comment) + if err != nil { + return err + } + + return send(cmd, args[0], args[1:], line) + }, + } + + addComment(cmd) + + 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() + if err != nil { + return fmt.Errorf("running ssh: %w", err) + } + + return nil +} diff --git a/internal/cli/ssh/ssh.go b/internal/cli/ssh/ssh.go index f707214..b15a08b 100644 --- a/internal/cli/ssh/ssh.go +++ b/internal/cli/ssh/ssh.go @@ -17,7 +17,7 @@ func Command() *cobra.Command { Short: "derive ed25519 SSH keys", } - group.AddCommand(public(), private()) + group.AddCommand(public(), private(), install(), to()) return group } diff --git a/internal/cli/ssh/to.go b/internal/cli/ssh/to.go new file mode 100644 index 0000000..8dc5149 --- /dev/null +++ b/internal/cli/ssh/to.go @@ -0,0 +1,95 @@ +package ssh + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "slices" + + "github.com/spf13/cobra" +) + +// StatusError says the tool should end with the status ssh ended with. +// Only "ssh to" gives one back; every other error ends the tool with +// status 1. +type StatusError struct { + Status int +} + +// Error says which status ssh ended with. +func (e StatusError) Error() string { + return fmt.Sprintf("ssh exited with status %d", e.Status) +} + +// to returns the command that runs ssh with the derived key held by an +// agent of the tool's own. +func to() *cobra.Command { + cmd := &cobra.Command{ + Use: "to [ssh arguments...]", + Short: "run ssh with the derived key served from its own agent", + Long: "Serves the derived key from an SSH agent that runs " + + "inside the tool and points the system ssh at it. The host " + + "and everything after it are given to ssh unchanged, the " + + "tool ends with the status ssh ended with, and the key is " + + "never written to disk.", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + key, comment, err := derived(cmd) + if err != nil { + return err + } + + served, err := key.Serve(cmd.Context(), comment) + if err != nil { + return err + } + + defer served.Stop() + + argv := slices.Concat([]string{ + "-o", "IdentityAgent=" + served.Socket(), + }, args) + + return connect(cmd.Context(), argv) + }, + } + + // Everything from the host onwards belongs to ssh, so flag + // reading stops at the first argument that is not a flag. + cmd.Flags().SetInterspersed(false) + + addComment(cmd) + + return cmd +} + +// connect runs ssh on the terminal the tool was given and turns the +// status it ended with into the status the tool ends with. +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.Stdin = os.Stdin + command.Stdout = os.Stdout + command.Stderr = os.Stderr + + err := command.Run() + if err == nil { + return nil + } + + var ended *exec.ExitError + if errors.As(err, &ended) { + status := ended.ExitCode() + if status < 0 { + // A signal ended ssh, and a signal has no status of its + // own to pass on. + status = 1 + } + + return StatusError{Status: status} + } + + return fmt.Errorf("running ssh: %w", err) +} diff --git a/internal/cli/ssh_test.go b/internal/cli/ssh_test.go new file mode 100644 index 0000000..6b787fc --- /dev/null +++ b/internal/cli/ssh_test.go @@ -0,0 +1,255 @@ +package cli_test + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "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" +) + +// 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. +const ( + directoryMode = 0o700 + fileMode = 0o600 + standInMode = 0o755 +) + +// failingStatus is the status the stand-in ssh ends with when a test +// wants to see a status handed on. +const failingStatus = 7 + +// The host, and where on it the key ends up. +const ( + host = "someone@example.com" + keptUnder = ".ssh" + 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. +const installer = ` +while [ $# -gt 1 ]; do + printf '%s\n' "$1" >> "$KEYFUNC_TEST_ARGUMENTS" + shift +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 +// 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. +const caller = ` +for argument in "$@"; do + printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS" +done +socket=${2#IdentityAgent=} +if [ -S "$socket" ]; then + printf '%s\n' "$socket" > "$KEYFUNC_TEST_SOCKET" +fi +exit "$KEYFUNC_TEST_STATUS" +` + +// pretended is where a stand-in ssh 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 string + // command holds what ssh was told to run on the host. + command string +} + +func TestTheKeyIsAddedToTheHostAndThenLeftAlone(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + require.Equal(t, "added\n", run(t, "ssh", "install", host)) + + directory, err := os.Stat(filepath.Join(pretend.home, keptUnder)) + require.NoError(t, err) + require.Equal(t, + os.FileMode(directoryMode), directory.Mode().Perm(), + ) + + path := filepath.Join(pretend.home, keptUnder, keptIn) + + file, err := os.Stat(path) + 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, "already present\n", run(t, "ssh", "install", host)) + require.Equal(t, added, read(t, path)) +} + +func TestTheKeyDoesNotRunIntoALineWithNoNewlineAtItsEnd(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + already := "ssh-ed25519 AAAAsomebodyelse somebody@else" + + 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), + ) +} + +func TestTheKeyLineIsNotOnTheCommandLine(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + run(t, "ssh", "install", host) + + require.NotContains(t, read(t, pretend.arguments), "ssh-ed25519") + require.NotContains(t, read(t, pretend.command), "ssh-ed25519") +} + +func TestWhatComesAfterTheDashesIsGivenToSSH(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + run(t, "ssh", "install", host, "--", "-p", "2222") + + require.Equal(t, + []string{"-p", "2222", host}, + recorded(t, pretend.arguments), + ) +} + +func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + arguments, noted := pretendCall(t) + + _, err := execute(t, "ssh", "to", host, "uptime") + + var passed ssh.StatusError + + require.ErrorAs(t, err, &passed) + require.Equal(t, failingStatus, passed.Status) + + given := recorded(t, arguments) + require.Equal(t, "-o", given[0]) + require.Equal(t, []string{host, "uptime"}, given[2:]) + + // The stand-in wrote the path down only because there really was + // a socket there while it ran. + socket := strings.TrimSpace(read(t, noted)) + require.Equal(t, "IdentityAgent="+socket, given[1]) + require.NoDirExists(t, filepath.Dir(socket)) +} + +func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretendCall(t) + + given := os.Args + + t.Cleanup(func() { os.Args = given }) + + os.Args = []string{"keyfunc", "ssh", "to", host, "uptime"} + + require.Equal(t, failingStatus, cli.Main()) +} + +// pretendHost puts the install stand-in on the path and gives back the +// places it writes to. +func pretendHost(t *testing.T) pretended { + t.Helper() + + pretend := pretended{ + home: t.TempDir(), + arguments: filepath.Join(t.TempDir(), "arguments"), + command: filepath.Join(t.TempDir(), "command"), + } + + t.Setenv("KEYFUNC_TEST_HOME", pretend.home) + t.Setenv("KEYFUNC_TEST_ARGUMENTS", pretend.arguments) + t.Setenv("KEYFUNC_TEST_COMMAND", pretend.command) + standIn(t, installer) + + return pretend +} + +// 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. +func pretendCall(t *testing.T) (string, string) { + t.Helper() + + arguments := filepath.Join(t.TempDir(), "arguments") + noted := filepath.Join(t.TempDir(), "socket") + + t.Setenv("KEYFUNC_TEST_ARGUMENTS", arguments) + t.Setenv("KEYFUNC_TEST_SOCKET", noted) + t.Setenv("KEYFUNC_TEST_STATUS", strconv.Itoa(failingStatus)) + standIn(t, 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) { + t.Helper() + + directory := t.TempDir() + + err := os.WriteFile( + filepath.Join(directory, "ssh"), + []byte("#!/bin/sh\n"+body), standInMode, + ) + require.NoError(t, err) + + t.Setenv("PATH", + directory+string(os.PathListSeparator)+os.Getenv("PATH"), + ) +} + +// read returns what is in a file. +func read(t *testing.T, path string) string { + t.Helper() + + //nolint:gosec // the path is a temporary file of the test's own + content, err := os.ReadFile(path) + require.NoError(t, err) + + return string(content) +} + +// recorded returns the arguments a stand-in wrote down, one per line. +func recorded(t *testing.T, path string) []string { + t.Helper() + + return strings.Split(strings.TrimSuffix(read(t, path), "\n"), "\n") +} diff --git a/internal/sshkey/agent.go b/internal/sshkey/agent.go new file mode 100644 index 0000000..d917e54 --- /dev/null +++ b/internal/sshkey/agent.go @@ -0,0 +1,86 @@ +package sshkey + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + + "golang.org/x/crypto/ssh/agent" +) + +// Agent is an SSH agent that holds one key and serves it on a unix +// socket. The socket sits in a directory of its own that only its +// owner may enter, and the key stays in memory: nothing is written to +// disk. +type Agent struct { + socket string + listener net.Listener +} + +// Serve starts an agent holding this key under the given comment. +// Stop takes it down again. +func (k *Key) Serve(ctx context.Context, comment string) (*Agent, error) { + keyring := agent.NewKeyring() + + err := keyring.Add(agent.AddedKey{ + PrivateKey: k.private, + Comment: comment, + }) + if err != nil { + return nil, fmt.Errorf("giving the key to the agent: %w", err) + } + + // A temporary directory is made enterable by its owner alone, + // which is the protection the socket inside it has. + directory, err := os.MkdirTemp("", "keyfunc-agent-") + if err != nil { + return nil, fmt.Errorf("making the agent directory: %w", err) + } + + socket := filepath.Join(directory, "socket") + + var listen net.ListenConfig + + listener, err := listen.Listen(ctx, "unix", socket) + if err != nil { + _ = os.RemoveAll(directory) + + return nil, fmt.Errorf("listening on the agent socket: %w", err) + } + + served := &Agent{socket: socket, listener: listener} + + go served.accept(keyring) + + return served, nil +} + +// Socket is the path to point ssh at. +func (a *Agent) Socket() string { + return a.socket +} + +// Stop takes the agent down and removes the socket and the directory +// it is in. +func (a *Agent) Stop() { + _ = a.listener.Close() + _ = os.RemoveAll(filepath.Dir(a.socket)) +} + +// accept answers connections until Stop closes the listener. +func (a *Agent) accept(keyring agent.Agent) { + for { + connection, err := a.listener.Accept() + if err != nil { + return + } + + go func() { + defer func() { _ = connection.Close() }() + + _ = agent.ServeAgent(keyring, connection) + }() + } +} diff --git a/internal/sshkey/sshkey_test.go b/internal/sshkey/sshkey_test.go index 02a40c9..2ab8cb6 100644 --- a/internal/sshkey/sshkey_test.go +++ b/internal/sshkey/sshkey_test.go @@ -1,6 +1,9 @@ package sshkey_test import ( + "net" + "os" + "path/filepath" "strings" "testing" @@ -8,8 +11,16 @@ import ( "git.eeqj.de/sneak/keyfunc/internal/sshkey" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" ) +// agentDirectoryMode is what the directory holding the agent socket +// has to be: nobody but its owner may enter it. +const agentDirectoryMode = 0o700 + +// exampleIndex is the key index every test here derives at. +const exampleIndex = 0 + // example returns the mnemonic every BIP-39 document uses to show its // test vectors: eleven abandons and about. func example() string { @@ -26,7 +37,7 @@ func TestTooFewBytesAreRefused(t *testing.T) { func TestTheCommentIsPutAtTheEndOfTheLine(t *testing.T) { t.Parallel() - key := forIndex(t, 0) + key := exampleKey(t) line, err := key.Line("hello") require.NoError(t, err) @@ -37,7 +48,7 @@ func TestTheCommentIsPutAtTheEndOfTheLine(t *testing.T) { func TestThePrivateKeyCarriesTheSamePublicKey(t *testing.T) { t.Parallel() - key := forIndex(t, 0) + key := exampleKey(t) line, err := key.Line("") require.NoError(t, err) @@ -54,11 +65,60 @@ func TestThePrivateKeyCarriesTheSamePublicKey(t *testing.T) { require.Equal(t, line, back) } -// forIndex derives the key for one index. -func forIndex(t *testing.T, index uint32) *sshkey.Key { +func TestTheAgentServesTheOneKeyAndNothingElse(t *testing.T) { + t.Parallel() + + key := exampleKey(t) + + served, err := key.Serve(t.Context(), "a comment") + require.NoError(t, err) + t.Cleanup(served.Stop) + + directory, err := os.Stat(filepath.Dir(served.Socket())) + require.NoError(t, err) + require.Equal(t, + os.FileMode(agentDirectoryMode), directory.Mode().Perm(), + ) + + var dialer net.Dialer + + connection, err := dialer.DialContext(t.Context(), "unix", served.Socket()) + require.NoError(t, err) + + defer func() { _ = connection.Close() }() + + held, err := agent.NewClient(connection).List() + require.NoError(t, err) + require.Len(t, held, 1) + + line, err := key.Line("a comment") + require.NoError(t, err) + require.Equal(t, line, held[0].String()) +} + +func TestStoppingTheAgentLeavesNothingBehind(t *testing.T) { + t.Parallel() + + served, err := exampleKey(t).Serve(t.Context(), "a comment") + require.NoError(t, err) + + directory := filepath.Dir(served.Socket()) + require.DirExists(t, directory) + + served.Stop() + require.NoDirExists(t, directory) + + var dialer net.Dialer + + _, err = dialer.DialContext(t.Context(), "unix", served.Socket()) + require.Error(t, err) +} + +// exampleKey derives the key the example mnemonic gives. +func exampleKey(t *testing.T) *sshkey.Key { t.Helper() - material, err := derive.Bytes(example(), sshkey.Application, index) + material, err := derive.Bytes(example(), sshkey.Application, exampleIndex) require.NoError(t, err) key, err := sshkey.New(material)