The mnemonic command: child mnemonics (closes #4)
All checks were successful
check / check (push) Successful in 19s

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
This commit is contained in:
clawbot
2026-09-07 15:43:10 +00:00
parent 279cba6bcf
commit cc68529c5f
6 changed files with 309 additions and 10 deletions

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

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

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"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,7 +30,7 @@ func Root() *cobra.Command {
} }
options.Add(root) options.Add(root)
root.AddCommand(ssh.Command()) root.AddCommand(ssh.Command(), mnemonic.Command())
return root return root
} }

View File

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

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

View File

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