Files
keepassgo/internal/passwords/generator.go
T
2026-04-09 06:42:21 -07:00

196 lines
4.7 KiB
Go

package passwords
import (
"crypto/rand"
"errors"
"fmt"
"math/big"
"slices"
"strings"
)
const (
lowercaseChars = "abcdefghijkmnopqrstuvwxyz"
uppercaseChars = "ABCDEFGHJKLMNPQRSTUVWXYZ"
digitChars = "23456789"
symbolChars = "!@#$%^&*()-_=+[]{}<>?/."
)
var ErrImpossibleProfile = errors.New("impossible password profile")
var ErrUnknownProfile = errors.New("unknown password profile")
type Profile struct {
Name string
Length int
Lowercase bool
Uppercase bool
Digits bool
Symbols bool
MinLowercase int
MinUppercase int
MinDigits int
MinSymbols int
ExcludeSimilar bool
}
func DefaultProfiles() map[string]Profile {
return map[string]Profile{
"strong": {
Name: "strong",
Length: 24,
Lowercase: true,
Uppercase: true,
Digits: true,
Symbols: true,
MinLowercase: 2,
MinUppercase: 2,
MinDigits: 2,
MinSymbols: 2,
ExcludeSimilar: true,
},
"memorable": {
Name: "memorable",
Length: 20,
Lowercase: true,
Uppercase: true,
Digits: true,
Symbols: false,
MinLowercase: 4,
MinUppercase: 2,
MinDigits: 2,
ExcludeSimilar: true,
},
}
}
func DefaultProfileNames() []string {
return ProfileNames(DefaultProfiles())
}
func LookupProfile(name string, profiles map[string]Profile) (Profile, error) {
profile, ok := profiles[strings.TrimSpace(name)]
if !ok {
return Profile{}, fmt.Errorf("%w %q", ErrUnknownProfile, strings.TrimSpace(name))
}
return profile, nil
}
func LookupDefaultProfile(name string) (Profile, error) {
return LookupProfile(name, DefaultProfiles())
}
func ProfileNames(profiles map[string]Profile) []string {
names := make([]string, 0, len(profiles))
for name := range profiles {
names = append(names, name)
}
slices.Sort(names)
return names
}
func Generate(profile Profile) (string, error) {
if err := validateProfile(profile); err != nil {
return "", err
}
var chars []byte
var pool strings.Builder
if profile.Lowercase {
pool.WriteString(lowercaseChars)
chars = append(chars, mustRandomChars(lowercaseChars, profile.MinLowercase)...)
}
if profile.Uppercase {
pool.WriteString(uppercaseChars)
chars = append(chars, mustRandomChars(uppercaseChars, profile.MinUppercase)...)
}
if profile.Digits {
pool.WriteString(digitChars)
chars = append(chars, mustRandomChars(digitChars, profile.MinDigits)...)
}
if profile.Symbols {
pool.WriteString(symbolChars)
chars = append(chars, mustRandomChars(symbolChars, profile.MinSymbols)...)
}
allChars := pool.String()
for len(chars) < profile.Length {
ch, err := randomChar(allChars)
if err != nil {
return "", err
}
chars = append(chars, ch)
}
if err := shuffle(chars); err != nil {
return "", err
}
return string(chars), nil
}
func validateProfile(profile Profile) error {
if profile.Length <= 0 {
return fmt.Errorf("%w: length must be positive", ErrImpossibleProfile)
}
required := profile.MinLowercase + profile.MinUppercase + profile.MinDigits + profile.MinSymbols
if required > profile.Length {
return fmt.Errorf("%w: minimum character counts exceed length", ErrImpossibleProfile)
}
if profile.MinLowercase > 0 && !profile.Lowercase {
return fmt.Errorf("%w: lowercase disabled with lowercase minimum", ErrImpossibleProfile)
}
if profile.MinUppercase > 0 && !profile.Uppercase {
return fmt.Errorf("%w: uppercase disabled with uppercase minimum", ErrImpossibleProfile)
}
if profile.MinDigits > 0 && !profile.Digits {
return fmt.Errorf("%w: digits disabled with digit minimum", ErrImpossibleProfile)
}
if profile.MinSymbols > 0 && !profile.Symbols {
return fmt.Errorf("%w: symbols disabled with symbol minimum", ErrImpossibleProfile)
}
if !profile.Lowercase && !profile.Uppercase && !profile.Digits && !profile.Symbols {
return fmt.Errorf("%w: no character sets enabled", ErrImpossibleProfile)
}
return nil
}
func mustRandomChars(chars string, count int) []byte {
if count <= 0 {
return nil
}
out := make([]byte, 0, count)
for i := 0; i < count; i++ {
ch, err := randomChar(chars)
if err != nil {
panic(err)
}
out = append(out, ch)
}
return out
}
func randomChar(chars string) (byte, error) {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
if err != nil {
return 0, fmt.Errorf("random index: %w", err)
}
return chars[n.Int64()], nil
}
func shuffle(chars []byte) error {
for i := len(chars) - 1; i > 0; i-- {
n, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
if err != nil {
return fmt.Errorf("shuffle password: %w", err)
}
j := int(n.Int64())
chars[i], chars[j] = chars[j], chars[i]
}
return nil
}