Rename module to sneak.berlin/go/x and rework bip39wordlist

Move to Go 1.26, embed the wordlist from english.txt, add
ValidateWordlist, NormalizeWordlist, and GenerateMnemonic with tests,
and remove the old root-level CLI that used the previous module path.
This commit is contained in:
2026-10-31 10:03:06 -07:00
parent 4d27e30ea8
commit 02386a6ff1
15 changed files with 2435 additions and 2530 deletions
File diff suppressed because it is too large Load Diff
-17
View File
@@ -1,17 +0,0 @@
package bip39wordlist_test
import (
"testing"
"github.com/stretchr/testify/assert"
"sneak.berlin/x/pkg/bip39wordlist"
)
func TestLength(t *testing.T) {
assert := assert.New(t)
wl := bip39wordlist.Wordlist
assert.Equal(2048, len(wl), "should have correct number of words")
assert.Equal(wl[0], "abandon", "first word should be correct")
assert.Equal(wl[len(wl)-1], "zoo", "last word should be correct")
}
File diff suppressed because it is too large Load Diff
+118
View File
@@ -0,0 +1,118 @@
package bip39wordlist
import (
"errors"
"fmt"
"strings"
"github.com/tyler-smith/go-bip39"
)
const (
// MinWordCount is the number of words in the shortest valid mnemonic,
// which encodes 128 bits of entropy.
MinWordCount = 12
// MaxWordCount is the number of words in the longest valid mnemonic,
// which encodes 256 bits of entropy.
MaxWordCount = 24
// WordCountStep is the granularity of valid word counts. Every valid
// mnemonic has a multiple of three words, because each group of three
// words carries 32 bits of entropy plus one checksum bit.
WordCountStep = 3
// entropyBitsPerWordGroup is the entropy carried by each group of
// WordCountStep words.
entropyBitsPerWordGroup = 32
)
var (
// ErrInvalidWordCount is returned when a mnemonic does not have 12,
// 15, 18, 21, or 24 words.
ErrInvalidWordCount = errors.New(
"bip39wordlist: mnemonic must have 12, 15, 18, 21, or 24 words",
)
// ErrUnknownWord is returned when a mnemonic contains a word that is
// not in the wordlist. The returned error names the word and its
// one-based position.
ErrUnknownWord = errors.New("bip39wordlist: word is not in the wordlist")
// ErrInvalidChecksum is returned when every word is valid but the
// checksum bits encoded in the final word do not match the entropy.
ErrInvalidChecksum = errors.New("bip39wordlist: mnemonic checksum mismatch")
)
// ValidWordCount reports whether n is a valid number of words for a
// mnemonic: a multiple of three from 12 to 24 inclusive.
func ValidWordCount(n int) bool {
return n >= MinWordCount && n <= MaxWordCount && n%WordCountStep == 0
}
// ValidateWordlist checks that mnemonic is a valid BIP39 English mnemonic:
// a valid number of words, every word in the wordlist, and a correct
// checksum. Letter case and the amount of whitespace between words are
// ignored, so any phrase ValidateWordlist accepts can be canonicalized with
// NormalizeWordlist. It returns nil for a valid mnemonic and otherwise an
// error wrapping ErrInvalidWordCount, ErrUnknownWord, or
// ErrInvalidChecksum.
func ValidateWordlist(mnemonic string) error {
words := splitWords(mnemonic)
if !ValidWordCount(len(words)) {
return fmt.Errorf("%w: got %d", ErrInvalidWordCount, len(words))
}
for i, word := range words {
if !Contains(word) {
return fmt.Errorf("%w: %q at position %d", ErrUnknownWord, word, i+1)
}
}
if !bip39.IsMnemonicValid(strings.Join(words, " ")) {
return ErrInvalidChecksum
}
return nil
}
// NormalizeWordlist returns mnemonic in canonical form: lowercase, with
// the words separated by exactly one space and no leading or trailing
// whitespace. It refuses invalid input: if mnemonic fails
// ValidateWordlist, that error is returned and the string is empty.
func NormalizeWordlist(mnemonic string) (string, error) {
err := ValidateWordlist(mnemonic)
if err != nil {
return "", err
}
return strings.Join(splitWords(mnemonic), " "), nil
}
// GenerateMnemonic returns a new random mnemonic of wordCount words, using
// entropy from crypto/rand. wordCount must satisfy ValidWordCount.
func GenerateMnemonic(wordCount int) (string, error) {
if !ValidWordCount(wordCount) {
return "", fmt.Errorf("%w: got %d", ErrInvalidWordCount, wordCount)
}
entropyBits := wordCount / WordCountStep * entropyBitsPerWordGroup
entropy, err := bip39.NewEntropy(entropyBits)
if err != nil {
return "", fmt.Errorf("bip39wordlist: generating entropy: %w", err)
}
mnemonic, err := bip39.NewMnemonic(entropy)
if err != nil {
return "", fmt.Errorf("bip39wordlist: encoding mnemonic: %w", err)
}
return mnemonic, nil
}
// splitWords lowercases mnemonic and splits it on any run of whitespace.
func splitWords(mnemonic string) []string {
return strings.Fields(strings.ToLower(mnemonic))
}
+143
View File
@@ -0,0 +1,143 @@
package bip39wordlist_test
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/x/pkg/bip39wordlist"
)
// vectorRepeats is how many times "abandon" appears in the standard BIP39
// test vector for all-zero 128-bit entropy, which ends with "about".
const vectorRepeats = bip39wordlist.MinWordCount - 1
// vectorMnemonic returns the standard BIP39 test vector for all-zero
// 128-bit entropy.
func vectorMnemonic() string {
return strings.Repeat("abandon ", vectorRepeats) + "about"
}
// messyVectorMnemonic returns vectorMnemonic with mixed case and irregular
// whitespace; it must normalize to vectorMnemonic.
func messyVectorMnemonic() string {
return " Abandon ABANDON\t" + strings.Repeat("abandon ", 4) + "\n" +
strings.Repeat("abandon ", 5) + "About \n"
}
func TestValidWordCount(t *testing.T) {
t.Parallel()
for _, n := range []int{12, 15, 18, 21, 24} {
assert.True(t, bip39wordlist.ValidWordCount(n), "%d words", n)
}
for _, n := range []int{0, 1, 9, 11, 13, 14, 16, 23, 25, 27, 36} {
assert.False(t, bip39wordlist.ValidWordCount(n), "%d words", n)
}
}
func TestValidateWordlistAcceptsValidMnemonics(t *testing.T) {
t.Parallel()
require.NoError(t, bip39wordlist.ValidateWordlist(vectorMnemonic()))
require.NoError(t, bip39wordlist.ValidateWordlist(messyVectorMnemonic()))
}
func TestValidateWordlistRejectsWrongWordCount(t *testing.T) {
t.Parallel()
err := bip39wordlist.ValidateWordlist("")
require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount)
err = bip39wordlist.ValidateWordlist(vectorMnemonic() + " abandon")
require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount)
assert.Contains(t, err.Error(), "got 13")
}
func TestValidateWordlistRejectsUnknownWord(t *testing.T) {
t.Parallel()
words := strings.Fields(vectorMnemonic())
words[4] = "bogus"
err := bip39wordlist.ValidateWordlist(strings.Join(words, " "))
require.ErrorIs(t, err, bip39wordlist.ErrUnknownWord)
assert.Contains(t, err.Error(), `"bogus" at position 5`)
}
func TestValidateWordlistRejectsBadChecksum(t *testing.T) {
t.Parallel()
// Twelve times "abandon" has valid words and count but the checksum
// bits in the last word are wrong.
allAbandon := strings.Repeat("abandon ", bip39wordlist.MinWordCount)
err := bip39wordlist.ValidateWordlist(allAbandon)
require.ErrorIs(t, err, bip39wordlist.ErrInvalidChecksum)
}
func TestNormalizeWordlistCanonicalizes(t *testing.T) {
t.Parallel()
normalized, err := bip39wordlist.NormalizeWordlist(messyVectorMnemonic())
require.NoError(t, err)
assert.Equal(t, vectorMnemonic(), normalized)
normalized, err = bip39wordlist.NormalizeWordlist(vectorMnemonic())
require.NoError(t, err)
assert.Equal(t, vectorMnemonic(), normalized)
}
func TestNormalizeWordlistRefusesInvalid(t *testing.T) {
t.Parallel()
normalized, err := bip39wordlist.NormalizeWordlist("Abandon Bogus about")
require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount)
assert.Empty(t, normalized)
normalized, err = bip39wordlist.NormalizeWordlist(
strings.Repeat("abandon ", bip39wordlist.MinWordCount),
)
require.ErrorIs(t, err, bip39wordlist.ErrInvalidChecksum)
assert.Empty(t, normalized)
}
func TestGenerateMnemonicProducesValidMnemonics(t *testing.T) {
t.Parallel()
for _, wordCount := range []int{12, 15, 18, 21, 24} {
mnemonic, err := bip39wordlist.GenerateMnemonic(wordCount)
require.NoError(t, err)
require.NoError(t, bip39wordlist.ValidateWordlist(mnemonic))
assert.Len(t, strings.Fields(mnemonic), wordCount)
normalized, err := bip39wordlist.NormalizeWordlist(mnemonic)
require.NoError(t, err)
assert.Equal(t, mnemonic, normalized, "generated mnemonic is canonical")
}
}
func TestGenerateMnemonicIsRandom(t *testing.T) {
t.Parallel()
first, err := bip39wordlist.GenerateMnemonic(bip39wordlist.MaxWordCount)
require.NoError(t, err)
second, err := bip39wordlist.GenerateMnemonic(bip39wordlist.MaxWordCount)
require.NoError(t, err)
assert.NotEqual(t, first, second)
}
func TestGenerateMnemonicRejectsInvalidWordCount(t *testing.T) {
t.Parallel()
for _, wordCount := range []int{0, 11, 13, 25} {
mnemonic, err := bip39wordlist.GenerateMnemonic(wordCount)
require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount)
assert.Empty(t, mnemonic)
}
}
+42
View File
@@ -0,0 +1,42 @@
// Package bip39wordlist provides the BIP39 English wordlist and helpers for
// validating, normalizing, and generating BIP39 mnemonic phrases.
//
// The wordlist is embedded in the binary from english.txt and parsed once
// at package initialization. The mnemonic helpers accept phrases in any
// letter case and with any whitespace between words; NormalizeWordlist
// returns the canonical lowercase, single-space-separated form that BIP39
// seed derivation requires, and refuses anything that is not a valid
// mnemonic.
package bip39wordlist
import (
_ "embed"
"slices"
"strings"
)
// WordlistSize is the number of words in a BIP39 wordlist.
const WordlistSize = 2048
//go:embed english.txt
var englishWordlistData string
// WordlistEnglish is the BIP39 English wordlist, one entry per word, in
// the canonical (sorted) BIP39 order. Callers must not modify it.
//
//nolint:gochecknoglobals // static lookup table parsed once from embedded data
var WordlistEnglish = strings.Fields(englishWordlistData)
// Wordlist is the default wordlist, which is WordlistEnglish. Callers must
// not modify it.
//
//nolint:gochecknoglobals // static lookup table, alias of WordlistEnglish
var Wordlist = WordlistEnglish
// Contains reports whether word is in the English wordlist. The comparison
// is exact, so word must already be lowercase.
func Contains(word string) bool {
_, found := slices.BinarySearch(WordlistEnglish, word)
return found
}
+45
View File
@@ -0,0 +1,45 @@
package bip39wordlist_test
import (
"slices"
"testing"
"github.com/stretchr/testify/assert"
"github.com/tyler-smith/go-bip39/wordlists"
"sneak.berlin/go/x/pkg/bip39wordlist"
)
func TestWordlistEnglishShape(t *testing.T) {
t.Parallel()
wordlist := bip39wordlist.WordlistEnglish
assert.Len(t, wordlist, bip39wordlist.WordlistSize)
assert.Equal(t, "abandon", wordlist[0])
assert.Equal(t, "zoo", wordlist[len(wordlist)-1])
assert.True(t, slices.IsSorted(wordlist), "wordlist must be sorted")
}
func TestWordlistEnglishMatchesReferenceImplementation(t *testing.T) {
t.Parallel()
assert.Equal(t, wordlists.English, bip39wordlist.WordlistEnglish)
}
func TestWordlistIsEnglish(t *testing.T) {
t.Parallel()
assert.Equal(t, bip39wordlist.WordlistEnglish, bip39wordlist.Wordlist)
}
func TestContains(t *testing.T) {
t.Parallel()
assert.True(t, bip39wordlist.Contains("abandon"))
assert.True(t, bip39wordlist.Contains("zoo"))
assert.True(t, bip39wordlist.Contains("legal"))
assert.False(t, bip39wordlist.Contains("Abandon"), "lookup is case sensitive")
assert.False(t, bip39wordlist.Contains("notaword"))
assert.False(t, bip39wordlist.Contains(""))
}