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