From cc68529c5fd738e839b0cdfa14ced25606d14cef Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 7 Sep 2026 15:43:10 +0000 Subject: [PATCH] The mnemonic command: child mnemonics (closes #4) keyfunc mnemonic prints a child mnemonic derived from the main one with BIP-85's own mnemonic application, in English, at 12, 18 or 24 words. Its entropy is the BIP-85 entropy cut to the length the word count needs, which is what that application asks for, so it does not go through the generator the other key types read their bytes from. The BIP-85 specification's own test vectors for the application are the tests. Two pieces the derivation package already had inside one function are now named: the master key a mnemonic stands for, and the refusal of a key index that has no hardened child. Both key types call them. Model: opus-5 --- internal/childmnemonic/childmnemonic.go | 63 ++++++++++ internal/childmnemonic/childmnemonic_test.go | 117 +++++++++++++++++++ internal/cli/cli.go | 3 +- internal/cli/cli_test.go | 30 +++++ internal/cli/mnemonic/mnemonic.go | 65 +++++++++++ internal/derive/derive.go | 41 +++++-- 6 files changed, 309 insertions(+), 10 deletions(-) create mode 100644 internal/childmnemonic/childmnemonic.go create mode 100644 internal/childmnemonic/childmnemonic_test.go create mode 100644 internal/cli/mnemonic/mnemonic.go 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))