diff --git a/controllers/apps/cluster/transformer_cluster_sharding_account.go b/controllers/apps/cluster/transformer_cluster_sharding_account.go index c04c65ca153..07b9571e582 100644 --- a/controllers/apps/cluster/transformer_cluster_sharding_account.go +++ b/controllers/apps/cluster/transformer_cluster_sharding_account.go @@ -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) } diff --git a/controllers/apps/component/transformer_component_account.go b/controllers/apps/component/transformer_component_account.go index d1e2e324322..3e72ee76283 100644 --- a/controllers/apps/component/transformer_component_account.go +++ b/controllers/apps/component/transformer_component_account.go @@ -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) } diff --git a/pkg/common/password.go b/pkg/common/password.go index 1b169d84084..93dc49c4769 100644 --- a/pkg/common/password.go +++ b/pkg/common/password.go @@ -24,6 +24,7 @@ import ( "encoding/binary" mathrand "math/rand" "time" + "unicode" "github.com/sethvargo/go-password/password" ) @@ -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{ @@ -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<> 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 } diff --git a/pkg/common/password_test.go b/pkg/common/password_test.go index 6cbd1f3a65b..45d86a18f8d 100644 --- a/pkg/common/password_test.go +++ b/pkg/common/password_test.go @@ -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) } @@ -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) } }) @@ -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) } @@ -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) + } + } + }) +}