为什么支付 API 需要加密与签名?

在支付系统中,API 接口直接暴露在公网,面临三类核心安全威胁:数据窃听(敏感信息被截获)、数据篡改(请求参数被中间人修改)、重放攻击(攻击者重复发送合法请求)。仅靠 HTTPS 是不够的——HTTPS 保护传输层,但无法防止应用层的重放和参数篡改。因此,必须在应用层实现端到端的加密与签名机制。

本文从零讲解支付 API 的完整安全方案,涵盖 RSA 非对称加密、AES 对称加密、HMAC-SHA256 签名、时间戳 Nonce 防重放,以及我们源码商城转卡码系统中实际使用的混合加密体系。

一、加密体系设计总览

一个成熟的支付 API 安全体系通常采用混合加密方案:

  • AES-256-GCM:对称加密,加密请求/响应体,速度快
  • RSA-2048:非对称加密,用于交换 AES 密钥
  • HMAC-SHA256:签名,验证数据完整性和来源
  • Timestamp + Nonce:防重放攻击

流程图如下:

# 加密流程(客户端 → 服务端)
 1. 客户端生成随机 AES_KEY + IV
 2. 用服务端 RSA 公钥加密 AES_KEY → encrypted_key
 3. 用 AES_KEY 加密请求体 → encrypted_data
 4. 拼接 timestamp + nonce + encrypted_data 计算 HMAC-SHA256 → sign
 5. 发送 {encrypted_key, encrypted_data, sign, timestamp, nonce}

# 解密流程(服务端接收)
 1. 校验 timestamp 是否在 5 分钟内(防重放)
 2. 检查 nonce 是否已使用(防重复)
 3. 用 AES_KEY 解密 encrypted_data
 4. 用服务端 RSA 私钥解密得到 AES_KEY
 5. 验证 HMAC-SHA256 签名

二、RSA 密钥对生成与管理

首先,我们需要生成 2048 位的 RSA 密钥对:

# 生成 RSA 私钥(2048 位)
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048

# 从私钥导出公钥
openssl pkey -in private_key.pem -pubout -out public_key.pem

# 查看密钥信息
openssl pkey -in private_key.pem -text -noout | head -5

生产环境中,私钥必须安全存储——建议使用 VaultAWS KMSHashiCorp Consul 的密钥管理服务,永远不要将私钥硬编码在代码仓库中。

Python 中的 RSA 加解密实现

# crypto_rsa.py — RSA 加密/解密工具
from cryptography.hazmat.primitives import hashes, padding
from cryptography.hazmat.primitives.asymmetric import rsa, padding as asym_padding
from cryptography.hazmat.primitives.serialization import load_pem_private_key, load_pem_public_key
from cryptography.hazmat.backends import default_backend
import base64

def rsa_encrypt(public_key_pem: str, plaintext: bytes) -> str:
    """使用 RSA 公钥加密数据"""
    public_key = load_pem_public_key(public_key_pem.encode(), backend=default_backend())
    ciphertext = public_key.encrypt(
        plaintext,
        asym_padding.OAEP(
            mgf=asym_padding.MGF1(algorithm=hashes.SHA256()),
            algorithm=hashes.SHA256(),
            label=None
        )
    )
    return base64.b64encode(ciphertext).decode()

def rsa_decrypt(private_key_pem: str, ciphertext_b64: str) -> bytes:
    """使用 RSA 私钥解密数据"""
    private_key = load_pem_private_key(private_key_pem.encode(), password=None, backend=default_backend())
    ciphertext = base64.b64decode(ciphertext_b64)
    plaintext = private_key.decrypt(
        ciphertext,
        asym_padding.OAEP(
            mgf=asym_padding.MGF1(algorithm=hashes.SHA256()),
            algorithm=hashes.SHA256(),
            label=None
        )
    )
    return plaintext

注意:RSA 单次加密的长度受密钥长度限制(2048 位密钥最多加密 190 字节),因此我们只用 RSA 加密 AES 密钥,而不是直接加密业务数据。

三、AES-256-GCM 对称加密

AES-GCM 是业界推荐的加密模式——它同时提供加密认证功能,能检测密文是否被篡改:

# crypto_aes.py — AES-256-GCM 加密/解密
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os, base64

def generate_aes_key() -> tuple:
    """生成 256 位 AES 密钥和 12 字节初始向量"""
    key = AESGCM.generate_key(bit_length=256)  # 32 字节
    nonce = os.urandom(12)  # GCM 推荐 12 字节 IV
    return key, nonce

def aes_encrypt(key: bytes, nonce: bytes, plaintext: str) -> str:
    """AES-256-GCM 加密"""
    aesgcm = AESGCM(key)
    ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None)
    # 返回 base64(nonce + ciphertext),其中包含认证标签(最后 16 字节)
    return base64.b64encode(nonce + ciphertext).decode()

def aes_decrypt(key: bytes, encrypted_b64: str) -> str:
    """AES-256-GCM 解密(自带完整性校验)"""
    aesgcm = AESGCM(key)
    data = base64.b64decode(encrypted_b64)
    nonce, ciphertext = data[:12], data[12:]
    plaintext = aesgcm.decrypt(nonce, ciphertext, None)
    return plaintext.decode()

四、HMAC-SHA256 签名与验签

签名用于确保请求来自合法客户端且参数未被篡改。我们使用 HMAC-SHA256timestamp + nonce + body 进行签名:

# crypto_sign.py — HMAC 签名与验证
import hmac, hashlib, time, os, json

def build_sign(secret_key: str, method: str, path: str,
               timestamp: int, nonce: str, body: str) -> str:
    """构建签名串并按 HMAC-SHA256 签名"""
    sign_str = f"{method}\n{path}\n{timestamp}\n{nonce}\n{body}"
    signature = hmac.new(
        secret_key.encode(),
        sign_str.encode(),
        hashlib.sha256
    ).hexdigest()
    return signature

def verify_sign(secret_key: str, method: str, path: str,
                timestamp: int, nonce: str, body: str,
                signature: str) -> bool:
    """验证签名"""
    # 1. 检查时间戳是否在允许窗口内(5 分钟)
    now = int(time.time())
    if abs(now - timestamp) > 300:
        return False  # 请求已过期

    # 2. 计算期望签名
    expected = build_sign(secret_key, method, path, timestamp, nonce, body)

    # 3. 安全比较(防止时序攻击)
    return hmac.compare_digest(expected, signature)

签名传输方式:客户端将签名放在 HTTP 头 X-Signature 中,同时携带 X-TimestampX-Nonce 头。服务端优先从请求头读取签名字段。

五、防重放攻击:Nonce 机制

即使有签名,攻击者仍然可以截获一个合法请求并重复发送(重放攻击)。解决方案是 Nonce(一次性随机数)机制:

# nonce_store.py — 基于 Redis 的 Nonce 去重
import redis, time

r = redis.Redis(host='localhost', port=6379, db=0)

def check_nonce(nonce: str, timestamp: int, ttl: int = 300) -> bool:
    """
    检查 nonce 是否已被使用
    返回 True 表示可用,False 表示重复(拒绝请求)
    """
    key = f"nonce:{nonce}"
    # SETNX:仅在键不存在时设置,返回 1 成功 0 失败
    result = r.setnx(key, str(timestamp))
    if result == 0:
        return False  # nonce 已存在 → 重放攻击!
    # 设置过期时间,防止 Redis 内存泄漏
    r.expire(key, ttl)
    return True

Nonce 结合时间戳窗口(5 分钟内有效)构成双层防护:

  • 时间戳窗口:淘汰过期请求
  • Nonce 去重:保证同一请求只被处理一次

六、完整请求流程实战

下面是一个完整的支付下单请求的加密与签名流程:

# client.py — 支付客户端加密请求
import requests, json, time, os

# 模拟订单数据
order_data = {
    "merchant_id": "M20260725001",
    "product_id": "card-qr-v3",
    "amount": "299.00",
    "currency": "CNY",
    "notify_url": "https://your-server.com/pay/notify",
    "timestamp": int(time.time())
}

# 1. 生成 AES 密钥
aes_key, nonce = generate_aes_key()
# 2. 用 AES 加密请求体
encrypted_body = aes_encrypt(aes_key, nonce, json.dumps(order_data))
# 3. 用 RSA 公钥加密 AES 密钥
with open("server_public_key.pem") as f:
    pub_key_pem = f.read()
encrypted_key = rsa_encrypt(pub_key_pem, aes_key)

# 4. 生成签名参数
t = int(time.time())
n = os.urandom(16).hex()
sign = build_sign("your_api_secret", "POST",
                  "/api/v1/pay/create", t, n, encrypted_body)

# 5. 发送请求
payload = {
    "encrypted_key": encrypted_key,
    "encrypted_data": encrypted_body,
    "timestamp": t,
    "nonce": n,
    "sign": sign
}
resp = requests.post(
    "https://api.your-server.com/api/v1/pay/create",
    json=payload,
    headers={"Content-Type": "application/json"}
)
print(f"Status: {resp.status_code}, Response: {resp.json()}")
# server.py — 服务端解密中间件(Flask 示例)
from flask import Flask, request, jsonify, abort

app = Flask(__name__)

# 加载 RSA 私钥(从安全存储读取)
with open("private_key.pem") as f:
    PRIVATE_KEY = f.read()

API_SECRET = "your_api_secret"  # 应与客户端一致

@app.route('/api/v1/pay/create', methods=['POST'])
def pay_create():
    data = request.get_json()

    # 1. 验签 & 防重放
    if not verify_sign(API_SECRET, "POST", "/api/v1/pay/create",
                       data['timestamp'], data['nonce'],
                       data['encrypted_data'], data['sign']):
        return jsonify({"code": 401, "msg": "签名验证失败"}), 401

    if not check_nonce(data['nonce'], data['timestamp']):
        return jsonify({"code": 401, "msg": "重复请求"}), 401

    # 2. 用 RSA 私钥解密 AES 密钥
    aes_key = rsa_decrypt(PRIVATE_KEY, data['encrypted_key'])

    # 3. 用 AES 密钥解密数据
    order_json = aes_decrypt(aes_key, data['encrypted_data'])
    order = json.loads(order_json)

    # 4. 处理业务逻辑...
    print(f"收到订单: {order['merchant_id']} - ¥{order['amount']}")

    return jsonify({
        "code": 0,
        "msg": "success",
        "data": {"order_no": "O" + str(int(time.time()))}
    })

七、签名算法的进阶优化

7.1 规范化的签名串

为了提高安全性,签名串应该对参数进行字典排序去除空格,防止签名因参数顺序不同而验证失败:

# 规范化签名串生成
def build_canonical_sign(params: dict, secret_key: str) -> str:
    """
    按参数名升序拼接,去除值为空的参数
    格式: k1=v1&k2=v2&key=SECRET
    """
    sorted_keys = sorted(params.keys())
    pairs = []
    for k in sorted_keys:
        v = params[k]
        if v != '' and v is not None:
            pairs.append(f"{k}={v}")
    sign_str = '&'.join(pairs)
    sign_str += f"&key={secret_key}"
    return hashlib.md5(sign_str.encode()).hexdigest().upper()

这种规范化签名方式在微信支付 V2 API 和支付宝开放平台中被广泛使用,兼容性极好。

7.2 密钥轮换策略

定期更换密钥是支付安全的基本要求:

  • RSA 密钥对建议每 90 天轮换一次
  • AES 会话密钥每次请求都重新生成(一次性使用)
  • HMAC 密钥建议每 30 天轮换
  • 支持密钥版本号(key_version),在请求头中携带,服务端根据版本选择对应密钥验签

八、常见安全坑点与解决方案

坑点风险解决方案
AES 密钥硬编码密钥泄露 → 全量数据脱密每次请求动态生成 RSA 加密传输
签名未包含请求体中间人可替换 body 重放签名必须包含 encrypted_data
Nonce 未设置过期Redis 内存无限增长设置 TTL = 时间戳窗口值 (300s)
时序攻击通过比较时间推断签名使用 hmac.compare_digest() 安全比较
未校验返回签名伪造服务端响应服务端也必须对返回数据签名
日志记录密文敏感信息泄露脱敏日志,仅记录 last 4 chars

九、生产环境部署建议

源码商城的转卡码系统中,我们采用了上述全套加密方案,并在生产环境运行数月无安全事件。以下是一些落地建议:

  1. 全链路加密:不只是下单接口,查询订单、退款、回调通知所有接口都要加密
  2. 密钥托管:使用 HashiCorp VaultAmazon KMS 管理密钥,代码中不出现明文密钥
  3. 安全 SDK:将加密逻辑封装成统一 SDK(pip install pay-crypto),避免各业务线重复实现
  4. API 网关集成:在 Nginx/Kong 层面做签名验签,业务服务零改动
  5. 监控告警:签名失败次数超过阈值时自动告警,及时发现暴力破解尝试
# Nginx Lua 验签示例(在 API 网关层验签)
location /api/ {
    access_by_lua_block {
        local timestamp = ngx.var.http_x_timestamp
        local nonce = ngx.var.http_x_nonce
        local sign = ngx.var.http_x_signature
        ngx.req.read_body()
        local body = ngx.req.get_body_data()

        -- 时间戳校验
        if not timestamp or math.abs(ngx.time() - tonumber(timestamp)) > 300 then
            ngx.exit(401)
        end

        -- 签名校验(调用外部 HMAC 函数)
        local expected = hmac_sha256("secret", timestamp .. nonce .. body)
        if sign ~= expected then
            ngx.exit(401)
        end
    }
    proxy_pass http://backend;
}

总结

支付系统的 API 安全不是一个可选项——它是关乎用户资金和企业生存的底线。本文从 RSA 非对称加密、AES 对称加密、HMAC 签名到 Nonce 防重放,完整讲解了支付 API 的安全通信体系。关键原则是:不要在 HTTPS 上裸奔应用层数据,加密和签名必须同时存在,时间戳和 Nonce 必须成对使用

我们的源码商城转卡码系统(card-qr-v3)内置了完整的 API 加密框架,开箱即用。如果你在自建支付系统,以上代码可以直接复用到你的项目中。