add charset modifier to secret generation #521

Merged
p4u1 merged 4 commits from Apfelwurm/abra:feature/special_charset_secret_generation into main 2025-03-21 10:29:21 +00:00
4 changed files with 75 additions and 17 deletions

View File

@ -33,6 +33,10 @@ type Secret struct {
// variable. For Example: // variable. For Example:
// SECRET_FOO=v1 # length=12 // SECRET_FOO=v1 # length=12
Length int Length int
// Charset comes from the charset modifier at the secret version environment
// variable. For Example:
// SECRET_FOO=v1 # charset=default,special
Charset string
// RemoteName is the name of the secret on the server. For example: // RemoteName is the name of the secret on the server. For example:
// name: ${STACK_NAME}_test_pass_two_${SECRET_TEST_PASS_TWO_VERSION} // name: ${STACK_NAME}_test_pass_two_${SECRET_TEST_PASS_TWO_VERSION}
// With the following: // With the following:
@ -43,38 +47,38 @@ type Secret struct {
RemoteName string RemoteName string
} }
// GeneratePasswords generates passwords. // GeneratePassword generates passwords.
func GeneratePasswords(count, length uint) ([]string, error) { func GeneratePassword(length uint, charset string) (string, error) {
Apfelwurm marked this conversation as resolved Outdated
Outdated
Review

the return type could also be changed to (string, error)

the return type could also be changed to `(string, error)`

done, also changed the name to fit the single generation

done, also changed the name to fit the single generation
Apfelwurm marked this conversation as resolved Outdated
Outdated
Review

I think GeneratePasswords is only used with count=1. If you are up for it, a seperate refactor commit would be nice, that changes the function to GeneratePassword(length uint, charset string) (string, error)

(Same for GeneratePassphrases)

I think GeneratePasswords is only used with `count=1`. If you are up for it, a seperate refactor commit would be nice, that changes the function to `GeneratePassword(length uint, charset string) (string, error)` (Same for `GeneratePassphrases`)

done :)

done :)
passwords, err := passgen.GeneratePasswords( passwords, err := passgen.GeneratePasswords(
count, 1,
length, length,
passgen.AlphabetDefault, charset,
) )
if err != nil { if err != nil {
return nil, err return "", err
} }
log.Debugf("generated %s", strings.Join(passwords, ", ")) log.Debugf("generated %s", strings.Join(passwords, ", "))
return passwords, nil return passwords[0], nil
} }
// GeneratePassphrases generates human readable and rememberable passphrases. // GeneratePassphrase generates human readable and rememberable passphrases.
func GeneratePassphrases(count uint) ([]string, error) { func GeneratePassphrase() (string, error) {
Apfelwurm marked this conversation as resolved Outdated
Outdated
Review

the return type could also be changed to (string, error)

the return type could also be changed to `(string, error)`

done, also changed the name to fit the single generation

done, also changed the name to fit the single generation
passphrases, err := passgen.GeneratePassphrases( passphrases, err := passgen.GeneratePassphrases(
count, 1,
passgen.PassphraseWordCountDefault, passgen.PassphraseWordCountDefault,
rune('-'), rune('-'),
passgen.PassphraseCasingDefault, passgen.PassphraseCasingDefault,
passgen.WordListDefault, passgen.WordListDefault,
) )
if err != nil { if err != nil {
return nil, err return "", err
} }
log.Debugf("generated %s", strings.Join(passphrases, ", ")) log.Debugf("generated %s", strings.Join(passphrases, ", "))
return passphrases, nil return passphrases[0], nil
} }
// ReadSecretsConfig reads secret names/versions from the recipe config. The // ReadSecretsConfig reads secret names/versions from the recipe config. The
@ -150,6 +154,8 @@ func ReadSecretsConfig(appEnvPath string, composeFiles []string, stackName strin
} }
value.Length = length value.Length = length
} }
value.Charset = resolveCharset(modifierValues["charset"])
break break
Apfelwurm marked this conversation as resolved Outdated
Outdated
Review

This can be combined into one line

This can be combined into one line

Is "charset" guaranteed to be present? Otherwise, you might want to do the presence check of the key and otherwise supply a default.

Is `"charset"` guaranteed to be present? Otherwise, you might want to do the presence check of the key and otherwise supply a default.
Outdated
Review

It retuens an empty string when not present, which is handled in resolveCharset. SO for me its fine to not check for the presence

It retuens an empty string when not present, which is handled in `resolveCharset`. SO for me its fine to not check for the presence

done :)

done :)
} }
secretValues[secretId] = value secretValues[secretId] = value
@ -158,6 +164,22 @@ func ReadSecretsConfig(appEnvPath string, composeFiles []string, stackName strin
return secretValues, nil return secretValues, nil
} }
// resolveCharset sets the passgen Alphabet required for a secret
func resolveCharset(input string) string {
switch strings.ToLower(input) {
case "special":
return passgen.AlphabetSpecial
case "safespecial":
return "!@#%^&*_-+="
case "default,special", "special,default":
return passgen.AlphabetDefault + passgen.AlphabetSpecial
case "default,safespecial", "safespecial,default":
return passgen.AlphabetDefault + "!@#%^&*_-+="
default:
return passgen.AlphabetDefault // Fallback to default
}
}
// GenerateSecrets generates secrets locally and sends them to a remote server for storage. // GenerateSecrets generates secrets locally and sends them to a remote server for storage.
func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server string) (map[string]string, error) { func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server string) (map[string]string, error) {
secretsGenerated := map[string]string{} secretsGenerated := map[string]string{}
@ -173,13 +195,13 @@ func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server
log.Debugf("attempting to generate and store %s on %s", secret.RemoteName, server) log.Debugf("attempting to generate and store %s on %s", secret.RemoteName, server)
if secret.Length > 0 { if secret.Length > 0 {
passwords, err := GeneratePasswords(1, uint(secret.Length)) password, err := GeneratePassword(uint(secret.Length), secret.Charset)
if err != nil { if err != nil {
ch <- err ch <- err
return return
} }
if err := client.StoreSecret(cl, secret.RemoteName, passwords[0], server); err != nil { if err := client.StoreSecret(cl, secret.RemoteName, password, server); err != nil {
if strings.Contains(err.Error(), "AlreadyExists") { if strings.Contains(err.Error(), "AlreadyExists") {
log.Warnf("%s already exists", secret.RemoteName) log.Warnf("%s already exists", secret.RemoteName)
ch <- nil ch <- nil
@ -191,15 +213,15 @@ func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server
mutex.Lock() mutex.Lock()
defer mutex.Unlock() defer mutex.Unlock()
secretsGenerated[secretName] = passwords[0] secretsGenerated[secretName] = password
} else { } else {
passphrases, err := GeneratePassphrases(1) passphrase, err := GeneratePassphrase()
if err != nil { if err != nil {
ch <- err ch <- err
return return
} }
if err := client.StoreSecret(cl, secret.RemoteName, passphrases[0], server); err != nil { if err := client.StoreSecret(cl, secret.RemoteName, passphrase, server); err != nil {
if strings.Contains(err.Error(), "AlreadyExists") { if strings.Contains(err.Error(), "AlreadyExists") {
log.Warnf("%s already exists", secret.RemoteName) log.Warnf("%s already exists", secret.RemoteName)
ch <- nil ch <- nil
@ -211,7 +233,7 @@ func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server
mutex.Lock() mutex.Lock()
defer mutex.Unlock() defer mutex.Unlock()
secretsGenerated[secretName] = passphrases[0] secretsGenerated[secretName] = passphrase
} }
ch <- nil ch <- nil
}(n, v) }(n, v)

View File

@ -17,16 +17,37 @@ func TestReadSecretsConfig(t *testing.T) {
assert.Equal(t, "test_example_com_test_pass_one_v2", secretsFromConfig["test_pass_one"].RemoteName) assert.Equal(t, "test_example_com_test_pass_one_v2", secretsFromConfig["test_pass_one"].RemoteName)
assert.Equal(t, "v2", secretsFromConfig["test_pass_one"].Version) assert.Equal(t, "v2", secretsFromConfig["test_pass_one"].Version)
assert.Equal(t, 0, secretsFromConfig["test_pass_one"].Length) assert.Equal(t, 0, secretsFromConfig["test_pass_one"].Length)
assert.Equal(t, "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789", secretsFromConfig["test_pass_one"].Charset)
// Has a length modifier // Has a length modifier
assert.Equal(t, "test_example_com_test_pass_two_v1", secretsFromConfig["test_pass_two"].RemoteName) assert.Equal(t, "test_example_com_test_pass_two_v1", secretsFromConfig["test_pass_two"].RemoteName)
assert.Equal(t, "v1", secretsFromConfig["test_pass_two"].Version) assert.Equal(t, "v1", secretsFromConfig["test_pass_two"].Version)
assert.Equal(t, 10, secretsFromConfig["test_pass_two"].Length) assert.Equal(t, 10, secretsFromConfig["test_pass_two"].Length)
assert.Equal(t, "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789", secretsFromConfig["test_pass_two"].Charset)
// Secret name does not include the secret id // Secret name does not include the secret id
assert.Equal(t, "test_example_com_pass_three_v2", secretsFromConfig["test_pass_three"].RemoteName) assert.Equal(t, "test_example_com_pass_three_v2", secretsFromConfig["test_pass_three"].RemoteName)
assert.Equal(t, "v2", secretsFromConfig["test_pass_three"].Version) assert.Equal(t, "v2", secretsFromConfig["test_pass_three"].Version)
assert.Equal(t, 0, secretsFromConfig["test_pass_three"].Length) assert.Equal(t, 0, secretsFromConfig["test_pass_three"].Length)
assert.Equal(t, "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789", secretsFromConfig["test_pass_three"].Charset)
// Has a length modifier and a charset=default,safespecial modifier
assert.Equal(t, "test_example_com_test_pass_four_v1", secretsFromConfig["test_pass_four"].RemoteName)
assert.Equal(t, "v1", secretsFromConfig["test_pass_four"].Version)
assert.Equal(t, 12, secretsFromConfig["test_pass_four"].Length)
assert.Equal(t, "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#%^&*_-+=", secretsFromConfig["test_pass_four"].Charset)
// Has a length modifier and a charset=default,special modifier
assert.Equal(t, "test_example_com_test_pass_five_v1", secretsFromConfig["test_pass_five"].RemoteName)
assert.Equal(t, "v1", secretsFromConfig["test_pass_five"].Version)
assert.Equal(t, 12, secretsFromConfig["test_pass_five"].Length)
assert.Equal(t, "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%^&*_-+=", secretsFromConfig["test_pass_five"].Charset)
// Has only a charset=default,special modifier, which gets setted but ignored in the generation
assert.Equal(t, "test_example_com_test_pass_six_v1", secretsFromConfig["test_pass_six"].RemoteName)
assert.Equal(t, "v1", secretsFromConfig["test_pass_six"].Version)
assert.Equal(t, 0, secretsFromConfig["test_pass_six"].Length)
assert.Equal(t, "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%^&*_-+=", secretsFromConfig["test_pass_six"].Charset)
} }
func TestReadSecretsConfigWithLongDomain(t *testing.T) { func TestReadSecretsConfigWithLongDomain(t *testing.T) {

View File

@ -1,3 +1,6 @@
SECRET_TEST_PASS_ONE_VERSION=v2 SECRET_TEST_PASS_ONE_VERSION=v2
SECRET_TEST_PASS_TWO_VERSION=v1 # length=10 SECRET_TEST_PASS_TWO_VERSION=v1 # length=10
SECRET_TEST_PASS_THREE_VERSION=v2 SECRET_TEST_PASS_THREE_VERSION=v2
SECRET_TEST_PASS_FOUR_VERSION=v1 # length=12 charset=default,safespecial
SECRET_TEST_PASS_FIVE_VERSION=v1 # length=12 charset=default,special
SECRET_TEST_PASS_SIX_VERSION=v1 # charset=default,special

View File

@ -8,6 +8,9 @@ services:
- test_pass_one - test_pass_one
- test_pass_two - test_pass_two
- test_pass_three - test_pass_three
- test_pass_four
- test_pass_five
- test_pass_six
secrets: secrets:
test_pass_one: test_pass_one:
@ -19,3 +22,12 @@ secrets:
test_pass_three: test_pass_three:
external: true external: true
name: ${STACK_NAME}_pass_three_${SECRET_TEST_PASS_THREE_VERSION} # secretId and name don't match name: ${STACK_NAME}_pass_three_${SECRET_TEST_PASS_THREE_VERSION} # secretId and name don't match
test_pass_four:
external: true
name: ${STACK_NAME}_test_pass_four_${SECRET_TEST_PASS_FOUR_VERSION}
test_pass_five:
external: true
name: ${STACK_NAME}_test_pass_five_${SECRET_TEST_PASS_FIVE_VERSION}
test_pass_six:
external: true
name: ${STACK_NAME}_test_pass_six_${SECRET_TEST_PASS_SIX_VERSION}