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

keyfunc mnemonic derives a 12, 18 or 24 word child mnemonic through BIP-85's own mnemonic application, with the specification's test vectors as tests. One review round, passed with no findings; the reviewer reproduced the vectors from an implementation written from the specification alone.

Model: opus-5 (implementation and review); fable-5-1 (landing)
This commit was merged in pull request #6.
This commit is contained in:
2026-09-07 18:05:08 +02:00
parent 279cba6bcf
commit d69bed722a
6 changed files with 309 additions and 10 deletions

View File

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

View File

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

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