From 8f42e363022dc1d4af62b6858c77dd8f0d1b125a Mon Sep 17 00:00:00 2001 From: Apfelwurm Date: Wed, 14 Jan 2026 12:33:51 +0100 Subject: [PATCH] feat: bytes/base64 secret generation --- pkg/secret/secret.go | 73 ++++++++++++++++++++++++++++- pkg/secret/secret_test.go | 83 +++++++++++++++++++++++++++++++++ pkg/secret/testdir/.env.sample | 3 ++ pkg/secret/testdir/compose.yaml | 12 +++++ 4 files changed, 170 insertions(+), 1 deletion(-) diff --git a/pkg/secret/secret.go b/pkg/secret/secret.go index b84aa295..039e68d4 100644 --- a/pkg/secret/secret.go +++ b/pkg/secret/secret.go @@ -5,6 +5,8 @@ package secret import ( "context" + "crypto/rand" + "encoding/base64" "errors" "fmt" "slices" @@ -39,6 +41,14 @@ type Secret struct { // variable. For Example: // SECRET_FOO=v1 # charset=default,special Charset string + // Encoding comes from the encoding modifier at the secret version environment + // variable. For Example: + // SECRET_FOO=v1 # encoding=base64 + Encoding string + // Prefix comes from the prefix modifier at the secret version environment + // variable. For Example: + // SECRET_FOO=v1 # prefix=base64: + Prefix string // Whether or not to skip generation of the secret or not // For example: SECRET_FOO=v1 # generate=false SkipGenerate bool @@ -87,6 +97,17 @@ func GeneratePassphrase() (string, error) { return passphrases[0], nil } +// generateRandomBytes generates random bytes as a string +func generateRandomBytes(length int) (string, error) { + randomBytes := make([]byte, length) + if _, err := rand.Read(randomBytes); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + // Return as string for consistent handling with other secret types + return string(randomBytes), nil +} + // ReadSecretsConfig reads secret names/versions from the recipe config. The // function generalises appEnv/composeFiles because some times you have an app // and some times you don't (as the caller). We need to be able to handle the @@ -177,6 +198,8 @@ func ReadSecretsConfig(appEnvPath string, composeFiles []string, stackName strin } value.Charset = resolveCharset(modifierValues["charset"]) + value.Encoding = resolveEncoding(value.Charset, modifierValues["encoding"], secretId) + value.Prefix = modifierValues["prefix"] break } secretValues[secretId] = value @@ -185,11 +208,45 @@ func ReadSecretsConfig(appEnvPath string, composeFiles []string, stackName strin return secretValues, nil } +// encodeSecret applies encoding to the generated secret value +func encodeSecret(value, encoding string) string { + switch strings.ToLower(encoding) { + case "base64": + return base64.StdEncoding.EncodeToString([]byte(value)) + default: + return value // No encoding applied + } +} + +// applyPrefix adds a prefix to the secret value +func applyPrefix(value, prefix string) string { + if prefix != "" { + return prefix + value + } + return value +} + +// resolveEncoding validates and resolves the encoding for a given charset and secretId +func resolveEncoding(charset, encoding, secretId string) string { + if charset == "bytes" { + if encoding == "" { + return "base64" + } else if encoding != "base64" { + log.Warnf(i18n.G("charset=bytes only supports encoding=base64, got encoding=%s for secret %s, defaulting to base64", encoding, secretId)) + return "base64" + } + } + + return encoding +} + // resolveCharset sets the passgen Alphabet required for a secret func resolveCharset(input string) string { switch strings.ToLower(input) { case "hex": return passgen.AlphabetNumericAmbiguous + "abcdef" + case "bytes": + return "bytes" case "special": return passgen.AlphabetSpecial case "safespecial": @@ -224,12 +281,23 @@ func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server log.Debug(i18n.G("attempting to generate and store %s on %s", secret.RemoteName, server)) if secret.Length > 0 { - password, err := GeneratePassword(uint(secret.Length), secret.Charset) + var password string + var err error + + if secret.Charset == "bytes" { + password, err = generateRandomBytes(secret.Length) + } else { + password, err = GeneratePassword(uint(secret.Length), secret.Charset) + } + if err != nil { ch <- err return } + password = encodeSecret(password, secret.Encoding) + password = applyPrefix(password, secret.Prefix) + if err := client.StoreSecret(cl, secret.RemoteName, password); err != nil { if strings.Contains(err.Error(), "AlreadyExists") { log.Warnf(i18n.G("%s already exists", secret.RemoteName)) @@ -250,6 +318,9 @@ func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server return } + passphrase = encodeSecret(passphrase, secret.Encoding) + passphrase = applyPrefix(passphrase, secret.Prefix) + if err := client.StoreSecret(cl, secret.RemoteName, passphrase); err != nil { if strings.Contains(err.Error(), "AlreadyExists") { log.Warnf(i18n.G("%s already exists", secret.RemoteName)) diff --git a/pkg/secret/secret_test.go b/pkg/secret/secret_test.go index 728a12ff..256a4836 100644 --- a/pkg/secret/secret_test.go +++ b/pkg/secret/secret_test.go @@ -18,42 +18,80 @@ func TestReadSecretsConfig(t *testing.T) { assert.Equal(t, "v2", secretsFromConfig["test_pass_one"].Version) assert.Equal(t, 0, secretsFromConfig["test_pass_one"].Length) assert.Equal(t, "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789", secretsFromConfig["test_pass_one"].Charset) + assert.Equal(t, "", secretsFromConfig["test_pass_one"].Encoding) + assert.Equal(t, "", secretsFromConfig["test_pass_one"].Prefix) // Has a length modifier 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, 10, secretsFromConfig["test_pass_two"].Length) assert.Equal(t, "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789", secretsFromConfig["test_pass_two"].Charset) + assert.Equal(t, "", secretsFromConfig["test_pass_two"].Encoding) + assert.Equal(t, "", secretsFromConfig["test_pass_two"].Prefix) // 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, "v2", secretsFromConfig["test_pass_three"].Version) assert.Equal(t, 0, secretsFromConfig["test_pass_three"].Length) assert.Equal(t, "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789", secretsFromConfig["test_pass_three"].Charset) + assert.Equal(t, "", secretsFromConfig["test_pass_three"].Encoding) + assert.Equal(t, "", secretsFromConfig["test_pass_three"].Prefix) // 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) + assert.Equal(t, "", secretsFromConfig["test_pass_four"].Encoding) + assert.Equal(t, "", secretsFromConfig["test_pass_four"].Prefix) // 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) + assert.Equal(t, "", secretsFromConfig["test_pass_five"].Encoding) + assert.Equal(t, "", secretsFromConfig["test_pass_five"].Prefix) // 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) + assert.Equal(t, "", secretsFromConfig["test_pass_six"].Encoding) + assert.Equal(t, "", secretsFromConfig["test_pass_six"].Prefix) // Has a length modifier and a charset=hex modifier assert.Equal(t, "test_example_com_test_pass_seven_v1", secretsFromConfig["test_pass_seven"].RemoteName) assert.Equal(t, "v1", secretsFromConfig["test_pass_seven"].Version) assert.Equal(t, 32, secretsFromConfig["test_pass_seven"].Length) assert.Equal(t, "0123456789abcdef", secretsFromConfig["test_pass_seven"].Charset) + assert.Equal(t, "", secretsFromConfig["test_pass_seven"].Encoding) + assert.Equal(t, "", secretsFromConfig["test_pass_seven"].Prefix) + + // Has a length modifier and an encoding=base64 modifier + assert.Equal(t, "test_example_com_test_pass_eight_v1", secretsFromConfig["test_pass_eight"].RemoteName) + assert.Equal(t, "v1", secretsFromConfig["test_pass_eight"].Version) + assert.Equal(t, 12, secretsFromConfig["test_pass_eight"].Length) + assert.Equal(t, "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789", secretsFromConfig["test_pass_eight"].Charset) + assert.Equal(t, "base64", secretsFromConfig["test_pass_eight"].Encoding) + assert.Equal(t, "", secretsFromConfig["test_pass_eight"].Prefix) + + // Has a length modifier and a prefix=base64: modifier + assert.Equal(t, "test_example_com_test_pass_nine_v1", secretsFromConfig["test_pass_nine"].RemoteName) + assert.Equal(t, "v1", secretsFromConfig["test_pass_nine"].Version) + assert.Equal(t, 16, secretsFromConfig["test_pass_nine"].Length) + assert.Equal(t, "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789", secretsFromConfig["test_pass_nine"].Charset) + assert.Equal(t, "", secretsFromConfig["test_pass_nine"].Encoding) + assert.Equal(t, "base64:", secretsFromConfig["test_pass_nine"].Prefix) + + // Has all modifiers: length, charset=bytes, and prefix=base64: (Laravel-style) + assert.Equal(t, "test_example_com_test_pass_ten_v1", secretsFromConfig["test_pass_ten"].RemoteName) + assert.Equal(t, "v1", secretsFromConfig["test_pass_ten"].Version) + assert.Equal(t, 32, secretsFromConfig["test_pass_ten"].Length) + assert.Equal(t, "bytes", secretsFromConfig["test_pass_ten"].Charset) + assert.Equal(t, "base64", secretsFromConfig["test_pass_ten"].Encoding) // Defaults to base64 for bytes + assert.Equal(t, "base64:", secretsFromConfig["test_pass_ten"].Prefix) } func TestReadSecretsConfigWithLongDomain(t *testing.T) { @@ -64,3 +102,48 @@ func TestReadSecretsConfigWithLongDomain(t *testing.T) { } assert.Contains(t, err.Error(), "is > 64 chars") } + +func TestEncodeSecret(t *testing.T) { + // base64 encoding + input := "testpassword123" + encoded := encodeSecret(input, "base64") + expected := "dGVzdHBhc3N3b3JkMTIz" + assert.Equal(t, expected, encoded) + + // no encoding (default) + noEncoding := encodeSecret(input, "") + assert.Equal(t, input, noEncoding) + + // unknown encoding (should return original) + unknownEncoding := encodeSecret(input, "unknown") + assert.Equal(t, input, unknownEncoding) +} + +func TestApplyPrefix(t *testing.T) { + input := "testvalue" + + // with prefix + prefixed := applyPrefix(input, "base64:") + assert.Equal(t, "base64:testvalue", prefixed) + + // with empty prefix + noPrefixed := applyPrefix(input, "") + assert.Equal(t, input, noPrefixed) +} + +func TestGenerateRandomBytes(t *testing.T) { + // random bytes generation with 32 bytes + key, err := generateRandomBytes(32) + assert.NoError(t, err) + assert.Equal(t, 32, len([]byte(key))) // Check raw byte length + + // random bytes generation with 16 bytes + key16, err := generateRandomBytes(16) + assert.NoError(t, err) + assert.Equal(t, 16, len([]byte(key16))) // Check raw byte length + + // that keys are different (randomness) + key2, err := generateRandomBytes(32) + assert.NoError(t, err) + assert.NotEqual(t, key, key2) +} diff --git a/pkg/secret/testdir/.env.sample b/pkg/secret/testdir/.env.sample index 023ced3e..679bd46a 100644 --- a/pkg/secret/testdir/.env.sample +++ b/pkg/secret/testdir/.env.sample @@ -5,3 +5,6 @@ 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 SECRET_TEST_PASS_SEVEN_VERSION=v1 # length=32 charset=hex +SECRET_TEST_PASS_EIGHT_VERSION=v1 # length=12 encoding=base64 +SECRET_TEST_PASS_NINE_VERSION=v1 # length=16 prefix=base64: +SECRET_TEST_PASS_TEN_VERSION=v1 # length=32 charset=bytes prefix=base64: diff --git a/pkg/secret/testdir/compose.yaml b/pkg/secret/testdir/compose.yaml index 1bf642f6..02f52b83 100644 --- a/pkg/secret/testdir/compose.yaml +++ b/pkg/secret/testdir/compose.yaml @@ -12,6 +12,9 @@ services: - test_pass_five - test_pass_six - test_pass_seven + - test_pass_eight + - test_pass_nine + - test_pass_ten secrets: test_pass_one: @@ -35,3 +38,12 @@ secrets: test_pass_seven: external: true name: ${STACK_NAME}_test_pass_seven_${SECRET_TEST_PASS_SEVEN_VERSION} + test_pass_eight: + external: true + name: ${STACK_NAME}_test_pass_eight_${SECRET_TEST_PASS_EIGHT_VERSION} + test_pass_nine: + external: true + name: ${STACK_NAME}_test_pass_nine_${SECRET_TEST_PASS_NINE_VERSION} + test_pass_ten: + external: true + name: ${STACK_NAME}_test_pass_ten_${SECRET_TEST_PASS_TEN_VERSION} -- 2.49.0