open-vault/helper/random/string_generator_test.go

833 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package random
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"math"
MRAND "math/rand"
"reflect"
"sort"
"testing"
"time"
)
func TestStringGenerator_Generate_successful(t *testing.T) {
type testCase struct {
timeout time.Duration
generator *StringGenerator
}
tests := map[string]testCase{
"common rules": {
timeout: 1 * time.Second,
generator: &StringGenerator{
Length: 20,
Rules: []Rule{
CharsetRule{
Charset: LowercaseRuneset,
MinChars: 1,
},
CharsetRule{
Charset: UppercaseRuneset,
MinChars: 1,
},
CharsetRule{
Charset: NumericRuneset,
MinChars: 1,
},
CharsetRule{
Charset: ShortSymbolRuneset,
MinChars: 1,
},
},
charset: AlphaNumericShortSymbolRuneset,
},
},
"charset not explicitly specified": {
timeout: 1 * time.Second,
generator: &StringGenerator{
Length: 20,
Rules: []Rule{
CharsetRule{
Charset: LowercaseRuneset,
MinChars: 1,
},
CharsetRule{
Charset: UppercaseRuneset,
MinChars: 1,
},
CharsetRule{
Charset: NumericRuneset,
MinChars: 1,
},
},
},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
// One context to rule them all, one context to find them, one context to bring them all and in the darkness bind them.
ctx, cancel := context.WithTimeout(context.Background(), test.timeout)
defer cancel()
runeset := map[rune]bool{}
runesFound := []rune{}
for i := 0; i < 100; i++ {
actual, err := test.generator.Generate(ctx, nil)
if err != nil {
t.Fatalf("no error expected, but got: %s", err)
}
for _, r := range actual {
if runeset[r] {
continue
}
runeset[r] = true
runesFound = append(runesFound, r)
}
}
sort.Sort(runes(runesFound))
expectedCharset := getChars(test.generator.Rules)
if !reflect.DeepEqual(runesFound, expectedCharset) {
t.Fatalf("Didn't find all characters from the charset\nActual : [%s]\nExpected: [%s]", string(runesFound), string(expectedCharset))
}
})
}
}
func TestStringGenerator_Generate_errors(t *testing.T) {
type testCase struct {
timeout time.Duration
generator *StringGenerator
rng io.Reader
}
tests := map[string]testCase{
"already timed out": {
timeout: 0,
generator: &StringGenerator{
Length: 20,
Rules: []Rule{
testCharsetRule{
fail: false,
},
},
charset: AlphaNumericShortSymbolRuneset,
},
rng: rand.Reader,
},
"impossible rules": {
timeout: 10 * time.Millisecond, // Keep this short so the test doesn't take too long
generator: &StringGenerator{
Length: 20,
Rules: []Rule{
testCharsetRule{
fail: true,
},
},
charset: AlphaNumericShortSymbolRuneset,
},
rng: rand.Reader,
},
"bad RNG reader": {
timeout: 10 * time.Millisecond, // Keep this short so the test doesn't take too long
generator: &StringGenerator{
Length: 20,
Rules: []Rule{},
charset: AlphaNumericShortSymbolRuneset,
},
rng: badReader{},
},
"0 length": {
timeout: 10 * time.Millisecond,
generator: &StringGenerator{
Length: 0,
Rules: []Rule{
CharsetRule{
Charset: []rune("abcde"),
MinChars: 0,
},
},
charset: []rune("abcde"),
},
rng: rand.Reader,
},
"-1 length": {
timeout: 10 * time.Millisecond,
generator: &StringGenerator{
Length: -1,
Rules: []Rule{
CharsetRule{
Charset: []rune("abcde"),
MinChars: 0,
},
},
charset: []rune("abcde"),
},
rng: rand.Reader,
},
"no charset": {
timeout: 10 * time.Millisecond,
generator: &StringGenerator{
Length: 20,
Rules: []Rule{},
},
rng: rand.Reader,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
// One context to rule them all, one context to find them, one context to bring them all and in the darkness bind them.
ctx, cancel := context.WithTimeout(context.Background(), test.timeout)
defer cancel()
actual, err := test.generator.Generate(ctx, test.rng)
if err == nil {
t.Fatalf("Expected error but none found")
}
if actual != "" {
t.Fatalf("Random string returned: %s", actual)
}
})
}
}
func TestRandomRunes_deterministic(t *testing.T) {
// These tests are to ensure that the charset selection doesn't do anything weird like selecting the same character
// over and over again. The number of test cases here should be kept to a minimum since they are sensitive to changes
type testCase struct {
rngSeed int64
charset string
length int
expected string
}
tests := map[string]testCase{
"small charset": {
rngSeed: 1585593298447807000,
charset: "abcde",
length: 20,
expected: "ddddddcdebbeebdbdbcd",
},
"common charset": {
rngSeed: 1585593298447807001,
charset: AlphaNumericShortSymbolCharset,
length: 20,
expected: "ON6lVjnBs84zJbUBVEzb",
},
"max size charset": {
rngSeed: 1585593298447807002,
charset: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_" +
"`abcdefghijklmnopqrstuvwxyz{|}~ĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠ" +
"ġĢģĤĥĦħĨĩĪīĬĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠ" +
"šŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſ℀℁ℂ℃℄℅℆ℇ℈℉ℊℋℌℍℎℏℐℑℒℓ℔ℕ№℗℘ℙℚℛℜℝ℞℟℠",
length: 20,
expected: "tųŎ℄ņ℃Œ.@řHš-}ħGIJLℏ",
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
rng := MRAND.New(MRAND.NewSource(test.rngSeed))
runes, err := randomRunes(rng, []rune(test.charset), test.length)
if err != nil {
t.Fatalf("Expected no error, but found: %s", err)
}
str := string(runes)
if str != test.expected {
t.Fatalf("Actual: %s Expected: %s", str, test.expected)
}
})
}
}
func TestRandomRunes_successful(t *testing.T) {
type testCase struct {
charset []rune // Assumes no duplicate runes
length int
}
tests := map[string]testCase{
"small charset": {
charset: []rune("abcde"),
length: 20,
},
"common charset": {
charset: AlphaNumericShortSymbolRuneset,
length: 20,
},
"max size charset": {
charset: []rune(
" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_" +
"`abcdefghijklmnopqrstuvwxyz{|}~ĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠ" +
"ġĢģĤĥĦħĨĩĪīĬĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠ" +
"šŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſ℀℁ℂ℃℄℅℆ℇ℈℉ℊℋℌℍℎℏℐℑℒℓ℔ℕ№℗℘ℙℚℛℜℝ℞℟℠",
),
length: 20,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
runeset := map[rune]bool{}
runesFound := []rune{}
for i := 0; i < 10000; i++ {
actual, err := randomRunes(rand.Reader, test.charset, test.length)
if err != nil {
t.Fatalf("no error expected, but got: %s", err)
}
for _, r := range actual {
if runeset[r] {
continue
}
runeset[r] = true
runesFound = append(runesFound, r)
}
}
sort.Sort(runes(runesFound))
// Sort the input too just to ensure that they can be compared
sort.Sort(runes(test.charset))
if !reflect.DeepEqual(runesFound, test.charset) {
t.Fatalf("Didn't find all characters from the charset\nActual : [%s]\nExpected: [%s]", string(runesFound), string(test.charset))
}
})
}
}
func TestRandomRunes_errors(t *testing.T) {
type testCase struct {
charset []rune
length int
rng io.Reader
}
tests := map[string]testCase{
"nil charset": {
charset: nil,
length: 20,
rng: rand.Reader,
},
"empty charset": {
charset: []rune{},
length: 20,
rng: rand.Reader,
},
"charset is too long": {
charset: []rune(" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_" +
"`abcdefghijklmnopqrstuvwxyz{|}~ĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠ" +
"ġĢģĤĥĦħĨĩĪīĬĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠ" +
"šŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſ℀℁ℂ℃℄℅℆ℇ℈℉ℊℋℌℍℎℏℐℑℒℓ℔ℕ№℗℘ℙℚℛℜℝ℞℟℠" +
"Σ",
),
length: 20,
rng: rand.Reader,
},
"length is zero": {
charset: []rune("abcde"),
length: 0,
rng: rand.Reader,
},
"length is negative": {
charset: []rune("abcde"),
length: -3,
rng: rand.Reader,
},
"reader failed": {
charset: []rune("abcde"),
length: 20,
rng: badReader{},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
actual, err := randomRunes(test.rng, test.charset, test.length)
if err == nil {
t.Fatalf("Expected error but none found")
}
if actual != nil {
t.Fatalf("Expected no value, but found [%s]", string(actual))
}
})
}
}
func BenchmarkStringGenerator_Generate(b *testing.B) {
lengths := []int{
8, 12, 16, 20, 24, 28,
}
type testCase struct {
generator *StringGenerator
}
benches := map[string]testCase{
"no restrictions": {
generator: &StringGenerator{
Rules: []Rule{
CharsetRule{
Charset: AlphaNumericFullSymbolRuneset,
},
},
},
},
"default generator": {
generator: DefaultStringGenerator,
},
"large symbol set": {
generator: &StringGenerator{
Rules: []Rule{
CharsetRule{
Charset: LowercaseRuneset,
MinChars: 1,
},
CharsetRule{
Charset: UppercaseRuneset,
MinChars: 1,
},
CharsetRule{
Charset: NumericRuneset,
MinChars: 1,
},
CharsetRule{
Charset: FullSymbolRuneset,
MinChars: 1,
},
},
},
},
"max symbol set": {
generator: &StringGenerator{
Rules: []Rule{
CharsetRule{
Charset: []rune(" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_" +
"`abcdefghijklmnopqrstuvwxyz{|}~ĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠ" +
"ġĢģĤĥĦħĨĩĪīĬĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠ" +
"šŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſ℀℁ℂ℃℄℅℆ℇ℈℉ℊℋℌℍℎℏℐℑℒℓ℔ℕ№℗℘ℙℚℛℜℝ℞℟℠"),
},
CharsetRule{
Charset: LowercaseRuneset,
MinChars: 1,
},
CharsetRule{
Charset: UppercaseRuneset,
MinChars: 1,
},
CharsetRule{
Charset: []rune("ĩĪīĬĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒ"),
MinChars: 1,
},
},
},
},
"restrictive charset rules": {
generator: &StringGenerator{
Rules: []Rule{
CharsetRule{
Charset: AlphaNumericShortSymbolRuneset,
},
CharsetRule{
Charset: []rune("A"),
MinChars: 1,
},
CharsetRule{
Charset: []rune("1"),
MinChars: 1,
},
CharsetRule{
Charset: []rune("a"),
MinChars: 1,
},
CharsetRule{
Charset: []rune("-"),
MinChars: 1,
},
},
},
},
}
for name, bench := range benches {
b.Run(name, func(b *testing.B) {
for _, length := range lengths {
bench.generator.Length = length
b.Run(fmt.Sprintf("length=%d", length), func(b *testing.B) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
b.ResetTimer()
for i := 0; i < b.N; i++ {
str, err := bench.generator.Generate(ctx, nil)
if err != nil {
b.Fatalf("Failed to generate string: %s", err)
}
if str == "" {
b.Fatalf("Didn't error but didn't generate a string")
}
}
})
}
})
}
// Mimic what the SQLCredentialsProducer is doing
b.Run("SQLCredentialsProducer", func(b *testing.B) {
sg := StringGenerator{
Length: 16, // 16 because the SQLCredentialsProducer prepends 4 characters to a 20 character password
charset: AlphaNumericRuneset,
Rules: nil,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
b.ResetTimer()
for i := 0; i < b.N; i++ {
str, err := sg.Generate(ctx, nil)
if err != nil {
b.Fatalf("Failed to generate string: %s", err)
}
if str == "" {
b.Fatalf("Didn't error but didn't generate a string")
}
}
})
}
// Ensure the StringGenerator can be properly JSON-ified
func TestStringGenerator_JSON(t *testing.T) {
expected := StringGenerator{
Length: 20,
charset: deduplicateRunes([]rune("teststring" + ShortSymbolCharset)),
Rules: []Rule{
testCharsetRule{
String: "teststring",
Integer: 123,
},
CharsetRule{
Charset: ShortSymbolRuneset,
MinChars: 1,
},
},
}
b, err := json.Marshal(expected)
if err != nil {
t.Fatalf("Failed to marshal to JSON: %s", err)
}
parser := PolicyParser{
RuleRegistry: Registry{
Rules: map[string]ruleConstructor{
"testrule": newTestRule,
"charset": ParseCharset,
},
},
}
actual, err := parser.ParsePolicy(string(b))
if err != nil {
t.Fatalf("Failed to parse JSON: %s", err)
}
if !reflect.DeepEqual(actual, expected) {
t.Fatalf("Actual: %#v\nExpected: %#v", actual, expected)
}
}
type badReader struct{}
func (badReader) Read([]byte) (int, error) {
return 0, fmt.Errorf("test error")
}
func TestValidate(t *testing.T) {
type testCase struct {
generator *StringGenerator
expectErr bool
}
tests := map[string]testCase{
"default generator": {
generator: DefaultStringGenerator,
expectErr: false,
},
"length is 0": {
generator: &StringGenerator{
Length: 0,
},
expectErr: true,
},
"length is negative": {
generator: &StringGenerator{
Length: -2,
},
expectErr: true,
},
"nil charset, no rules": {
generator: &StringGenerator{
Length: 5,
charset: nil,
},
expectErr: true,
},
"zero length charset, no rules": {
generator: &StringGenerator{
Length: 5,
charset: []rune{},
},
expectErr: true,
},
"rules require password longer than length": {
generator: &StringGenerator{
Length: 5,
charset: []rune("abcde"),
Rules: []Rule{
CharsetRule{
Charset: []rune("abcde"),
MinChars: 6,
},
},
},
expectErr: true,
},
"charset has non-printable characters": {
generator: &StringGenerator{
Length: 0,
charset: []rune{
'a',
'b',
0, // Null character
'd',
'e',
},
},
expectErr: true,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
err := test.generator.validateConfig()
if test.expectErr && err == nil {
t.Fatalf("err expected, got nil")
}
if !test.expectErr && err != nil {
t.Fatalf("no error expected, got: %s", err)
}
})
}
}
type testNonCharsetRule struct {
String string `mapstructure:"string" json:"string"`
}
func (tr testNonCharsetRule) Pass([]rune) bool { return true }
func (tr testNonCharsetRule) Type() string { return "testNonCharsetRule" }
func TestGetChars(t *testing.T) {
type testCase struct {
rules []Rule
expected []rune
}
tests := map[string]testCase{
"nil rules": {
rules: nil,
expected: []rune(nil),
},
"empty rules": {
rules: []Rule{},
expected: []rune(nil),
},
"rule without chars": {
rules: []Rule{
testNonCharsetRule{
String: "teststring",
},
},
expected: []rune(nil),
},
"rule with chars": {
rules: []Rule{
CharsetRule{
Charset: []rune("abcdefghij"),
MinChars: 1,
},
},
expected: []rune("abcdefghij"),
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
actual := getChars(test.rules)
if !reflect.DeepEqual(actual, test.expected) {
t.Fatalf("Actual: %v\nExpected: %v", actual, test.expected)
}
})
}
}
func TestDeduplicateRunes(t *testing.T) {
type testCase struct {
input []rune
expected []rune
}
tests := map[string]testCase{
"empty string": {
input: []rune(""),
expected: []rune(nil),
},
"no duplicates": {
input: []rune("abcde"),
expected: []rune("abcde"),
},
"in order duplicates": {
input: []rune("aaaabbbbcccccccddddeeeee"),
expected: []rune("abcde"),
},
"out of order duplicates": {
input: []rune("abcdeabcdeabcdeabcde"),
expected: []rune("abcde"),
},
"unicode no duplicates": {
input: []rune("日本語"),
expected: []rune("日本語"),
},
"unicode in order duplicates": {
input: []rune("日日日日本本本語語語語語"),
expected: []rune("日本語"),
},
"unicode out of order duplicates": {
input: []rune("日本語日本語日本語日本語"),
expected: []rune("日本語"),
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
actual := deduplicateRunes(test.input)
if !reflect.DeepEqual(actual, test.expected) {
t.Fatalf("Actual: %#v\nExpected:%#v", actual, test.expected)
}
})
}
}
func TestRandomRunes_Bias(t *testing.T) {
type testCase struct {
charset []rune
maxStdDev float64
}
tests := map[string]testCase{
"small charset": {
charset: []rune("abcde"),
maxStdDev: 2700,
},
"lowercase characters": {
charset: LowercaseRuneset,
maxStdDev: 1000,
},
"alphabetical characters": {
charset: AlphabeticRuneset,
maxStdDev: 800,
},
"alphanumeric": {
charset: AlphaNumericRuneset,
maxStdDev: 800,
},
"alphanumeric with symbol": {
charset: AlphaNumericShortSymbolRuneset,
maxStdDev: 800,
},
"charset evenly divisible into 256": {
charset: append(AlphaNumericRuneset, '!', '@'),
maxStdDev: 800,
},
"large charset": {
charset: FullSymbolRuneset,
maxStdDev: 800,
},
"just under half size charset": {
charset: []rune(" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_" +
"`abcdefghijklmnopqrstuvwxyz{|}~ĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğ"),
maxStdDev: 800,
},
"half size charset": {
charset: []rune(" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_" +
"`abcdefghijklmnopqrstuvwxyz{|}~ĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠ"),
maxStdDev: 800,
},
}
for name, test := range tests {
t.Run(fmt.Sprintf("%s (%d chars)", name, len(test.charset)), func(t *testing.T) {
runeCounts := map[rune]int{}
generations := 50000
length := 100
for i := 0; i < generations; i++ {
str, err := randomRunes(nil, test.charset, length)
if err != nil {
t.Fatal(err)
}
for _, r := range str {
runeCounts[r]++
}
}
chars := charCounts{}
var sum float64
for r, count := range runeCounts {
chars = append(chars, charCount{r, count})
sum += float64(count)
}
mean := sum / float64(len(runeCounts))
var stdDev float64
for _, count := range runeCounts {
stdDev += math.Pow(float64(count)-mean, 2)
}
stdDev = math.Sqrt(stdDev / float64(len(runeCounts)))
t.Logf("Mean : %10.4f", mean)
if stdDev > test.maxStdDev {
t.Fatalf("Standard deviation is too large: %.2f > %.2f", stdDev, test.maxStdDev)
}
})
}
}
type charCount struct {
r rune
count int
}
type charCounts []charCount
func (s charCounts) Len() int { return len(s) }
func (s charCounts) Less(i, j int) bool { return s[i].r < s[j].r }
func (s charCounts) Swap(i, j int) { s[i], s[j] = s[j], s[i] }