feat: bytes/base64 secret generation #776

Merged
decentral1se merged 1 commits from Apfelwurm/abra:feat/base64secretgeneration into main 2026-02-18 23:06:16 +00:00
4 changed files with 170 additions and 1 deletions

View File

@ -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))

View File

@ -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)
}

View File

@ -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:

View File

@ -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}