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:
erio
2026-04-17 17:00:29 +08:00
parent 6cfdf4ec05
commit fd0c9a1305
4 changed files with 151 additions and 25 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
@@ -290,19 +291,29 @@ func (s *PaymentConfigService) mergeConfig(ctx context.Context, id int64, newCon
return existing, nil
}
func (s *PaymentConfigService) decryptConfig(encrypted string) (map[string]string, error) {
if encrypted == "" {
// decryptConfig parses a stored provider config.
// New records are plaintext JSON; legacy records are AES-256-GCM ciphertext
// ("iv:authTag:ciphertext"). Values that cannot be parsed as either — including
// legacy ciphertext with no/invalid TOTP_ENCRYPTION_KEY — are treated as empty,
// letting the admin re-enter the config via the UI to complete the migration.
func (s *PaymentConfigService) decryptConfig(stored string) (map[string]string, error) {
if stored == "" {
return nil, nil
}
decrypted, err := payment.Decrypt(encrypted, s.encryptionKey)
if err != nil {
return nil, fmt.Errorf("decrypt config: %w", err)
var cfg map[string]string
if err := json.Unmarshal([]byte(stored), &cfg); err == nil {
return cfg, nil
}
var raw map[string]string
if err := json.Unmarshal([]byte(decrypted), &raw); err != nil {
return nil, fmt.Errorf("unmarshal decrypted config: %w", err)
if len(s.encryptionKey) == payment.AES256KeySize {
if plaintext, err := payment.Decrypt(stored, s.encryptionKey); err == nil {
if err := json.Unmarshal([]byte(plaintext), &cfg); err == nil {
return cfg, nil
}
}
}
return raw, nil
slog.Warn("payment provider config unreadable, treating as empty for re-entry",
"stored_len", len(stored))
return nil, nil
}
func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id int64) error {
@@ -317,14 +328,13 @@ func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id in
return s.entClient.PaymentProviderInstance.DeleteOneID(id).Exec(ctx)
}
// encryptConfig serialises a provider config for storage.
// New records are written as plaintext JSON; the historical AES-GCM wrapping
// has been dropped but decryptConfig still accepts old ciphertext during migration.
func (s *PaymentConfigService) encryptConfig(cfg map[string]string) (string, error) {
data, err := json.Marshal(cfg)
if err != nil {
return "", fmt.Errorf("marshal config: %w", err)
}
enc, err := payment.Encrypt(string(data), s.encryptionKey)
if err != nil {
return "", fmt.Errorf("encrypt config: %w", err)
}
return enc, nil
return string(data), nil
}