|
|
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
|
|
|
}
|