summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-07-07 16:24:30 +0200
committerGab Virebent <gabriel1@virebent.art>2026-07-07 16:24:30 +0200
commitcc21fb4978b54d643d8fb3f5a454e62da4b396c2 (patch)
tree3050c8d4b68e28989cbe611001f8c6e961a08e51
downloadqpass-cc21fb4978b54d643d8fb3f5a454e62da4b396c2.tar.gz
qpass-cc21fb4978b54d643d8fb3f5a454e62da4b396c2.tar.xz
qpass-cc21fb4978b54d643d8fb3f5a454e62da4b396c2.zip
Initial commitHEADmain
-rw-r--r--.gitignore1
-rw-r--r--Makefile27
-rw-r--r--README.md72
-rw-r--r--cmd/qpass/main.go264
-rw-r--r--go.mod3
-rw-r--r--internal/entropy/entropy.go136
-rw-r--r--internal/entropy/entropy_test.go93
-rw-r--r--internal/passgen/passgen.go124
-rw-r--r--internal/passgen/passgen_test.go76
9 files changed, 796 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e660fd9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+bin/
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..652ad92
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,27 @@
+BINARY := qpass
+PREFIX ?= /usr/local
+BINDIR ?= $(PREFIX)/bin
+GO ?= go
+
+.PHONY: all build clean fmt install test uninstall
+
+all: build
+
+build:
+ mkdir -p bin
+ CGO_ENABLED=0 $(GO) build -buildvcs=false -trimpath -ldflags="-s -w" -o bin/$(BINARY) ./cmd/qpass
+
+fmt:
+ $(GO) fmt ./...
+
+test:
+ $(GO) test ./...
+
+install: build
+ install -Dm755 bin/$(BINARY) "$(DESTDIR)$(BINDIR)/$(BINARY)"
+
+uninstall:
+ rm -f "$(DESTDIR)$(BINDIR)/$(BINARY)"
+
+clean:
+ rm -rf bin
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5b3cabf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,72 @@
+# qpass
+
+`qpass` is a small Go password generator with a conservative cryptographic
+randomness model.
+
+By default it uses Go's `crypto/rand` OS CSPRNG. It can also run in hybrid mode,
+where local OS randomness is mixed with bytes from an external quantum,
+hardware, or certified randomness source. The external source is additive: it
+never replaces the OS CSPRNG as the safety baseline.
+
+## Usage
+
+```bash
+go run ./cmd/qpass
+go run ./cmd/qpass -stats
+go run ./cmd/qpass -length 32 -count 5
+go run ./cmd/qpass -length 32 -symbols=false
+```
+
+Hybrid entropy mode:
+
+```bash
+go run ./cmd/qpass -rng hybrid -entropy-file random-bytes.bin
+go run ./cmd/qpass -rng hybrid -entropy-device /dev/qrandom
+qrng-tool --raw | go run ./cmd/qpass -rng hybrid -entropy-stdin
+```
+
+Legacy compatibility:
+
+```bash
+go run ./cmd/qpass -length 32 -quantum-file random-bytes.bin
+qrng-tool --raw | go run ./cmd/qpass -length 32 -quantum-file -
+```
+
+`-quantum-file` is kept as an alias for older usage. New scripts should prefer
+`-rng hybrid` with `-entropy-file`, `-entropy-device`, or `-entropy-stdin`.
+
+Entropy checks:
+
+```bash
+go run ./cmd/qpass entropy status
+go run ./cmd/qpass entropy test -entropy-file random-bytes.bin
+go run ./cmd/qpass entropy test -entropy-device /dev/qrandom -require-external
+```
+
+## Notes
+
+- `-rng os` uses only Go's `crypto/rand.Reader`.
+- `-rng hybrid` mixes `crypto/rand.Reader` with the configured external source
+ using SHA-512 domain-separated blocks.
+- Without `-require-external`, hybrid mode degrades to OS CSPRNG if the external
+ source runs out or fails, and prints a warning on stderr.
+- With `-require-external`, generation fails when the external source cannot
+ provide enough bytes.
+- `-replace-global-rand` installs the selected engine as `crypto/rand.Reader`
+ for this process. It is an advanced integration option; normal password
+ generation passes the reader explicitly.
+- Entanglement-based randomness certification needs physical hardware and a
+ Bell-test/randomness-amplification protocol.
+- A normal laptop cannot reproduce that guarantee in software.
+- For everyday password generation, OS CSPRNG via `crypto/rand` is the
+ practical baseline.
+- `qpass` prints only passwords by default. Use `-stats` when you want the
+ alphabet size and estimated entropy on stderr.
+
+## Development
+
+```bash
+make fmt
+make test
+make build
+```
diff --git a/cmd/qpass/main.go b/cmd/qpass/main.go
new file mode 100644
index 0000000..8063ab8
--- /dev/null
+++ b/cmd/qpass/main.go
@@ -0,0 +1,264 @@
+package main
+
+import (
+ crand "crypto/rand"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+
+ "quantum-passgen/internal/entropy"
+ "quantum-passgen/internal/passgen"
+)
+
+func main() {
+ if len(os.Args) > 1 && os.Args[1] == "entropy" {
+ runEntropy(os.Args[2:])
+ return
+ }
+ runGenerate(os.Args[1:])
+}
+
+func runGenerate(args []string) {
+ opts := passgen.DefaultOptions()
+ fs := flag.NewFlagSet("qpass", flag.ExitOnError)
+ count := fs.Int("count", 1, "number of passwords to generate")
+ stats := fs.Bool("stats", false, "print alphabet size and estimated entropy to stderr")
+ rngMode := fs.String("rng", "os", "random engine: os or hybrid")
+ entropyFile := fs.String("entropy-file", "", "external entropy file/device; use - for stdin")
+ entropyDevice := fs.String("entropy-device", "", "external entropy device path")
+ entropyStdin := fs.Bool("entropy-stdin", false, "read external entropy from stdin")
+ quantumFile := fs.String("quantum-file", "", "deprecated alias for -entropy-file")
+ requireExternal := fs.Bool("require-external", false, "fail if external entropy cannot provide enough bytes")
+ replaceGlobal := fs.Bool("replace-global-rand", false, "advanced: install selected engine as crypto/rand.Reader for this process")
+
+ fs.IntVar(&opts.Length, "length", opts.Length, "password length")
+ fs.BoolVar(&opts.UseLower, "lower", opts.UseLower, "include lowercase letters")
+ fs.BoolVar(&opts.UseUpper, "upper", opts.UseUpper, "include uppercase letters")
+ fs.BoolVar(&opts.UseDigits, "digits", opts.UseDigits, "include digits")
+ fs.BoolVar(&opts.UseSymbols, "symbols", opts.UseSymbols, "include symbols")
+ fs.Parse(args)
+
+ if *count <= 0 {
+ exitf("count must be positive")
+ }
+
+ random, hybrid, cleanup := configureRandom(randomConfig{
+ mode: *rngMode,
+ entropyFile: *entropyFile,
+ entropyDevice: *entropyDevice,
+ entropyStdin: *entropyStdin,
+ quantumFile: *quantumFile,
+ requireExternal: *requireExternal,
+ rngFlagSet: flagWasSet(fs, "rng"),
+ })
+ defer cleanup()
+ opts.Random = random
+
+ if *replaceGlobal {
+ restore := entropy.InstallGlobal(random)
+ defer restore()
+ }
+
+ for i := 0; i < *count; i++ {
+ password, err := passgen.Generate(opts)
+ if err != nil {
+ exitf("%v", err)
+ }
+ fmt.Println(password)
+ }
+
+ if *stats {
+ alphabetSize := passgen.AlphabetSize(opts)
+ entropyBits, err := passgen.EntropyBits(opts.Length, alphabetSize)
+ if err != nil {
+ exitf("%v", err)
+ }
+ fmt.Fprintf(os.Stderr, "alphabet=%d estimated_entropy=%.1f bits\n", alphabetSize, entropyBits)
+ }
+
+ if hybrid != nil && hybrid.Degraded() {
+ fmt.Fprintf(os.Stderr, "qpass: warning: external entropy degraded; continued with OS CSPRNG: %v\n", hybrid.DegradeError())
+ }
+}
+
+type randomConfig struct {
+ mode string
+ entropyFile string
+ entropyDevice string
+ entropyStdin bool
+ quantumFile string
+ requireExternal bool
+ rngFlagSet bool
+}
+
+func configureRandom(cfg randomConfig) (io.Reader, *entropy.HybridReader, func()) {
+ cleanup := func() {}
+ source, closeSource := openExternalSource(cfg)
+ if closeSource != nil {
+ cleanup = closeSource
+ }
+
+ mode := strings.ToLower(strings.TrimSpace(cfg.mode))
+ if mode == "" {
+ mode = "os"
+ }
+ if source != nil && mode == "os" && !cfg.rngFlagSet {
+ mode = "hybrid"
+ }
+
+ switch mode {
+ case "os":
+ if source != nil {
+ cleanup()
+ exitf("-rng os cannot be combined with an external entropy source")
+ }
+ return crand.Reader, nil, cleanup
+ case "hybrid":
+ if source == nil {
+ cleanup()
+ exitf("-rng hybrid requires -entropy-file, -entropy-device, -entropy-stdin, or -quantum-file")
+ }
+ reader, err := entropy.NewHybridReader(crand.Reader, source, entropy.HybridOptions{
+ RequireExternal: cfg.requireExternal,
+ })
+ if err != nil {
+ cleanup()
+ exitf("configure hybrid entropy: %v", err)
+ }
+ return reader, reader, cleanup
+ default:
+ cleanup()
+ exitf("unknown rng mode %q", cfg.mode)
+ return nil, nil, cleanup
+ }
+}
+
+func openExternalSource(cfg randomConfig) (io.Reader, func()) {
+ sources := 0
+ sourceLabel := ""
+ sourcePath := ""
+
+ if cfg.quantumFile != "" {
+ sources++
+ sourceLabel = "-quantum-file"
+ sourcePath = cfg.quantumFile
+ }
+ if cfg.entropyFile != "" {
+ sources++
+ sourceLabel = "-entropy-file"
+ sourcePath = cfg.entropyFile
+ }
+ if cfg.entropyDevice != "" {
+ sources++
+ sourceLabel = "-entropy-device"
+ sourcePath = cfg.entropyDevice
+ }
+ if cfg.entropyStdin {
+ sources++
+ sourceLabel = "-entropy-stdin"
+ sourcePath = "-"
+ }
+
+ if sources == 0 {
+ return nil, nil
+ }
+ if sources > 1 {
+ exitf("choose only one external entropy source")
+ }
+ if sourcePath == "-" {
+ return os.Stdin, nil
+ }
+
+ file, err := os.Open(sourcePath)
+ if err != nil {
+ exitf("open %s: %v", sourceLabel, err)
+ }
+ return file, func() {
+ if err := file.Close(); err != nil {
+ fmt.Fprintf(os.Stderr, "qpass: warning: close %s: %v\n", sourceLabel, err)
+ }
+ }
+}
+
+func flagWasSet(fs *flag.FlagSet, name string) bool {
+ found := false
+ fs.Visit(func(f *flag.Flag) {
+ if f.Name == name {
+ found = true
+ }
+ })
+ return found
+}
+
+func runEntropy(args []string) {
+ if len(args) == 0 {
+ exitf("usage: qpass entropy status|test")
+ }
+
+ switch args[0] {
+ case "status":
+ runEntropyStatus(args[1:])
+ case "test":
+ runEntropyTest(args[1:])
+ default:
+ exitf("unknown entropy command %q", args[0])
+ }
+}
+
+func runEntropyStatus(args []string) {
+ fs := flag.NewFlagSet("qpass entropy status", flag.ExitOnError)
+ fs.Parse(args)
+
+ var b [1]byte
+ if _, err := io.ReadFull(crand.Reader, b[:]); err != nil {
+ exitf("OS CSPRNG unavailable: %v", err)
+ }
+
+ fmt.Println("os_csprng=available")
+ fmt.Println("external_source=not_configured")
+ fmt.Println("global_override=disabled_by_default")
+}
+
+func runEntropyTest(args []string) {
+ fs := flag.NewFlagSet("qpass entropy test", flag.ExitOnError)
+ entropyFile := fs.String("entropy-file", "", "external entropy file/device; use - for stdin")
+ entropyDevice := fs.String("entropy-device", "", "external entropy device path")
+ entropyStdin := fs.Bool("entropy-stdin", false, "read external entropy from stdin")
+ quantumFile := fs.String("quantum-file", "", "deprecated alias for -entropy-file")
+ requireExternal := fs.Bool("require-external", false, "fail if external entropy cannot provide enough bytes")
+ bytesToRead := fs.Int("bytes", 64, "number of bytes to read without printing them")
+ fs.Parse(args)
+
+ if *bytesToRead <= 0 {
+ exitf("bytes must be positive")
+ }
+
+ random, hybrid, cleanup := configureRandom(randomConfig{
+ mode: "hybrid",
+ entropyFile: *entropyFile,
+ entropyDevice: *entropyDevice,
+ entropyStdin: *entropyStdin,
+ quantumFile: *quantumFile,
+ requireExternal: *requireExternal,
+ })
+ defer cleanup()
+
+ buf := make([]byte, *bytesToRead)
+ if _, err := io.ReadFull(random, buf); err != nil {
+ exitf("entropy test failed: %v", err)
+ }
+
+ fmt.Printf("mode=hybrid\n")
+ fmt.Printf("read_bytes=%d\n", len(buf))
+ fmt.Printf("external_degraded=%t\n", hybrid.Degraded())
+ if hybrid.Degraded() {
+ fmt.Printf("degrade_error=%v\n", hybrid.DegradeError())
+ }
+}
+
+func exitf(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, "qpass: "+format+"\n", args...)
+ os.Exit(1)
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..e579fe9
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module quantum-passgen
+
+go 1.22
diff --git a/internal/entropy/entropy.go b/internal/entropy/entropy.go
new file mode 100644
index 0000000..886ecba
--- /dev/null
+++ b/internal/entropy/entropy.go
@@ -0,0 +1,136 @@
+package entropy
+
+import (
+ crand "crypto/rand"
+ "crypto/sha512"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "io"
+ "sync"
+)
+
+const blockSize = sha512.Size
+
+var ErrExternalEntropyRequired = errors.New("external entropy source required")
+
+type HybridOptions struct {
+ RequireExternal bool
+}
+
+type HybridReader struct {
+ base io.Reader
+ external io.Reader
+ requireExternal bool
+
+ mu sync.Mutex
+ counter uint64
+ degraded bool
+ degradeErr error
+ buffer []byte
+}
+
+func NewHybridReader(base io.Reader, external io.Reader, opts HybridOptions) (*HybridReader, error) {
+ if base == nil {
+ base = crand.Reader
+ }
+ if external == nil {
+ return nil, ErrExternalEntropyRequired
+ }
+
+ return &HybridReader{
+ base: base,
+ external: external,
+ requireExternal: opts.RequireExternal,
+ }, nil
+}
+
+func (r *HybridReader) Read(p []byte) (int, error) {
+ if len(p) == 0 {
+ return 0, nil
+ }
+
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ written := 0
+ for written < len(p) {
+ if len(r.buffer) > 0 {
+ n := copy(p[written:], r.buffer)
+ written += n
+ r.buffer = r.buffer[n:]
+ continue
+ }
+
+ block, err := r.nextBlockLocked()
+ if err != nil {
+ return written, err
+ }
+
+ n := copy(p[written:], block[:])
+ written += n
+ if n < len(block) {
+ r.buffer = append(r.buffer[:0], block[n:]...)
+ }
+ }
+
+ return written, nil
+}
+
+func (r *HybridReader) Degraded() bool {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.degraded
+}
+
+func (r *HybridReader) DegradeError() error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.degradeErr
+}
+
+func (r *HybridReader) nextBlockLocked() ([blockSize]byte, error) {
+ var base [blockSize]byte
+ if _, err := io.ReadFull(r.base, base[:]); err != nil {
+ return [blockSize]byte{}, fmt.Errorf("read OS CSPRNG: %w", err)
+ }
+
+ if r.degraded {
+ return base, nil
+ }
+
+ var external [blockSize]byte
+ if _, err := io.ReadFull(r.external, external[:]); err != nil {
+ if r.requireExternal {
+ return [blockSize]byte{}, fmt.Errorf("read external entropy: %w", err)
+ }
+ r.degraded = true
+ r.degradeErr = err
+ return base, nil
+ }
+
+ var counter [8]byte
+ binary.BigEndian.PutUint64(counter[:], r.counter)
+ r.counter++
+
+ h := sha512.New()
+ h.Write([]byte("qpass hybrid entropy v1\x00"))
+ h.Write(counter[:])
+ h.Write(base[:])
+ h.Write(external[:])
+
+ var out [blockSize]byte
+ copy(out[:], h.Sum(nil))
+ return out, nil
+}
+
+func InstallGlobal(reader io.Reader) func() {
+ if reader == nil {
+ reader = crand.Reader
+ }
+ previous := crand.Reader
+ crand.Reader = reader
+ return func() {
+ crand.Reader = previous
+ }
+}
diff --git a/internal/entropy/entropy_test.go b/internal/entropy/entropy_test.go
new file mode 100644
index 0000000..4ee1a6b
--- /dev/null
+++ b/internal/entropy/entropy_test.go
@@ -0,0 +1,93 @@
+package entropy
+
+import (
+ "bytes"
+ "io"
+ "strings"
+ "testing"
+)
+
+func TestHybridReaderMixesBaseAndExternal(t *testing.T) {
+ base := strings.NewReader(strings.Repeat("a", 4096))
+ external := strings.NewReader(strings.Repeat("b", 4096))
+ reader, err := NewHybridReader(base, external, HybridOptions{})
+ if err != nil {
+ t.Fatalf("NewHybridReader returned error: %v", err)
+ }
+
+ out := make([]byte, 128)
+ if _, err := io.ReadFull(reader, out); err != nil {
+ t.Fatalf("Read returned error: %v", err)
+ }
+
+ if bytes.Equal(out, bytes.Repeat([]byte("a"), len(out))) {
+ t.Fatalf("hybrid output matched raw base reader")
+ }
+ if reader.Degraded() {
+ t.Fatalf("hybrid reader degraded with enough external bytes")
+ }
+}
+
+func TestHybridReaderDegradesToBaseWhenExternalFails(t *testing.T) {
+ base := strings.NewReader(strings.Repeat("x", 4096))
+ external := strings.NewReader("")
+ reader, err := NewHybridReader(base, external, HybridOptions{})
+ if err != nil {
+ t.Fatalf("NewHybridReader returned error: %v", err)
+ }
+
+ out := make([]byte, 32)
+ if _, err := io.ReadFull(reader, out); err != nil {
+ t.Fatalf("Read returned error: %v", err)
+ }
+
+ if !bytes.Equal(out, bytes.Repeat([]byte("x"), len(out))) {
+ t.Fatalf("degraded output should come from base reader")
+ }
+ if !reader.Degraded() {
+ t.Fatalf("hybrid reader should report degraded state")
+ }
+ if reader.DegradeError() == nil {
+ t.Fatalf("hybrid reader should keep the external source error")
+ }
+}
+
+func TestHybridReaderBuffersSmallReads(t *testing.T) {
+ base := strings.NewReader(strings.Repeat("a", blockSize))
+ external := strings.NewReader(strings.Repeat("b", blockSize))
+ reader, err := NewHybridReader(base, external, HybridOptions{})
+ if err != nil {
+ t.Fatalf("NewHybridReader returned error: %v", err)
+ }
+
+ var one [1]byte
+ for i := 0; i < blockSize; i++ {
+ if _, err := io.ReadFull(reader, one[:]); err != nil {
+ t.Fatalf("Read %d returned error: %v", i, err)
+ }
+ }
+
+ if reader.Degraded() {
+ t.Fatalf("small reads should reuse one mixed block before reading external again")
+ }
+}
+
+func TestHybridReaderRequiresExternalWhenConfigured(t *testing.T) {
+ base := strings.NewReader(strings.Repeat("x", 4096))
+ external := strings.NewReader("")
+ reader, err := NewHybridReader(base, external, HybridOptions{RequireExternal: true})
+ if err != nil {
+ t.Fatalf("NewHybridReader returned error: %v", err)
+ }
+
+ out := make([]byte, 1)
+ if _, err := io.ReadFull(reader, out); err == nil {
+ t.Fatalf("Read returned nil error with required external entropy")
+ }
+}
+
+func TestNewHybridReaderRejectsMissingExternalSource(t *testing.T) {
+ if _, err := NewHybridReader(strings.NewReader("base"), nil, HybridOptions{}); err != ErrExternalEntropyRequired {
+ t.Fatalf("NewHybridReader error = %v, want %v", err, ErrExternalEntropyRequired)
+ }
+}
diff --git a/internal/passgen/passgen.go b/internal/passgen/passgen.go
new file mode 100644
index 0000000..1c3241d
--- /dev/null
+++ b/internal/passgen/passgen.go
@@ -0,0 +1,124 @@
+package passgen
+
+import (
+ "crypto/rand"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+)
+
+const (
+ lowerChars = "abcdefghijklmnopqrstuvwxyz"
+ upperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ digitChars = "0123456789"
+ symbolChars = "!@#$%^&*()-_=+[]{};:,.?/"
+)
+
+var ErrEmptyAlphabet = errors.New("alphabet is empty")
+
+type Options struct {
+ Length int
+ UseLower bool
+ UseUpper bool
+ UseDigits bool
+ UseSymbols bool
+ Random io.Reader
+}
+
+func DefaultOptions() Options {
+ return Options{
+ Length: 24,
+ UseLower: true,
+ UseUpper: true,
+ UseDigits: true,
+ UseSymbols: true,
+ }
+}
+
+func Generate(opts Options) (string, error) {
+ if opts.Length <= 0 {
+ return "", fmt.Errorf("length must be positive")
+ }
+
+ alphabet := buildAlphabet(opts)
+ if alphabet == "" {
+ return "", ErrEmptyAlphabet
+ }
+
+ random := opts.Random
+ if random == nil {
+ random = rand.Reader
+ }
+
+ password := make([]byte, opts.Length)
+ for i := range password {
+ n, err := randomIndex(len(alphabet), random)
+ if err != nil {
+ return "", fmt.Errorf("choose password character: %w", err)
+ }
+ password[i] = alphabet[n]
+ }
+
+ return string(password), nil
+}
+
+func EntropyBits(length int, alphabetSize int) (float64, error) {
+ if length <= 0 {
+ return 0, fmt.Errorf("length must be positive")
+ }
+ if alphabetSize <= 1 {
+ return 0, fmt.Errorf("alphabet size must be greater than 1")
+ }
+
+ return float64(length) * math.Log2(float64(alphabetSize)), nil
+}
+
+func AlphabetSize(opts Options) int {
+ return len(buildAlphabet(opts))
+}
+
+func buildAlphabet(opts Options) string {
+ alphabet := ""
+ if opts.UseLower {
+ alphabet += lowerChars
+ }
+ if opts.UseUpper {
+ alphabet += upperChars
+ }
+ if opts.UseDigits {
+ alphabet += digitChars
+ }
+ if opts.UseSymbols {
+ alphabet += symbolChars
+ }
+ return alphabet
+}
+
+func randomIndex(size int, random io.Reader) (int, error) {
+ if size <= 0 {
+ return 0, ErrEmptyAlphabet
+ }
+ if size > 256 {
+ return 0, fmt.Errorf("alphabet size %d exceeds supported maximum 256", size)
+ }
+
+ cutoff := 256 - (256 % size)
+ for {
+ value, err := randomByte(random)
+ if err != nil {
+ return 0, err
+ }
+ if int(value) < cutoff {
+ return int(value) % size, nil
+ }
+ }
+}
+
+func randomByte(random io.Reader) (byte, error) {
+ var b [1]byte
+ if _, err := io.ReadFull(random, b[:]); err != nil {
+ return 0, fmt.Errorf("read random byte: %w", err)
+ }
+ return b[0], nil
+}
diff --git a/internal/passgen/passgen_test.go b/internal/passgen/passgen_test.go
new file mode 100644
index 0000000..8c70420
--- /dev/null
+++ b/internal/passgen/passgen_test.go
@@ -0,0 +1,76 @@
+package passgen
+
+import (
+ "bytes"
+ "io"
+ "strings"
+ "testing"
+)
+
+func TestGenerateUsesRequestedLengthAndAlphabet(t *testing.T) {
+ opts := Options{
+ Length: 64,
+ UseLower: true,
+ UseUpper: false,
+ UseDigits: false,
+ }
+
+ password, err := Generate(opts)
+ if err != nil {
+ t.Fatalf("Generate returned error: %v", err)
+ }
+
+ if len(password) != opts.Length {
+ t.Fatalf("password length = %d, want %d", len(password), opts.Length)
+ }
+
+ for _, ch := range password {
+ if !strings.ContainsRune(lowerChars, ch) {
+ t.Fatalf("password contains character outside requested alphabet: %q", ch)
+ }
+ }
+}
+
+func TestGenerateRejectsEmptyAlphabet(t *testing.T) {
+ _, err := Generate(Options{Length: 24})
+ if err != ErrEmptyAlphabet {
+ t.Fatalf("Generate error = %v, want %v", err, ErrEmptyAlphabet)
+ }
+}
+
+func TestGenerateUsesProvidedRandomReader(t *testing.T) {
+ opts := Options{
+ Length: 3,
+ UseLower: true,
+ UseUpper: false,
+ UseDigits: false,
+ UseSymbols: false,
+ Random: bytes.NewReader([]byte{0, 1, 2}),
+ }
+
+ password, err := Generate(opts)
+ if err != nil {
+ t.Fatalf("Generate returned error: %v", err)
+ }
+
+ if password != "abc" {
+ t.Fatalf("password = %q, want %q", password, "abc")
+ }
+}
+
+func TestGenerateReturnsRandomReaderError(t *testing.T) {
+ opts := Options{
+ Length: 1,
+ UseLower: true,
+ UseUpper: false,
+ UseDigits: false,
+ UseSymbols: false,
+ Random: strings.NewReader(""),
+ }
+
+ if _, err := Generate(opts); err == nil {
+ t.Fatalf("Generate returned nil error")
+ } else if !strings.Contains(err.Error(), io.EOF.Error()) {
+ t.Fatalf("Generate error = %v, want EOF context", err)
+ }
+}