The ssh install and ssh to commands (closes #2)
All checks were successful
check / check (push) Successful in 21s

install runs the system ssh and hands the host a short shell script to
run, with the public key line on the connection's standard input rather
than on a command line, where anyone else on the host could read it out
of the process list. The script makes ~/.ssh and authorized_keys if
they are missing, adds the line unless the same line is already there,
and says which of the two it did.

to serves the key from an agent inside the tool, on a unix socket in a
temporary directory only its owner can enter, and points ssh at it with
-o IdentityAgent. The socket and directory go when the command ends and
the private key is never written to disk. Only this command hands back
the status ssh ended with instead of ending with status 1.

The tests put a stand-in ssh on the path: for install it runs the
script the tool sends against a directory standing in for the host's
home directory, so the file, the modes and the second run that changes
nothing are all watched happening.

Model: opus-5
This commit is contained in:
2026-09-07 15:52:12 +00:00
parent 279cba6bcf
commit 05d67708ec
7 changed files with 612 additions and 12 deletions

View File

@@ -2,6 +2,7 @@
package cli
import (
"errors"
"fmt"
"os"
@@ -35,14 +36,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
}

View 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
}

View File

@@ -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
}

95
internal/cli/ssh/to.go Normal file
View 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
View 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
View 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)
}()
}
}

View File

@@ -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,59 @@ 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)
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)