Compare commits

..

4 Commits

Author SHA1 Message Date
8aeed7b901 ssh install works over sftp and runs nothing on the host (closes #10)
All checks were successful
check / check (push) Successful in 4s
ssh install no longer runs a command on the host. It reads .ssh/authorized_keys over sftp, takes the empty reading only from sftp's own message about that path, appends the derived key locally when it is not already present, uploads the result beside the file with mode 0600 and renames it over the original. Any other failure prints what sftp said, writes nothing and exits 1. sftp batch mode disables password prompts, so a key or agent is required; a directory the owner cannot enter reads as a host with no file, which README.md states.

Model: opus-5 (implementation); fable-5-1 (landing)
2026-09-08 08:20:17 +02:00
a2a0890ded Merge pull request '0.1.0: deterministic SSH keys, age identities with encryption, and child mnemonics from one mnemonic' (#9) from next into main
All checks were successful
check / check (push) Successful in 22s
Reviewed-on: #9
2026-09-08 04:49:04 +02:00
b9c8631788 The ssh install and ssh to commands (closes #2)
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)
2026-09-07 18:49:42 +02:00
5bbeec86d6 The age commands: pub, priv, encrypt and decrypt (closes #3)
All checks were successful
check / check (push) Successful in 28s
keyfunc age derives an age identity at the generic path the way secret's agehd does, prints the recipient or the identity, and encrypts to or decrypts with it, the derived recipient always among encrypt's recipients. Two review rounds; the second passed with no findings, the clamping step now pinned by a fixed identity test.

Model: opus-5 (implementation and review); fable-5-1 (landing)
2026-09-07 18:35:11 +02:00
9 changed files with 1160 additions and 20 deletions

View File

@@ -88,17 +88,49 @@ Prints the unencrypted private key in OpenSSH format (the
and nothing else, so it can be redirected into a file. The key's comment is the
same as for `pub`.
### `keyfunc ssh install <[user@]host> [-- ssh options...]`
### `keyfunc ssh install <[user@]host> [-- sftp options...]`
Runs the system `ssh` to the host and, on the host:
Adds the `pub` line to `~/.ssh/authorized_keys` on the host. No command is run
on the host: the file is fetched, changed here, and written back with the
system `sftp` client in batch mode.
- creates `~/.ssh` with mode `0700` if it is missing;
- creates `~/.ssh/authorized_keys` with mode `0600` if it is missing;
- appends the `pub` line only if an identical line is not already there.
The first connection fetches `~/.ssh/authorized_keys`. The file reads as empty
only when `sftp` reported that file as not being there — the one line naming
that path. The same wording anywhere else in the session does not count: `ssh`
writes `No such file or directory` about an `-i` it cannot find, on a session
that then authenticates through the agent. When `sftp` failed for any other
reason — the file is there and cannot be read, the connection did not come up —
the tool prints what `sftp` said and exits with status 1 without writing
anything, rather than put a file back holding the new key alone. What `sftp`
cannot tell apart is a missing file and one in a directory it cannot enter, so a
`~/.ssh` whose mode shuts the user out reads as a host with no file; the second
connection sets that mode to `0700` and writes, as on a host that has none. If
an identical line is already in the file, the tool prints
`already present` and connects no further. Otherwise the line is added (after a
newline, if the file did not end with one) and a second connection:
It then prints `added` or `already present`. How this `ssh` connection
authenticates is up to the user's normal `ssh` setup (existing keys, agent,
password). Anything after `--` is passed to `ssh` unchanged.
- creates `~/.ssh` and sets it to mode `0700`;
- uploads the new file as `~/.ssh/authorized_keys.keyfunc-<random>` and sets it
to mode `0600`;
- renames that file over `~/.ssh/authorized_keys`.
The tool then prints `added`. So a run that adds a line connects twice. The
rename is the step that either happens or does not: the file on the host is
never half-written. `sftp` does it in one step against servers that offer
OpenSSH's POSIX rename extension, as OpenSSH's own server does; a server
without it may refuse to rename onto a file that is already there.
If a step fails, the tool prints what `sftp` said, removes nothing, and exits
with status 1. It names the uploaded file only when the step that failed was
the upload or one after it, which is where a file of that name can be on the
host; a failure before the upload names none. Everything `sftp`
writes goes to standard error, so the tool's own standard output is only
`added` or `already present`.
Anything after `--` is passed to `sftp` unchanged, which is where the port goes
(`-P 2222`, not `-p`). How the connection authenticates is up to the user's
normal `ssh` setup, except that batch mode does not prompt: a key or an agent
has to do it, not a typed password.
### `keyfunc ssh to <host> [ssh arguments...]`

View File

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

270
internal/cli/ssh/install.go Normal file
View File

@@ -0,0 +1,270 @@
package ssh
import (
"bytes"
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"github.com/spf13/cobra"
)
// Where the key goes on the host and what the file it arrives in is
// called before it is renamed into place. The random end of that name
// keeps two runs at once from writing to the same file.
const (
directory = ".ssh"
authorized = ".ssh/authorized_keys"
sidecarPrefix = ".ssh/authorized_keys.keyfunc-"
sidecarBytes = 8
)
// The modes the host is left with, as sftp's chmod spells them, and
// the mode of the copy made here on the way.
const (
directoryMode = "700"
fileMode = "600"
localMode = 0o600
)
// install returns the command that adds the public key to a host.
func install() *cobra.Command {
cmd := &cobra.Command{
Use: "install <[user@]host> [-- sftp options...]",
Short: "add the public key to a host's authorized_keys",
Long: "Downloads the host's authorized_keys with the system " +
"sftp, adds the public key to it here unless the same " +
"line is already there, and uploads the result as a file " +
"beside it which is then renamed over it. Nothing is run " +
"on the host. Anything after -- is given to sftp " +
"unchanged, which is where the port goes (-P).",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
key, comment, err := derived(cmd)
if err != nil {
return err
}
line, err := key.Line(comment)
if err != nil {
return err
}
return add(cmd, args[0], args[1:], line)
},
}
addComment(cmd)
return cmd
}
// add puts the key line in the host's authorized_keys. The file is
// fetched in one sftp session and written back in another, so a run
// that adds a line connects twice; a run that finds the line already
// there connects once and stops.
func add(cmd *cobra.Command, host string, options []string, line string) error {
work, err := os.MkdirTemp("", "keyfunc-install-")
if err != nil {
return fmt.Errorf("making a temporary directory: %w", err)
}
defer func() { _ = os.RemoveAll(work) }()
content, err := fetch(cmd, host, options,
filepath.Join(work, "authorized_keys"),
)
if err != nil {
return err
}
merged, added := merge(content, line)
if !added {
return write(cmd, "already present\n")
}
return upload(cmd, host, options, work, merged)
}
// upload writes the new file to the host and renames it over
// authorized_keys, which is the step that either happens or does not.
// Nothing is removed when a step fails: the file left behind is named
// so that it can be looked at and cleared away by hand.
func upload(
cmd *cobra.Command, host string, options []string,
work, merged string,
) error {
local := filepath.Join(work, "authorized_keys.merged")
err := os.WriteFile(local, []byte(merged), localMode)
if err != nil {
return fmt.Errorf("writing the new file: %w", err)
}
sidecar, err := sidecarName()
if err != nil {
return err
}
// The mkdir may fail: the directory is usually there already.
said, err := session(cmd, host, options, []string{
"-mkdir " + directory,
"chmod " + directoryMode + " " + directory,
"put " + quoted(local) + " " + sidecar,
"chmod " + fileMode + " " + sidecar,
"rename " + sidecar + " " + authorized,
})
if err != nil {
// sftp echoes each command as it runs it and stops at the
// first that fails, so the name is in what it said only once
// the put was reached, which is where a file of that name
// can be on the host. Before that there is none to name.
if strings.Contains(said, sidecar) {
return fmt.Errorf(
"%w; %s may be left on the host", err, sidecar,
)
}
return err
}
return write(cmd, "added\n")
}
// session runs one sftp session with the user's own options and the
// batch of commands, which sftp reads from its standard input and
// stops at the first of which that fails, unless it begins with a
// dash. sftp echoes the commands as it runs them, so everything it
// says goes to the error output and the tool's own output stays the
// one word it prints. What it said is also given back: a session that
// failed says there what went wrong, and the status alone does not.
func session(
cmd *cobra.Command, host string, options []string, batch []string,
) (string, error) {
argv := slices.Concat(
[]string{"-b", "-"}, options, []string{host},
)
var said bytes.Buffer
//nolint:gosec // the options are the user's own, meant for sftp
command := exec.CommandContext(cmd.Context(), "sftp", argv...)
command.Stdin = strings.NewReader(strings.Join(batch, "\n") + "\n")
command.Stdout = &said
command.Stderr = &said
err := command.Run()
_, _ = cmd.ErrOrStderr().Write(said.Bytes())
if err != nil {
return said.String(), fmt.Errorf("running sftp: %w", err)
}
return said.String(), nil
}
// merge returns the file with the key line on the end, and whether it
// had to be added. A file whose last line has no newline at its end
// gets one first, so that the two lines do not run into each other.
func merge(content, line string) (string, bool) {
if slices.Contains(strings.Split(content, "\n"), line) {
return content, false
}
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
return content + line + "\n", true
}
// fetch brings the host's authorized_keys into the given path and
// returns what is in it. A host that has no such file reads as empty,
// but only when that is what sftp said about it: a file that is there
// and cannot be read fails the run, because writing back over it
// would leave the host with the new key and nothing else.
func fetch(
cmd *cobra.Command, host string, options []string, into string,
) (string, error) {
said, err := session(cmd, host, options, []string{
"get " + authorized + " " + quoted(into),
})
if err != nil {
if absent(said) {
return "", nil
}
return "", err
}
//nolint:gosec // the path is a temporary file of the tool's own
content, err := os.ReadFile(into)
if err != nil {
return "", fmt.Errorf("reading the fetched file: %w", err)
}
return string(content), nil
}
// absent says whether sftp reported the file that was asked for as
// not being there, which is the one failure of the fetch that is read
// as an empty authorized_keys. The reading is taken only from the
// line in which sftp reports on that file, because ssh writes "no
// such file" into the same output for reasons of its own — a missing
// -i identity file draws that warning on a session that then
// authenticates through the agent — and a real read failure on such a
// session must not pass for an empty file.
func absent(said string) bool {
for line := range strings.Lines(said) {
named, is := reportedNotFound(strings.TrimSpace(line))
if is && (named == authorized ||
strings.HasSuffix(named, "/"+authorized)) {
return true
}
}
return false
}
// reportedNotFound returns the path an sftp line reports as not being
// there, and whether the line is such a report. The client writes one
// wording for a remote file it cannot find, naming the path the
// server expanded, which is the absolute one.
func reportedNotFound(line string) (string, bool) {
const (
before = `File "`
after = `" not found.`
)
if !strings.HasPrefix(line, before) ||
!strings.HasSuffix(line, after) {
return "", false
}
return strings.TrimSuffix(strings.TrimPrefix(line, before), after), true
}
// sidecarName returns the name the new file is uploaded under.
func sidecarName() (string, error) {
random := make([]byte, sidecarBytes)
_, err := rand.Read(random)
if err != nil {
return "", fmt.Errorf("making a name for the new file: %w", err)
}
return sidecarPrefix + hex.EncodeToString(random), nil
}
// quoted puts the double quotes around a path that sftp needs when the
// path has a space in it. Only paths of the tool's own making are
// given to it, and they hold no quote of their own.
func quoted(path string) string {
return `"` + path + `"`
}

View File

@@ -0,0 +1,78 @@
//nolint:testpackage // absent is what these wordings are read by
package ssh
import "testing"
// What a session says besides its report on the file that was asked
// for: sftp echoes the command it is running, and ssh warns about an
// identity file it cannot find in the words of a missing file even
// though the session goes on to authenticate.
const (
echoed = `sftp> get .ssh/authorized_keys "/tmp/keyfunc/authorized_keys"
`
warning = `Warning: Identity file /gone not accessible: ` +
"No such file or directory.\n"
)
// TestAbsenceIsReadOnlyFromWhatSFTPSaidAboutAuthorizedKeys holds the
// wordings the OpenSSH client was seen to use against a real server:
// a file it cannot find is reported one way, naming the path the
// server expanded, and everything else it says is a failure.
func TestAbsenceIsReadOnlyFromWhatSFTPSaidAboutAuthorizedKeys(t *testing.T) {
t.Parallel()
sessions := map[string]struct {
said string
want bool
}{
"the file is not there": {
said: echoed +
`File "/home/someone/.ssh/authorized_keys" not found.` + "\n",
want: true,
},
"the file is not there, named as it was asked for": {
said: echoed + `File ".ssh/authorized_keys" not found.` + "\n",
want: true,
},
"the file is not there and an identity file is not either": {
said: warning + echoed +
`File "/home/someone/.ssh/authorized_keys" not found.` + "\n",
want: true,
},
"the file is there and cannot be read": {
said: echoed +
`remote open "/home/someone/.ssh/authorized_keys": ` +
"Permission denied\n",
want: false,
},
"only an identity file is not there": {
said: warning + echoed +
`remote open "/home/someone/.ssh/authorized_keys": ` +
"Permission denied\n",
want: false,
},
"some other file is not there": {
said: echoed + `File "/home/someone/.ssh/known_hosts" not found.` +
"\n",
want: false,
},
"the connection did not come up": {
said: "ssh: connect to host example.com port 22: " +
"Connection refused\nConnection closed\n",
want: false,
},
}
for name, session := range sessions {
t.Run(name, func(t *testing.T) {
t.Parallel()
if absent(session.said) != session.want {
t.Errorf(
"read as absent: %t, wanted %t, from:\n%s",
!session.want, session.want, session.said,
)
}
})
}
}

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

510
internal/cli/ssh_test.go Normal file
View File

@@ -0,0 +1,510 @@
package cli_test
import (
"bytes"
"os"
"path/filepath"
"slices"
"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-ins need so that they 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, and failedStatus is the status the
// tool itself ends with when something went wrong.
const (
failingStatus = 7
failedStatus = 1
)
// notADirectory is what a test puts where the .ssh directory belongs
// to make a step of the write session fail.
const notADirectory = "a file where the directory belongs\n"
// missingIdentity is a path with no file at it, handed to sftp after
// the dashes so that ssh warns about it in the words of a missing
// file.
const missingIdentity = "/nonexistent/keyfunc-test-identity"
// The host, and where on it the key ends up.
const (
host = "someone@example.com"
keptUnder = ".ssh"
keptIn = "authorized_keys"
)
// The tool's own name, as it stands in the arguments a test hands to
// Main, the ssh subcommand both commands the tests here drive live
// under, and the one of those two these tests name most.
const (
tool = "keyfunc"
subcommand = "ssh"
installing = "install"
)
// The key line the example mnemonic gives at index 0, as it stands in
// an authorized_keys file.
const keyLine = vectorZero + " keyfunc/ssh/0\n"
// installer is a stand-in for the system sftp for the install
// command. It writes down the arguments and every command of the
// batch it is given, echoes each command as sftp does, and carries
// the commands out against a directory standing in for the host's
// home directory, so that what keyfunc sends can be watched doing its
// work. A command that begins with a dash may fail; any other failure
// ends the session, as it does in sftp's own batch mode.
//
// The two ways a get can fail are worded as the OpenSSH client words
// them, both naming the path the server expanded: a file that is not
// there, which is the one failure the tool reads as an empty file, and
// a file that is there and cannot be read, which is not. An -i naming
// a file that is not here draws the warning ssh writes for it, which
// carries the wording of a missing file into a session that goes on to
// authenticate.
const installer = `
previous=
for argument in "$@"; do
printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS"
if [ "$previous" = -i ] && [ ! -e "$argument" ]; then
printf 'Warning: Identity file %s not accessible: %s.\n' \
"$argument" "No such file or directory" >&2
fi
previous=$argument
done
home="$KEYFUNC_TEST_HOME"
while IFS= read -r line; do
printf 'sftp> %s\n' "$line"
printf '%s\n' "$line" >> "$KEYFUNC_TEST_BATCH"
allowed=no
case "$line" in
-*)
line=${line#-}
allowed=yes
;;
esac
eval "set -- $line"
worked=yes
case "$1" in
get)
if [ ! -e "$home/$2" ]; then
worked=no
printf 'File "%s" not found.\n' "$home/$2" >&2
elif ! cp "$home/$2" "$3" 2>/dev/null; then
worked=no
printf 'remote open "%s": Permission denied\n' "$home/$2" >&2
fi
;;
put) cp "$2" "$home/$3" 2>/dev/null || worked=no ;;
mkdir) mkdir "$home/$2" 2>/dev/null || worked=no ;;
chmod) chmod "$2" "$home/$3" 2>/dev/null || worked=no ;;
rename) mv "$home/$2" "$home/$3" 2>/dev/null || worked=no ;;
esac
if [ "$worked" = no ] && [ "$allowed" = no ]; then
printf 'sftp: %s failed\n' "$1" >&2
exit 1
fi
done
`
// 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 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 the arguments of every session, one per line.
arguments string
// batch holds the commands of every session, one per line.
batch string
}
func TestTheKeyIsAddedToAHostThatHasNoFileYet(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
require.Equal(t, "added\n", install(t, 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())
require.Equal(t, keyLine, read(t, path))
}
func TestAKeyThatIsAlreadyThereIsLeftAlone(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
path := seed(t, pretend, "somebody else\n"+keyLine)
require.Equal(t, "already present\n", install(t, host))
require.Equal(t, "somebody else\n"+keyLine, read(t, path))
// The fetch and nothing after it: the tool did not connect again.
require.Len(t, recorded(t, pretend.batch), 1)
}
func TestAnEmptyFileGetsTheKeyAndNoBlankLineBeforeIt(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
path := seed(t, pretend, "")
require.Equal(t, "added\n", install(t, host))
require.Equal(t, keyLine, read(t, path))
}
func TestTheKeyDoesNotRunIntoALineWithNoNewlineAtItsEnd(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
already := "ssh-ed25519 AAAAsomebodyelse somebody@else"
path := seed(t, pretend, already)
require.Equal(t, "added\n", install(t, host))
require.Equal(t, already+"\n"+keyLine, read(t, path))
}
func TestTheFileIsUploadedBesideTheOldOneAndThenRenamedOverIt(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
require.Equal(t, "added\n", install(t, host))
sent := recorded(t, pretend.batch)
require.Len(t, sent, 6)
// The name of the uploaded file is random, so it is read off the
// put and then looked for in the two commands that follow.
beside := strings.Fields(sent[3])[2]
require.True(t,
strings.HasPrefix(beside, ".ssh/authorized_keys.keyfunc-"),
)
require.True(t, strings.HasPrefix(sent[0], "get .ssh/authorized_keys "))
require.Equal(t, "-mkdir .ssh", sent[1])
require.Equal(t, "chmod 700 .ssh", sent[2])
require.Equal(t, "put", strings.Fields(sent[3])[0])
require.Equal(t, "chmod 600 "+beside, sent[4])
require.Equal(t, "rename "+beside+" .ssh/authorized_keys", sent[5])
}
func TestAFileThatCannotBeReadIsNotWrittenOver(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
unreadable := unfetchable(t, pretend)
printed, said, err := attempt(t, host)
require.Error(t, err)
require.Empty(t, printed)
require.Contains(t, said, "Permission denied")
// The fetch and nothing after it, and what was on the host is
// still what is on the host.
require.Len(t, recorded(t, pretend.batch), 1)
require.DirExists(t, unreadable)
}
func TestAWarningAboutAnotherFileIsNotTakenForTheOneAskedFor(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
unreadable := unfetchable(t, pretend)
// ssh warns about an -i it cannot find in the words of a missing
// file, on a session that then authenticates perfectly well. That
// warning is not sftp reporting on authorized_keys, so the fetch
// failure is still a failure.
printed, said, err := attempt(t, host, "--", "-i", missingIdentity)
require.Error(t, err)
require.Empty(t, printed)
require.Contains(t, said, "No such file or directory")
require.Contains(t, said, "Permission denied")
require.Len(t, recorded(t, pretend.batch), 1)
require.DirExists(t, unreadable)
// The same run again, this way for the status it ends with.
given := os.Args
t.Cleanup(func() { os.Args = given })
os.Args = []string{
tool, subcommand, installing, host, "--", "-i", missingIdentity,
}
require.Equal(t, failedStatus, cli.Main())
}
func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
// A file where the .ssh directory belongs: nothing is there to
// fetch, and then the put has nowhere to put anything, so the
// write session ends at the put.
inTheWay := filepath.Join(pretend.home, keptUnder)
require.NoError(t,
os.WriteFile(inTheWay, []byte(notADirectory), fileMode),
)
printed, said, err := attempt(t, host)
require.Error(t, err)
require.Empty(t, printed)
require.Contains(t, said, "put failed")
// The put is the last command the session got to, and the file it
// was uploading is the one the message names.
sent := recorded(t, pretend.batch)
require.Len(t, sent, 4)
require.Equal(t, "put", strings.Fields(sent[3])[0])
require.Contains(t, err.Error(), strings.Fields(sent[3])[2])
require.Equal(t, notADirectory, read(t, inTheWay))
// The same run again, this way for the status it ends with.
given := os.Args
t.Cleanup(func() { os.Args = given })
os.Args = []string{tool, subcommand, installing, host}
require.Equal(t, failedStatus, cli.Main())
}
func TestTheKeyLineIsNotSentAsACommand(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
install(t, host)
require.NotContains(t, read(t, pretend.arguments), "ssh-ed25519")
require.NotContains(t, read(t, pretend.batch), "ssh-ed25519")
}
func TestWhatComesAfterTheDashesIsGivenToSFTP(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
install(t, host, "--", "-P", "2222")
// The same arguments twice over: adding a line takes two
// connections, one to fetch the file and one to write it back.
session := []string{"-b", "-", "-P", "2222", host}
require.Equal(t,
slices.Concat(session, session),
recorded(t, pretend.arguments),
)
}
func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
arguments, noted := pretendCall(t)
_, err := execute(t, subcommand, "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{tool, subcommand, "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"),
batch: filepath.Join(t.TempDir(), "batch"),
}
t.Setenv("KEYFUNC_TEST_HOME", pretend.home)
t.Setenv("KEYFUNC_TEST_ARGUMENTS", pretend.arguments)
t.Setenv("KEYFUNC_TEST_BATCH", pretend.batch)
standIn(t, "sftp", installer)
return pretend
}
// install runs the install command, requires it to have worked, and
// gives back what the tool itself printed.
func install(t *testing.T, args ...string) string {
t.Helper()
printed, _, err := attempt(t, args...)
require.NoError(t, err)
return printed
}
// attempt runs the install command with the tool's own output kept
// apart from what the stand-in said, since the stand-in echoes its
// batch as sftp does. It gives back what the tool printed, what the
// stand-in said, and how the run ended.
func attempt(t *testing.T, args ...string) (string, string, error) {
t.Helper()
var printed, said bytes.Buffer
root := cli.Root()
root.SetOut(&printed)
root.SetErr(&said)
root.SetArgs(slices.Concat([]string{subcommand, installing}, args))
err := root.ExecuteContext(t.Context())
return printed.String(), said.String(), err
}
// seed puts an authorized_keys file on the stand-in host before the
// tool runs and gives back its path.
func seed(t *testing.T, pretend pretended, content string) string {
t.Helper()
directory := filepath.Join(pretend.home, keptUnder)
require.NoError(t, os.Mkdir(directory, directoryMode))
path := filepath.Join(directory, keptIn)
require.NoError(t, os.WriteFile(path, []byte(content), fileMode))
return path
}
// unfetchable puts a directory where authorized_keys belongs on the
// stand-in host, which the stand-in can see but cannot fetch: that is
// how a file that is there and cannot be read looks from here. It
// gives back the path.
func unfetchable(t *testing.T, pretend pretended) string {
t.Helper()
require.NoError(t,
os.Mkdir(filepath.Join(pretend.home, keptUnder), directoryMode),
)
path := filepath.Join(pretend.home, keptUnder, keptIn)
require.NoError(t, os.Mkdir(path, directoryMode))
return path
}
// pretendCall puts the to stand-in on the path and gives back the file
// the arguments are written down in and the file the agent socket is
// noted in.
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, "ssh", caller)
return arguments, noted
}
// standIn writes a stand-in for one of the system programs and puts it
// first on the path, so that the tool finds it instead of the real
// one.
func standIn(t *testing.T, name, body string) {
t.Helper()
directory := t.TempDir()
err := os.WriteFile(
filepath.Join(directory, name),
[]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 lines a stand-in wrote down.
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,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)