From 597fe545b74069f74ffe6922c3282664067c71aa Mon Sep 17 00:00:00 2001 From: zizi Date: Wed, 20 May 2026 14:53:18 +0800 Subject: [PATCH] feat: add payment encryption helpers Add AES-GCM encryption utilities for payment credentials with key length and ciphertext validation. --- payment/crypto.go | 70 ++++++++++++++++++++++++++++++++++++++++++ payment/crypto_test.go | 64 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 payment/crypto.go create mode 100644 payment/crypto_test.go diff --git a/payment/crypto.go b/payment/crypto.go new file mode 100644 index 00000000..f72449f3 --- /dev/null +++ b/payment/crypto.go @@ -0,0 +1,70 @@ +package payment + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" + "io" +) + +var encryptionKey []byte + +func InitEncryptionKey(key string) { + encryptionKey = []byte(key) +} + +func Encrypt(plaintext string) (string, error) { + gcm, err := newGCM() + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func Decrypt(encoded string) (string, error) { + gcm, err := newGCM() + if err != nil { + return "", err + } + + ciphertext, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize+gcm.Overhead() { + return "", fmt.Errorf("ciphertext too short") + } + + nonce := ciphertext[:nonceSize] + encrypted := ciphertext[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, encrypted, nil) + if err != nil { + return "", err + } + + return string(plaintext), nil +} + +func newGCM() (cipher.AEAD, error) { + if len(encryptionKey) != 32 { + return nil, fmt.Errorf("invalid encryption key length: got %d, want 32", len(encryptionKey)) + } + + block, err := aes.NewCipher(encryptionKey) + if err != nil { + return nil, err + } + + return cipher.NewGCM(block) +} diff --git a/payment/crypto_test.go b/payment/crypto_test.go new file mode 100644 index 00000000..c16cc059 --- /dev/null +++ b/payment/crypto_test.go @@ -0,0 +1,64 @@ +package payment + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +const testEncryptionKey = "12345678901234567890123456789012" + +func setTestEncryptionKey(t *testing.T, key string) { + t.Helper() + previousKey := encryptionKey + InitEncryptionKey(key) + t.Cleanup(func() { + encryptionKey = previousKey + }) +} + +func TestEncryptDecryptRoundTrip(t *testing.T) { + setTestEncryptionKey(t, testEncryptionKey) + + encrypted, err := Encrypt("hello payment") + require.NoError(t, err) + require.NotEmpty(t, encrypted) + + decrypted, err := Decrypt(encrypted) + require.NoError(t, err) + require.Equal(t, "hello payment", decrypted) +} + +func TestEncryptRejectsInvalidKeyLength(t *testing.T) { + setTestEncryptionKey(t, "short") + + _, err := Encrypt("hello payment") + require.Error(t, err) + require.Contains(t, err.Error(), "invalid encryption key length") +} + +func TestDecryptRejectsInvalidBase64(t *testing.T) { + setTestEncryptionKey(t, testEncryptionKey) + + _, err := Decrypt("not-base64") + require.Error(t, err) +} + +func TestDecryptRejectsTooShortCiphertext(t *testing.T) { + setTestEncryptionKey(t, testEncryptionKey) + + _, err := Decrypt("c2hvcnQ=") + require.Error(t, err) + require.Contains(t, err.Error(), "ciphertext too short") +} + +func TestEncryptUsesRandomNonce(t *testing.T) { + setTestEncryptionKey(t, testEncryptionKey) + + first, err := Encrypt("same plaintext") + require.NoError(t, err) + second, err := Encrypt("same plaintext") + require.NoError(t, err) + + require.NotEqual(t, first, second) +}