Compare commits
4 Commits
73c80ab173
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a2a0890ded | |||
| b9c8631788 | |||
| 5bbeec86d6 | |||
| d69bed722a |
1
go.mod
1
go.mod
@@ -3,6 +3,7 @@ module git.eeqj.de/sneak/keyfunc
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/age v1.2.1
|
||||||
git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd
|
git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd
|
||||||
github.com/btcsuite/btcd v0.24.2
|
github.com/btcsuite/btcd v0.24.2
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.6
|
github.com/btcsuite/btcd/btcutil v1.1.6
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1,3 +1,7 @@
|
|||||||
|
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
|
||||||
|
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
|
||||||
|
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
|
||||||
|
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
|
||||||
git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd h1:6YFV6horz2wDFPWWhour8qx8gLGyO0qoplwEeOuQ2J4=
|
git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd h1:6YFV6horz2wDFPWWhour8qx8gLGyO0qoplwEeOuQ2J4=
|
||||||
git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd/go.mod h1:gKCcMZvlBOqusn/BxR8IyFmSJQr6R4vvjJ926iNpOSI=
|
git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd/go.mod h1:gKCcMZvlBOqusn/BxR8IyFmSJQr6R4vvjJ926iNpOSI=
|
||||||
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
||||||
|
|||||||
203
internal/agekey/agekey.go
Normal file
203
internal/agekey/agekey.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
// Package agekey turns derived bytes into an age identity and uses
|
||||||
|
// that identity to encrypt and decrypt.
|
||||||
|
package agekey
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"filippo.io/age/armor"
|
||||||
|
"github.com/btcsuite/btcd/btcutil/bech32"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Application is the number this key type occupies in the
|
||||||
|
// derivation path. It spells AGE the way BIP-85 spells RSA, as
|
||||||
|
// the ASCII codes of the letters written out.
|
||||||
|
Application = 657169
|
||||||
|
|
||||||
|
// keySize is how long an X25519 secret key is.
|
||||||
|
keySize = 32
|
||||||
|
|
||||||
|
// humanPart is what age puts in front of a secret key when it
|
||||||
|
// writes one down.
|
||||||
|
humanPart = "age-secret-key-"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrSize is returned when the derived bytes are not the length an
|
||||||
|
// X25519 secret key has to be.
|
||||||
|
var ErrSize = errors.New("an age identity needs 32 derived bytes")
|
||||||
|
|
||||||
|
// ErrNotRecipient is returned when the file was encrypted to someone
|
||||||
|
// else, so this key cannot open it.
|
||||||
|
var ErrNotRecipient = errors.New(
|
||||||
|
"this mnemonic and index are not a recipient of the file",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Key is one age identity.
|
||||||
|
type Key struct {
|
||||||
|
identity *age.X25519Identity
|
||||||
|
}
|
||||||
|
|
||||||
|
// New makes the identity whose X25519 secret key is the derived bytes,
|
||||||
|
// clamped the way that curve requires.
|
||||||
|
func New(derived []byte) (*Key, error) {
|
||||||
|
if len(derived) != keySize {
|
||||||
|
return nil, fmt.Errorf("%w, got %d", ErrSize, len(derived))
|
||||||
|
}
|
||||||
|
|
||||||
|
scalar := make([]byte, keySize)
|
||||||
|
copy(scalar, derived)
|
||||||
|
clamp(scalar)
|
||||||
|
|
||||||
|
// age offers no way to make an identity out of bytes, so the
|
||||||
|
// scalar goes in the way age writes a secret key down.
|
||||||
|
written, err := bech32.EncodeFromBase256(humanPart, scalar)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("writing the secret key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
identity, err := age.ParseX25519Identity(strings.ToUpper(written))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading the secret key back: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Key{identity: identity}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clamp makes the scalar one the curve accepts: the lowest three bits
|
||||||
|
// off, the highest bit off and the one below it on, as RFC 7748 says.
|
||||||
|
func clamp(scalar []byte) {
|
||||||
|
const (
|
||||||
|
lowestThreeOff = 0b1111_1000
|
||||||
|
highestOff = 0b0111_1111
|
||||||
|
secondHighestOn = 0b0100_0000
|
||||||
|
)
|
||||||
|
|
||||||
|
scalar[0] &= lowestThreeOff
|
||||||
|
scalar[len(scalar)-1] &= highestOff
|
||||||
|
scalar[len(scalar)-1] |= secondHighestOn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient returns the public key, the age1... line.
|
||||||
|
func (k *Key) Recipient() string {
|
||||||
|
return k.identity.Recipient().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity returns the secret key, the AGE-SECRET-KEY-1... line.
|
||||||
|
func (k *Key) Identity() string {
|
||||||
|
return k.identity.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt copies src to dst, encrypted to this key and to every extra
|
||||||
|
// recipient named, so the same mnemonic can always read it back again.
|
||||||
|
// Armored output is the text form age also reads.
|
||||||
|
func (k *Key) Encrypt(
|
||||||
|
dst io.Writer, src io.Reader, to []string, armored bool,
|
||||||
|
) error {
|
||||||
|
all, err := recipients(k.identity.Recipient(), to)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := dst
|
||||||
|
|
||||||
|
var text io.WriteCloser
|
||||||
|
|
||||||
|
if armored {
|
||||||
|
text = armor.NewWriter(dst)
|
||||||
|
out = text
|
||||||
|
}
|
||||||
|
|
||||||
|
err = encrypt(out, src, all)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if text == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = text.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("finishing the text form: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recipients returns the key's own recipient followed by the ones
|
||||||
|
// named on the command line, so what the key encrypts it can read.
|
||||||
|
func recipients(mine age.Recipient, to []string) ([]age.Recipient, error) {
|
||||||
|
list := []age.Recipient{mine}
|
||||||
|
|
||||||
|
for _, name := range to {
|
||||||
|
parsed, err := age.ParseX25519Recipient(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading recipient %q: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list = append(list, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encrypt writes src into dst for the recipients.
|
||||||
|
func encrypt(dst io.Writer, src io.Reader, to []age.Recipient) error {
|
||||||
|
sealed, err := age.Encrypt(dst, to...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("starting the encryption: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(sealed, src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encrypting: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = sealed.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("finishing the encryption: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt copies src to dst, decrypted with this key. The text form is
|
||||||
|
// recognised by the line it starts with, so it needs no flag.
|
||||||
|
func (k *Key) Decrypt(dst io.Writer, src io.Reader) error {
|
||||||
|
plain, err := age.Decrypt(unarmored(src), k.identity)
|
||||||
|
|
||||||
|
noMatch := &age.NoIdentityMatchError{}
|
||||||
|
if errors.As(err, &noMatch) {
|
||||||
|
return ErrNotRecipient
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decrypting: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(dst, plain)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading the decrypted file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unarmored strips the text form when the input begins with the line
|
||||||
|
// that starts one, and leaves binary input alone.
|
||||||
|
func unarmored(src io.Reader) io.Reader {
|
||||||
|
buffered := bufio.NewReader(src)
|
||||||
|
|
||||||
|
start, err := buffered.Peek(len(armor.Header))
|
||||||
|
if err == nil && string(start) == armor.Header {
|
||||||
|
return armor.NewReader(buffered)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffered
|
||||||
|
}
|
||||||
169
internal/agekey/agekey_test.go
Normal file
169
internal/agekey/agekey_test.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package agekey_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/keyfunc/internal/agekey"
|
||||||
|
"git.eeqj.de/sneak/keyfunc/internal/derive"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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
|
||||||
|
// test vectors: eleven abandons and about.
|
||||||
|
func example() string {
|
||||||
|
return strings.Repeat("abandon ", 11) + "about"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTooFewBytesAreRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := agekey.New([]byte("short"))
|
||||||
|
require.ErrorIs(t, err, agekey.ErrSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheSameMnemonicAlwaysGivesTheSameKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
require.Equal(t, recipientZero, forIndex(t, 0).Recipient())
|
||||||
|
require.Equal(t, recipientOne, forIndex(t, 1).Recipient())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheSameMnemonicAlwaysGivesTheSameSecretKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
require.Equal(t, identityZero, forIndex(t, 0).Identity())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhatWasEncryptedComesBack(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Every byte value, so nothing assumes the input is text, and
|
||||||
|
// then text, which is what most of it will be.
|
||||||
|
payloads := map[string][]byte{
|
||||||
|
"every byte": everyByte(),
|
||||||
|
"text": []byte("the quick brown fox\nand a second line\n"),
|
||||||
|
}
|
||||||
|
|
||||||
|
forms := map[string]bool{"binary": false, "armored": true}
|
||||||
|
|
||||||
|
for name, payload := range payloads {
|
||||||
|
for form, armored := range forms {
|
||||||
|
t.Run(name+" "+form, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
key := forIndex(t, 0)
|
||||||
|
|
||||||
|
var sealed, opened bytes.Buffer
|
||||||
|
|
||||||
|
err := key.Encrypt(
|
||||||
|
&sealed, bytes.NewReader(payload), nil, armored,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = key.Decrypt(&opened, &sealed)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, payload, opened.Bytes())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheArmoredFormIsText(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var sealed bytes.Buffer
|
||||||
|
|
||||||
|
err := forIndex(t, 0).Encrypt(
|
||||||
|
&sealed, strings.NewReader("hello"), nil, true,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, strings.HasPrefix(
|
||||||
|
sealed.String(), "-----BEGIN AGE ENCRYPTED FILE-----",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFileForSomebodyElseIsRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var sealed, opened bytes.Buffer
|
||||||
|
|
||||||
|
err := forIndex(t, 1).Encrypt(
|
||||||
|
&sealed, strings.NewReader("hello"), nil, false,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = forIndex(t, 0).Decrypt(&opened, &sealed)
|
||||||
|
require.ErrorIs(t, err, agekey.ErrNotRecipient)
|
||||||
|
require.Empty(t, opened.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnExtraRecipientCanReadItTooAndSoCanTheDerivedOne(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mine, theirs := forIndex(t, 0), forIndex(t, 1)
|
||||||
|
|
||||||
|
var sealed bytes.Buffer
|
||||||
|
|
||||||
|
err := mine.Encrypt(
|
||||||
|
&sealed, strings.NewReader("hello"),
|
||||||
|
[]string{theirs.Recipient()}, false,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, key := range []*agekey.Key{mine, theirs} {
|
||||||
|
var opened bytes.Buffer
|
||||||
|
|
||||||
|
require.NoError(t, key.Decrypt(&opened, bytes.NewReader(sealed.Bytes())))
|
||||||
|
require.Equal(t, "hello", opened.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARecipientThatIsNotOneIsRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := forIndex(t, 0).Encrypt(
|
||||||
|
&bytes.Buffer{}, strings.NewReader("hello"),
|
||||||
|
[]string{"not a recipient"}, false,
|
||||||
|
)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// everyByte returns a payload holding all 256 byte values.
|
||||||
|
func everyByte() []byte {
|
||||||
|
out := make([]byte, 256)
|
||||||
|
for i := range out {
|
||||||
|
out[i] = byte(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// forIndex derives the key for one index.
|
||||||
|
func forIndex(t *testing.T, index uint32) *agekey.Key {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
material, err := derive.Bytes(example(), agekey.Application, index)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
key, err := agekey.New(material)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return key
|
||||||
|
}
|
||||||
63
internal/childmnemonic/childmnemonic.go
Normal file
63
internal/childmnemonic/childmnemonic.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
117
internal/childmnemonic/childmnemonic_test.go
Normal file
117
internal/childmnemonic/childmnemonic_test.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
263
internal/cli/age/age.go
Normal file
263
internal/cli/age/age.go
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
// Package age groups the commands that derive age identities and
|
||||||
|
// encrypt and decrypt with them.
|
||||||
|
package age
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/keyfunc/internal/agekey"
|
||||||
|
"git.eeqj.de/sneak/keyfunc/internal/cli/options"
|
||||||
|
"git.eeqj.de/sneak/keyfunc/internal/derive"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Command returns the age command and everything under it.
|
||||||
|
func Command() *cobra.Command {
|
||||||
|
group := &cobra.Command{
|
||||||
|
Use: "age",
|
||||||
|
Short: "derive age identities and encrypt and decrypt with them",
|
||||||
|
}
|
||||||
|
|
||||||
|
group.AddCommand(public(), private(), encrypt(), decrypt())
|
||||||
|
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
// public returns the command that prints the recipient.
|
||||||
|
func public() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "pub",
|
||||||
|
Short: "print the recipient, the age1... public key",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
key, err := derived(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return write(cmd, key.Recipient())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// private returns the command that prints the identity.
|
||||||
|
func private() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "priv",
|
||||||
|
Short: "print the identity, the AGE-SECRET-KEY-1... line",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
key, err := derived(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return write(cmd, key.Identity())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// encrypt returns the command that encrypts a file or standard input.
|
||||||
|
func encrypt() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "encrypt [file]",
|
||||||
|
Short: "encrypt to the derived recipient and any others given",
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: runEncrypt,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringArray(
|
||||||
|
"to", nil,
|
||||||
|
"another recipient to encrypt to, as well as the derived one",
|
||||||
|
)
|
||||||
|
cmd.Flags().Bool(
|
||||||
|
"armor", false,
|
||||||
|
"write the text form instead of the binary one",
|
||||||
|
)
|
||||||
|
addOutput(cmd)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// decrypt returns the command that decrypts a file or standard input.
|
||||||
|
func decrypt() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "decrypt [file]",
|
||||||
|
Short: "decrypt with the derived identity",
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: runDecrypt,
|
||||||
|
}
|
||||||
|
|
||||||
|
addOutput(cmd)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// runEncrypt encrypts to the derived recipient and any others given.
|
||||||
|
func runEncrypt(cmd *cobra.Command, args []string) error {
|
||||||
|
key, err := derived(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
to, err := cmd.Flags().GetStringArray("to")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading the recipients: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
armored, err := cmd.Flags().GetBool("armor")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading the armor flag: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return through(cmd, args, func(dst io.Writer, src io.Reader) error {
|
||||||
|
return key.Encrypt(dst, src, to, armored)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDecrypt decrypts with the derived identity.
|
||||||
|
func runDecrypt(cmd *cobra.Command, args []string) error {
|
||||||
|
key, err := derived(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return through(cmd, args, key.Decrypt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// through opens the input and the output the arguments ask for, hands
|
||||||
|
// them to the work, and finishes the output afterwards either way.
|
||||||
|
func through(
|
||||||
|
cmd *cobra.Command, args []string,
|
||||||
|
work func(io.Writer, io.Reader) error,
|
||||||
|
) error {
|
||||||
|
src, closeSrc, err := input(cmd, args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer closeSrc()
|
||||||
|
|
||||||
|
dst, done, err := output(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = work(dst, src)
|
||||||
|
|
||||||
|
return done(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// input returns what to read from: the named file, or the command's
|
||||||
|
// own input when no file is named. The second result closes a file
|
||||||
|
// that was opened and does nothing otherwise.
|
||||||
|
func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return cmd.InOrStdin(), func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("opening %s: %w", args[0], err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)+".")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("creating a file beside %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
|
||||||
|
}
|
||||||
|
|
||||||
|
// addOutput gives a command its output file flag.
|
||||||
|
func addOutput(cmd *cobra.Command) {
|
||||||
|
cmd.Flags().StringP(
|
||||||
|
"output", "o", "",
|
||||||
|
"write to this file instead of standard output",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// write sends one line to wherever the command's output goes.
|
||||||
|
func write(cmd *cobra.Command, line string) error {
|
||||||
|
_, err := fmt.Fprintln(cmd.OutOrStdout(), line)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("writing the key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// derived returns the age key for this run.
|
||||||
|
func derived(cmd *cobra.Command) (*agekey.Key, error) {
|
||||||
|
index, err := options.Index(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
words, err := options.Mnemonic(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
material, err := derive.Bytes(words, agekey.Application, index)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return agekey.New(material)
|
||||||
|
}
|
||||||
102
internal/cli/age_test.go
Normal file
102
internal/cli/age_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/keyfunc/internal/agekey"
|
||||||
|
"git.eeqj.de/sneak/keyfunc/internal/mnemonic"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTheAgeCommandsPrintTheKey(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
recipient := strings.TrimSpace(run(t, "age", "pub"))
|
||||||
|
require.True(t, strings.HasPrefix(recipient, "age1"))
|
||||||
|
|
||||||
|
identity := strings.TrimSpace(run(t, "age", "priv"))
|
||||||
|
require.True(t, strings.HasPrefix(identity, "AGE-SECRET-KEY-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFileEncryptedByTheToolIsReadBackByIt(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
plain := written(t, "notes.txt", "the secret\n")
|
||||||
|
sealed := filepath.Join(t.TempDir(), "notes.age")
|
||||||
|
|
||||||
|
run(t, "age", "encrypt", "-o", sealed, plain)
|
||||||
|
require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheArmoredFormIsTextThatDecrypts(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
plain := written(t, "notes.txt", "the secret\n")
|
||||||
|
|
||||||
|
armored := run(t, "age", "encrypt", "--armor", plain)
|
||||||
|
require.True(t, strings.HasPrefix(
|
||||||
|
armored, "-----BEGIN AGE ENCRYPTED FILE-----",
|
||||||
|
))
|
||||||
|
|
||||||
|
sealed := written(t, "notes.age", armored)
|
||||||
|
require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnotherRecipientIsAddedAndTheDerivedOneStays(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
theirs := strings.TrimSpace(run(t, "age", "pub", "-n", "7"))
|
||||||
|
plain := written(t, "notes.txt", "the secret\n")
|
||||||
|
sealed := filepath.Join(t.TempDir(), "notes.age")
|
||||||
|
|
||||||
|
run(t, "age", "encrypt", "--to", theirs, "-o", sealed, plain)
|
||||||
|
|
||||||
|
require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed))
|
||||||
|
require.Equal(t,
|
||||||
|
"the secret\n", run(t, "age", "decrypt", "-n", "7", sealed),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFileForAnotherKeyIsRefused(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
plain := written(t, "notes.txt", "the secret\n")
|
||||||
|
sealed := filepath.Join(t.TempDir(), "notes.age")
|
||||||
|
|
||||||
|
run(t, "age", "encrypt", "-n", "7", "-o", sealed, plain)
|
||||||
|
|
||||||
|
_, err := execute(t, "age", "decrypt", sealed)
|
||||||
|
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 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), name)
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(contents), 0o600))
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"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/options"
|
||||||
"git.eeqj.de/sneak/keyfunc/internal/cli/ssh"
|
"git.eeqj.de/sneak/keyfunc/internal/cli/ssh"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -29,20 +32,28 @@ func Root() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
options.Add(root)
|
options.Add(root)
|
||||||
root.AddCommand(ssh.Command())
|
root.AddCommand(ssh.Command(), age.Command(), mnemonic.Command())
|
||||||
|
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main runs the tool and returns the status the process should exit
|
// Main runs the tool and returns the status the process should exit
|
||||||
// with.
|
// with. An error ends the tool with status 1, except when it carries a
|
||||||
|
// status of its own, which "ssh to" uses to hand on the status ssh
|
||||||
|
// ended with. ssh has already said whatever it had to say in that
|
||||||
|
// case, so nothing more is printed.
|
||||||
func Main() int {
|
func Main() int {
|
||||||
err := Root().Execute()
|
err := Root().Execute()
|
||||||
if err != nil {
|
if err == nil {
|
||||||
fmt.Fprintln(os.Stderr, "keyfunc: "+err.Error())
|
return 0
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
var passed ssh.StatusError
|
||||||
|
if errors.As(err, &passed) {
|
||||||
|
return passed.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(os.Stderr, "keyfunc: "+err.Error())
|
||||||
|
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/keyfunc/internal/childmnemonic"
|
||||||
"git.eeqj.de/sneak/keyfunc/internal/cli"
|
"git.eeqj.de/sneak/keyfunc/internal/cli"
|
||||||
"git.eeqj.de/sneak/keyfunc/internal/derive"
|
"git.eeqj.de/sneak/keyfunc/internal/derive"
|
||||||
"git.eeqj.de/sneak/keyfunc/internal/mnemonic"
|
"git.eeqj.de/sneak/keyfunc/internal/mnemonic"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
bip39 "github.com/tyler-smith/go-bip39"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,6 +22,12 @@ const (
|
|||||||
"0I4FKs+eVUulTPHfk9VtXw1tMF"
|
"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:
|
// example returns the mnemonic the README gives its test vectors for:
|
||||||
// eleven abandons and about.
|
// eleven abandons and about.
|
||||||
func example() string {
|
func example() string {
|
||||||
@@ -81,6 +89,28 @@ func TestTheMnemonicCommandIsUsed(t *testing.T) {
|
|||||||
require.Equal(t, vectorZero+" keyfunc/ssh/0", line)
|
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
|
// run executes the tool with the given arguments and returns what it
|
||||||
// wrote to standard output.
|
// wrote to standard output.
|
||||||
func run(t *testing.T, args ...string) string {
|
func run(t *testing.T, args ...string) string {
|
||||||
|
|||||||
65
internal/cli/mnemonic/mnemonic.go
Normal file
65
internal/cli/mnemonic/mnemonic.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
96
internal/cli/ssh/install.go
Normal file
96
internal/cli/ssh/install.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// script is what runs on the host. It reads the key line from its own
|
||||||
|
// standard input, so the line never appears on a command line, where
|
||||||
|
// anyone else on the host could read it out of the process list. It
|
||||||
|
// contains no single quote, so the whole of it travels through ssh
|
||||||
|
// inside one pair of them. The umask keeps anything it makes to the
|
||||||
|
// owner from the start; the modes are then set outright, whatever the
|
||||||
|
// umask on the host turns out to be. A file whose last line has no
|
||||||
|
// newline at its end gets one before the key line goes on, so that the
|
||||||
|
// two do not run into each other.
|
||||||
|
const script = `
|
||||||
|
set -e
|
||||||
|
umask 077
|
||||||
|
directory="$HOME/.ssh"
|
||||||
|
file="$directory/authorized_keys"
|
||||||
|
if [ ! -d "$directory" ]; then
|
||||||
|
mkdir -p "$directory"
|
||||||
|
chmod 700 "$directory"
|
||||||
|
fi
|
||||||
|
if [ ! -f "$file" ]; then
|
||||||
|
: > "$file"
|
||||||
|
chmod 600 "$file"
|
||||||
|
fi
|
||||||
|
IFS= read -r line
|
||||||
|
if grep -q -x -F -e "$line" "$file"; then
|
||||||
|
echo "already present"
|
||||||
|
else
|
||||||
|
if [ -s "$file" ] && [ -n "$(tail -c 1 "$file")" ]; then
|
||||||
|
printf "\n" >> "$file"
|
||||||
|
fi
|
||||||
|
printf "%s\n" "$line" >> "$file"
|
||||||
|
echo "added"
|
||||||
|
fi
|
||||||
|
`
|
||||||
|
|
||||||
|
// install returns the command that adds the public key to a host.
|
||||||
|
func install() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "install <[user@]host> [-- ssh options...]",
|
||||||
|
Short: "add the public key to a host's authorized_keys",
|
||||||
|
Long: "Runs the system ssh to the host, which makes ~/.ssh and " +
|
||||||
|
"~/.ssh/authorized_keys there if they are missing and adds " +
|
||||||
|
"the public key unless the same line is already in the " +
|
||||||
|
"file. Anything after -- is given to ssh unchanged.",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
key, comment, err := derived(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
line, err := key.Line(comment)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return send(cmd, args[0], args[1:], line)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
addComment(cmd)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// send runs ssh to the host with the user's options, gives it the
|
||||||
|
// script to run there, and writes the key line to its standard input.
|
||||||
|
// What the host says, added or already present, is passed straight on.
|
||||||
|
func send(cmd *cobra.Command, host string, options []string, line string) error {
|
||||||
|
argv := slices.Concat(options, []string{
|
||||||
|
host, "/bin/sh -c '" + script + "'",
|
||||||
|
})
|
||||||
|
|
||||||
|
//nolint:gosec // the options are the user's own, meant for ssh
|
||||||
|
command := exec.CommandContext(cmd.Context(), "ssh", argv...)
|
||||||
|
command.Stdin = strings.NewReader(line + "\n")
|
||||||
|
command.Stdout = cmd.OutOrStdout()
|
||||||
|
command.Stderr = cmd.ErrOrStderr()
|
||||||
|
|
||||||
|
err := command.Run()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("running ssh: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ func Command() *cobra.Command {
|
|||||||
Short: "derive ed25519 SSH keys",
|
Short: "derive ed25519 SSH keys",
|
||||||
}
|
}
|
||||||
|
|
||||||
group.AddCommand(public(), private())
|
group.AddCommand(public(), private(), install(), to())
|
||||||
|
|
||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
|
|||||||
95
internal/cli/ssh/to.go
Normal file
95
internal/cli/ssh/to.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatusError says the tool should end with the status ssh ended with.
|
||||||
|
// Only "ssh to" gives one back; every other error ends the tool with
|
||||||
|
// status 1.
|
||||||
|
type StatusError struct {
|
||||||
|
Status int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error says which status ssh ended with.
|
||||||
|
func (e StatusError) Error() string {
|
||||||
|
return fmt.Sprintf("ssh exited with status %d", e.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// to returns the command that runs ssh with the derived key held by an
|
||||||
|
// agent of the tool's own.
|
||||||
|
func to() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "to <host> [ssh arguments...]",
|
||||||
|
Short: "run ssh with the derived key served from its own agent",
|
||||||
|
Long: "Serves the derived key from an SSH agent that runs " +
|
||||||
|
"inside the tool and points the system ssh at it. The host " +
|
||||||
|
"and everything after it are given to ssh unchanged, the " +
|
||||||
|
"tool ends with the status ssh ended with, and the key is " +
|
||||||
|
"never written to disk.",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
key, comment, err := derived(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
served, err := key.Serve(cmd.Context(), comment)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer served.Stop()
|
||||||
|
|
||||||
|
argv := slices.Concat([]string{
|
||||||
|
"-o", "IdentityAgent=" + served.Socket(),
|
||||||
|
}, args)
|
||||||
|
|
||||||
|
return connect(cmd.Context(), argv)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything from the host onwards belongs to ssh, so flag
|
||||||
|
// reading stops at the first argument that is not a flag.
|
||||||
|
cmd.Flags().SetInterspersed(false)
|
||||||
|
|
||||||
|
addComment(cmd)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// connect runs ssh on the terminal the tool was given and turns the
|
||||||
|
// status it ended with into the status the tool ends with.
|
||||||
|
func connect(ctx context.Context, argv []string) error {
|
||||||
|
//nolint:gosec // the arguments are the user's own, meant for ssh
|
||||||
|
command := exec.CommandContext(ctx, "ssh", argv...)
|
||||||
|
command.Stdin = os.Stdin
|
||||||
|
command.Stdout = os.Stdout
|
||||||
|
command.Stderr = os.Stderr
|
||||||
|
|
||||||
|
err := command.Run()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var ended *exec.ExitError
|
||||||
|
if errors.As(err, &ended) {
|
||||||
|
status := ended.ExitCode()
|
||||||
|
if status < 0 {
|
||||||
|
// A signal ended ssh, and a signal has no status of its
|
||||||
|
// own to pass on.
|
||||||
|
status = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return StatusError{Status: status}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("running ssh: %w", err)
|
||||||
|
}
|
||||||
255
internal/cli/ssh_test.go
Normal file
255
internal/cli/ssh_test.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/keyfunc/internal/cli"
|
||||||
|
"git.eeqj.de/sneak/keyfunc/internal/cli/ssh"
|
||||||
|
"git.eeqj.de/sneak/keyfunc/internal/mnemonic"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The modes the host is supposed to end up with, and the mode the
|
||||||
|
// stand-in ssh needs so that it can be run at all.
|
||||||
|
const (
|
||||||
|
directoryMode = 0o700
|
||||||
|
fileMode = 0o600
|
||||||
|
standInMode = 0o755
|
||||||
|
)
|
||||||
|
|
||||||
|
// failingStatus is the status the stand-in ssh ends with when a test
|
||||||
|
// wants to see a status handed on.
|
||||||
|
const failingStatus = 7
|
||||||
|
|
||||||
|
// The host, and where on it the key ends up.
|
||||||
|
const (
|
||||||
|
host = "someone@example.com"
|
||||||
|
keptUnder = ".ssh"
|
||||||
|
keptIn = "authorized_keys"
|
||||||
|
)
|
||||||
|
|
||||||
|
// installer is a stand-in for the system ssh for the install command.
|
||||||
|
// It writes down what it was given and then runs the command meant for
|
||||||
|
// the host right here, with the home directory pointed at a directory
|
||||||
|
// standing in for the host's, so that what keyfunc sends can be
|
||||||
|
// watched doing its work.
|
||||||
|
const installer = `
|
||||||
|
while [ $# -gt 1 ]; do
|
||||||
|
printf '%s\n' "$1" >> "$KEYFUNC_TEST_ARGUMENTS"
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
printf '%s' "$1" > "$KEYFUNC_TEST_COMMAND"
|
||||||
|
HOME="$KEYFUNC_TEST_HOME"
|
||||||
|
export HOME
|
||||||
|
eval "$1"
|
||||||
|
`
|
||||||
|
|
||||||
|
// caller is a stand-in for the system ssh for the to command. It
|
||||||
|
// writes down the arguments it was given, notes the agent socket if
|
||||||
|
// there really is one at the path it was handed, and ends with the
|
||||||
|
// status the test asked for.
|
||||||
|
const caller = `
|
||||||
|
for argument in "$@"; do
|
||||||
|
printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS"
|
||||||
|
done
|
||||||
|
socket=${2#IdentityAgent=}
|
||||||
|
if [ -S "$socket" ]; then
|
||||||
|
printf '%s\n' "$socket" > "$KEYFUNC_TEST_SOCKET"
|
||||||
|
fi
|
||||||
|
exit "$KEYFUNC_TEST_STATUS"
|
||||||
|
`
|
||||||
|
|
||||||
|
// pretended is where a stand-in ssh writes down what it was asked to
|
||||||
|
// do.
|
||||||
|
type pretended struct {
|
||||||
|
// home stands in for the home directory on the host.
|
||||||
|
home string
|
||||||
|
// arguments holds what ssh was given before the command, one per
|
||||||
|
// line.
|
||||||
|
arguments string
|
||||||
|
// command holds what ssh was told to run on the host.
|
||||||
|
command string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheKeyIsAddedToTheHostAndThenLeftAlone(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
pretend := pretendHost(t)
|
||||||
|
|
||||||
|
require.Equal(t, "added\n", run(t, "ssh", "install", host))
|
||||||
|
|
||||||
|
directory, err := os.Stat(filepath.Join(pretend.home, keptUnder))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t,
|
||||||
|
os.FileMode(directoryMode), directory.Mode().Perm(),
|
||||||
|
)
|
||||||
|
|
||||||
|
path := filepath.Join(pretend.home, keptUnder, keptIn)
|
||||||
|
|
||||||
|
file, err := os.Stat(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, os.FileMode(fileMode), file.Mode().Perm())
|
||||||
|
|
||||||
|
added := read(t, path)
|
||||||
|
require.Equal(t, vectorZero+" keyfunc/ssh/0\n", added)
|
||||||
|
|
||||||
|
require.Equal(t, "already present\n", run(t, "ssh", "install", host))
|
||||||
|
require.Equal(t, added, read(t, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheKeyDoesNotRunIntoALineWithNoNewlineAtItsEnd(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
pretend := pretendHost(t)
|
||||||
|
already := "ssh-ed25519 AAAAsomebodyelse somebody@else"
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
os.Mkdir(filepath.Join(pretend.home, keptUnder), directoryMode),
|
||||||
|
)
|
||||||
|
|
||||||
|
path := filepath.Join(pretend.home, keptUnder, keptIn)
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(already), fileMode))
|
||||||
|
|
||||||
|
require.Equal(t, "added\n", run(t, "ssh", "install", host))
|
||||||
|
require.Equal(t,
|
||||||
|
already+"\n"+vectorZero+" keyfunc/ssh/0\n",
|
||||||
|
read(t, path),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheKeyLineIsNotOnTheCommandLine(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
pretend := pretendHost(t)
|
||||||
|
|
||||||
|
run(t, "ssh", "install", host)
|
||||||
|
|
||||||
|
require.NotContains(t, read(t, pretend.arguments), "ssh-ed25519")
|
||||||
|
require.NotContains(t, read(t, pretend.command), "ssh-ed25519")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhatComesAfterTheDashesIsGivenToSSH(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
pretend := pretendHost(t)
|
||||||
|
|
||||||
|
run(t, "ssh", "install", host, "--", "-p", "2222")
|
||||||
|
|
||||||
|
require.Equal(t,
|
||||||
|
[]string{"-p", "2222", host},
|
||||||
|
recorded(t, pretend.arguments),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
arguments, noted := pretendCall(t)
|
||||||
|
|
||||||
|
_, err := execute(t, "ssh", "to", host, "uptime")
|
||||||
|
|
||||||
|
var passed ssh.StatusError
|
||||||
|
|
||||||
|
require.ErrorAs(t, err, &passed)
|
||||||
|
require.Equal(t, failingStatus, passed.Status)
|
||||||
|
|
||||||
|
given := recorded(t, arguments)
|
||||||
|
require.Equal(t, "-o", given[0])
|
||||||
|
require.Equal(t, []string{host, "uptime"}, given[2:])
|
||||||
|
|
||||||
|
// The stand-in wrote the path down only because there really was
|
||||||
|
// a socket there while it ran.
|
||||||
|
socket := strings.TrimSpace(read(t, noted))
|
||||||
|
require.Equal(t, "IdentityAgent="+socket, given[1])
|
||||||
|
require.NoDirExists(t, filepath.Dir(socket))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
pretendCall(t)
|
||||||
|
|
||||||
|
given := os.Args
|
||||||
|
|
||||||
|
t.Cleanup(func() { os.Args = given })
|
||||||
|
|
||||||
|
os.Args = []string{"keyfunc", "ssh", "to", host, "uptime"}
|
||||||
|
|
||||||
|
require.Equal(t, failingStatus, cli.Main())
|
||||||
|
}
|
||||||
|
|
||||||
|
// pretendHost puts the install stand-in on the path and gives back the
|
||||||
|
// places it writes to.
|
||||||
|
func pretendHost(t *testing.T) pretended {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
pretend := pretended{
|
||||||
|
home: t.TempDir(),
|
||||||
|
arguments: filepath.Join(t.TempDir(), "arguments"),
|
||||||
|
command: filepath.Join(t.TempDir(), "command"),
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("KEYFUNC_TEST_HOME", pretend.home)
|
||||||
|
t.Setenv("KEYFUNC_TEST_ARGUMENTS", pretend.arguments)
|
||||||
|
t.Setenv("KEYFUNC_TEST_COMMAND", pretend.command)
|
||||||
|
standIn(t, installer)
|
||||||
|
|
||||||
|
return pretend
|
||||||
|
}
|
||||||
|
|
||||||
|
// pretendCall puts the to stand-in on the path and gives back the file
|
||||||
|
// the arguments are written down in and the file the agent socket is
|
||||||
|
// noted in.
|
||||||
|
func pretendCall(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
arguments := filepath.Join(t.TempDir(), "arguments")
|
||||||
|
noted := filepath.Join(t.TempDir(), "socket")
|
||||||
|
|
||||||
|
t.Setenv("KEYFUNC_TEST_ARGUMENTS", arguments)
|
||||||
|
t.Setenv("KEYFUNC_TEST_SOCKET", noted)
|
||||||
|
t.Setenv("KEYFUNC_TEST_STATUS", strconv.Itoa(failingStatus))
|
||||||
|
standIn(t, caller)
|
||||||
|
|
||||||
|
return arguments, noted
|
||||||
|
}
|
||||||
|
|
||||||
|
// standIn writes a stand-in for the system ssh and puts it first on
|
||||||
|
// the path, so that the tool finds it instead of the real one.
|
||||||
|
func standIn(t *testing.T, body string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
|
||||||
|
err := os.WriteFile(
|
||||||
|
filepath.Join(directory, "ssh"),
|
||||||
|
[]byte("#!/bin/sh\n"+body), standInMode,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Setenv("PATH",
|
||||||
|
directory+string(os.PathListSeparator)+os.Getenv("PATH"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// read returns what is in a file.
|
||||||
|
func read(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
//nolint:gosec // the path is a temporary file of the test's own
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return string(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recorded returns the arguments a stand-in wrote down, one per line.
|
||||||
|
func recorded(t *testing.T, path string) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return strings.Split(strings.TrimSuffix(read(t, path), "\n"), "\n")
|
||||||
|
}
|
||||||
@@ -35,24 +35,47 @@ func Path(application, index uint32) string {
|
|||||||
return fmt.Sprintf("m/%d'/%d'/%d'", purpose, application, index)
|
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.
|
// Bytes returns the bytes for an application number and a key index.
|
||||||
// The mnemonic becomes a seed with an empty passphrase, the seed
|
// The mnemonic becomes a seed with an empty passphrase, the seed
|
||||||
// becomes a master key, the master key gives BIP-85 entropy at the
|
// 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.
|
// path, and the entropy seeds the generator the bytes are read from.
|
||||||
// An index above MaxIndex is refused before any of that happens.
|
// An index above MaxIndex is refused before any of that happens.
|
||||||
func Bytes(words string, application, index uint32) ([]byte, error) {
|
func Bytes(words string, application, index uint32) ([]byte, error) {
|
||||||
if index > MaxIndex {
|
err := CheckIndex(index)
|
||||||
return nil, fmt.Errorf(
|
if err != nil {
|
||||||
"%w: %d is above %d",
|
return nil, err
|
||||||
ErrIndexTooLarge, index, MaxIndex,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
seed := bip39.NewSeed(words, "")
|
master, err := Master(words)
|
||||||
|
|
||||||
master, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("making the master key: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
entropy, err := bip85.DeriveBIP85Entropy(master, Path(application, index))
|
entropy, err := bip85.DeriveBIP85Entropy(master, Path(application, index))
|
||||||
|
|||||||
86
internal/sshkey/agent.go
Normal file
86
internal/sshkey/agent.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package sshkey
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Agent is an SSH agent that holds one key and serves it on a unix
|
||||||
|
// socket. The socket sits in a directory of its own that only its
|
||||||
|
// owner may enter, and the key stays in memory: nothing is written to
|
||||||
|
// disk.
|
||||||
|
type Agent struct {
|
||||||
|
socket string
|
||||||
|
listener net.Listener
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve starts an agent holding this key under the given comment.
|
||||||
|
// Stop takes it down again.
|
||||||
|
func (k *Key) Serve(ctx context.Context, comment string) (*Agent, error) {
|
||||||
|
keyring := agent.NewKeyring()
|
||||||
|
|
||||||
|
err := keyring.Add(agent.AddedKey{
|
||||||
|
PrivateKey: k.private,
|
||||||
|
Comment: comment,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("giving the key to the agent: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A temporary directory is made enterable by its owner alone,
|
||||||
|
// which is the protection the socket inside it has.
|
||||||
|
directory, err := os.MkdirTemp("", "keyfunc-agent-")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("making the agent directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
socket := filepath.Join(directory, "socket")
|
||||||
|
|
||||||
|
var listen net.ListenConfig
|
||||||
|
|
||||||
|
listener, err := listen.Listen(ctx, "unix", socket)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.RemoveAll(directory)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("listening on the agent socket: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
served := &Agent{socket: socket, listener: listener}
|
||||||
|
|
||||||
|
go served.accept(keyring)
|
||||||
|
|
||||||
|
return served, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Socket is the path to point ssh at.
|
||||||
|
func (a *Agent) Socket() string {
|
||||||
|
return a.socket
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop takes the agent down and removes the socket and the directory
|
||||||
|
// it is in.
|
||||||
|
func (a *Agent) Stop() {
|
||||||
|
_ = a.listener.Close()
|
||||||
|
_ = os.RemoveAll(filepath.Dir(a.socket))
|
||||||
|
}
|
||||||
|
|
||||||
|
// accept answers connections until Stop closes the listener.
|
||||||
|
func (a *Agent) accept(keyring agent.Agent) {
|
||||||
|
for {
|
||||||
|
connection, err := a.listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() { _ = connection.Close() }()
|
||||||
|
|
||||||
|
_ = agent.ServeAgent(keyring, connection)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package sshkey_test
|
package sshkey_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -8,8 +11,16 @@ import (
|
|||||||
"git.eeqj.de/sneak/keyfunc/internal/sshkey"
|
"git.eeqj.de/sneak/keyfunc/internal/sshkey"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/crypto/ssh/agent"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// agentDirectoryMode is what the directory holding the agent socket
|
||||||
|
// has to be: nobody but its owner may enter it.
|
||||||
|
const agentDirectoryMode = 0o700
|
||||||
|
|
||||||
|
// exampleIndex is the key index every test here derives at.
|
||||||
|
const exampleIndex = 0
|
||||||
|
|
||||||
// example returns the mnemonic every BIP-39 document uses to show its
|
// example returns the mnemonic every BIP-39 document uses to show its
|
||||||
// test vectors: eleven abandons and about.
|
// test vectors: eleven abandons and about.
|
||||||
func example() string {
|
func example() string {
|
||||||
@@ -26,7 +37,7 @@ func TestTooFewBytesAreRefused(t *testing.T) {
|
|||||||
func TestTheCommentIsPutAtTheEndOfTheLine(t *testing.T) {
|
func TestTheCommentIsPutAtTheEndOfTheLine(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
key := forIndex(t, 0)
|
key := exampleKey(t)
|
||||||
|
|
||||||
line, err := key.Line("hello")
|
line, err := key.Line("hello")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -37,7 +48,7 @@ func TestTheCommentIsPutAtTheEndOfTheLine(t *testing.T) {
|
|||||||
func TestThePrivateKeyCarriesTheSamePublicKey(t *testing.T) {
|
func TestThePrivateKeyCarriesTheSamePublicKey(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
key := forIndex(t, 0)
|
key := exampleKey(t)
|
||||||
|
|
||||||
line, err := key.Line("")
|
line, err := key.Line("")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -54,11 +65,60 @@ func TestThePrivateKeyCarriesTheSamePublicKey(t *testing.T) {
|
|||||||
require.Equal(t, line, back)
|
require.Equal(t, line, back)
|
||||||
}
|
}
|
||||||
|
|
||||||
// forIndex derives the key for one index.
|
func TestTheAgentServesTheOneKeyAndNothingElse(t *testing.T) {
|
||||||
func forIndex(t *testing.T, index uint32) *sshkey.Key {
|
t.Parallel()
|
||||||
|
|
||||||
|
key := exampleKey(t)
|
||||||
|
|
||||||
|
served, err := key.Serve(t.Context(), "a comment")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(served.Stop)
|
||||||
|
|
||||||
|
directory, err := os.Stat(filepath.Dir(served.Socket()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t,
|
||||||
|
os.FileMode(agentDirectoryMode), directory.Mode().Perm(),
|
||||||
|
)
|
||||||
|
|
||||||
|
var dialer net.Dialer
|
||||||
|
|
||||||
|
connection, err := dialer.DialContext(t.Context(), "unix", served.Socket())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { _ = connection.Close() }()
|
||||||
|
|
||||||
|
held, err := agent.NewClient(connection).List()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, held, 1)
|
||||||
|
|
||||||
|
line, err := key.Line("a comment")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, line, held[0].String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoppingTheAgentLeavesNothingBehind(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
served, err := exampleKey(t).Serve(t.Context(), "a comment")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
directory := filepath.Dir(served.Socket())
|
||||||
|
require.DirExists(t, directory)
|
||||||
|
|
||||||
|
served.Stop()
|
||||||
|
require.NoDirExists(t, directory)
|
||||||
|
|
||||||
|
var dialer net.Dialer
|
||||||
|
|
||||||
|
_, err = dialer.DialContext(t.Context(), "unix", served.Socket())
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exampleKey derives the key the example mnemonic gives.
|
||||||
|
func exampleKey(t *testing.T) *sshkey.Key {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
material, err := derive.Bytes(example(), sshkey.Application, index)
|
material, err := derive.Bytes(example(), sshkey.Application, exampleIndex)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
key, err := sshkey.New(material)
|
key, err := sshkey.New(material)
|
||||||
|
|||||||
Reference in New Issue
Block a user