package wordkey import ( "crypto/rand" _ "embed" "fmt" "io" "math" "math/big" "sort" "strings" "unicode/utf8" ) // DefaultCount is the number of words Generate draws when the caller does // not ask for more: 7 words of a list of 7776 are about 90 bits. const DefaultCount = 7 // MinListSize is the fewest words of a list that Generate accepts (spec // §38.1: at least 6 words of a list of 2048 or more). const MinListSize = 2048 //go:embed lists/en.txt var listEN string //go:embed lists/es.txt var listES string // lists are the word lists built into the module, by language. Each is a // plain UTF-8 file, one word per line; lists/README.md says where each comes // from and its license. var lists = map[string]string{ "en": listEN, "es": listES, } // alphabets are the letters that a word of a list of each language may hold, // as the list writes it: lower case, in NFC. A word with a letter of another // script that looks like one of these, such as the Cyrillic U+0430 for the // Latin a, would be written down and typed again with the letter of the // keyboard, and the capsule would not open. The alphabet of a list comes from // here, never from the list. The English one has the ASCII hyphen of the four // compound words of the list of the EFF, such as t-shirt, kept so that every // word keeps its number of dice. var alphabets = map[string]string{ "en": "abcdefghijklmnopqrstuvwxyz-", "es": "abcdefghijklmnopqrstuvwxyzáéíóúüñ", } // Languages returns the languages of the built-in word lists, sorted. func Languages() []string { var l []string for k := range lists { l = append(l, k) } sort.Strings(l) return l } // List returns the built-in word list of lang, after checking it with // CheckList. func List(lang string) ([]string, error) { text, ok := lists[lang] if !ok { return nil, fmt.Errorf("wordkey: no word list for %q; the lists are %s", lang, strings.Join(Languages(), ", ")) } words := strings.Split(strings.TrimSuffix(text, "\n"), "\n") if err := CheckList(lang, words); err != nil { return nil, fmt.Errorf("wordkey: the list %q: %w", lang, err) } return words, nil } // CheckList reports why words cannot be a list of the language lang for // Generate: a language without an alphabet in this package; fewer than // MinListSize words; a word that is not one word of MinLetters characters or // more once normalized, that holds a character Check refuses or one that is // not a letter of the alphabet of lang; or a word that is the same as // another once normalized. Two words such as «papa» and «papá» would be one // word with less entropy than the list promises. func CheckList(lang string, words []string) error { alphabet, ok := alphabets[lang] if !ok { return fmt.Errorf("no alphabet for the language %q", lang) } if len(words) < MinListSize { return fmt.Errorf("%d words, fewer than %d", len(words), MinListSize) } seen := make(map[string]string, len(words)) for i, w := range words { n := Normalize(w) if len(n) != 1 || n[0] != strings.TrimSpace(n[0]) || utf8.RuneCountInString(n[0]) < MinLetters { return fmt.Errorf("line %d, %q, is not one word of %d or more letters", i+1, w, MinLetters) } if err := checkRunes(n[0]); err != nil { return fmt.Errorf("line %d: %w", i+1, err) } for _, r := range w { if !strings.ContainsRune(alphabet, r) { return fmt.Errorf("line %d, %q, holds U+%04X, which is not in the alphabet of %q", i+1, w, r, lang) } } if prev, ok := seen[n[0]]; ok { return fmt.Errorf("line %d, %q, is the same word as %q once normalized", i+1, w, prev) } seen[n[0]] = w } return nil } // Generate draws n different words of list, uniformly, with random, which // is crypto/rand.Reader when nil. Each word adds log2(len(list)) bits, a // little less for each word already drawn. n must be MinWords or more. func Generate(list []string, n int, random io.Reader) ([]string, error) { if n < MinWords { return nil, fmt.Errorf("wordkey: a key of words needs at least %d words, not %d", MinWords, n) } if n > len(list)/2 { return nil, fmt.Errorf("wordkey: %d words of a list of %d", n, len(list)) } if random == nil { random = rand.Reader } picked := make(map[int]bool, n) words := make([]string, 0, n) size := big.NewInt(int64(len(list))) for len(words) < n { i, err := rand.Int(random, size) if err != nil { return nil, fmt.Errorf("wordkey: %w", err) } if picked[int(i.Int64())] { continue } picked[int(i.Int64())] = true words = append(words, list[i.Int64()]) } return words, nil } // Bits is the strength of count words that Generate draws from a list of // size words: log2 of the number of draws in order, size·(size−1)·…, which // whoever knows the list must search, before the rounds of PBKDF2. func Bits(size, count int) float64 { b := 0.0 for i := range count { b += math.Log2(float64(size - i)) } return b }