package mnemonic import ( "crypto/sha256" "encoding/hex" "encoding/json" "os" "strings" "testing" ) // TestWordListIsTheBIP39List: the vendored list is checked by checksum and by the // properties BIP39 promises, because one changed word silently changes every seed this // package will ever produce. func TestWordListIsTheBIP39List(t *testing.T) { text, err := os.ReadFile("english.txt") if err != nil { t.Fatal(err) } const want = "2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda" sum := sha256.Sum256(text) if got := hex.EncodeToString(sum[:]); got != want { t.Fatalf("english.txt is %s, want %s", got, want) } if len(words) != 2048 { t.Fatalf("the list holds %d words, want 2048", len(words)) } for i, word := range words { if word == "" || strings.ContainsAny(word, " \t\n") { t.Fatalf("word %d is %q", i, word) } if len(word) < 3 || len(word) > 8 { t.Fatalf("word %d %q is %d letters", i, word, len(word)) } for _, r := range word { if r < 'a' || r > 'z' { t.Fatalf("word %d %q is not lowercase ascii", i, word) } } if i > 0 && word <= words[i-1] { t.Fatalf("the list is not in ascending order at %d: %q then %q", i, words[i-1], word) } } if len(index) != 2048 { t.Fatalf("the index holds %d words", len(index)) } } // vector is one entry of the official BIP39 English test vectors. // // They are vendored from the BIP39 reference implementation // (trezor/python-mnemonic vectors.json) so the test compares against the standard rather // than against this package's own output. type vector struct { Entropy string `json:"entropy"` Mnemonic string `json:"mnemonic"` Seed string `json:"seed"` Xprv string `json:"xprv"` } // loadVectors reads the vendored vectors. func loadVectors(t *testing.T) []vector { t.Helper() raw, err := os.ReadFile("vectors.json") if err != nil { t.Fatal(err) } var vectors []vector if err := json.Unmarshal(raw, &vectors); err != nil { t.Fatal(err) } if len(vectors) < 20 { t.Fatalf("only %d vectors", len(vectors)) } return vectors } // TestOfficialVectors: entropy to words, words to seed, both directions, for all 24 // official English vectors. func TestOfficialVectors(t *testing.T) { for _, v := range loadVectors(t) { entropy, err := hex.DecodeString(v.Entropy) if err != nil { t.Fatal(err) } phrase, err := FromEntropy(entropy) if err != nil { t.Fatalf("%s: %v", v.Entropy, err) } if phrase != v.Mnemonic { t.Fatalf("entropy %s gave\n %s\nwant\n %s", v.Entropy, phrase, v.Mnemonic) } back, err := Entropy(v.Mnemonic) if err != nil { t.Fatalf("%s: %v", v.Mnemonic, err) } if hex.EncodeToString(back) != v.Entropy { t.Fatalf("%s gave entropy %s", v.Mnemonic, hex.EncodeToString(back)) } // The vectors' seeds use the passphrase "TREZOR". seed, err := Seed(v.Mnemonic, "TREZOR") if err != nil { t.Fatalf("%s: %v", v.Mnemonic, err) } if hex.EncodeToString(seed[:]) != v.Seed { t.Fatalf("%s gave seed\n %s\nwant\n %s", v.Mnemonic, hex.EncodeToString(seed[:]), v.Seed) } } } // TestNewIsAValidSeed: a generated phrase validates, round-trips through its entropy and // is the number of words asked for. func TestNewIsAValidSeed(t *testing.T) { for _, count := range Lengths { phrase, err := New(count) if err != nil { t.Fatalf("%d words: %v", count, err) } if got := len(Split(phrase)); got != count { t.Fatalf("asked for %d words, got %d", count, got) } if err := Validate(phrase); err != nil { t.Fatalf("%d words: %v (%s)", count, err, phrase) } entropy, err := Entropy(phrase) if err != nil { t.Fatal(err) } again, err := FromEntropy(entropy) if err != nil { t.Fatal(err) } if again != phrase { t.Fatalf("%d words: %q then %q", count, phrase, again) } } // A phrase is not the same twice, which is what makes it a seed rather than a // constant. Two collisions in a row would mean the entropy source is not one. first, _ := New(12) second, _ := New(12) if first == second { t.Fatal("two fresh phrases are identical") } } // TestValidateRefusesATypo: one wrong word is caught, in each position that can be wrong. func TestValidateRefusesATypo(t *testing.T) { const phrase = "abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon about" if err := Validate(phrase); err != nil { t.Fatalf("the reference phrase is invalid: %v", err) } cases := map[string]string{ "an unknown word": strings.Replace(phrase, "about", "abandon", 1)[:len(phrase)-len("about")] + "abbey", "a swapped word": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about abandon", "a missing word": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", "an extra word": phrase + " abandon", "empty": "", "one word": "abandon", } for name, bad := range cases { if err := Validate(bad); err == nil { t.Errorf("%s was accepted as a seed: %q", name, bad) } } // A real word in the wrong place is the interesting case: the checksum catches it. wrongChecksum := strings.Replace(phrase, "about", "abandon", 1) if err := Validate(wrongChecksum); err == nil { t.Fatalf("a phrase with the wrong checksum was accepted") } else if !strings.Contains(err.Error(), "checksum") { t.Fatalf("the refusal does not name the checksum: %v", err) } // An unknown word is reported as what it is, not as a checksum failure. if err := Validate("abandon abandon zebrafish abandon abandon abandon " + "abandon abandon abandon abandon abandon about"); err == nil || !strings.Contains(err.Error(), "not a word") { t.Fatalf("an unknown word was not named: %v", err) } } // TestNormalizeAcceptsWhatAPersonTypes: case, padding and line breaks are not errors. func TestNormalizeAcceptsWhatAPersonTypes(t *testing.T) { const phrase = "abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon about" for name, typed := range map[string]string{ "upper case": strings.ToUpper(phrase), "newlines": strings.ReplaceAll(phrase, " ", "\n"), "double gaps": strings.ReplaceAll(phrase, " ", " "), "padded": " " + phrase + "\n\n", "tabs": strings.ReplaceAll(phrase, " ", "\t"), } { if got := Normalize(typed); got != phrase { t.Errorf("%s normalised to %q", name, got) } if _, err := Seed(typed, ""); err != nil { t.Errorf("%s did not derive a seed: %v", name, err) } } } // TestSeedDependsOnThePassphrase: the passphrase is part of the wallet, and no checksum // catches a different one. func TestSeedDependsOnThePassphrase(t *testing.T) { const phrase = "abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon about" plain, err := Seed(phrase, "") if err != nil { t.Fatal(err) } withPass, err := Seed(phrase, "TREZOR") if err != nil { t.Fatal(err) } if plain == withPass { t.Fatal("a passphrase did not change the seed") } // The empty passphrase is not the same as a missing one: both derive, and they are // the same value, which is the property a restore depends on. again, err := Seed(Normalize(phrase), "") if err != nil || again != plain { t.Fatalf("the same phrase and passphrase gave a different seed: %v", err) } } // TestNonASCIIPassphraseIsRefused: BIP39 normalises with NFKD, this build has no table, // and a silent difference here is a different wallet. func TestNonASCIIPassphraseIsRefused(t *testing.T) { const phrase = "abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon about" if _, err := Seed(phrase, "café"); err == nil { t.Fatal("a non-ASCII passphrase was accepted") } else if !strings.Contains(err.Error(), "ASCII") { t.Fatalf("the refusal does not explain itself: %v", err) } // The phrase itself is ASCII by construction, so a non-ASCII word is simply not a // word in the list. if err := Validate("café abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon about"); err == nil { t.Fatal("a non-ASCII phrase was accepted") } } // TestEntropyLengths: the five lengths BIP39 defines, and what it does not. func TestEntropyLengths(t *testing.T) { if _, err := FromEntropy(make([]byte, 15)); err == nil { t.Fatal("15 bytes of entropy produced a phrase") } for _, size := range []int{16, 20, 24, 28, 32} { phrase, err := FromEntropy(make([]byte, size)) if err != nil { t.Fatalf("%d bytes: %v", size, err) } if len(Split(phrase)) != size*3/4 { t.Fatalf("%d bytes gave %d words", size, len(Split(phrase))) } } } // TestNewFromIsDeterministic: the fixed-entropy entry point is what the vectors use, so it // must not add entropy of its own. func TestNewFromIsDeterministic(t *testing.T) { const phrase = "abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon about" entropy, err := hex.DecodeString("00000000000000000000000000000000") if err != nil { t.Fatal(err) } generated, err := NewFrom(strings.NewReader(string(entropy)), 12) if err != nil { t.Fatal(err) } if generated != phrase { t.Fatalf("NewFrom gave %q, want %q", generated, phrase) } // A short entropy source is an error, not a phrase padded with zeroes. if _, err := NewFrom(strings.NewReader("short"), 12); err == nil { t.Fatal("a short entropy source produced a phrase") } }