mnemonic_test.go raw
1 package mnemonic
2
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "encoding/json"
7 "os"
8 "strings"
9 "testing"
10 )
11
12 // TestWordListIsTheBIP39List: the vendored list is checked by checksum and by the
13 // properties BIP39 promises, because one changed word silently changes every seed this
14 // package will ever produce.
15 func TestWordListIsTheBIP39List(t *testing.T) {
16 text, err := os.ReadFile("english.txt")
17
18 if err != nil {
19 t.Fatal(err)
20 }
21
22 const want = "2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda"
23
24 sum := sha256.Sum256(text)
25
26 if got := hex.EncodeToString(sum[:]); got != want {
27 t.Fatalf("english.txt is %s, want %s", got, want)
28 }
29
30 if len(words) != 2048 {
31 t.Fatalf("the list holds %d words, want 2048", len(words))
32 }
33
34 for i, word := range words {
35 if word == "" || strings.ContainsAny(word, " \t\n") {
36 t.Fatalf("word %d is %q", i, word)
37 }
38
39 if len(word) < 3 || len(word) > 8 {
40 t.Fatalf("word %d %q is %d letters", i, word, len(word))
41 }
42
43 for _, r := range word {
44 if r < 'a' || r > 'z' {
45 t.Fatalf("word %d %q is not lowercase ascii", i, word)
46 }
47 }
48
49 if i > 0 && word <= words[i-1] {
50 t.Fatalf("the list is not in ascending order at %d: %q then %q",
51 i, words[i-1], word)
52 }
53 }
54
55 if len(index) != 2048 {
56 t.Fatalf("the index holds %d words", len(index))
57 }
58 }
59
60 // vector is one entry of the official BIP39 English test vectors.
61 //
62 // They are vendored from the BIP39 reference implementation
63 // (trezor/python-mnemonic vectors.json) so the test compares against the standard rather
64 // than against this package's own output.
65 type vector struct {
66 Entropy string `json:"entropy"`
67 Mnemonic string `json:"mnemonic"`
68 Seed string `json:"seed"`
69 Xprv string `json:"xprv"`
70 }
71
72 // loadVectors reads the vendored vectors.
73 func loadVectors(t *testing.T) []vector {
74 t.Helper()
75
76 raw, err := os.ReadFile("vectors.json")
77
78 if err != nil {
79 t.Fatal(err)
80 }
81
82 var vectors []vector
83
84 if err := json.Unmarshal(raw, &vectors); err != nil {
85 t.Fatal(err)
86 }
87
88 if len(vectors) < 20 {
89 t.Fatalf("only %d vectors", len(vectors))
90 }
91
92 return vectors
93 }
94
95 // TestOfficialVectors: entropy to words, words to seed, both directions, for all 24
96 // official English vectors.
97 func TestOfficialVectors(t *testing.T) {
98 for _, v := range loadVectors(t) {
99 entropy, err := hex.DecodeString(v.Entropy)
100
101 if err != nil {
102 t.Fatal(err)
103 }
104
105 phrase, err := FromEntropy(entropy)
106
107 if err != nil {
108 t.Fatalf("%s: %v", v.Entropy, err)
109 }
110
111 if phrase != v.Mnemonic {
112 t.Fatalf("entropy %s gave\n %s\nwant\n %s", v.Entropy, phrase, v.Mnemonic)
113 }
114
115 back, err := Entropy(v.Mnemonic)
116
117 if err != nil {
118 t.Fatalf("%s: %v", v.Mnemonic, err)
119 }
120
121 if hex.EncodeToString(back) != v.Entropy {
122 t.Fatalf("%s gave entropy %s", v.Mnemonic, hex.EncodeToString(back))
123 }
124
125 // The vectors' seeds use the passphrase "TREZOR".
126 seed, err := Seed(v.Mnemonic, "TREZOR")
127
128 if err != nil {
129 t.Fatalf("%s: %v", v.Mnemonic, err)
130 }
131
132 if hex.EncodeToString(seed[:]) != v.Seed {
133 t.Fatalf("%s gave seed\n %s\nwant\n %s",
134 v.Mnemonic, hex.EncodeToString(seed[:]), v.Seed)
135 }
136 }
137 }
138
139 // TestNewIsAValidSeed: a generated phrase validates, round-trips through its entropy and
140 // is the number of words asked for.
141 func TestNewIsAValidSeed(t *testing.T) {
142 for _, count := range Lengths {
143 phrase, err := New(count)
144
145 if err != nil {
146 t.Fatalf("%d words: %v", count, err)
147 }
148
149 if got := len(Split(phrase)); got != count {
150 t.Fatalf("asked for %d words, got %d", count, got)
151 }
152
153 if err := Validate(phrase); err != nil {
154 t.Fatalf("%d words: %v (%s)", count, err, phrase)
155 }
156
157 entropy, err := Entropy(phrase)
158
159 if err != nil {
160 t.Fatal(err)
161 }
162
163 again, err := FromEntropy(entropy)
164
165 if err != nil {
166 t.Fatal(err)
167 }
168
169 if again != phrase {
170 t.Fatalf("%d words: %q then %q", count, phrase, again)
171 }
172 }
173
174 // A phrase is not the same twice, which is what makes it a seed rather than a
175 // constant. Two collisions in a row would mean the entropy source is not one.
176 first, _ := New(12)
177 second, _ := New(12)
178
179 if first == second {
180 t.Fatal("two fresh phrases are identical")
181 }
182 }
183
184 // TestValidateRefusesATypo: one wrong word is caught, in each position that can be wrong.
185 func TestValidateRefusesATypo(t *testing.T) {
186 const phrase = "abandon abandon abandon abandon abandon abandon " +
187 "abandon abandon abandon abandon abandon about"
188
189 if err := Validate(phrase); err != nil {
190 t.Fatalf("the reference phrase is invalid: %v", err)
191 }
192
193 cases := map[string]string{
194 "an unknown word": strings.Replace(phrase, "about", "abandon", 1)[:len(phrase)-len("about")] + "abbey",
195 "a swapped word": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about abandon",
196 "a missing word": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
197 "an extra word": phrase + " abandon",
198 "empty": "",
199 "one word": "abandon",
200 }
201
202 for name, bad := range cases {
203 if err := Validate(bad); err == nil {
204 t.Errorf("%s was accepted as a seed: %q", name, bad)
205 }
206 }
207
208 // A real word in the wrong place is the interesting case: the checksum catches it.
209 wrongChecksum := strings.Replace(phrase, "about", "abandon", 1)
210
211 if err := Validate(wrongChecksum); err == nil {
212 t.Fatalf("a phrase with the wrong checksum was accepted")
213 } else if !strings.Contains(err.Error(), "checksum") {
214 t.Fatalf("the refusal does not name the checksum: %v", err)
215 }
216
217 // An unknown word is reported as what it is, not as a checksum failure.
218 if err := Validate("abandon abandon zebrafish abandon abandon abandon " +
219 "abandon abandon abandon abandon abandon about"); err == nil ||
220 !strings.Contains(err.Error(), "not a word") {
221 t.Fatalf("an unknown word was not named: %v", err)
222 }
223 }
224
225 // TestNormalizeAcceptsWhatAPersonTypes: case, padding and line breaks are not errors.
226 func TestNormalizeAcceptsWhatAPersonTypes(t *testing.T) {
227 const phrase = "abandon abandon abandon abandon abandon abandon " +
228 "abandon abandon abandon abandon abandon about"
229
230 for name, typed := range map[string]string{
231 "upper case": strings.ToUpper(phrase),
232 "newlines": strings.ReplaceAll(phrase, " ", "\n"),
233 "double gaps": strings.ReplaceAll(phrase, " ", " "),
234 "padded": " " + phrase + "\n\n",
235 "tabs": strings.ReplaceAll(phrase, " ", "\t"),
236 } {
237 if got := Normalize(typed); got != phrase {
238 t.Errorf("%s normalised to %q", name, got)
239 }
240
241 if _, err := Seed(typed, ""); err != nil {
242 t.Errorf("%s did not derive a seed: %v", name, err)
243 }
244 }
245 }
246
247 // TestSeedDependsOnThePassphrase: the passphrase is part of the wallet, and no checksum
248 // catches a different one.
249 func TestSeedDependsOnThePassphrase(t *testing.T) {
250 const phrase = "abandon abandon abandon abandon abandon abandon " +
251 "abandon abandon abandon abandon abandon about"
252
253 plain, err := Seed(phrase, "")
254
255 if err != nil {
256 t.Fatal(err)
257 }
258
259 withPass, err := Seed(phrase, "TREZOR")
260
261 if err != nil {
262 t.Fatal(err)
263 }
264
265 if plain == withPass {
266 t.Fatal("a passphrase did not change the seed")
267 }
268
269 // The empty passphrase is not the same as a missing one: both derive, and they are
270 // the same value, which is the property a restore depends on.
271 again, err := Seed(Normalize(phrase), "")
272
273 if err != nil || again != plain {
274 t.Fatalf("the same phrase and passphrase gave a different seed: %v", err)
275 }
276 }
277
278 // TestNonASCIIPassphraseIsRefused: BIP39 normalises with NFKD, this build has no table,
279 // and a silent difference here is a different wallet.
280 func TestNonASCIIPassphraseIsRefused(t *testing.T) {
281 const phrase = "abandon abandon abandon abandon abandon abandon " +
282 "abandon abandon abandon abandon abandon about"
283
284 if _, err := Seed(phrase, "café"); err == nil {
285 t.Fatal("a non-ASCII passphrase was accepted")
286 } else if !strings.Contains(err.Error(), "ASCII") {
287 t.Fatalf("the refusal does not explain itself: %v", err)
288 }
289
290 // The phrase itself is ASCII by construction, so a non-ASCII word is simply not a
291 // word in the list.
292 if err := Validate("café abandon abandon abandon abandon abandon " +
293 "abandon abandon abandon abandon abandon about"); err == nil {
294 t.Fatal("a non-ASCII phrase was accepted")
295 }
296 }
297
298 // TestEntropyLengths: the five lengths BIP39 defines, and what it does not.
299 func TestEntropyLengths(t *testing.T) {
300 if _, err := FromEntropy(make([]byte, 15)); err == nil {
301 t.Fatal("15 bytes of entropy produced a phrase")
302 }
303
304 for _, size := range []int{16, 20, 24, 28, 32} {
305 phrase, err := FromEntropy(make([]byte, size))
306
307 if err != nil {
308 t.Fatalf("%d bytes: %v", size, err)
309 }
310
311 if len(Split(phrase)) != size*3/4 {
312 t.Fatalf("%d bytes gave %d words", size, len(Split(phrase)))
313 }
314 }
315 }
316
317 // TestNewFromIsDeterministic: the fixed-entropy entry point is what the vectors use, so it
318 // must not add entropy of its own.
319 func TestNewFromIsDeterministic(t *testing.T) {
320 const phrase = "abandon abandon abandon abandon abandon abandon " +
321 "abandon abandon abandon abandon abandon about"
322
323 entropy, err := hex.DecodeString("00000000000000000000000000000000")
324
325 if err != nil {
326 t.Fatal(err)
327 }
328
329 generated, err := NewFrom(strings.NewReader(string(entropy)), 12)
330
331 if err != nil {
332 t.Fatal(err)
333 }
334
335 if generated != phrase {
336 t.Fatalf("NewFrom gave %q, want %q", generated, phrase)
337 }
338
339 // A short entropy source is an error, not a phrase padded with zeroes.
340 if _, err := NewFrom(strings.NewReader("short"), 12); err == nil {
341 t.Fatal("a short entropy source produced a phrase")
342 }
343 }
344