Compare commits
1 Commits
main
...
73c80ab173
| Author | SHA1 | Date | |
|---|---|---|---|
| 73c80ab173 |
@@ -11,18 +11,13 @@ import (
|
||||
)
|
||||
|
||||
// The recipients the example mnemonic produces at the first two
|
||||
// indexes, and the secret key behind the first of them. They are what
|
||||
// makes the derivation reproducible: if the recipients change, every
|
||||
// file anyone encrypted becomes unreadable, and if the secret key
|
||||
// changes, the key is no longer the one other tools derive from the
|
||||
// same mnemonic.
|
||||
// indexes. They are what makes the derivation reproducible: if these
|
||||
// change, every file anyone encrypted becomes unreadable.
|
||||
const (
|
||||
recipientZero = "age1xwdy9y6ckyfsgjc8k02e9uhsf3fmjy0ufysew" +
|
||||
"lj68kmx5n67e3nsg2mftq"
|
||||
recipientOne = "age1pmm92sxaf5mazjwvjph7dx2zq9r5p8l3rarfg" +
|
||||
"qm7hmakqhvgyy4q5p3w7j"
|
||||
identityZero = "AGE-SECRET-KEY-19QKK2P38598XLXMQFFU3P7J9PLDD" +
|
||||
"7527T70JDHGDJ7AMNF3XT44S00JFU5"
|
||||
)
|
||||
|
||||
// example returns the mnemonic every BIP-39 document uses to show its
|
||||
@@ -45,10 +40,12 @@ func TestTheSameMnemonicAlwaysGivesTheSameKey(t *testing.T) {
|
||||
require.Equal(t, recipientOne, forIndex(t, 1).Recipient())
|
||||
}
|
||||
|
||||
func TestTheSameMnemonicAlwaysGivesTheSameSecretKey(t *testing.T) {
|
||||
func TestTheIdentityIsWrittenTheWayAgeWritesOne(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, identityZero, forIndex(t, 0).Identity())
|
||||
require.True(t,
|
||||
strings.HasPrefix(forIndex(t, 0).Identity(), "AGE-SECRET-KEY-1"),
|
||||
)
|
||||
}
|
||||
|
||||
func TestWhatWasEncryptedComesBack(t *testing.T) {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// Package childmnemonic derives a mnemonic from another mnemonic.
|
||||
package childmnemonic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.eeqj.de/sneak/keyfunc/internal/derive"
|
||||
"git.eeqj.de/sneak/secret/pkg/bip85"
|
||||
"github.com/btcsuite/btcd/btcutil/hdkeychain"
|
||||
bip39 "github.com/tyler-smith/go-bip39"
|
||||
)
|
||||
|
||||
// english is the number BIP-85 gives the English word list.
|
||||
const english = 0
|
||||
|
||||
// The lengths a child mnemonic may have. BIP-85 also allows 15 and 21
|
||||
// words; these three are the ones this tool offers.
|
||||
const (
|
||||
twelve = 12
|
||||
eighteen = 18
|
||||
twentyFour = 24
|
||||
)
|
||||
|
||||
// DefaultWords is how long a child mnemonic is when the user does not
|
||||
// say.
|
||||
const DefaultWords = twelve
|
||||
|
||||
// ErrWordCount is returned for a length this tool does not offer.
|
||||
var ErrWordCount = errors.New("a child mnemonic has 12, 18 or 24 words")
|
||||
|
||||
// Derive returns the English child mnemonic of that many words at that
|
||||
// key index, taken from the master key with BIP-85's own mnemonic
|
||||
// application, number 39. The words come straight from the BIP-85
|
||||
// entropy, cut to the length the word count needs, rather than from
|
||||
// the generator the other key types read their bytes from.
|
||||
func Derive(
|
||||
master *hdkeychain.ExtendedKey,
|
||||
words, index uint32,
|
||||
) (string, error) {
|
||||
switch words {
|
||||
case twelve, eighteen, twentyFour:
|
||||
default:
|
||||
return "", fmt.Errorf("%w, not %d", ErrWordCount, words)
|
||||
}
|
||||
|
||||
err := derive.CheckIndex(index)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
entropy, err := bip85.DeriveBIP39Entropy(master, english, words, index)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("deriving entropy: %w", err)
|
||||
}
|
||||
|
||||
child, err := bip39.NewMnemonic(entropy)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("turning the entropy into words: %w", err)
|
||||
}
|
||||
|
||||
return child, nil
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package childmnemonic_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/keyfunc/internal/childmnemonic"
|
||||
"git.eeqj.de/sneak/keyfunc/internal/derive"
|
||||
"github.com/btcsuite/btcd/btcutil/hdkeychain"
|
||||
"github.com/stretchr/testify/require"
|
||||
bip39 "github.com/tyler-smith/go-bip39"
|
||||
)
|
||||
|
||||
// The lengths the tool offers, and one it does not.
|
||||
const (
|
||||
twelve = 12
|
||||
eighteen = 18
|
||||
twentyFour = 24
|
||||
fifteen = 15
|
||||
)
|
||||
|
||||
// specificationKey is the master key the BIP-85 specification gives
|
||||
// every one of its test vectors for.
|
||||
const specificationKey = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBq" +
|
||||
"sx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
|
||||
|
||||
// The three English child mnemonics at key index 0 that the BIP-85
|
||||
// specification gives for that master key.
|
||||
const (
|
||||
twelveWords = "girl mad pet galaxy egg matter matrix prison refuse " +
|
||||
"sense ordinary nose"
|
||||
|
||||
eighteenWords = "near account window bike charge season chef number " +
|
||||
"sketch tomorrow excuse sniff circle vital hockey outdoor " +
|
||||
"supply token"
|
||||
|
||||
twentyFourWords = "puppy ocean match cereal symbol another shed " +
|
||||
"magic wrap hammer bulb intact gadget divorce twin tonight " +
|
||||
"reason outdoor destroy simple truth cigar social volcano"
|
||||
)
|
||||
|
||||
// example returns the mnemonic every BIP-39 document uses to show its
|
||||
// test vectors: eleven abandons and about.
|
||||
func example() string {
|
||||
return strings.Repeat("abandon ", 11) + "about"
|
||||
}
|
||||
|
||||
func TestTheSpecificationTestVectors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, twelveWords, fromSpecificationKey(t, twelve))
|
||||
require.Equal(t, eighteenWords, fromSpecificationKey(t, eighteen))
|
||||
require.Equal(t, twentyFourWords, fromSpecificationKey(t, twentyFour))
|
||||
}
|
||||
|
||||
func TestTheLongestChildMnemonicPassesItsOwnChecksum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
child := fromSpecificationKey(t, twentyFour)
|
||||
|
||||
require.Len(t, strings.Fields(child), twentyFour)
|
||||
require.True(t, bip39.IsMnemonicValid(child))
|
||||
}
|
||||
|
||||
func TestEachKeyIndexGivesItsOwnChildMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
master, err := derive.Master(example())
|
||||
require.NoError(t, err)
|
||||
|
||||
first, err := childmnemonic.Derive(master, twelve, 0)
|
||||
require.NoError(t, err)
|
||||
require.True(t, bip39.IsMnemonicValid(first))
|
||||
|
||||
second, err := childmnemonic.Derive(master, twelve, 1)
|
||||
require.NoError(t, err)
|
||||
require.True(t, bip39.IsMnemonicValid(second))
|
||||
|
||||
require.NotEqual(t, first, second)
|
||||
}
|
||||
|
||||
func TestALengthTheToolDoesNotOfferIsRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
master, err := hdkeychain.NewKeyFromString(specificationKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = childmnemonic.Derive(master, fifteen, 0)
|
||||
require.ErrorIs(t, err, childmnemonic.ErrWordCount)
|
||||
}
|
||||
|
||||
func TestAnIndexWithNoHardenedChildIsRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
master, err := hdkeychain.NewKeyFromString(specificationKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = childmnemonic.Derive(master, twelve, derive.MaxIndex+1)
|
||||
require.ErrorIs(t, err, derive.ErrIndexTooLarge)
|
||||
|
||||
_, err = childmnemonic.Derive(master, twelve, derive.MaxIndex)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// fromSpecificationKey derives the child mnemonic of that length at key
|
||||
// index 0 from the master key the specification gives.
|
||||
func fromSpecificationKey(t *testing.T, words uint32) string {
|
||||
t.Helper()
|
||||
|
||||
master, err := hdkeychain.NewKeyFromString(specificationKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
child, err := childmnemonic.Derive(master, words, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
return child
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.eeqj.de/sneak/keyfunc/internal/agekey"
|
||||
"git.eeqj.de/sneak/keyfunc/internal/cli/options"
|
||||
@@ -14,6 +13,10 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ownerOnly is the mode a file the tool creates gets, since a
|
||||
// decrypted file is as secret as what went into it.
|
||||
const ownerOnly = 0o600
|
||||
|
||||
// Command returns the age command and everything under it.
|
||||
func Command() *cobra.Command {
|
||||
group := &cobra.Command{
|
||||
@@ -129,7 +132,7 @@ func runDecrypt(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// through opens the input and the output the arguments ask for, hands
|
||||
// them to the work, and finishes the output afterwards either way.
|
||||
// them to the work, and closes an output file afterwards either way.
|
||||
func through(
|
||||
cmd *cobra.Command, args []string,
|
||||
work func(io.Writer, io.Reader) error,
|
||||
@@ -141,14 +144,19 @@ func through(
|
||||
|
||||
defer closeSrc()
|
||||
|
||||
dst, done, err := output(cmd)
|
||||
dst, closeDst, err := output(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = work(dst, src)
|
||||
if err != nil {
|
||||
_ = closeDst()
|
||||
|
||||
return done(err)
|
||||
return err
|
||||
}
|
||||
|
||||
return closeDst()
|
||||
}
|
||||
|
||||
// input returns what to read from: the named file, or the command's
|
||||
@@ -167,61 +175,28 @@ func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) {
|
||||
return file, func() { _ = file.Close() }, nil
|
||||
}
|
||||
|
||||
// output returns what to write to: a new file beside the one --output
|
||||
// names, or the command's own output when it names none. The second
|
||||
// result finishes the write, and is given whatever the work returned:
|
||||
// the new file takes the named file's place only when the work
|
||||
// succeeded, so a file that is already there survives a run that
|
||||
// failed.
|
||||
func output(cmd *cobra.Command) (io.Writer, func(error) error, error) {
|
||||
// output returns what to write to: the file --output names, or the
|
||||
// command's own output. The second result closes a file that was
|
||||
// opened, so a failure to finish writing is not lost.
|
||||
func output(cmd *cobra.Command) (io.Writer, func() error, error) {
|
||||
name, err := cmd.Flags().GetString("output")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("reading the output file: %w", err)
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
return cmd.OutOrStdout(), func(failed error) error {
|
||||
return failed
|
||||
}, nil
|
||||
return cmd.OutOrStdout(), func() error { return nil }, nil
|
||||
}
|
||||
|
||||
// The file is made in the same directory so that putting it in
|
||||
// place is a rename and never a copy, and it is readable only by
|
||||
// its owner, which is the mode it keeps once renamed.
|
||||
file, err := os.CreateTemp(filepath.Dir(name), filepath.Base(name)+".")
|
||||
//nolint:gosec // writing the file the user named is the point
|
||||
file, err := os.OpenFile(
|
||||
name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, ownerOnly,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("creating a file beside %s: %w", name, err)
|
||||
return nil, nil, fmt.Errorf("creating %s: %w", name, err)
|
||||
}
|
||||
|
||||
return file, func(failed error) error {
|
||||
return finish(file, name, failed)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// finish closes the new file and puts it in the named file's place, or
|
||||
// throws it away when the work failed. It returns the error the caller
|
||||
// should report.
|
||||
func finish(file *os.File, name string, failed error) error {
|
||||
closeErr := file.Close()
|
||||
|
||||
if failed != nil || closeErr != nil {
|
||||
_ = os.Remove(file.Name())
|
||||
|
||||
if failed != nil {
|
||||
return failed
|
||||
}
|
||||
|
||||
return fmt.Errorf("finishing %s: %w", name, closeErr)
|
||||
}
|
||||
|
||||
err := os.Rename(file.Name(), name)
|
||||
if err != nil {
|
||||
_ = os.Remove(file.Name())
|
||||
|
||||
return fmt.Errorf("putting %s in place: %w", name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return file, file.Close, nil
|
||||
}
|
||||
|
||||
// addOutput gives a command its output file flag.
|
||||
|
||||
@@ -72,24 +72,6 @@ func TestAFileForAnotherKeyIsRefused(t *testing.T) {
|
||||
require.ErrorIs(t, err, agekey.ErrNotRecipient)
|
||||
}
|
||||
|
||||
func TestARefusedDecryptionLeavesTheOutputFileAlone(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
plain := written(t, "notes.txt", "the secret\n")
|
||||
sealed := filepath.Join(t.TempDir(), "notes.age")
|
||||
existing := written(t, "notes.out", "what was already there\n")
|
||||
|
||||
run(t, "age", "encrypt", "-n", "7", "-o", sealed, plain)
|
||||
|
||||
_, err := execute(t, "age", "decrypt", "-o", existing, sealed)
|
||||
require.ErrorIs(t, err, agekey.ErrNotRecipient)
|
||||
|
||||
//nolint:gosec // the test made this path itself
|
||||
kept, err := os.ReadFile(existing)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "what was already there\n", string(kept))
|
||||
}
|
||||
|
||||
// written puts the contents in a file of that name in a directory of
|
||||
// this test's own and returns the path to it.
|
||||
func written(t *testing.T, name, contents string) string {
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.eeqj.de/sneak/keyfunc/internal/cli/age"
|
||||
"git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic"
|
||||
"git.eeqj.de/sneak/keyfunc/internal/cli/options"
|
||||
"git.eeqj.de/sneak/keyfunc/internal/cli/ssh"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -32,28 +30,20 @@ func Root() *cobra.Command {
|
||||
}
|
||||
|
||||
options.Add(root)
|
||||
root.AddCommand(ssh.Command(), age.Command(), mnemonic.Command())
|
||||
root.AddCommand(ssh.Command(), age.Command())
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
// Main runs the tool and returns the status the process should exit
|
||||
// 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.
|
||||
// with.
|
||||
func Main() int {
|
||||
err := Root().Execute()
|
||||
if err == nil {
|
||||
return 0
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "keyfunc: "+err.Error())
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
var passed ssh.StatusError
|
||||
if errors.As(err, &passed) {
|
||||
return passed.Status
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, "keyfunc: "+err.Error())
|
||||
|
||||
return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -5,12 +5,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/keyfunc/internal/childmnemonic"
|
||||
"git.eeqj.de/sneak/keyfunc/internal/cli"
|
||||
"git.eeqj.de/sneak/keyfunc/internal/derive"
|
||||
"git.eeqj.de/sneak/keyfunc/internal/mnemonic"
|
||||
"github.com/stretchr/testify/require"
|
||||
bip39 "github.com/tyler-smith/go-bip39"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
@@ -22,12 +20,6 @@ const (
|
||||
"0I4FKs+eVUulTPHfk9VtXw1tMF"
|
||||
)
|
||||
|
||||
// The two child mnemonic lengths the tests ask for.
|
||||
const (
|
||||
twelve = 12
|
||||
twentyFour = 24
|
||||
)
|
||||
|
||||
// example returns the mnemonic the README gives its test vectors for:
|
||||
// eleven abandons and about.
|
||||
func example() string {
|
||||
@@ -89,28 +81,6 @@ func TestTheMnemonicCommandIsUsed(t *testing.T) {
|
||||
require.Equal(t, vectorZero+" keyfunc/ssh/0", line)
|
||||
}
|
||||
|
||||
func TestAChildMnemonicIsPrinted(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
short := strings.Fields(run(t, "mnemonic"))
|
||||
require.Len(t, short, twelve)
|
||||
require.True(t, bip39.IsMnemonicValid(strings.Join(short, " ")))
|
||||
|
||||
long := strings.Fields(run(t, "mnemonic", "--words", "24"))
|
||||
require.Len(t, long, twentyFour)
|
||||
|
||||
next := strings.Fields(run(t, "mnemonic", "-n", "1"))
|
||||
require.NotEqual(t, short, next)
|
||||
}
|
||||
|
||||
func TestALengthTheToolDoesNotOfferIsRefused(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
out, err := execute(t, "mnemonic", "--words", "15")
|
||||
require.ErrorIs(t, err, childmnemonic.ErrWordCount)
|
||||
require.Empty(t, out)
|
||||
}
|
||||
|
||||
// run executes the tool with the given arguments and returns what it
|
||||
// wrote to standard output.
|
||||
func run(t *testing.T, args ...string) string {
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
// Package mnemonic is the command that prints a child mnemonic.
|
||||
package mnemonic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.eeqj.de/sneak/keyfunc/internal/childmnemonic"
|
||||
"git.eeqj.de/sneak/keyfunc/internal/cli/options"
|
||||
"git.eeqj.de/sneak/keyfunc/internal/derive"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Command returns the mnemonic command.
|
||||
func Command() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "mnemonic",
|
||||
Short: "print a child mnemonic derived from the main one",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
child, err := derived(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), child)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing the mnemonic: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().Uint32(
|
||||
"words", childmnemonic.DefaultWords,
|
||||
"how long the child mnemonic is: 12, 18 or 24 words",
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// derived returns the child mnemonic this run asks for.
|
||||
func derived(cmd *cobra.Command) (string, error) {
|
||||
index, err := options.Index(cmd)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
words, err := cmd.Flags().GetUint32("words")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading the word count: %w", err)
|
||||
}
|
||||
|
||||
parent, err := options.Mnemonic(cmd)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
master, err := derive.Master(parent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return childmnemonic.Derive(master, words, index)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
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",
|
||||
}
|
||||
|
||||
group.AddCommand(public(), private(), install(), to())
|
||||
group.AddCommand(public(), private())
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -35,47 +35,24 @@ func Path(application, index uint32) string {
|
||||
return fmt.Sprintf("m/%d'/%d'/%d'", purpose, application, index)
|
||||
}
|
||||
|
||||
// CheckIndex refuses a key index above MaxIndex, so that every key
|
||||
// type turns such an index down before deriving anything.
|
||||
func CheckIndex(index uint32) error {
|
||||
if index > MaxIndex {
|
||||
return fmt.Errorf(
|
||||
"%w: %d is above %d",
|
||||
ErrIndexTooLarge, index, MaxIndex,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Master returns the BIP-32 master key a mnemonic stands for: the
|
||||
// mnemonic becomes a seed with an empty passphrase, and the seed
|
||||
// becomes the key.
|
||||
func Master(words string) (*hdkeychain.ExtendedKey, error) {
|
||||
seed := bip39.NewSeed(words, "")
|
||||
|
||||
master, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("making the master key: %w", err)
|
||||
}
|
||||
|
||||
return master, nil
|
||||
}
|
||||
|
||||
// Bytes returns the bytes for an application number and a key index.
|
||||
// The mnemonic becomes a seed with an empty passphrase, the seed
|
||||
// becomes a master key, the master key gives BIP-85 entropy at the
|
||||
// path, and the entropy seeds the generator the bytes are read from.
|
||||
// An index above MaxIndex is refused before any of that happens.
|
||||
func Bytes(words string, application, index uint32) ([]byte, error) {
|
||||
err := CheckIndex(index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if index > MaxIndex {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: %d is above %d",
|
||||
ErrIndexTooLarge, index, MaxIndex,
|
||||
)
|
||||
}
|
||||
|
||||
master, err := Master(words)
|
||||
seed := bip39.NewSeed(words, "")
|
||||
|
||||
master, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("making the master key: %w", err)
|
||||
}
|
||||
|
||||
entropy, err := bip85.DeriveBIP85Entropy(master, Path(application, index))
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
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,9 +1,6 @@
|
||||
package sshkey_test
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -11,16 +8,8 @@ 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 {
|
||||
@@ -37,7 +26,7 @@ func TestTooFewBytesAreRefused(t *testing.T) {
|
||||
func TestTheCommentIsPutAtTheEndOfTheLine(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := exampleKey(t)
|
||||
key := forIndex(t, 0)
|
||||
|
||||
line, err := key.Line("hello")
|
||||
require.NoError(t, err)
|
||||
@@ -48,7 +37,7 @@ func TestTheCommentIsPutAtTheEndOfTheLine(t *testing.T) {
|
||||
func TestThePrivateKeyCarriesTheSamePublicKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := exampleKey(t)
|
||||
key := forIndex(t, 0)
|
||||
|
||||
line, err := key.Line("")
|
||||
require.NoError(t, err)
|
||||
@@ -65,60 +54,11 @@ func TestThePrivateKeyCarriesTheSamePublicKey(t *testing.T) {
|
||||
require.Equal(t, line, back)
|
||||
}
|
||||
|
||||
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 {
|
||||
// forIndex derives the key for one index.
|
||||
func forIndex(t *testing.T, index uint32) *sshkey.Key {
|
||||
t.Helper()
|
||||
|
||||
material, err := derive.Bytes(example(), sshkey.Application, exampleIndex)
|
||||
material, err := derive.Bytes(example(), sshkey.Application, index)
|
||||
require.NoError(t, err)
|
||||
|
||||
key, err := sshkey.New(material)
|
||||
|
||||
Reference in New Issue
Block a user