你的支付密钥我们连自己都看不到: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
}

几个关键设计点:

  1. 每次加密生成随机Nonce——同样的明文,每次加密后的密文都不同,防止攻击者通过密文比对推断明文
  2. Nonce前置于密文——解密时先提取Nonce,再解密,格式简单可靠
  3. 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

生产环境

生产环境的主密钥通过以下方式注入:

  1. 云服务商的密钥管理服务(KMS)——如阿里云KMS、AWS KMS
  2. Docker Secrets——Docker Swarm模式下的密钥管理
  3. 环境变量——由运维人员手动设置,不写入任何配置文件
# 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. 生成新的主密钥,版本号+1
  2. 新的加密请求使用新密钥
  3. 旧的加密数据仍然用旧密钥解密
  4. 后台任务逐步用新密钥重新加密旧数据
  5. 确认所有数据都重新加密后,删除旧密钥
// 后台任务:重新加密旧数据
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_secretsaction字段标识具体操作类型:

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)
}

审计日志的设计原则:

  1. 只记录访问行为,不记录密钥内容——日志里不会出现明文密钥
  2. 不可篡改——审计日志表只有INSERT权限,没有UPDATE和DELETE
  3. 定期归档——超过保留期限的日志归档到对象存储
-- 审计日志表的权限控制
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加密不是万能的。它的安全边界在于:

  1. 主密钥的安全——如果主密钥泄露,加密形同虚设。所以主密钥的管理是重中之重
  2. Nonce的唯一性——同一个密钥下,Nonce绝对不能重复。我们用crypto/rand生成12字节随机Nonce,重复概率极低(2^96分之一)
  3. 内存安全——解密后的明文存在于进程内存中。如果服务器被入侵,攻击者可能从内存中提取明文。这是所有服务端加密方案的共同限制

对于更高安全要求的场景(如金融级),可以考虑使用HSM(硬件安全模块)来保护主密钥。但对餐饮SaaS来说,AES-256-GCM + 环境变量管理主密钥,已经是一个在安全性和成本之间取得良好平衡的方案。

小结

支付密钥加密的核心原则:

原则 实现方式
密钥不落库 主密钥只存于环境变量
认证加密 AES-256-GCM,防篡改+防窃取
每次随机Nonce 同样的明文产生不同的密文
版本化轮换 不停机更换密钥
访问审计 每次解密都记录日志
Mock开发 开发环境不碰真实密钥

下一篇,我们聊性能——3秒出单、200ms响应,餐饮收银系统的性能密码。


系列导航:[龙讯餐饮SaaS架构实战]

← 上一篇:100家店的数据如何互不偷看:多租户隔离深度解析 | 下一篇 →3秒出单200ms响应:餐饮收银系统性能密码

  1. 从单体到多租户:餐饮SaaS架构演进之路
  2. 100家店的数据如何互不偷看:多租户隔离深度解析
  3. 你的支付密钥我们连自己都看不到:AES-256-GCM加密实战
  4. 3秒出单200ms响应:餐饮收银系统性能密码
  5. 从1家店到100家店代码一行不用改:多门店架构设计
  6. 退款5年可追溯:餐饮SaaS合规体系构建
  7. 微信扫码→下单→支付→出票,30秒全链路
  8. Go + PostgreSQL + Redis:餐饮SaaS后端架构全景