From cc21fb4978b54d643d8fb3f5a454e62da4b396c2 Mon Sep 17 00:00:00 2001 From: Gab Virebent Date: Tue, 7 Jul 2026 16:24:30 +0200 Subject: Initial commit --- internal/passgen/passgen.go | 124 +++++++++++++++++++++++++++++++++++++++ internal/passgen/passgen_test.go | 76 ++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 internal/passgen/passgen.go create mode 100644 internal/passgen/passgen_test.go (limited to 'internal/passgen') 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) + } +} -- cgit v1.2.3