微信支付平台证书模式:/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。
官方文档:
- 平台证书产品介绍 https://pay.weixin.qq.com/doc/v3/merchant/4012068814
- 下载平台证书 https://pay.weixin.qq.com/doc/v3/merchant/4012551764
二、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 返回 false | AAD 错:associated_data 传了空串、传了 "certificate\n",或干脆没调 setAAD |
invalid key length / 解密直接 panic | APIv3 密钥不是 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,不从 X509SerialNumber反推; - 刷新时不清空,按 serial 放入,按
expire_time淘汰; - 刷新间隔小于 12 小时;未知 serial 触发刷新有单飞与冷却;
- 回调链路里不同步下载证书;
- 证书文件原子替换,多机走中控分发;
- 敏感字段加密用
effective_time较新的证书,请求头Wechatpay-Serial与之一致; - 换证与切公钥两条流程不同时跑。