The ssh install and ssh to commands (closes #2)
All checks were successful
check / check (push) Successful in 2m30s
All checks were successful
check / check (push) Successful in 2m30s
keyfunc ssh install appends the public line on a host through the system ssh, only when absent, feeding the line on standard input; keyfunc ssh to serves the derived key from an in-process agent on a private socket and runs the system ssh with it, the private key never on disk. Two review rounds; the second passed with no findings. Model: opus-5 (implementation and review); fable-5-1 (landing)
This commit was merged in pull request #8.
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
@@ -37,14 +38,22 @@ func Root() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Main runs the tool and returns the status the process should exit
|
// 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 {
|
func Main() int {
|
||||||
err := Root().Execute()
|
err := Root().Execute()
|
||||||
if err != nil {
|
if err == nil {
|
||||||
fmt.Fprintln(os.Stderr, "keyfunc: "+err.Error())
|
return 0
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
var passed ssh.StatusError
|
||||||
|
if errors.As(err, &passed) {
|
||||||
|
return passed.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(os.Stderr, "keyfunc: "+err.Error())
|
||||||
|
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
|
|||||||
96
internal/cli/ssh/install.go
Normal file
96
internal/cli/ssh/install.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ func Command() *cobra.Command {
|
|||||||
Short: "derive ed25519 SSH keys",
|
Short: "derive ed25519 SSH keys",
|
||||||
}
|
}
|
||||||
|
|
||||||
group.AddCommand(public(), private())
|
group.AddCommand(public(), private(), install(), to())
|
||||||
|
|
||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
|
|||||||
95
internal/cli/ssh/to.go
Normal file
95
internal/cli/ssh/to.go
Normal file
@@ -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 <host> [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)
|
||||||
|
}
|
||||||
255
internal/cli/ssh_test.go
Normal file
255
internal/cli/ssh_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
86
internal/sshkey/agent.go
Normal file
86
internal/sshkey/agent.go
Normal file
@@ -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)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package sshkey_test
|
package sshkey_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -8,8 +11,16 @@ import (
|
|||||||
"git.eeqj.de/sneak/keyfunc/internal/sshkey"
|
"git.eeqj.de/sneak/keyfunc/internal/sshkey"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/crypto/ssh"
|
"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
|
// example returns the mnemonic every BIP-39 document uses to show its
|
||||||
// test vectors: eleven abandons and about.
|
// test vectors: eleven abandons and about.
|
||||||
func example() string {
|
func example() string {
|
||||||
@@ -26,7 +37,7 @@ func TestTooFewBytesAreRefused(t *testing.T) {
|
|||||||
func TestTheCommentIsPutAtTheEndOfTheLine(t *testing.T) {
|
func TestTheCommentIsPutAtTheEndOfTheLine(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
key := forIndex(t, 0)
|
key := exampleKey(t)
|
||||||
|
|
||||||
line, err := key.Line("hello")
|
line, err := key.Line("hello")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -37,7 +48,7 @@ func TestTheCommentIsPutAtTheEndOfTheLine(t *testing.T) {
|
|||||||
func TestThePrivateKeyCarriesTheSamePublicKey(t *testing.T) {
|
func TestThePrivateKeyCarriesTheSamePublicKey(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
key := forIndex(t, 0)
|
key := exampleKey(t)
|
||||||
|
|
||||||
line, err := key.Line("")
|
line, err := key.Line("")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -54,11 +65,60 @@ func TestThePrivateKeyCarriesTheSamePublicKey(t *testing.T) {
|
|||||||
require.Equal(t, line, back)
|
require.Equal(t, line, back)
|
||||||
}
|
}
|
||||||
|
|
||||||
// forIndex derives the key for one index.
|
func TestTheAgentServesTheOneKeyAndNothingElse(t *testing.T) {
|
||||||
func forIndex(t *testing.T, index uint32) *sshkey.Key {
|
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()
|
t.Helper()
|
||||||
|
|
||||||
material, err := derive.Bytes(example(), sshkey.Application, index)
|
material, err := derive.Bytes(example(), sshkey.Application, exampleIndex)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
key, err := sshkey.New(material)
|
key, err := sshkey.New(material)
|
||||||
|
|||||||
Reference in New Issue
Block a user