From 4db662a5fedf3a842b8204b0bfec1a731bc04d24 Mon Sep 17 00:00:00 2001 From: Naveen Mahalingam Date: Mon, 8 Apr 2024 21:26:01 -0700 Subject: [PATCH] init password Generator/Sequencer with rules --- cmd/password-generator/main.go | 10 +++- password/charset.go | 14 +++++- password/errors.go | 8 +-- password/generator.go | 81 ++++++++++++++++++++++-------- password/generator_test.go | 91 +++++++++++++++++++++++++++++++--- password/rule.go | 60 ++++++++++++++++++++++ password/sequencer.go | 41 ++++++++------- password/sequencer_test.go | 65 +++++++++++++++++------- password/utils_test.go | 4 +- 9 files changed, 302 insertions(+), 72 deletions(-) create mode 100644 password/rule.go diff --git a/cmd/password-generator/main.go b/cmd/password-generator/main.go index 0e308db..80a7f30 100644 --- a/cmd/password-generator/main.go +++ b/cmd/password-generator/main.go @@ -77,7 +77,10 @@ func main() { } func generateRandomPasswords(charset password.Charset, numChars int, count *big.Int, printIndex bool, seed int64) { - generator, err := password.NewGenerator(charset, numChars) + generator, err := password.NewGenerator( + password.WithCharset(charset), + password.WithLength(numChars), + ) if err != nil { fmt.Printf("ERROR: failed to instantiate generator: %v\n", err) os.Exit(1) @@ -93,7 +96,10 @@ func generateRandomPasswords(charset password.Charset, numChars int, count *big. } func generateSequencedPasswords(charset password.Charset, numChars int, count *big.Int, startIdx *big.Int, printIndex bool) { - sequencer, err := password.NewSequencer(charset, numChars) + sequencer, err := password.NewSequencer( + password.WithCharset(charset), + password.WithLength(numChars), + ) if err != nil { fmt.Printf("ERROR: failed to instantiate generator: %v\n", err) os.Exit(1) diff --git a/password/charset.go b/password/charset.go index ca4da9c..e8982e9 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,6 +33,18 @@ 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 be591f4..b5afc15 100644 --- a/password/errors.go +++ b/password/errors.go @@ -3,7 +3,9 @@ package password import "errors" var ( - ErrEmptyCharset = errors.New("cannot generate passwords with empty charset") - ErrZeroLenPassword = errors.New("cannot generate passwords with 0 length") - ErrInvalidN = errors.New("value of N exceeds valid range") + 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") ) diff --git a/password/generator.go b/password/generator.go index 81858f8..f92b989 100644 --- a/password/generator.go +++ b/password/generator.go @@ -18,40 +18,55 @@ type Generator interface { } type generator struct { - charset []rune - charsetLen int - numChars int - pool *sync.Pool - rng *rand.Rand + charset []rune + charsetLen int + charsetSymbols []rune + charsetSymbolsLen int + minSymbols int + maxSymbols int + numChars int + pool *sync.Pool + rng *rand.Rand } // NewGenerator returns a password generator that implements the Generator // interface. -func NewGenerator(charset Charset, numChars int) (Generator, error) { - if len(charset) == 0 { - return nil, ErrEmptyCharset +func NewGenerator(rules ...Rule) (Generator, error) { + g := &generator{ + rng: rand.New(rand.NewSource(time.Now().UnixNano())), } - if numChars <= 0 { - return nil, ErrZeroLenPassword + 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) // create a storage pool with enough objects to support enough parallelism - pool := &sync.Pool{ + g.pool = &sync.Pool{ New: func() any { - return make([]rune, numChars) + return make([]rune, g.numChars) }, } for idx := 0; idx < 25; idx++ { - pool.Put(make([]rune, numChars)) + g.pool.Put(make([]rune, g.numChars)) } - return &generator{ - charset: []rune(charset), - charsetLen: len(charset), - numChars: numChars, - pool: pool, - rng: rand.New(rand.NewSource(time.Now().UnixNano())), - }, nil + // 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 } // Generate returns a randomly generated password. @@ -64,13 +79,35 @@ func (g *generator) Generate() string { for idx := range password { // generate a random new character char := g.charset[g.rng.Intn(g.charsetLen)] - // avoid repetition of previous character - for idx > 0 && char == password[idx-1] { + + // 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)] } + // set password[idx] = char } + + // guarantee a minimum and maximum number of symbols + if g.minSymbols > 0 || g.maxSymbols > 0 { + numSymbolsToGenerate := g.minSymbols + g.rng.Intn(g.maxSymbols-g.minSymbols) + 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-- + } + } + return string(password) } diff --git a/password/generator_test.go b/password/generator_test.go index 0cd30ea..8b878fc 100644 --- a/password/generator_test.go +++ b/password/generator_test.go @@ -8,13 +8,11 @@ import ( "github.com/stretchr/testify/assert" ) -var ( - testGenCharset = AlphaNumeric.WithoutAmbiguity().WithoutDuplicates() - testGenNumChars = 12 -) - func BenchmarkGenerator_Generate(b *testing.B) { - g, err := NewGenerator(testGenCharset, testGenNumChars) + g, err := NewGenerator( + WithCharset(AlphaNumeric.WithoutAmbiguity().WithoutDuplicates()), + WithLength(12), + ) assert.Nil(b, err) assert.NotEmpty(b, g.Generate()) @@ -24,7 +22,10 @@ func BenchmarkGenerator_Generate(b *testing.B) { } func TestGenerator_Generate(t *testing.T) { - g, err := NewGenerator(testGenCharset, testGenNumChars) + g, err := NewGenerator( + WithCharset(AlphaNumeric.WithoutAmbiguity().WithoutDuplicates()), + WithLength(12), + ) assert.Nil(t, err) g.SetSeed(1) @@ -55,3 +56,79 @@ func TestGenerator_Generate(t *testing.T) { fmt.Println(sb.String()) } } + +func TestGenerator_Generate_WithSymbols(t *testing.T) { + t.Run("min 0 max 3", func(t *testing.T) { + g, err := NewGenerator( + WithCharset(Charset("abcdef123456-+!@#$%").WithoutAmbiguity().WithoutDuplicates()), + WithLength(12), + WithNumSymbols(0, 3), + ) + assert.Nil(t, err) + g.SetSeed(1) + + expectedPasswords := []string{ + "f4e23abc6ecf", + "242$e32fd3ce", + "5ebd1bcfd12b", + "213df12f4c1c", + "54efb35ecfb5", + "ed63@ad1eb1-", + "6ecfbdcfd15b", + "bfa643bece16", + "fdf3$c3f1cba", + "c345f321f56!", + } + sb := strings.Builder{} + for idx := 0; idx < 100; idx++ { + password := g.Generate() + assert.NotEmpty(t, password) + if idx < len(expectedPasswords) { + assert.Equal(t, expectedPasswords[idx], password) + if expectedPasswords[idx] != password { + sb.WriteString(fmt.Sprintf("%#v,\n", password)) + } + } + } + if sb.Len() > 0 { + fmt.Println(sb.String()) + } + }) + + t.Run("min 1 max 3", func(t *testing.T) { + g, err := NewGenerator( + WithCharset(Charset("abcdef123456-+!@#$%").WithoutAmbiguity().WithoutDuplicates()), + WithLength(12), + WithNumSymbols(1, 3), + ) + assert.Nil(t, err) + g.SetSeed(1) + + expectedPasswords := []string{ + "f4e23a%@6ecf", + "424e-2fd#ce4", + "ebd1bcf-12bc", + "df1@f4c1c54@", + "$cfb5aed%3da", + "1a6@cfbdcfd1", + "5bfbfa64-bec", + "16efdf!6c3f1", + "ac34#f321f5!", + "@eb5d3ef5aef", + } + sb := strings.Builder{} + for idx := 0; idx < 100; idx++ { + password := g.Generate() + assert.NotEmpty(t, password) + if idx < len(expectedPasswords) { + assert.Equal(t, expectedPasswords[idx], password) + if expectedPasswords[idx] != password { + sb.WriteString(fmt.Sprintf("%#v,\n", password)) + } + } + } + if sb.Len() > 0 { + fmt.Println(sb.String()) + } + }) +} diff --git a/password/rule.go b/password/rule.go new file mode 100644 index 0000000..f16c940 --- /dev/null +++ b/password/rule.go @@ -0,0 +1,60 @@ +package password + +// Rule controls how the Generator/Sequencer generates passwords. +type Rule func(any) + +var ( + defaultRules = []Rule{ + WithCharset(AlphaNumeric), + WithLength(8), + } +) + +// WithCharset sets the Charset the Generator/Sequencer can use. +func WithCharset(c Charset) Rule { + return func(a any) { + switch v := a.(type) { + case *generator: + v.charset = []rune(c) + case *sequencer: + v.charset = []rune(c) + } + } +} + +// WithLength sets the length of the generated password. +func WithLength(l int) Rule { + return func(a any) { + switch v := a.(type) { + case *generator: + v.numChars = l + case *sequencer: + v.numChars = l + } + } +} + +// WithNumSymbols controls the min/max number of symbols that can appear in the +// password. +// +// Note: This works only on a Generator and is ineffective with a Sequencer. +func WithNumSymbols(min, max int) Rule { + // sanitize min and max + if min < 0 { + min = 0 + } + if max < 0 { + max = 0 + } + if min > max { + min = max + } + + return func(a any) { + switch v := a.(type) { + case *generator: + v.minSymbols = min + v.maxSymbols = max + } + } +} diff --git a/password/sequencer.go b/password/sequencer.go index 3b26814..a13ee8a 100644 --- a/password/sequencer.go +++ b/password/sequencer.go @@ -52,6 +52,7 @@ type sequencer struct { mutex sync.Mutex n *big.Int nMax *big.Int + numChars int password []int passwordChars []rune passwordMaxIdx int @@ -60,28 +61,32 @@ type sequencer struct { // NewSequencer returns a password sequencer that implements the Sequencer // interface. -func NewSequencer(charset Charset, numChars int) (Sequencer, error) { - if len(charset) == 0 { +func NewSequencer(rules ...Rule) (Sequencer, error) { + s := &sequencer{ + rng: rand.New(rand.NewSource(time.Now().UnixNano())), + } + for _, rule := range append(defaultRules, rules...) { + rule(s) + } + + // init the variables + s.base = big.NewInt(int64(len(s.charset))) + s.charsetLen = len(s.charset) + s.charsetMaxIdx = len(s.charset) - 1 + s.maxWords = MaximumPossibleWords(Charset(s.charset), s.numChars) + s.n = big.NewInt(0) + s.nMax = new(big.Int).Sub(s.maxWords, biOne) + s.password = make([]int, s.numChars) + s.passwordChars = make([]rune, s.numChars) + s.passwordMaxIdx = s.numChars - 1 + + if len(s.charset) == 0 { return nil, ErrEmptyCharset } - if numChars <= 0 { + if s.numChars <= 0 { return nil, ErrZeroLenPassword } - maxWords := MaximumPossibleWords(charset, numChars) - - return &sequencer{ - base: big.NewInt(int64(len(charset))), - charset: []rune(charset), - charsetLen: len(charset), - charsetMaxIdx: len(charset) - 1, - maxWords: maxWords, - n: big.NewInt(0), - nMax: new(big.Int).Sub(maxWords, biOne), - password: make([]int, numChars), - passwordChars: make([]rune, numChars), // avoids frequent mallocs - passwordMaxIdx: numChars - 1, - rng: rand.New(rand.NewSource(time.Now().UnixNano())), - }, nil + return s, nil } // First moves to the first possible password and returns the same. diff --git a/password/sequencer_test.go b/password/sequencer_test.go index 61b8590..eebdf13 100644 --- a/password/sequencer_test.go +++ b/password/sequencer_test.go @@ -12,13 +12,11 @@ import ( "github.com/stretchr/testify/assert" ) -var ( - testSeqCharset = AlphaNumeric.WithoutAmbiguity().WithoutDuplicates() - testSeqNumChars = 12 -) - func BenchmarkSequencer_GotoN(b *testing.B) { - s, err := NewSequencer(testSeqCharset, testSeqNumChars) + s, err := NewSequencer( + WithCharset(AlphaNumeric.WithoutAmbiguity().WithoutDuplicates()), + WithLength(12), + ) assert.Nil(b, err) n := big.NewInt(math.MaxInt) @@ -33,7 +31,10 @@ func BenchmarkSequencer_GotoN(b *testing.B) { } func BenchmarkSequencer_Next(b *testing.B) { - s, err := NewSequencer(testSeqCharset, testSeqNumChars) + s, err := NewSequencer( + WithCharset(AlphaNumeric.WithoutAmbiguity().WithoutDuplicates()), + WithLength(12), + ) assert.Nil(b, err) s.First() @@ -44,7 +45,10 @@ func BenchmarkSequencer_Next(b *testing.B) { } func BenchmarkSequencer_NextN(b *testing.B) { - s, err := NewSequencer(testSeqCharset, testSeqNumChars) + s, err := NewSequencer( + WithCharset(AlphaNumeric.WithoutAmbiguity().WithoutDuplicates()), + WithLength(12), + ) assert.Nil(b, err) s.First() @@ -56,7 +60,10 @@ func BenchmarkSequencer_NextN(b *testing.B) { } func BenchmarkSequencer_Prev(b *testing.B) { - s, err := NewSequencer(testSeqCharset, testSeqNumChars) + s, err := NewSequencer( + WithCharset(AlphaNumeric.WithoutAmbiguity().WithoutDuplicates()), + WithLength(12), + ) assert.Nil(b, err) s.Last() @@ -67,7 +74,10 @@ func BenchmarkSequencer_Prev(b *testing.B) { } func BenchmarkSequencer_PrevN(b *testing.B) { - s, err := NewSequencer(testSeqCharset, testSeqNumChars) + s, err := NewSequencer( + WithCharset(AlphaNumeric.WithoutAmbiguity().WithoutDuplicates()), + WithLength(12), + ) assert.Nil(b, err) _, _ = s.GotoN(big.NewInt(math.MaxInt)) @@ -79,16 +89,25 @@ func BenchmarkSequencer_PrevN(b *testing.B) { } func TestSequencer(t *testing.T) { - s, err := NewSequencer("", 0) + s, err := NewSequencer( + WithCharset(""), + WithLength(3), + ) assert.Nil(t, s) assert.NotNil(t, err) assert.True(t, errors.Is(err, ErrEmptyCharset)) - s, err = NewSequencer("AB", 0) + s, err = NewSequencer( + WithCharset("AB"), + WithLength(0), + ) assert.Nil(t, s) assert.NotNil(t, err) assert.True(t, errors.Is(err, ErrZeroLenPassword)) - s, err = NewSequencer("AB", 3) + s, err = NewSequencer( + WithCharset("AB"), + WithLength(3), + ) assert.Nil(t, err) assert.Equal(t, "AAA", s.Get()) assert.Equal(t, "0", s.GetN().String()) @@ -152,7 +171,10 @@ func TestSequencer(t *testing.T) { } func TestSequencer_GotoN(t *testing.T) { - s, err := NewSequencer("AB", 3) + s, err := NewSequencer( + WithCharset("AB"), + WithLength(3), + ) assert.Nil(t, err) pw, err := s.GotoN(big.NewInt(-1)) @@ -162,7 +184,10 @@ func TestSequencer_GotoN(t *testing.T) { assert.NotNil(t, err) assert.True(t, errors.Is(err, ErrInvalidN)) - s, err = NewSequencer("AB", 4) + s, err = NewSequencer( + WithCharset("AB"), + WithLength(4), + ) assert.Nil(t, err) expectedPasswords := []string{ "AAAA", @@ -190,7 +215,10 @@ func TestSequencer_GotoN(t *testing.T) { } func TestSequencer_Stream(t *testing.T) { - s, err := NewSequencer("AB", 3) + s, err := NewSequencer( + WithCharset("AB"), + WithLength(3), + ) assert.Nil(t, err) ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) @@ -238,7 +266,10 @@ func TestSequencer_Stream(t *testing.T) { } func TestSequencer_Stream_Limited(t *testing.T) { - s, err := NewSequencer("AB", 3) + s, err := NewSequencer( + WithCharset("AB"), + WithLength(3), + ) assert.Nil(t, err) ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) diff --git a/password/utils_test.go b/password/utils_test.go index 6395ed5..a25dd71 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, "1475789056", MaximumPossibleWords(Symbols, 8).String()) + assert.Equal(t, "500246412961", MaximumPossibleWords(Symbols, 8).String()) assert.Equal(t, "53459728531456", MaximumPossibleWords(Alphabets, 8).String()) assert.Equal(t, "218340105584896", MaximumPossibleWords(AlphaNumeric, 8).String()) - assert.Equal(t, "1113034787454976", MaximumPossibleWords(AllChars, 8).String()) + assert.Equal(t, "4702525276151521", MaximumPossibleWords(AllChars, 8).String()) }