diff --git a/core/pkg/serverless/hostfunctions/secrets.go b/core/pkg/serverless/hostfunctions/secrets.go index 5dce599..8ed506a 100644 --- a/core/pkg/serverless/hostfunctions/secrets.go +++ b/core/pkg/serverless/hostfunctions/secrets.go @@ -5,6 +5,7 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rand" + "encoding/base64" "encoding/hex" "fmt" "time" @@ -72,6 +73,13 @@ func (s *DBSecretsManager) Set(ctx context.Context, namespace, name, value strin return fmt.Errorf("failed to encrypt secret: %w", err) } + // Store the ciphertext as an EXPLICIT base64 string (bugboard #837): the + // rqlite client serializes a raw []byte parameter as base64 and reads it + // back as that base64 TEXT — not the original bytes — so a raw-blob write + // round-tripped into base64 that decrypt() could never open. Encoding here + // (and decoding in Get) makes the round-trip deterministic and symmetric. + encoded := base64.StdEncoding.EncodeToString(encrypted) + // Upsert the secret query := ` INSERT INTO function_secrets (id, namespace, name, encrypted_value, created_at, updated_at) @@ -83,7 +91,7 @@ func (s *DBSecretsManager) Set(ctx context.Context, namespace, name, value strin id := fmt.Sprintf("%s:%s", namespace, name) now := time.Now() - if _, err := s.db.Exec(ctx, query, id, namespace, name, encrypted, now, now); err != nil { + if _, err := s.db.Exec(ctx, query, id, namespace, name, encoded, now, now); err != nil { return fmt.Errorf("failed to save secret: %w", err) } @@ -105,7 +113,15 @@ func (s *DBSecretsManager) Get(ctx context.Context, namespace, name string) (str return "", serverless.ErrSecretNotFound } - decrypted, err := s.decrypt(rows[0].EncryptedValue) + // Decode the base64 wrapper written by Set. Fall back to the raw bytes for + // any value that isn't valid base64 (defensive — should not occur once all + // writes go through the encode path above). bugboard #837. + ciphertext, decErr := base64.StdEncoding.DecodeString(string(rows[0].EncryptedValue)) + if decErr != nil { + ciphertext = rows[0].EncryptedValue + } + + decrypted, err := s.decrypt(ciphertext) if err != nil { return "", fmt.Errorf("failed to decrypt secret: %w", err) } diff --git a/core/pkg/serverless/hostfunctions/secrets_test.go b/core/pkg/serverless/hostfunctions/secrets_test.go index 4ad1f70..2b3b3af 100644 --- a/core/pkg/serverless/hostfunctions/secrets_test.go +++ b/core/pkg/serverless/hostfunctions/secrets_test.go @@ -3,6 +3,7 @@ package hostfunctions import ( "context" "database/sql" + "encoding/base64" "errors" "strings" "testing" @@ -35,7 +36,20 @@ func (f *fakeSecretsDB) Exec(ctx context.Context, query string, args ...any) (sq if strings.Contains(query, "INSERT INTO function_secrets") { namespace, _ := args[1].(string) name, _ := args[2].(string) - enc, _ := args[3].([]byte) + // Emulate how real rqlite persists the param into the encrypted_value + // column (bugboard #837): a string is stored as text as-is, while a raw + // []byte param is base64-encoded and stored as text — and READ BACK as + // that base64 text, never the original bytes. The fix passes an explicit + // base64 string from Set and decodes it in Get, so the round-trip is + // symmetric. (A regression to a raw []byte Set + no-decode Get would + // fail this test: decrypt would receive base64 ASCII, not ciphertext.) + var enc []byte + switch v := args[3].(type) { + case string: + enc = []byte(v) + case []byte: + enc = []byte(base64.StdEncoding.EncodeToString(v)) + } cp := make([]byte, len(enc)) copy(cp, enc) f.store[storeKey(namespace, name)] = cp