Files

196 lines
5.1 KiB
Go

// 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:
//
// <word><digit><delim><word><digit><delim><word><digit>
//
// 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
}