Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: ensure GeneratePassword returns password with mixed cases #8904

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -164,12 +164,14 @@ func (t *clusterShardingAccountTransformer) buildPassword(transCtx *clusterTrans

func (t *clusterShardingAccountTransformer) generatePassword(account appsv1.SystemAccount) []byte {
config := account.PasswordGenerationPolicy
passwd, _ := common.GeneratePassword((int)(config.Length), (int)(config.NumDigits), (int)(config.NumSymbols), false, config.Seed)
passwd, _ := common.GeneratePassword((int)(config.Length), (int)(config.NumDigits), (int)(config.NumSymbols), config.Seed)
switch config.LetterCase {
case appsv1.UpperCases:
passwd = strings.ToUpper(passwd)
case appsv1.LowerCases:
passwd = strings.ToLower(passwd)
case appsv1.MixedCases:
passwd, _ = common.EnsureMixedCase(passwd, config.Seed)
}
return []byte(passwd)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,14 @@ func (t *componentAccountTransformer) buildPassword(ctx *componentTransformConte

func (t *componentAccountTransformer) generatePassword(account synthesizedSystemAccount) []byte {
config := account.PasswordGenerationPolicy
passwd, _ := common.GeneratePassword((int)(config.Length), (int)(config.NumDigits), (int)(config.NumSymbols), false, config.Seed)
passwd, _ := common.GeneratePassword((int)(config.Length), (int)(config.NumDigits), (int)(config.NumSymbols), config.Seed)
switch config.LetterCase {
case appsv1.UpperCases:
passwd = strings.ToUpper(passwd)
case appsv1.LowerCases:
passwd = strings.ToLower(passwd)
case appsv1.MixedCases:
passwd, _ = common.EnsureMixedCase(passwd, config.Seed)
}
return []byte(passwd)
}
Expand Down
73 changes: 59 additions & 14 deletions pkg/common/password.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"encoding/binary"
mathrand "math/rand"
"time"
"unicode"

"github.com/sethvargo/go-password/password"
)
Expand All @@ -45,19 +46,11 @@ func (r *PasswordReader) Seed(seed int64) {
r.rand.Seed(seed)
}

// GeneratePassword generates a password with the given requirements and seed.
func GeneratePassword(length, numDigits, numSymbols int, noUpper bool, seed string) (string, error) {
var rand *mathrand.Rand
if len(seed) == 0 {
rand = mathrand.New(mathrand.NewSource(time.Now().UnixNano()))
} else {
h := sha256.New()
_, err := h.Write([]byte(seed))
if err != nil {
return "", err
}
uSeed := binary.BigEndian.Uint64(h.Sum(nil))
rand = mathrand.New(mathrand.NewSource(int64(uSeed)))
// GeneratePassword generates a password with the given requirements and seed in lowercase.
func GeneratePassword(length, numDigits, numSymbols int, seed string) (string, error) {
rand, err := newRngFromSeed(seed)
if err != nil {
return "", err
}
passwordReader := &PasswordReader{rand: rand}
gen, err := password.NewGenerator(&password.GeneratorInput{
Expand All @@ -70,5 +63,57 @@ func GeneratePassword(length, numDigits, numSymbols int, noUpper bool, seed stri
if err != nil {
return "", err
}
return gen.Generate(length, numDigits, numSymbols, noUpper, true)
return gen.Generate(length, numDigits, numSymbols, true, true)
}

// EnsureMixedCase randomizes the letter casing in the given string, ensuring
// that the result contains at least one uppercase and one lowercase letter
func EnsureMixedCase(in, seed string) (string, error) {
runes := []rune(in)
letterIndices := make([]int, 0, len(runes))
for i, r := range runes {
if unicode.IsLetter(r) {
letterIndices = append(letterIndices, i)
}
}
L := len(letterIndices)

if L < 2 {
return in, nil
}

// Get a random number x in [1, 2^L - 2], whose binary list will be used to determine the letter casing.
// avoid the all-0 and all-1 patterns, which cause all-lowercase and all-uppercase password.
rng, err := newRngFromSeed(seed)
if err != nil {
return in, err
}
x := uint64(rng.Int63n(int64((1<<L)-2))) + 1

for i := 0; i < L; i++ {
bit := (x >> i) & 1
idx := letterIndices[i]
if bit == 0 {
runes[idx] = unicode.ToLower(runes[idx])
} else {
runes[idx] = unicode.ToUpper(runes[idx])
}
}
return string(runes), nil
}

// newRngFromSeed initializes a *mathrand.Rand from the given seed. If seed is empty,
// it uses the current time, making the output non-deterministic.
func newRngFromSeed(seed string) (*mathrand.Rand, error) {
if seed == "" {
return mathrand.New(mathrand.NewSource(time.Now().UnixNano())), nil
}
// Convert seed string to a 64-bit integer via SHA-256
h := sha256.New()
if _, err := h.Write([]byte(seed)); err != nil {
return nil, err
}
sum := h.Sum(nil)
uSeed := binary.BigEndian.Uint64(sum)
return mathrand.New(mathrand.NewSource(int64(uSeed))), nil
}
79 changes: 75 additions & 4 deletions pkg/common/password_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func testGeneratorGeneratePasswordWithSeed(t *testing.T) {
resultSeedFirstTime := ""
resultSeedEachTime := ""
for i := 0; i < N; i++ {
res, err := GeneratePassword(10, 5, 0, false, seed)
res, err := GeneratePassword(10, 5, 0, seed)
if err != nil {
t.Error(err)
}
Expand All @@ -52,11 +52,11 @@ func testGeneratorGeneratePassword(t *testing.T) {
t.Run("exceeds_length", func(t *testing.T) {
t.Parallel()

if _, err := GeneratePassword(0, 1, 0, false, ""); err != password.ErrExceedsTotalLength {
if _, err := GeneratePassword(0, 1, 0, ""); err != password.ErrExceedsTotalLength {
t.Errorf("expected %q to be %q", err, password.ErrExceedsTotalLength)
}

if _, err := GeneratePassword(0, 0, 1, false, ""); err != password.ErrExceedsTotalLength {
if _, err := GeneratePassword(0, 0, 1, ""); err != password.ErrExceedsTotalLength {
t.Errorf("expected %q to be %q", err, password.ErrExceedsTotalLength)
}
})
Expand All @@ -67,7 +67,7 @@ func testGeneratorGeneratePassword(t *testing.T) {
resultSeedEachTime := ""
hasDiffPassword := false
for i := 0; i < N; i++ {
res, err := GeneratePassword(i%len(password.LowerLetters), 0, 0, true, "")
res, err := GeneratePassword(i%(len(password.LowerLetters)+len(password.UpperLetters)), 0, 0, "")
if err != nil {
t.Error(err)
}
Expand All @@ -93,3 +93,74 @@ func TestGeneratorGeneratePassword(t *testing.T) {
func TestGeneratorGeneratePasswordWithSeed(t *testing.T) {
testGeneratorGeneratePasswordWithSeed(t)
}

// containsUppercase checks if s has at least one uppercase letter (A-Z).
func containsUppercase(s string) bool {
for _, r := range s {
if r >= 'A' && r <= 'Z' {
return true
}
}
return false
}

// containsLowercase checks if s has at least one lowercase letter (a-z).
func containsLowercase(s string) bool {
for _, r := range s {
if r >= 'a' && r <= 'z' {
return true
}
}
return false
}

// TestGeneratorEnsureMixedCase verifies two requirements:
// 1) When noUpper = false, the generated password contains uppercase and lowercase letters.
// 2) Passwords generated with the same seed are identical.
func TestGeneratorEnsureMixedCase(t *testing.T) {
t.Run("should_contain_mixed_case_when_noUpper_false", func(t *testing.T) {
length := 12
numDigits := 3
numSymbols := 2
seed := ""

// Generate multiple passwords and check they have both upper and lower letters.
for i := 0; i < 100; i++ {
pwd, err := GeneratePassword(length, numDigits, numSymbols, seed)
if err != nil {
t.Fatalf("unexpected error generating password: %v", err)
}
pwd, err = EnsureMixedCase(pwd, seed)
if err != nil {
t.Fatalf("unexpected error Ensuring mixed-case password: %v", err)
}
if !containsUppercase(pwd) || !containsLowercase(pwd) {
t.Errorf("password %q does not contain both uppercase and lowercase letters", pwd)
}
}
})

t.Run("should_produce_same_result_with_same_seed", func(t *testing.T) {
length := 10
numDigits := 2
numSymbols := 1
seed := "fixed-seed-123"

var firstPwd string
for i := 0; i < 50; i++ {
pwd, err := GeneratePassword(length, numDigits, numSymbols, seed)
if err != nil {
t.Fatalf("unexpected error generating password with seed: %v", err)
}
pwd, err = EnsureMixedCase(pwd, seed)
if err != nil {
t.Fatalf("unexpected error Ensuring mixed-case password: %v", err)
}
if i == 0 {
firstPwd = pwd
} else if pwd != firstPwd {
t.Errorf("expected the same password for the same seed, but got %q vs %q", firstPwd, pwd)
}
}
})
}