Skip to content

Commit 926ac7c

Browse files
[bug] rework Windows user password generation to match security constraints (#7507) (#7621)
* fix: re-implement Windows user password generation to match security constraints * doc: add changelog fragment * feat: bump user generated password to a range of 64-127 * feat: add null bytes checks in the generated password * feat: add password charsets unit-tests * fix: log hasLower, hasUpper, hasDigit, hasSpecial at the correct check (cherry picked from commit b364d3d) Co-authored-by: Panos Koutsovasilis <[email protected]>
1 parent 47588b7 commit 926ac7c

File tree

3 files changed

+215
-11
lines changed

3 files changed

+215
-11
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
kind: bug-fix
2+
summary: Rework Windows user password generation to meet security policy constraints
3+
component: elastic-agent
4+
pr: https://github.com/elastic/elastic-agent/pull/7507
5+
issue: https://github.com/elastic/elastic-agent/issues/7506

internal/pkg/agent/install/user_windows.go

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"errors"
1212
"fmt"
1313
"math/big"
14-
"strings"
1514
"syscall"
1615
"unsafe"
1716

@@ -20,7 +19,16 @@ import (
2019
)
2120

2221
const (
23-
passwordLength = 127 // maximum length allowed by Windows
22+
// Reference: https://learn.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/password-must-meet-complexity-requirements
23+
passwordMinLength = 64 // Minimum length for better security
24+
passwordMaxLength = 127 // Upper limit to avoid policy issues
25+
// Character pools - ensuring a mix of character categories
26+
passwordCharsLower = "abcdefghijklmnopqrstuvwxyz"
27+
passwordCharsUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
28+
passwordCharsDigits = "1234567890"
29+
passwordCharsSpecial = "'-!\"#$%&()*,./:;?@[]^_`{|}~+<=>" //nolint:gosec // G101: False positive on Potential hardcoded credentials
30+
// Combined pool for general randomization
31+
passwordAllChars = passwordCharsLower + passwordCharsUpper + passwordCharsDigits + passwordCharsSpecial
2432
)
2533

2634
var (
@@ -238,19 +246,81 @@ func SetUserPassword(name string, password string) error {
238246
return nil
239247
}
240248

241-
// RandomPassword generates a random password.
249+
// RandomPassword generates a secure password that meets Windows policy requirements.
242250
func RandomPassword() (string, error) {
243-
runes := []rune("abcdefghijklmnopqrstuvwxyz1234567890!@#$%^&*ABCDEFGHIJKLMNOPQRSTUVWXYZ")
244-
maxN := big.NewInt(int64(len(runes)))
245-
var sb strings.Builder
246-
for i := 0; i < passwordLength; i++ {
247-
n, err := rand.Int(rand.Reader, maxN)
251+
// Generate a random length within the allowed range
252+
length, err := rand.Int(rand.Reader, big.NewInt(passwordMaxLength-passwordMinLength+1))
253+
if err != nil {
254+
return "", fmt.Errorf("failed to generate random length: %w", err)
255+
}
256+
passwordLength := int(length.Int64()) + passwordMinLength
257+
password := make([]rune, passwordLength)
258+
259+
// Ensure Windows password constraints are met by guaranteeing at least one character from each category
260+
filledPositions := make(map[int]any)
261+
for _, chars := range []string{passwordCharsUpper, passwordCharsLower, passwordCharsDigits, passwordCharsSpecial} {
262+
// Generate a random position for the character
263+
var position int
264+
for {
265+
posBigInt, err := rand.Int(rand.Reader, big.NewInt(int64(passwordLength)))
266+
if err != nil {
267+
return "", fmt.Errorf("failed to generate random position: %w", err)
268+
}
269+
position = int(posBigInt.Int64())
270+
if _, filled := filledPositions[position]; filled {
271+
// Position already has a missing character, generate a new one
272+
continue
273+
}
274+
break
275+
}
276+
// Generate a random character from the given characters
277+
char, err := randomChar(chars)
278+
if err != nil {
279+
// err information is added by randomChar
280+
return "", err
281+
}
282+
// Insert the character into the password
283+
password[position] = char
284+
filledPositions[position] = nil
285+
}
286+
287+
// Fill the remaining positions with random characters
288+
for i := 0; i < passwordLength; {
289+
// Check if the current position has already been filled
290+
if _, filled := filledPositions[i]; filled {
291+
// Position already has a character
292+
i++
293+
continue
294+
}
295+
// Generate a random character from the combined pool of characters
296+
char, err := randomChar(passwordAllChars)
248297
if err != nil {
249-
return "", fmt.Errorf("failed to generate random integer: %w", err)
298+
// err information is added by randomChar
299+
return "", err
300+
}
301+
// Ensure no consecutive duplicate characters to the left
302+
if i > 0 && password[i-1] == char {
303+
continue
250304
}
251-
sb.WriteRune(runes[n.Int64()])
305+
// Ensure no consecutive duplicate characters to the right
306+
if i+1 < passwordLength && password[i+1] == char {
307+
continue
308+
}
309+
// Insert the character into the password
310+
password[i] = char
311+
i++
312+
}
313+
314+
return string(password), nil
315+
}
316+
317+
// randomChar selects a random character from a given string.
318+
func randomChar(charset string) (rune, error) {
319+
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
320+
if err != nil {
321+
return 0, fmt.Errorf("failed to generate random character: %w", err)
252322
}
253-
return sb.String(), nil
323+
return rune(charset[n.Int64()]), nil
254324
}
255325

256326
// LOCALGROUP_INFO_0 structure contains a local group name.
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License 2.0;
3+
// you may not use this file except in compliance with the Elastic License 2.0.
4+
5+
//go:build windows
6+
7+
package install
8+
9+
import (
10+
"fmt"
11+
"strings"
12+
"testing"
13+
"unicode"
14+
15+
"github.com/stretchr/testify/assert"
16+
)
17+
18+
func TestPasswordCharSets(t *testing.T) {
19+
for _, tc := range []struct {
20+
name string
21+
charSet string
22+
valid func(rune) error
23+
}{
24+
{
25+
name: "lowercase characters",
26+
charSet: passwordCharsLower,
27+
valid: func(r rune) error {
28+
if unicode.IsLower(r) {
29+
return nil
30+
}
31+
return fmt.Errorf("character %q is not lowercase", r)
32+
},
33+
},
34+
{
35+
name: "uppercase characters",
36+
charSet: passwordCharsUpper,
37+
valid: func(r rune) error {
38+
if unicode.IsUpper(r) {
39+
return nil
40+
}
41+
return fmt.Errorf("character %q is not uppercase", r)
42+
},
43+
},
44+
{
45+
name: "digit characters",
46+
charSet: passwordCharsDigits,
47+
valid: func(r rune) error {
48+
if unicode.IsDigit(r) {
49+
return nil
50+
}
51+
return fmt.Errorf("character %q is not a digit", r)
52+
},
53+
},
54+
{
55+
name: "special characters",
56+
charSet: passwordCharsSpecial,
57+
valid: func(r rune) error {
58+
if unicode.IsPunct(r) || unicode.IsSymbol(r) {
59+
return nil
60+
}
61+
return fmt.Errorf("character %q is not a special character", r)
62+
},
63+
},
64+
} {
65+
t.Run(tc.name, func(t *testing.T) {
66+
for _, char := range tc.charSet {
67+
assert.NoError(t, tc.valid(char))
68+
}
69+
})
70+
}
71+
}
72+
73+
// TestRandomPassword tries to ensure that generated passwords meet the windows security constraints.
74+
func TestRandomPassword(t *testing.T) {
75+
const testTimes = 100000
76+
passwords := make(map[string]struct{})
77+
for i := 0; i < testTimes; i++ {
78+
password, err := RandomPassword()
79+
if err != nil {
80+
t.Fatalf("RandomPassword() returned an error: %v", err)
81+
}
82+
83+
length := len(password)
84+
if length < passwordMinLength || length > passwordMaxLength {
85+
t.Fatalf("password %q is not within the allowed length range", password)
86+
}
87+
88+
hasLower := false
89+
hasUpper := false
90+
hasDigit := false
91+
hasSpecial := false
92+
93+
if strings.ContainsRune(password, 0) {
94+
t.Fatalf("password %q contains null character", password)
95+
}
96+
97+
for _, char := range password {
98+
switch {
99+
case strings.ContainsRune(passwordCharsLower, char):
100+
hasLower = true
101+
case strings.ContainsRune(passwordCharsUpper, char):
102+
hasUpper = true
103+
case strings.ContainsRune(passwordCharsDigits, char):
104+
hasDigit = true
105+
case strings.ContainsRune(passwordCharsSpecial, char):
106+
hasSpecial = true
107+
default:
108+
t.Fatalf("password %q contains an invalid character %q", password, string(char))
109+
}
110+
}
111+
112+
if !hasLower || !hasUpper || !hasDigit || !hasSpecial {
113+
t.Fatalf("password %q does not contain all required character categories (hasLower=%v, hasUpper=%v, hasDigit=%v, hasSpecial=%v)",
114+
password, hasLower, hasUpper, hasDigit, hasSpecial)
115+
}
116+
117+
// Check for consecutive duplicate digits
118+
for j := 1; j < length; j++ {
119+
if password[j] == password[j-1] {
120+
t.Fatalf("password %q contains consecutive duplicate digits %q at positions %d %d", password, password[j], j, j-1)
121+
}
122+
}
123+
124+
if _, exists := passwords[password]; exists {
125+
t.Fatalf("password %q is not unique", password)
126+
}
127+
passwords[password] = struct{}{}
128+
}
129+
}

0 commit comments

Comments
 (0)