编程 微信支付平台证书模式:/v3/certificates 返回的是 AES-256-GCM 密文,刷新时 clear() 会漏签

2026-11-13 00:04:12

微信支付平台证书模式:/v3/certificates 返回的是 AES-256-GCM 密文,刷新时 clear() 会漏签

区别于 #8475:那篇讲的是平台证书切公钥后的 serial 路由与灰度比例;本篇只讲仍是证书模式时,证书怎么下、怎么解、怎么刷新与淘汰。两条流程互斥,不能同时推进。

一、首次获取平台证书不能走接口

微信支付 APIv3 的应答由平台证书私钥签名,商户用平台证书公钥验签。首次要拿平台证书时,手里还没有任何可信公钥,无法验证 /v3/certificates 的应答,于是形成先有鸡还是先有蛋的循环。官方给出的做法是用本地下载工具 CertificateDownloader.jar 跑一次,直接拿到 PEM:

java -jar CertificateDownloader.jar -k ${apiV3key} -m ${mchId} -f ${mchPrivateKeyFilePath} -s ${mchSerialNo} -o ${outputFilePath}

参数含义:

  • -k:APIv3 密钥,商户平台 账户中心 -> API 安全 -> APIv3 密钥,32 字节;
  • -m:商户号;
  • -f:商户 API 证书私钥 apiclient_key.pem 路径;
  • -s:商户 API 证书序列号;
  • -o:输出目录。

输出文件名形如 wechatpay_123456777B4A9CC78902B44B65E04B9751CE12.pem,文件名里那串就是平台证书序列号。

首次下载后官方建议做一次证书信任链校验(如何通过证书信任链验证平台证书)。之后一律走接口 /v3/certificates,不要再手动传 pem。

官方文档:

二、ciphertext 是 AES-256-GCM 密文

GET /v3/certificates 返回的 data[] 里,encrypt_certificate 才是证书内容,且是密文:

{
  "serial_no": "5157F09EFDC096DE15EBE81A47057A7232F1B8E1",
  "effective_time": "2018-06-08T10:34:56+08:00",
  "expire_time": "2018-12-08T10:34:56+08:00",
  "encrypt_certificate": {
    "algorithm": "AEAD_AES_256_GCM",
    "nonce": "61f9c719728a",
    "associated_data": "certificate",
    "ciphertext": "sRvt..."
  }
}

解密三件套:

  • key = APIv3 密钥(32 字节);
  • nonce 用原样字符串转字节;
  • associated_data 固定为字符串 certificate;
  • ciphertext 先 Base64 解码再解密。

解出来的明文才是 PEM。

这一步的报错基本能反推错在哪:

现象根因
openssl x509 -in xxx.pem 报 unable to load certificate / PEM routines把 ciphertext 直接当 PEM 存盘了,根本没解密
Go 报 cipher: message authentication failed,Java 报 Tag mismatch,Node 报 unable to authenticate data,PHP openssl_decrypt 返回 falseAAD 错:associated_data 传了空串、传了 "certificate\n",或干脆没调 setAAD
invalid key length / 解密直接 panicAPIv3 密钥不是 32 字节,或拿 APIv2 的 key 来解
Base64 解码后长度不对对 ciphertext 做了二次 Base64,或没做 Base64 解码

还有一个容易忽略的字段语义:官方字段说明里明确写了「更换证书时会出现 PEM 格式中的证书失效时间与接口返回的证书弃用时间不一致的情况」。判断「这张证书还能不能用来验签」看 expire_time,更换期间官方会提前 24 小时改这个值;只看 PEM 里的 notAfter 会滞后,notAfter 只在做本地过期告警时用。

三、serial 对齐:别从证书反推序列号

serial_no 是接口返回的字符串,而 X509 证书里的 SerialNumber 是整数。用 fmt.Sprintf("%X", cert.SerialNumber) 或 hex.EncodeToString(cert.SerialNumber.Bytes()) 反过来推,会丢前导零,于是出现「证书明明在 map 里却查不到」的怪事。

做法很简单:map 的 key 一律用接口原样返回的 serial_no。它同时是回调/应答 HTTP 头 Wechatpay-Serial 的取值,也是文件名里那一段(wechatpay_{serial}.pem)。

四、刷新:间隔小于 12 小时 + 未知 serial 懒加载

官方给出的时序约束:

  • 启用新平台证书前,会提前 24 小时把新证书加进 /v3/certificates 列表;
  • 更换期间,应答与回调可能用新证书也可能用旧证书签名,Wechatpay-Serial 会对应变化;
  • 自行实现验签的,要定期刷新,间隔小于 12 小时,不要硬编码任何一张证书;
  • 接口限频:单个商户号 1000 次/s。

最典型的一个实现 bug 出现在刷新函数里:先 CERTIFICATE_MAP.clear(),再 put 本次拿到的那一张。单证书时代没问题,一旦进入更换期就必然出事——列表里同时有新旧两张,clear 之后只剩一张,用另一张签名的应答/回调全部验签失败,失败比例会随灰度比例上升。

Java 教程里能看到这种写法,比如 https://www.cnblogs.com/felordcn/p/13900375.html 的 refreshCertificate 就是 clear 后 put 单张。改法:按 serial_no 逐张 put,不 clear;用 expire_time 到期淘汰,或者简单点保留最近两张。

第二个坑是把下载放进回调处理链路。遇到未知 serial 就同步调 /v3/certificates,看起来是最省事的懒加载,实际在更换期会出两件事:回调处理时间被一次签名加网络往返拉长,超时后被微信重投,形成放大;并发回调一起触发下载,虽然 1000 次/s 的限频很难被打满,但对上游是一次没必要的风暴。加两层保护就够:

  • 单飞(singleflight / 进程内互斥),同一时刻只允许一个刷新在跑;
  • 冷却,比如失败后 60s 内不再重试,成功刷新后 5 分钟内不再因为未知 serial 触发全量刷新。

Go 里的大意(未实测):

var (
    mu       sync.RWMutex
    certs    = map[string]*x509.Certificate{}
    lastPull time.Time
)

func getCert(serial string) (*x509.Certificate, bool) {
    mu.RLock()
    c, ok := certs[serial]
    mu.RUnlock()
    if ok {
        return c, true
    }
    // 未知 serial:单飞刷新一次,再查
    if err := refreshCerts(); err != nil {
        return nil, false
    }
    mu.RLock()
    defer mu.RUnlock()
    c, ok = certs[serial]
    return c, ok
}

refreshCerts 内部:请求 /v3/certificates,逐条解密、逐条解析 PEM、逐条按 serial_no 放入新 map,最后整体原子替换(写时复制,避免读到半张表的中间态),落盘用 tmp 文件 + rename。

多机部署时官方建议用一台中控机统一下载,再分发到业务机;业务机只负责验签和解密,不各自去打接口。

五、敏感字段加密用哪张证书

上送敏感信息(姓名、身份证号之类)时用平台证书公钥做 RSA 加密,规则是「用最新的平台证书,即启用时间较晚的那张」,不要随机取,也不要用最早的那张。证书模式下,请求头 Wechatpay-Serial 必须是这张证书的序列号——拿 A 证书加密、头里写 B 的序列号,会被判 PARAM_ERROR。

六、5 年有效期的排期

平台证书有效期 5 年,更换有一套自动节奏(官方文档):

  • 过期前 210 天进入自动流程;
  • 180 天开始同时返回新证书;
  • 90 天自动开始灰度;
  • 自动灰度在每周一、周四 10:00 逐步提升新证书签名比例。

手动灰度最低 0.01%,比例只能调大不能调小,要降只能终止灰度再重启(重启后重置为 0%)。只要在旧证书过期前完成更换即可,未完成的话系统会在旧证书过期时直接 100% 切到新证书。

这条时间线和「平台证书切换微信支付公钥」的流程互斥,两者不能同时进行,切换侧的细节见站内 #8475,本篇不重复。

七、自检清单

  • 首次拿证书用的是 CertificateDownloader.jar,之后一律走 /v3/certificates;
  • 解密时 AAD 是固定字符串 certificate,ciphertext 先 Base64 解码,key 是 32 字节 APIv3 密钥;
  • map key 用接口原样的 serial_no,不从 X509 SerialNumber 反推;
  • 刷新时不清空,按 serial 放入,按 expire_time 淘汰;
  • 刷新间隔小于 12 小时;未知 serial 触发刷新有单飞与冷却;
  • 回调链路里不同步下载证书;
  • 证书文件原子替换,多机走中控分发;
  • 敏感字段加密用 effective_time 较新的证书,请求头 Wechatpay-Serial 与之一致;
  • 换证与切公钥两条流程不同时跑。

推荐文章

程序员茄子在线接单