From c9ce5ff4421ed790a59e27bc6ff245f925d769a2 Mon Sep 17 00:00:00 2001 From: sneak Date: Sat, 31 Oct 2026 10:03:06 -0700 Subject: [PATCH] Add internal randstream and pwgen packages --- internal/pwgen/pwgen.go | 195 +++++++++++++++++++++++++ internal/pwgen/pwgen_test.go | 176 ++++++++++++++++++++++ internal/randstream/randstream.go | 76 ++++++++++ internal/randstream/randstream_test.go | 111 ++++++++++++++ 4 files changed, 558 insertions(+) create mode 100644 internal/pwgen/pwgen.go create mode 100644 internal/pwgen/pwgen_test.go create mode 100644 internal/randstream/randstream.go create mode 100644 internal/randstream/randstream_test.go diff --git a/internal/pwgen/pwgen.go b/internal/pwgen/pwgen.go new file mode 100644 index 0000000..58a7536 --- /dev/null +++ b/internal/pwgen/pwgen.go @@ -0,0 +1,195 @@ +// 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 +} diff --git a/internal/pwgen/pwgen_test.go b/internal/pwgen/pwgen_test.go new file mode 100644 index 0000000..c1494b0 --- /dev/null +++ b/internal/pwgen/pwgen_test.go @@ -0,0 +1,176 @@ +package pwgen_test + +import ( + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sneak.berlin/go/x/internal/pwgen" +) + +const generateRounds = 300 + +// testWords mixes usable words with entries the filter must drop: +// capitalized, too short, too long, punctuation, digits, and non-ASCII. +func testWords() []string { + return []string{ + "apple", "banana", "cherry", "grape", "lemon", "mango", "melon", "peach", + "Capital", "ab", "abc", "toolongwordhere", "has-dash", "num3er", "éclair", + } +} + +func usableTestWords() map[string]bool { + return map[string]bool{ + "apple": true, "banana": true, "cherry": true, "grape": true, + "lemon": true, "mango": true, "melon": true, "peach": true, + } +} + +func TestNewFiltersWords(t *testing.T) { + t.Parallel() + + generator, err := pwgen.New(pwgen.Params{Words: testWords()}) + require.NoError(t, err) + + assert.Equal(t, len(usableTestWords()), generator.UsableWords()) +} + +func TestNewHonorsLengthBounds(t *testing.T) { + t.Parallel() + + generator, err := pwgen.New(pwgen.Params{ + Words: testWords(), + MinWordLength: 2, + MaxWordLength: 3, + }) + require.NoError(t, err) + + assert.Equal(t, 2, generator.UsableWords(), `only "ab" and "abc" fit`) +} + +func TestNewRejectsNoUsableWords(t *testing.T) { + t.Parallel() + + _, err := pwgen.New(pwgen.Params{Words: []string{"Capital", "ab"}}) + require.ErrorIs(t, err, pwgen.ErrNoUsableWords) + + _, err = pwgen.New(pwgen.Params{Words: []string{}}) + require.ErrorIs(t, err, pwgen.ErrNoUsableWords) +} + +func TestNewRejectsInvalidBounds(t *testing.T) { + t.Parallel() + + _, err := pwgen.New(pwgen.Params{ + Words: testWords(), + MinWordLength: 9, + MaxWordLength: 4, + }) + require.ErrorIs(t, err, pwgen.ErrInvalidWordLength) + + _, err = pwgen.New(pwgen.Params{Words: testWords(), MinWordLength: -1}) + require.ErrorIs(t, err, pwgen.ErrInvalidWordLength) +} + +func TestGenerateFormat(t *testing.T) { + t.Parallel() + + generator, err := pwgen.New(pwgen.Params{Words: testWords()}) + require.NoError(t, err) + + usable := usableTestWords() + + for range generateRounds { + password, err := generator.Generate() + require.NoError(t, err) + + words, digits, delimiter := parsePassword(t, password) + + assert.Contains(t, pwgen.Delimiters, string(delimiter)) + assert.Len(t, words, pwgen.WordCount) + + for _, word := range words { + assert.True(t, usable[word], "unexpected word %q in %q", word, password) + } + + for _, digit := range digits { + assert.True(t, digit >= '0' && digit <= '9', "digit in %q", password) + } + } +} + +func TestGenerateVariesDelimitersAndDigits(t *testing.T) { + t.Parallel() + + generator, err := pwgen.New(pwgen.Params{Words: testWords()}) + require.NoError(t, err) + + delimitersSeen := map[byte]bool{} + digitsSeen := map[byte]bool{} + passwordsSeen := map[string]bool{} + + for range generateRounds { + password, err := generator.Generate() + require.NoError(t, err) + + _, digits, delimiter := parsePassword(t, password) + delimitersSeen[delimiter] = true + passwordsSeen[password] = true + + for _, digit := range digits { + digitsSeen[digit] = true + } + } + + assert.Greater(t, len(delimitersSeen), 1, "delimiter should vary") + assert.Greater(t, len(digitsSeen), 1, "digits should vary") + assert.Greater(t, len(passwordsSeen), generateRounds/2, "passwords repeat") +} + +func TestNewWithSystemDictionary(t *testing.T) { + t.Parallel() + + generator, err := pwgen.New(pwgen.Params{}) + if errors.Is(err, pwgen.ErrNoUsableWords) { + t.Skip("this system has no word list") + } + + require.NoError(t, err) + assert.Greater(t, generator.UsableWords(), 1000) + + password, err := generator.Generate() + require.NoError(t, err) + + parsePassword(t, password) +} + +// parsePassword splits a password of the form +// into its words, +// digits, and the delimiter, failing the test if it is not of that shape. +func parsePassword(t *testing.T, password string) ([]string, []byte, byte) { + t.Helper() + + delimiterIndex := strings.IndexFunc(password, func(r rune) bool { + return (r < 'a' || r > 'z') && (r < '0' || r > '9') + }) + require.NotEqual(t, -1, delimiterIndex, "no delimiter in %q", password) + + delimiter := password[delimiterIndex] + parts := strings.Split(password, string(delimiter)) + require.Len(t, parts, pwgen.WordCount, "wrong shape: %q", password) + + words := make([]string, 0, len(parts)) + digits := make([]byte, 0, len(parts)) + + for _, part := range parts { + require.GreaterOrEqual(t, len(part), 2, "short part in %q", password) + + words = append(words, part[:len(part)-1]) + digits = append(digits, part[len(part)-1]) + } + + return words, digits, delimiter +} diff --git a/internal/randstream/randstream.go b/internal/randstream/randstream.go new file mode 100644 index 0000000..ae39fdf --- /dev/null +++ b/internal/randstream/randstream.go @@ -0,0 +1,76 @@ +// Package randstream provides a fast, unbounded stream of pseudorandom +// bytes as an io.Reader. It backs `sutil generate random`. +// +// It exists because reading /dev/urandom is slow: operating system random +// devices are built for small requests of key material, not for filling +// disks or producing gigabytes of test data. A Reader instead expands a +// 32-byte key with the ChaCha8 generator from math/rand/v2, which runs at +// close to memory bandwidth and never blocks. +// +// When Params.Seed is set, the key is SHA-256 of the seed string and the +// stream is fully deterministic: the same seed always produces the same +// bytes, which makes test data reproducible. When Seed is empty, the key +// is read from crypto/rand (the operating system's secure random source, +// /dev/urandom or its equivalent) and the stream is unpredictable. +// +// The output is not meant to be used as cryptographic key material; use +// crypto/rand for that. +package randstream + +import ( + "crypto/rand" + "crypto/sha256" + "fmt" + mathrand "math/rand/v2" +) + +// KeySize is the size in bytes of the key that seeds the stream. +const KeySize = 32 + +// Params configures New. +type Params struct { + // Seed, when non-empty, makes the stream deterministic: the same seed + // always yields the same byte sequence. When empty, the stream is + // keyed from crypto/rand and differs on every call to New. + Seed string +} + +// Reader is an unbounded stream of pseudorandom bytes. Read never returns +// an error and never returns fewer bytes than requested. A Reader is not +// safe for concurrent use. +type Reader struct { + generator *mathrand.ChaCha8 +} + +// New returns a Reader configured by p. +func New(p Params) (*Reader, error) { + key, err := keyFromParams(p) + if err != nil { + return nil, err + } + + return &Reader{generator: mathrand.NewChaCha8(key)}, nil +} + +// Read fills b with pseudorandom bytes. It always returns len(b) and a nil +// error. +func (r *Reader) Read(b []byte) (int, error) { + return r.generator.Read(b) +} + +// keyFromParams derives the ChaCha8 key: SHA-256 of the seed string, or +// fresh bytes from crypto/rand when no seed is given. +func keyFromParams(p Params) ([KeySize]byte, error) { + if p.Seed != "" { + return sha256.Sum256([]byte(p.Seed)), nil + } + + var key [KeySize]byte + + _, err := rand.Read(key[:]) + if err != nil { + return key, fmt.Errorf("randstream: reading key from crypto/rand: %w", err) + } + + return key, nil +} diff --git a/internal/randstream/randstream_test.go b/internal/randstream/randstream_test.go new file mode 100644 index 0000000..e4c5e1c --- /dev/null +++ b/internal/randstream/randstream_test.go @@ -0,0 +1,111 @@ +package randstream_test + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sneak.berlin/go/x/internal/randstream" +) + +const ( + sampleSize = 1 << 20 + helloSeed = "hello" +) + +func TestSeededStreamIsDeterministic(t *testing.T) { + t.Parallel() + + first := readSample(t, randstream.Params{Seed: helloSeed}, sampleSize) + second := readSample(t, randstream.Params{Seed: helloSeed}, sampleSize) + + assert.Equal(t, first, second) +} + +func TestDifferentSeedsDiffer(t *testing.T) { + t.Parallel() + + hello := readSample(t, randstream.Params{Seed: helloSeed}, sampleSize) + world := readSample(t, randstream.Params{Seed: "world"}, sampleSize) + + assert.NotEqual(t, hello, world) +} + +func TestUnseededStreamsDiffer(t *testing.T) { + t.Parallel() + + first := readSample(t, randstream.Params{Seed: ""}, sampleSize) + second := readSample(t, randstream.Params{Seed: ""}, sampleSize) + + assert.NotEqual(t, first, second) +} + +func TestReadAlwaysFillsBuffer(t *testing.T) { + t.Parallel() + + stream, err := randstream.New(randstream.Params{Seed: "fill"}) + require.NoError(t, err) + + for _, size := range []int{0, 1, 7, 64, 65, 4096, sampleSize} { + buffer := make([]byte, size) + + n, err := stream.Read(buffer) + require.NoError(t, err) + assert.Equal(t, size, n) + } +} + +func TestStreamIsContinuousAcrossReads(t *testing.T) { + t.Parallel() + + const headSize, tailSize = 300, 700 + + whole := readSample(t, randstream.Params{Seed: "continuous"}, headSize+tailSize) + + stream, err := randstream.New(randstream.Params{Seed: "continuous"}) + require.NoError(t, err) + + head := make([]byte, headSize) + tail := make([]byte, tailSize) + + _, err = io.ReadFull(stream, head) + require.NoError(t, err) + + _, err = io.ReadFull(stream, tail) + require.NoError(t, err) + + assert.Equal(t, whole[:headSize], head) + assert.Equal(t, whole[headSize:], tail) +} + +func TestOutputIsNotTriviallyPatterned(t *testing.T) { + t.Parallel() + + sample := readSample(t, randstream.Params{Seed: "entropy"}, sampleSize) + + var seen [256]bool + for _, b := range sample { + seen[b] = true + } + + for value, wasSeen := range seen { + assert.True(t, wasSeen, "byte value %d never appeared", value) + } +} + +// readSample reads size bytes from a new Reader configured by p. +func readSample(t *testing.T, p randstream.Params, size int) []byte { + t.Helper() + + stream, err := randstream.New(p) + require.NoError(t, err) + + sample := make([]byte, size) + + _, err = io.ReadFull(stream, sample) + require.NoError(t, err) + + return sample +}