fix(serverless): get_secret round-trip via secrets.Encrypt + string scan (#837)

The base64 wrapper wasn't enough: DBSecretsManager scanned encrypted_value
into []byte, so the rqlite client applied base64 binary semantics on read and
the ciphertext never round-tripped — get_secret stayed empty. Mirror the
proven push-credentials store exactly: encrypt to a 'enc:'-prefixed base64
string via pkg/secrets and scan the column into a STRING for Decrypt. Text
round-trips cleanly through rqlite regardless of the BLOB column.
This commit is contained in:
anonpenguin23 2026-06-15 14:46:36 +03:00
parent a59017350b
commit 123ca90b65
2 changed files with 18 additions and 67 deletions

View File

@ -2,15 +2,13 @@ package hostfunctions
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"time"
"github.com/DeBrosOfficial/network/pkg/rqlite"
"github.com/DeBrosOfficial/network/pkg/secrets"
"github.com/DeBrosOfficial/network/pkg/serverless"
"go.uber.org/zap"
)
@ -68,18 +66,18 @@ func NewDBSecretsManager(db rqlite.Client, encryptionKeyHex string, allowEphemer
// Set stores an encrypted secret.
func (s *DBSecretsManager) Set(ctx context.Context, namespace, name, value string) error {
encrypted, err := s.encrypt([]byte(value))
// Encrypt to a "enc:"-prefixed base64 STRING and store/read it as a string —
// the proven pattern used by the push-credentials store. bugboard #837: the
// previous code stored the raw AES-GCM bytes as a []byte param and read them
// back into []byte, but the rqlite client applies base64 binary semantics to
// []byte on both legs and the round-trip never reproduced the ciphertext, so
// decrypt() always failed and get_secret returned empty. A text string round-
// trips cleanly.
encrypted, err := secrets.Encrypt(value, s.encryptionKey)
if err != nil {
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)
@ -91,7 +89,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, encoded, now, now); err != nil {
if _, err := s.db.Exec(ctx, query, id, namespace, name, encrypted, now, now); err != nil {
return fmt.Errorf("failed to save secret: %w", err)
}
@ -102,8 +100,10 @@ func (s *DBSecretsManager) Set(ctx context.Context, namespace, name, value strin
func (s *DBSecretsManager) Get(ctx context.Context, namespace, name string) (string, error) {
query := `SELECT encrypted_value FROM function_secrets WHERE namespace = ? AND name = ?`
// Scan into a STRING (not []byte) so the rqlite client returns the stored
// text verbatim instead of applying base64 binary semantics (bugboard #837).
var rows []struct {
EncryptedValue []byte `db:"encrypted_value"`
EncryptedValue string `db:"encrypted_value"`
}
if err := s.db.Query(ctx, &rows, query, namespace, name); err != nil {
return "", fmt.Errorf("failed to query secret: %w", err)
@ -113,20 +113,12 @@ func (s *DBSecretsManager) Get(ctx context.Context, namespace, name string) (str
return "", serverless.ErrSecretNotFound
}
// 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)
decrypted, err := secrets.Decrypt(rows[0].EncryptedValue, s.encryptionKey)
if err != nil {
return "", fmt.Errorf("failed to decrypt secret: %w", err)
}
return string(decrypted), nil
return decrypted, nil
}
// List returns all secret names for a namespace.
@ -164,44 +156,3 @@ func (s *DBSecretsManager) Delete(ctx context.Context, namespace, name string) e
return nil
}
// encrypt encrypts data using AES-256-GCM.
func (s *DBSecretsManager) encrypt(plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(s.encryptionKey)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
// decrypt decrypts data using AES-256-GCM.
func (s *DBSecretsManager) decrypt(ciphertext []byte) ([]byte, error) {
block, err := aes.NewCipher(s.encryptionKey)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
return gcm.Open(nil, nonce, ciphertext, nil)
}

View File

@ -66,15 +66,15 @@ func (f *fakeSecretsDB) Query(ctx context.Context, dest any, query string, args
namespace, _ := args[0].(string)
name, _ := args[1].(string)
rows, ok := dest.(*[]struct {
EncryptedValue []byte `db:"encrypted_value"`
EncryptedValue string `db:"encrypted_value"`
})
if !ok {
return errors.New("unexpected dest type")
}
if enc, found := f.store[storeKey(namespace, name)]; found {
*rows = append(*rows, struct {
EncryptedValue []byte `db:"encrypted_value"`
}{EncryptedValue: enc})
EncryptedValue string `db:"encrypted_value"`
}{EncryptedValue: string(enc)})
}
return nil
}