diff --git a/README.md b/README.md index 60581e2..296eee3 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,12 @@ goos: linux goarch: amd64 pkg: github.com/jedib0t/go-passwords/password cpu: AMD Ryzen 9 5900X 12-Core Processor -BenchmarkGenerator_Generate-12 6537692 171.2 ns/op 40 B/op 2 allocs/op -BenchmarkSequencer_GotoN-12 4000642 290.0 ns/op 32 B/op 3 allocs/op -BenchmarkSequencer_Next-12 13113400 88.12 ns/op 16 B/op 1 allocs/op -BenchmarkSequencer_NextN-12 6000421 196.9 ns/op 32 B/op 3 allocs/op -BenchmarkSequencer_Prev-12 12717573 92.34 ns/op 16 B/op 1 allocs/op -BenchmarkSequencer_PrevN-12 3909879 302.3 ns/op 32 B/op 3 allocs/op +BenchmarkGenerator_Generate-12 6245260 188.2 ns/op 40 B/op 2 allocs/op +BenchmarkSequencer_GotoN-12 4359440 270.6 ns/op 32 B/op 3 allocs/op +BenchmarkSequencer_Next-12 13632730 83.67 ns/op 16 B/op 1 allocs/op +BenchmarkSequencer_NextN-12 6608569 181.5 ns/op 32 B/op 3 allocs/op +BenchmarkSequencer_Prev-12 13509426 87.51 ns/op 16 B/op 1 allocs/op +BenchmarkSequencer_PrevN-12 4266948 276.8 ns/op 32 B/op 3 allocs/op PASS ok github.com/jedib0t/go-passwords/password 8.178s ``` @@ -29,8 +29,11 @@ ok github.com/jedib0t/go-passwords/password 8.178s ### Random Passwords ```golang generator, err := password.NewGenerator( - password.WithCharset(password.AlphaNumeric), - password.WithLength(8), + password.WithCharset(password.AllChars.WithoutAmbiguity().WithoutDuplicates()), + password.WithLength(12), + password.WithMinLowerCase(5), + password.WithMinpperCase(2), + password.WithNumSymbols(1, 1), ) if err != nil { panic(err.Error()) @@ -42,16 +45,16 @@ ok github.com/jedib0t/go-passwords/password 8.178s
Output...
-Password #  1: "GcXO27vy"
-Password #  2: "BK9zGIPN"
-Password #  3: "o5UCF2i2"
-Password #  4: "YuoEsl4p"
-Password #  5: "fMiLzaL7"
-Password #  6: "5VpPTeJG"
-Password #  7: "LvGoxO1O"
-Password #  8: "nWD9rSaj"
-Password #  9: "qMjwMI9n"
-Password # 10: "kIbf3Wsm"
+Password #  1: "CmHii4zek_wU"
+Password #  2: "m+GicmQEnxkz"
+Password #  3: "FruTpkprFNR="
+Password #  4: "p@xjqBH3bbfi"
+Password #  5: "D(WadeVLTfhm"
+Password #  6: "uLpSFv%pMidL"
+Password #  7: "bbBQ*gypmhTx"
+Password #  8: "abshu4}HNpwE"
+Password #  9: "UjGpDsP{4mfi"
+Password # 10: "toKue=dvUPzz"
 
diff --git a/go.mod b/go.mod index 51d9b47..082cf50 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/jedib0t/go-passwords -go 1.21 +go 1.22 require github.com/stretchr/testify v1.9.0 diff --git a/password/charset.go b/password/charset.go index e8982e9..42f1fae 100644 --- a/password/charset.go +++ b/password/charset.go @@ -14,7 +14,7 @@ const ( AlphabetsLower Charset = "abcdefghijklmnopqrstuvwxyz" AlphabetsUpper Charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" Numbers Charset = "0123456789" - Symbols Charset = "~!@#$%^&*()-_=+[{]}\\|;:,<.>/?" + Symbols Charset = "~!@#$%^&*()-_=+[{]}|;:,<.>/?" AllChars = AlphaNumeric + Symbols AlphaNumeric = Alphabets + Numbers @@ -33,18 +33,6 @@ func (c Charset) Contains(r rune) bool { return false } -// ExtractSymbols extracts and returns a Charset with just the symbols from the -// source Charset. -func (c Charset) ExtractSymbols() Charset { - sb := strings.Builder{} - for _, r := range c { - if strings.Contains(string(Symbols), string(r)) { - sb.WriteRune(r) - } - } - return Charset(sb.String()) -} - // Shuffle reorders the Charset using the given RNG. func (c Charset) Shuffle(rng *rand.Rand) Charset { cRunes := []rune(c) diff --git a/password/errors.go b/password/errors.go index b5afc15..5dccc70 100644 --- a/password/errors.go +++ b/password/errors.go @@ -3,9 +3,14 @@ package password import "errors" var ( - ErrEmptyCharset = errors.New("cannot generate passwords with empty charset") - ErrInvalidN = errors.New("value of N exceeds valid range") - ErrMinSymbolsTooLong = errors.New("minimum number of symbols requested longer than password") - ErrNoSymbolsInCharset = errors.New("found no symbols to use in charset") - ErrZeroLenPassword = errors.New("cannot generate passwords with 0 length") + ErrEmptyCharset = errors.New("cannot generate passwords with empty charset") + ErrInvalidN = errors.New("value of N exceeds valid range") + ErrMinLowerCaseTooLong = errors.New("minimum number of lower-case characters requested longer than password") + ErrMinUpperCaseTooLong = errors.New("minimum number of upper-case characters requested longer than password") + ErrMinSymbolsTooLong = errors.New("minimum number of symbols requested longer than password") + ErrNoLowerCaseInCharset = errors.New("found no lower-case characters to use in charset") + ErrNoUpperCaseInCharset = errors.New("found no upper-case characters to use in charset") + ErrNoSymbolsInCharset = errors.New("found no symbols to use in charset") + ErrRequirementsNotMet = errors.New("minimum number of lower-case+upper-case+symbols requested longer than password") + ErrZeroLenPassword = errors.New("cannot generate passwords with 0 length") ) diff --git a/password/generator.go b/password/generator.go index 6ef5b75..0171af3 100644 --- a/password/generator.go +++ b/password/generator.go @@ -1,9 +1,10 @@ package password import ( - "math/rand" + "math/rand/v2" "sync" "time" + "unicode" ) var ( @@ -14,14 +15,17 @@ type Generator interface { // Generate returns a randomly generated password. Generate() string // SetSeed overrides the seed value for the RNG. - SetSeed(seed int64) + SetSeed(seed uint64) } type generator struct { charset []rune - charsetLen int + charsetCaseLower []rune + charsetCaseUpper []rune + charsetNonSymbols []rune charsetSymbols []rune - charsetSymbolsLen int + minLowerCase int + minUpperCase int minSymbols int maxSymbols int numChars int @@ -32,17 +36,18 @@ type generator struct { // NewGenerator returns a password generator that implements the Generator // interface. func NewGenerator(rules ...Rule) (Generator, error) { - g := &generator{ - rng: rand.New(rand.NewSource(time.Now().UnixNano())), - } + g := &generator{} + g.SetSeed(uint64(time.Now().UnixNano())) for _, opt := range append(defaultRules, rules...) { opt(g) } - // init the variables - g.charsetLen = len(g.charset) - g.charsetSymbols = []rune(Charset(g.charset).ExtractSymbols()) - g.charsetSymbolsLen = len(g.charsetSymbols) + // split the charsets + g.charsetCaseLower = filterRunes(g.charset, unicode.IsLower) + g.charsetCaseUpper = filterRunes(g.charset, unicode.IsUpper) + g.charsetNonSymbols = filterRunes(g.charset, func(r rune) bool { return !Symbols.Contains(r) }) + g.charsetSymbols = filterRunes(g.charset, Symbols.Contains) + // create a storage pool with enough objects to support enough parallelism g.pool = &sync.Pool{ New: func() any { @@ -53,20 +58,7 @@ func NewGenerator(rules ...Rule) (Generator, error) { g.pool.Put(make([]rune, g.numChars)) } - // validate the inputs - if g.charsetLen == 0 { - return nil, ErrEmptyCharset - } - if g.numChars <= 0 { - return nil, ErrZeroLenPassword - } - if g.minSymbols > g.numChars { - return nil, ErrMinSymbolsTooLong - } - if g.minSymbols > 0 && g.charsetSymbolsLen == 0 { - return nil, ErrNoSymbolsInCharset - } - return g, nil + return g.sanitize() } // Generate returns a randomly generated password. @@ -75,46 +67,76 @@ func (g *generator) Generate() string { password := g.pool.Get().([]rune) defer g.pool.Put(password) - // overwrite the contents of the []rune and stringify it for response - for idx := range password { - // generate a random new character - char := g.charset[g.rng.Intn(g.charsetLen)] - - // avoid repetition of previous character and ignore symbols - for (idx > 0 && char == password[idx-1]) || Symbols.Contains(char) { - char = g.charset[g.rng.Intn(g.charsetLen)] + idx := 0 + fillPassword := func(count int, runes []rune) { + for ; count > 0 && idx < len(password); count-- { + password[idx] = runes[g.rng.IntN(len(runes))] + idx++ } - - // set - password[idx] = char } + fillPassword(g.minLowerCase, g.charsetCaseLower) + fillPassword(g.minUpperCase, g.charsetCaseUpper) + fillPassword(g.numSymbolsToGenerate(), g.charsetSymbols) + fillPassword(len(password)-idx, g.charsetNonSymbols) - // guarantee a minimum and maximum number of symbols - if g.minSymbols > 0 || g.maxSymbols > 0 { - numSymbolsToGenerate := g.minSymbols - if g.maxSymbols > g.minSymbols { - numSymbolsToGenerate += g.rng.Intn(g.maxSymbols-g.minSymbols) + 1 - } - for numSymbolsToGenerate > 0 { - // generate a random new symbol - char := g.charsetSymbols[g.rng.Intn(g.charsetSymbolsLen)] - - // find a random non-symbol location in the password - location := g.rng.Intn(g.numChars) - for Symbols.Contains(password[location]) { - location = g.rng.Intn(g.numChars) - } - - // set - password[location] = char - numSymbolsToGenerate-- - } - } + // shuffle it all + g.rng.Shuffle(len(password), func(i, j int) { + password[i], password[j] = password[j], password[i] + }) return string(password) } // SetSeed overrides the seed value for the RNG. -func (g *generator) SetSeed(seed int64) { - g.rng = rand.New(rand.NewSource(seed)) +func (g *generator) SetSeed(seed uint64) { + g.rng = rand.New(rand.NewPCG(seed, seed+100)) +} + +func (g *generator) numSymbolsToGenerate() int { + if g.minSymbols > 0 || g.maxSymbols > 0 { + return g.rng.IntN(g.maxSymbols-g.minSymbols+1) + g.minSymbols + } + return 0 +} + +func (g *generator) sanitize() (Generator, error) { + // validate the inputs + if len(g.charset) == 0 { + return nil, ErrEmptyCharset + } + if g.numChars <= 0 { + return nil, ErrZeroLenPassword + } + if g.minLowerCase > 0 && len(g.charsetCaseLower) == 0 { + return nil, ErrNoLowerCaseInCharset + } + if g.minLowerCase > g.numChars { + return nil, ErrMinLowerCaseTooLong + } + if g.minUpperCase > 0 && len(g.charsetCaseUpper) == 0 { + return nil, ErrNoUpperCaseInCharset + } + if g.minUpperCase > g.numChars { + return nil, ErrMinUpperCaseTooLong + } + if g.minSymbols > g.numChars { + return nil, ErrMinSymbolsTooLong + } + if g.minLowerCase+g.minUpperCase+g.minSymbols > g.numChars { + return nil, ErrRequirementsNotMet + } + if g.minSymbols > 0 && len(g.charsetSymbols) == 0 { + return nil, ErrNoSymbolsInCharset + } + return g, nil +} + +func filterRunes(runes []rune, truth func(r rune) bool) []rune { + var rsp []rune + for _, r := range runes { + if truth(r) { + rsp = append(rsp, r) + } + } + return rsp } diff --git a/password/generator_test.go b/password/generator_test.go index 508f1b9..9aea4ed 100644 --- a/password/generator_test.go +++ b/password/generator_test.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" "testing" + "unicode" "github.com/stretchr/testify/assert" ) @@ -30,16 +31,16 @@ func TestGenerator_Generate(t *testing.T) { g.SetSeed(1) expectedPasswords := []string{ - "7mfsqtsNrNcj", - "LRagoTFi9iGz", - "7RP42TsEV3se", - "ZyL9CvRu5Ged", - "y7y3wxVnRPMG", - "qEJcmaT4yiL6", - "XgVmEC15ZFH1", - "XZzNALhgjLuV", - "WoD3jbLU92tn", - "XZxQd37ftWbo", + "KkeonkPQHv4r", + "sHL31fveTcKB", + "MHnSCTtKBds2", + "oEqJBeZ8Qmie", + "G2CGWSDAQUuz", + "RtwGPgyAq9tN", + "3kPAu4cMxN8t", + "FgWWYrjqnx19", + "uCFmDFDAoLZY", + "pMgNoVa9z5Vv", } sb := strings.Builder{} for idx := 0; idx < 100; idx++ { @@ -57,6 +58,52 @@ func TestGenerator_Generate(t *testing.T) { } } +func TestGenerator_Generate_WithAMixOfEverything(t *testing.T) { + g, err := NewGenerator( + WithCharset(AllChars.WithoutAmbiguity().WithoutDuplicates()), + WithLength(12), + WithMinLowerCase(5), + WithMinUpperCase(2), + WithNumSymbols(1, 1), + ) + assert.Nil(t, err) + g.SetSeed(1) + + expectedPasswords := []string{ + "r{rnUqHeg5QP", + "m1RNe4$eXuda", + "tq%wKqhhTMAK", + "r1PkMr@qta2t", + "hsPv+wzGiChh", + "uth= 5, password) + numUpperCase := len(filterRunes([]rune(password), unicode.IsUpper)) + assert.True(t, numUpperCase >= 2, password) + numSymbols := len(filterRunes([]rune(password), Symbols.Contains)) + assert.True(t, numSymbols == 1, password) + } + if sb.Len() > 0 { + fmt.Println(sb.String()) + } +} + func TestGenerator_Generate_WithSymbols(t *testing.T) { t.Run("min 0 max 3", func(t *testing.T) { g, err := NewGenerator( @@ -68,16 +115,16 @@ func TestGenerator_Generate_WithSymbols(t *testing.T) { g.SetSeed(1) expectedPasswords := []string{ - "f4e23ab@6ecf", - "2+2$e32fd3ce", - "5#bd1bcfd12b", - "3d$12$4%1c54", - "ecfb5aed%3da", - "b1a6ecf-dc-d", - "bfb#a643bece", - "6efd$36-3f1@", - "%5f321f564eb", - "eb5d3ef5-ef-", + "324c22b2f55c", + "a5d355bf1c@4", + "33c!b3#+acab", + "e5c21aaf3353", + "%3cd1$5bd31e", + "+615abf$@536", + "b%ccc-c5+3f3", + "#4d1b52e!36!", + "-e3a6cda4#!1", + "162e6bb#ee53", } sb := strings.Builder{} for idx := 0; idx < 100; idx++ { @@ -138,6 +185,21 @@ func TestGenerator_Generate_WithSymbols(t *testing.T) { }) } +func TestGenerator_numSymbolsToGenerate(t *testing.T) { + minSymbols, maxSymbols := 0, 3 + + g := &generator{ + minSymbols: minSymbols, + maxSymbols: maxSymbols, + } + g.SetSeed(1) + for idx := 0; idx < 10000; idx++ { + numSymbols := g.numSymbolsToGenerate() + assert.True(t, numSymbols >= minSymbols, numSymbols) + assert.True(t, numSymbols <= maxSymbols, numSymbols) + } +} + func getNumSymbols(pw string) int { rsp := 0 for _, r := range pw { diff --git a/password/rule.go b/password/rules.go similarity index 62% rename from password/rule.go rename to password/rules.go index f16c940..bc70e97 100644 --- a/password/rule.go +++ b/password/rules.go @@ -34,6 +34,32 @@ func WithLength(l int) Rule { } } +// WithMinLowerCase controls the minimum number of lower case characters that +// can appear in the password. +// +// Note: This works only on a Generator and is ineffective with a Sequencer. +func WithMinLowerCase(min int) Rule { + return func(a any) { + switch v := a.(type) { + case *generator: + v.minLowerCase = min + } + } +} + +// WithMinUpperCase controls the minimum number of upper case characters that +// can appear in the password. +// +// Note: This works only on a Generator and is ineffective with a Sequencer. +func WithMinUpperCase(min int) Rule { + return func(a any) { + switch v := a.(type) { + case *generator: + v.minUpperCase = min + } + } +} + // WithNumSymbols controls the min/max number of symbols that can appear in the // password. // diff --git a/password/sequencer.go b/password/sequencer.go index a13ee8a..ad055e1 100644 --- a/password/sequencer.go +++ b/password/sequencer.go @@ -4,7 +4,7 @@ import ( "context" "fmt" "math/big" - "math/rand" + "math/rand/v2" "sync" "time" ) @@ -37,6 +37,8 @@ type Sequencer interface { PrevN(n *big.Int) string // Reset cleans up state and moves to the first possible word. Reset() + // SetSeed overrides the seed value for the RNG. + SetSeed(seed uint64) // Stream sends all possible passwords in order to the given channel. If you // want to limit output, pass in a *big.Int with the number of passwords you // want to be generated and streamed. @@ -62,9 +64,8 @@ type sequencer struct { // NewSequencer returns a password sequencer that implements the Sequencer // interface. func NewSequencer(rules ...Rule) (Sequencer, error) { - s := &sequencer{ - rng: rand.New(rand.NewSource(time.Now().UnixNano())), - } + s := &sequencer{} + s.SetSeed(uint64(time.Now().UnixNano())) for _, rule := range append(defaultRules, rules...) { rule(s) } @@ -201,11 +202,11 @@ func (s *sequencer) Reset() { } // SetSeed changes the seed value of the RNG. -func (s *sequencer) SetSeed(seed int64) { +func (s *sequencer) SetSeed(seed uint64) { s.mutex.Lock() defer s.mutex.Unlock() - s.rng = rand.New(rand.NewSource(seed)) + s.rng = rand.New(rand.NewPCG(seed, seed+100)) } // Stream sends all possible passwords in order to the given channel. If you diff --git a/password/utils_test.go b/password/utils_test.go index a25dd71..7eeadf9 100644 --- a/password/utils_test.go +++ b/password/utils_test.go @@ -10,8 +10,8 @@ func TestMaximumPossibleWords(t *testing.T) { assert.Equal(t, "10", MaximumPossibleWords(Numbers, 1).String()) assert.Equal(t, "10000", MaximumPossibleWords(Numbers, 4).String()) assert.Equal(t, "100000000", MaximumPossibleWords(Numbers, 8).String()) - assert.Equal(t, "500246412961", MaximumPossibleWords(Symbols, 8).String()) + assert.Equal(t, "377801998336", MaximumPossibleWords(Symbols, 8).String()) assert.Equal(t, "53459728531456", MaximumPossibleWords(Alphabets, 8).String()) assert.Equal(t, "218340105584896", MaximumPossibleWords(AlphaNumeric, 8).String()) - assert.Equal(t, "4702525276151521", MaximumPossibleWords(AllChars, 8).String()) + assert.Equal(t, "4304672100000000", MaximumPossibleWords(AllChars, 8).String()) }