fix(payment): store provider config as plaintext JSON with legacy ciphertext fallback
Without TOTP_ENCRYPTION_KEY, saved payment configs were lost on restart because the AES round-trip failed silently. Write new records as plaintext JSON; read path tries JSON first, falls back to legacy AES decrypt when a key is present, and treats unreadable values as empty so admins can re-enter them via the UI.
This commit is contained in:
@@ -10,12 +10,15 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AES256KeySize is the required key length (in bytes) for AES-256-GCM.
|
||||
const AES256KeySize = 32
|
||||
|
||||
// Encrypt encrypts plaintext using AES-256-GCM with the given 32-byte key.
|
||||
// The output format is "iv:authTag:ciphertext" where each component is base64-encoded,
|
||||
// matching the Node.js crypto.ts format for cross-compatibility.
|
||||
func Encrypt(plaintext string, key []byte) (string, error) {
|
||||
if len(key) != 32 {
|
||||
return "", fmt.Errorf("encryption key must be 32 bytes, got %d", len(key))
|
||||
if len(key) != AES256KeySize {
|
||||
return "", fmt.Errorf("encryption key must be %d bytes, got %d", AES256KeySize, len(key))
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
@@ -52,8 +55,8 @@ func Encrypt(plaintext string, key []byte) (string, error) {
|
||||
// Decrypt decrypts a ciphertext string produced by Encrypt.
|
||||
// The input format is "iv:authTag:ciphertext" where each component is base64-encoded.
|
||||
func Decrypt(ciphertext string, key []byte) (string, error) {
|
||||
if len(key) != 32 {
|
||||
return "", fmt.Errorf("encryption key must be 32 bytes, got %d", len(key))
|
||||
if len(key) != AES256KeySize {
|
||||
return "", fmt.Errorf("encryption key must be %d bytes, got %d", AES256KeySize, len(key))
|
||||
}
|
||||
|
||||
parts := strings.SplitN(ciphertext, ":", 3)
|
||||
|
||||
@@ -261,6 +261,9 @@ func (lb *DefaultLoadBalancer) buildSelection(selected *dbent.PaymentProviderIns
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt instance %d config: %w", selected.ID, err)
|
||||
}
|
||||
if config == nil {
|
||||
config = map[string]string{}
|
||||
}
|
||||
|
||||
if selected.PaymentMode != "" {
|
||||
config["paymentMode"] = selected.PaymentMode
|
||||
@@ -275,16 +278,29 @@ func (lb *DefaultLoadBalancer) buildSelection(selected *dbent.PaymentProviderIns
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (lb *DefaultLoadBalancer) decryptConfig(encrypted string) (map[string]string, error) {
|
||||
plaintext, err := Decrypt(encrypted, lb.encryptionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// decryptConfig parses a stored provider config.
|
||||
// New records are plaintext JSON; legacy records are AES-256-GCM ciphertext.
|
||||
// Unreadable values (legacy ciphertext without a valid key, or malformed data)
|
||||
// are treated as empty so the service keeps running while the admin re-enters
|
||||
// the config via the UI.
|
||||
func (lb *DefaultLoadBalancer) decryptConfig(stored string) (map[string]string, error) {
|
||||
if stored == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var config map[string]string
|
||||
if err := json.Unmarshal([]byte(plaintext), &config); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal config: %w", err)
|
||||
if err := json.Unmarshal([]byte(stored), &config); err == nil {
|
||||
return config, nil
|
||||
}
|
||||
return config, nil
|
||||
if len(lb.encryptionKey) == AES256KeySize {
|
||||
if plaintext, err := Decrypt(stored, lb.encryptionKey); err == nil {
|
||||
if err := json.Unmarshal([]byte(plaintext), &config); err == nil {
|
||||
return config, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
slog.Warn("payment provider config unreadable, treating as empty for re-entry",
|
||||
"stored_len", len(stored))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetInstanceDailyAmount returns the total completed order amount for an instance today.
|
||||
|
||||
@@ -452,6 +452,103 @@ func TestStartOfDay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptConfig_PlaintextAndLegacyCompat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := make([]byte, AES256KeySize)
|
||||
for i := range key {
|
||||
key[i] = byte(i + 1)
|
||||
}
|
||||
wrongKey := make([]byte, AES256KeySize)
|
||||
for i := range wrongKey {
|
||||
wrongKey[i] = byte(0xFF - i)
|
||||
}
|
||||
|
||||
plaintextJSON := `{"appId":"app-123","secret":"sec-xyz"}`
|
||||
|
||||
legacyEncrypted, err := Encrypt(plaintextJSON, key)
|
||||
if err != nil {
|
||||
t.Fatalf("seed Encrypt: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
stored string
|
||||
key []byte
|
||||
want map[string]string
|
||||
}{
|
||||
{
|
||||
name: "empty stored returns nil map",
|
||||
stored: "",
|
||||
key: key,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "plaintext JSON parses directly",
|
||||
stored: plaintextJSON,
|
||||
key: nil,
|
||||
want: map[string]string{"appId": "app-123", "secret": "sec-xyz"},
|
||||
},
|
||||
{
|
||||
name: "plaintext JSON works even with key present",
|
||||
stored: plaintextJSON,
|
||||
key: key,
|
||||
want: map[string]string{"appId": "app-123", "secret": "sec-xyz"},
|
||||
},
|
||||
{
|
||||
name: "legacy ciphertext with correct key decrypts",
|
||||
stored: legacyEncrypted,
|
||||
key: key,
|
||||
want: map[string]string{"appId": "app-123", "secret": "sec-xyz"},
|
||||
},
|
||||
{
|
||||
name: "legacy ciphertext with no key treated as empty",
|
||||
stored: legacyEncrypted,
|
||||
key: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "legacy ciphertext with wrong key treated as empty",
|
||||
stored: legacyEncrypted,
|
||||
key: wrongKey,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "garbage data treated as empty",
|
||||
stored: "not-json-and-not-ciphertext",
|
||||
key: key,
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
lb := NewDefaultLoadBalancer(nil, tt.key)
|
||||
got, err := lb.decryptConfig(tt.stored)
|
||||
if err != nil {
|
||||
t.Fatalf("decryptConfig unexpected error: %v", err)
|
||||
}
|
||||
if !stringMapEqual(got, tt.want) {
|
||||
t.Fatalf("decryptConfig = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// stringMapEqual compares two map[string]string values; nil and empty are equal.
|
||||
func stringMapEqual(a, b map[string]string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k, v := range a {
|
||||
if bv, ok := b[k]; !ok || bv != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user