// Package pwgen generates memorable passwords from the system dictionary. // It backs `sutil generate password`. // // A password is three dictionary words, each followed by one decimal // digit, joined by a delimiter that is chosen at random from Delimiters // and used in both positions: // // // // for example "marble7-sprout2-velvet9". // // Words come from the operating system's word list (/usr/share/dict/words // on macOS and most Unix systems) through github.com/kavfixnel/words, // filtered to words made only of lowercase ASCII letters whose length is // within the configured bounds. That drops proper nouns, abbreviations, // and anything awkward to type. All random choices use crypto/rand. package pwgen import ( "crypto/rand" "errors" "fmt" "math/big" "strconv" "strings" "github.com/kavfixnel/words" ) const ( // WordCount is the number of words in every generated password. WordCount = 3 // Delimiters is the set of characters from which the delimiter that // separates the words of a password is chosen. Delimiters = ".-_|~+=:,!@#%^&*" // DefaultMinWordLength is the shortest word used when // Params.MinWordLength is zero. DefaultMinWordLength = 4 // DefaultMaxWordLength is the longest word used when // Params.MaxWordLength is zero. DefaultMaxWordLength = 8 // digitCount is the number of decimal digits (0 through 9) one of // which follows every word. digitCount = 10 ) var ( // ErrNoUsableWords is returned by New when the word source yields no // words within the length bounds. On a system without a word list, // github.com/kavfixnel/words returns an empty list rather than an // error, so this is also the error for "no system dictionary". ErrNoUsableWords = errors.New("pwgen: no usable words in word list") // ErrInvalidWordLength is returned by New when the word length bounds // are negative or the minimum exceeds the maximum. ErrInvalidWordLength = errors.New("pwgen: invalid word length bounds") ) // Params configures New. type Params struct { // Words, when non-nil, is used instead of the system dictionary. It // is filtered by the same length and character rules. Words []string // MinWordLength is the shortest word to use; zero means // DefaultMinWordLength. MinWordLength int // MaxWordLength is the longest word to use; zero means // DefaultMaxWordLength. MaxWordLength int } // Generator produces passwords from a fixed set of usable words. It is // safe for concurrent use. type Generator struct { words []string } // New loads and filters the word source described by p. It returns // ErrNoUsableWords if no words survive filtering, which on a system // without a word list is the normal outcome. func New(p Params) (*Generator, error) { minLength, maxLength := p.MinWordLength, p.MaxWordLength if minLength == 0 { minLength = DefaultMinWordLength } if maxLength == 0 { maxLength = DefaultMaxWordLength } if minLength < 0 || maxLength < minLength { return nil, fmt.Errorf( "%w: min %d, max %d", ErrInvalidWordLength, minLength, maxLength, ) } source := p.Words if source == nil { systemWords, err := words.NewWordList(nil) if err != nil { return nil, fmt.Errorf("pwgen: reading system word list: %w", err) } source = systemWords } usable := filterWords(source, minLength, maxLength) if len(usable) == 0 { return nil, ErrNoUsableWords } return &Generator{words: usable}, nil } // UsableWords returns the number of distinct words the generator chooses // from, which determines the strength of its passwords. func (g *Generator) UsableWords() int { return len(g.words) } // Generate returns a new random password. func (g *Generator) Generate() (string, error) { delimiterIndex, err := randomIndex(len(Delimiters)) if err != nil { return "", err } delimiter := Delimiters[delimiterIndex] var password strings.Builder for i := range WordCount { if i > 0 { password.WriteByte(delimiter) } wordIndex, err := randomIndex(len(g.words)) if err != nil { return "", err } digit, err := randomIndex(digitCount) if err != nil { return "", err } password.WriteString(g.words[wordIndex]) password.WriteString(strconv.Itoa(digit)) } return password.String(), nil } // filterWords returns the entries of source that isUsableWord accepts. func filterWords(source []string, minLength, maxLength int) []string { usable := make([]string, 0, len(source)) for _, word := range source { if isUsableWord(word, minLength, maxLength) { usable = append(usable, word) } } return usable } // isUsableWord reports whether word is made only of lowercase ASCII // letters and has a length within the bounds. func isUsableWord(word string, minLength, maxLength int) bool { if len(word) < minLength || len(word) > maxLength { return false } return !strings.ContainsFunc(word, func(r rune) bool { return r < 'a' || r > 'z' }) } // randomIndex returns a uniformly random integer in [0, n) from // crypto/rand. func randomIndex(n int) (int, error) { value, err := rand.Int(rand.Reader, big.NewInt(int64(n))) if err != nil { return 0, fmt.Errorf("pwgen: reading from crypto/rand: %w", err) } return int(value.Int64()), nil }