diff --git a/internal/childmnemonic/childmnemonic.go b/internal/childmnemonic/childmnemonic.go new file mode 100644 index 0000000..f795f1a --- /dev/null +++ b/internal/childmnemonic/childmnemonic.go @@ -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 +} diff --git a/internal/childmnemonic/childmnemonic_test.go b/internal/childmnemonic/childmnemonic_test.go new file mode 100644 index 0000000..ccd8990 --- /dev/null +++ b/internal/childmnemonic/childmnemonic_test.go @@ -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 +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index b901700..7e95c4b 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -5,6 +5,7 @@ import ( "fmt" "os" + "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" @@ -29,7 +30,7 @@ func Root() *cobra.Command { } options.Add(root) - root.AddCommand(ssh.Command()) + root.AddCommand(ssh.Command(), mnemonic.Command()) return root } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 75b3df8..fa11a40 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -5,10 +5,12 @@ 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" ) @@ -20,6 +22,12 @@ 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 { @@ -81,6 +89,28 @@ 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 { diff --git a/internal/cli/mnemonic/mnemonic.go b/internal/cli/mnemonic/mnemonic.go new file mode 100644 index 0000000..e157e18 --- /dev/null +++ b/internal/cli/mnemonic/mnemonic.go @@ -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) +} diff --git a/internal/derive/derive.go b/internal/derive/derive.go index fe9176c..bd95906 100644 --- a/internal/derive/derive.go +++ b/internal/derive/derive.go @@ -35,24 +35,47 @@ 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) { - if index > MaxIndex { - return nil, fmt.Errorf( - "%w: %d is above %d", - ErrIndexTooLarge, index, MaxIndex, - ) + err := CheckIndex(index) + if err != nil { + return nil, err } - seed := bip39.NewSeed(words, "") - - master, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams) + master, err := Master(words) if err != nil { - return nil, fmt.Errorf("making the master key: %w", err) + return nil, err } entropy, err := bip85.DeriveBIP85Entropy(master, Path(application, index))