你的支付密钥我们连自己都看不到:AES-256-GCM加密实战
你的支付密钥我们连自己都看不到:AES-256-GCM加密实战
做餐饮SaaS,支付是绕不开的话题。每个商户接入微信支付、支付宝,都需要配置API密钥(api_key)、商户证书(apiclient_key)等敏感信息。这些信息如果明文存储在数据库里,一旦数据库被拖库,所有商户的支付密钥全部泄露。
龙讯餐饮V2的做法是:用AES-256-GCM加密存储,加密密钥不在数据库里,连我们自己都解不开。
为什么选AES-256-GCM?
对称加密算法有很多选择,我们选AES-256-GCM有三个原因:
| 特性 | AES-256-GCM | AES-256-CBC | ChaCha20-Poly1305 |
|---|---|---|---|
| 认证加密 | ✅ 内置 | ❌ 需要额外HMAC | ✅ 内置 |
| 硬件加速 | ✅ AES-NI | ✅ AES-NI | ❌ 纯软件 |
| 密文长度 | 明文+16字节 | 明文+16字节+padding | 明文+16字节 |
| Go标准库支持 | ✅ crypto/aes | ✅ crypto/aes | ✅ golang.org/x/crypto |
认证加密(Authenticated Encryption with Associated Data,AEAD) 是关键。传统的CBC模式只提供加密,不提供完整性验证——攻击者可以篡改密文,解密后得到不同的明文。GCM模式同时提供加密和认证,任何篡改都会被检测到。
硬件加速也很重要。Go的crypto/aes包在支持AES-NI指令集的CPU上,性能比纯软件实现快10倍以上。餐饮收银系统的高峰期,每秒可能有上百次加密解密操作,硬件加速是刚需。
加密方案设计
整体架构
商户配置支付密钥
↓
应用层:从环境变量读取主密钥(Master Key)
↓
生成随机Nonce(12字节)
↓
AES-256-GCM加密 → 密文 + Auth Tag
↓
存储到数据库:nonce || ciphertext || tag
核心原则:主密钥只存在于环境变量中,永远不进数据库、不进日志、不进代码仓库。
加密实现
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"io"
)
type Encryptor struct {
aead cipher.AEAD
}
func NewEncryptor(masterKeyHex string) (*Encryptor, error) {
key, err := hex.DecodeString(masterKeyHex)
if err != nil {
return nil, fmt.Errorf("invalid master key: %w", err)
}
if len(key) != 32 {
return nil, fmt.Errorf("master key must be 32 bytes, got %d", len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &Encryptor{aead: aead}, nil
}
func (e *Encryptor) Encrypt(plaintext string) (string, error) {
nonce := make([]byte, e.aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
// nonce作为Additional Data的一部分,密文包含nonce
ciphertext := e.aead.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func (e *Encryptor) Decrypt(encoded string) (string, error) {
ciphertext, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
if len(ciphertext) < e.aead.NonceSize()+e.aead.Overhead() {
return "", fmt.Errorf("ciphertext too short")
}
nonce := ciphertext[:e.aead.NonceSize()]
ciphertext = ciphertext[e.aead.NonceSize():]
plaintext, err := e.aead.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("decryption failed: %w", err)
}
return string(plaintext), nil
}
几个关键设计点:
- 每次加密生成随机Nonce——同样的明文,每次加密后的密文都不同,防止攻击者通过密文比对推断明文
- Nonce前置于密文——解密时先提取Nonce,再解密,格式简单可靠
- Base64编码存储——数据库字段用TEXT类型,避免二进制存储的兼容性问题
数据库存储
type TenantPaymentSecret struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
TenantID int64 `gorm:"column:tenant_id;not null;uniqueIndex:uk_tenant_channel"`
Channel string `gorm:"column:channel;size:20;not null;uniqueIndex:uk_tenant_channel"` // wechat / alipay / unionpay
EncryptedSecret string `gorm:"column:encrypted_secret;size:500;not null"` // 加密后的API密钥
IV string `gorm:"column:iv;size:50;not null"` // 初始化向量
Version int `gorm:"column:version;not null;default:1"` // 密钥版本,用于轮换
CreatedAt time.Time `gorm:"column:created_at;not null;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;autoUpdateTime"`
}
注意:Channel字段标识支付渠道(wechat/alipay/unionpay),与TenantID组成唯一索引,确保每个租户每个渠道只有一条密钥记录。IV字段存储AES-256-GCM的初始化向量,与密文分开存储增加了安全性。
密钥管理:主密钥在哪里?
主密钥(Master Key)是整个加密体系的根。如果主密钥泄露,所有商户的支付密钥都不安全。我们的管理策略:
开发环境
开发环境使用Docker Compose的.env文件:
# .env(不提交到代码仓库,.gitignore已排除)
MASTER_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
生产环境
生产环境的主密钥通过以下方式注入:
- 云服务商的密钥管理服务(KMS)——如阿里云KMS、AWS KMS
- Docker Secrets——Docker Swarm模式下的密钥管理
- 环境变量——由运维人员手动设置,不写入任何配置文件
# docker-compose.prod.yml
services:
api:
image: longxunpos/api:latest
environment:
- MASTER_KEY=${MASTER_KEY} # 从宿主机环境变量注入
secrets:
- master_key
secrets:
master_key:
external: true
密钥访问控制
主密钥只在应用启动时读取一次,缓存在内存中。没有任何API可以读取主密钥:
// 全局单例,启动时初始化
var encryptor *Encryptor
func InitEncryptor(masterKey string) error {
var err error
encryptor, err = NewEncryptor(masterKey)
return err
}
// 所有加密解密都通过这个单例
func GetEncryptor() *Encryptor {
return encryptor
}
即使有人拿到了数据库的完整备份,没有主密钥也无法解密。即使有人拿到了服务器的shell,主密钥只存在于进程的内存中,不会落盘。
密钥轮换:不停止服务的密钥更换
密钥轮换(Key Rotation)是安全最佳实践——即使当前密钥没有泄露,也应该定期更换。但轮换的难点在于:不能停止服务,已加密的数据要能继续解密。
我们的方案是版本化密钥:
type KeyRegistry struct {
keys map[int]*Encryptor // key_version → Encryptor
}
func (r *KeyRegistry) Encrypt(plaintext string) (string, int, error) {
// 用最新版本的密钥加密
latestVersion := r.latestVersion()
enc := r.keys[latestVersion]
ciphertext, err := enc.Encrypt(plaintext)
return ciphertext, latestVersion, err
}
func (r *KeyRegistry) Decrypt(ciphertext string, keyVersion int) (string, error) {
// 用对应版本的密钥解密
enc, ok := r.keys[keyVersion]
if !ok {
return "", fmt.Errorf("unknown key version: %d", keyVersion)
}
return enc.Decrypt(ciphertext)
}
轮换流程:
- 生成新的主密钥,版本号+1
- 新的加密请求使用新密钥
- 旧的加密数据仍然用旧密钥解密
- 后台任务逐步用新密钥重新加密旧数据
- 确认所有数据都重新加密后,删除旧密钥
// 后台任务:重新加密旧数据
func (s *KeyRotationService) RotateKeys(ctx context.Context) error {
var secrets []TenantPaymentSecret
s.db.Where("version < ?", s.registry.latestVersion()).Find(&secrets)
for _, sec := range secrets {
// 用旧密钥解密
plaintext, err := s.registry.Decrypt(sec.EncryptedSecret, sec.Version)
if err != nil {
log.Printf("decrypt failed for secret %d: %v", sec.ID, err)
continue
}
// 用新密钥加密
newCiphertext, newVersion, err := s.registry.Encrypt(plaintext)
if err != nil {
log.Printf("encrypt failed for secret %d: %v", sec.ID, err)
continue
}
// 更新数据库
s.db.Model(&sec).Updates(map[string]interface{}{
"encrypted_secret": newCiphertext,
"version": newVersion,
})
}
return nil
}
这个方案的好处是:轮换过程中,服务完全不受影响。新请求用新密钥,旧请求用旧密钥,互不干扰。
审计日志:谁在什么时候访问了什么?
支付密钥的每一次访问都必须被记录。这不是可选的,是合规要求。
我们并没有为密钥访问创建独立的审计表,而是复用系统通用的AuditLog。密钥的加密、解密、轮换操作通过AuditLog记录,table_name字段值为tenant_payment_secrets,action字段标识具体操作类型:
type AuditLog struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
TenantID int64 `gorm:"column:tenant_id;not null;index"`
UserID int64 `gorm:"column:user_id;not null;index"`
Action string `gorm:"column:action;size:20;not null"` // encrypt / decrypt / rotate
TblName string `gorm:"column:table_name;size:50;not null"` // tenant_payment_secrets
RecordID int64 `gorm:"column:record_id;not null"`
BeforeData *string `gorm:"column:before_data;type:jsonb"` // 变更前数据(JSON)
AfterData *string `gorm:"column:after_data;type:jsonb"` // 变更后数据(JSON)
IPAddress *string `gorm:"column:ip_address;size:50"`
UserAgent *string `gorm:"column:user_agent;size:500"`
PrevHash string `gorm:"column:prev_hash;size:64;not null;default:''"`
RecordHash string `gorm:"column:record_hash;size:64;not null"` // 防篡改哈希
CreatedAt time.Time `gorm:"column:created_at;not null;autoCreateTime;index"`
}
func (s *PaymentService) GetDecryptedSecret(ctx context.Context, secretID int64) (string, error) {
// 1. 查询加密数据
var secret TenantPaymentSecret
if err := s.db.First(&secret, secretID).Error; err != nil {
return "", err
}
// 2. 验证租户权限
tenantID := ctx.Value(CtxKeyTenantID).(int64)
if secret.TenantID != tenantID {
return "", fmt.Errorf("access denied")
}
// 3. 记录审计日志(通过通用AuditLog)
s.auditLog.Log(ctx, "decrypt", "tenant_payment_secrets", secretID, nil, nil)
// 4. 解密
return s.registry.Decrypt(secret.EncryptedSecret, secret.Version)
}
审计日志的设计原则:
- 只记录访问行为,不记录密钥内容——日志里不会出现明文密钥
- 不可篡改——审计日志表只有INSERT权限,没有UPDATE和DELETE
- 定期归档——超过保留期限的日志归档到对象存储
-- 审计日志表的权限控制
REVOKE UPDATE, DELETE ON audit_logs FROM PUBLIC;
-- 只有应用角色有INSERT权限
GRANT INSERT ON audit_logs TO app_role;
开发环境的Mock模式
开发环境不应该使用真实的支付密钥。我们提供了Mock模式:
type PaymentClient interface {
Pay(ctx context.Context, req *PayRequest) (*PayResponse, error)
Refund(ctx context.Context, req *RefundRequest) (*RefundResponse, error)
Query(ctx context.Context, orderNo string) (*QueryResponse, error)
}
// 真实实现
type WechatPayClient struct { /* ... */ }
// Mock实现
type MockPayClient struct {
ShouldSucceed bool
}
func (m *MockPayClient) Pay(ctx context.Context, req *PayRequest) (*PayResponse, error) {
if m.ShouldSucceed {
return &PayResponse{
TradeNo: fmt.Sprintf("MOCK_%d", time.Now().UnixNano()),
Status: "SUCCESS",
}, nil
}
return nil, fmt.Errorf("mock payment failed")
}
通过环境变量切换:
func NewPaymentClient(channel string) PaymentClient {
if os.Getenv("PAYMENT_MOCK") == "true" {
return &MockPayClient{ShouldSucceed: true}
}
switch channel {
case "wechat":
return NewWechatPayClient()
case "alipay":
return NewAlipayPayClient()
default:
panic("unknown payment channel: " + channel)
}
}
加密方案的安全边界
需要明确的是,AES-256-GCM加密不是万能的。它的安全边界在于:
- 主密钥的安全——如果主密钥泄露,加密形同虚设。所以主密钥的管理是重中之重
- Nonce的唯一性——同一个密钥下,Nonce绝对不能重复。我们用
crypto/rand生成12字节随机Nonce,重复概率极低(2^96分之一) - 内存安全——解密后的明文存在于进程内存中。如果服务器被入侵,攻击者可能从内存中提取明文。这是所有服务端加密方案的共同限制
对于更高安全要求的场景(如金融级),可以考虑使用HSM(硬件安全模块)来保护主密钥。但对餐饮SaaS来说,AES-256-GCM + 环境变量管理主密钥,已经是一个在安全性和成本之间取得良好平衡的方案。
小结
支付密钥加密的核心原则:
| 原则 | 实现方式 |
|---|---|
| 密钥不落库 | 主密钥只存于环境变量 |
| 认证加密 | AES-256-GCM,防篡改+防窃取 |
| 每次随机Nonce | 同样的明文产生不同的密文 |
| 版本化轮换 | 不停机更换密钥 |
| 访问审计 | 每次解密都记录日志 |
| Mock开发 | 开发环境不碰真实密钥 |
下一篇,我们聊性能——3秒出单、200ms响应,餐饮收银系统的性能密码。
系列导航:[龙讯餐饮SaaS架构实战]
← 上一篇:100家店的数据如何互不偷看:多租户隔离深度解析 | 下一篇 →3秒出单200ms响应:餐饮收银系统性能密码
- 从单体到多租户:餐饮SaaS架构演进之路
- 100家店的数据如何互不偷看:多租户隔离深度解析
- 你的支付密钥我们连自己都看不到:AES-256-GCM加密实战
- 3秒出单200ms响应:餐饮收银系统性能密码
- 从1家店到100家店代码一行不用改:多门店架构设计
- 退款5年可追溯:餐饮SaaS合规体系构建
- 微信扫码→下单→支付→出票,30秒全链路
- Go + PostgreSQL + Redis:餐饮SaaS后端架构全景