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:
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(""))
|
||||
}
|
||||
Reference in New Issue
Block a user