微信支付停发平台证书:自动下载证书的代码先挂,灰度期回调验签还会随机失败
一、症状:两条报错,加一个随机失败
线上最先暴露的通常是指标监控:自动下载平台证书的任务开始持续报错,日志里是这两条之一。
{"code":"RESOURCE_NOT_EXISTS","message":"无可用的平台证书,请在商户平台-API安全申请使用微信支付公钥。可查看指引 https://pay.weixin.qq.com/doc/v3/merchant/4012153196"}
{"code":"NOT_ENOUGH","message":"平台证书已过期失效,请联系微信支付技术支持。"}
第二条对应的是平台证书过期导致返回的证书列表为空。
如果服务没有把返回码当成致命错误而是吞掉,症状会延后出现:回调验签开始成片失败,或者灰度切换期间下单报错:
Cannot found the serial(6AFA797DF4EBC38DD0119B26B3B7C2D4XXXXXXXX)'s configuration;
which's from the response(header:Wechatpay-Serial);
your's 2729667D831C9BAA47C49E471F5F2A28XXXXXXXX
原因不复杂:验签时用了与当前接口要求不匹配的平台证书。灰度期间系统同时存在新旧两份有效证书,下单接口的响应头 Wechatpay-Serial 明确标注了本次应答使用的证书序列号,商户必须以该序列号去证书列表里匹配。
背景是微信支付已对新申请商户号以及平台证书已过期的商户不再签发平台证书。新申请的商户号默认为微信支付公钥模式;商户可以自主切回平台证书模式,但要接受平台证书 5 年有效期、到期需主动更换的风险。调用「获取平台证书」接口拿不到证书时,返回的就是上面两条报错。
二、先分清两套密钥,别在最底层搞错
| 微信支付平台证书 | 商户 API 证书 | |
|---|---|---|
| 内容 | 微信支付平台标识与公钥 | 商户号、公司名称、公私钥信息 |
| 来源 | 微信支付生成,商户通过下载平台证书接口或下载工具获取全部有效期内证书 | 商户平台申请 |
| 用途 | 微信支付签应答/回调,商户验签;部分 v3 接口敏感字段由商户用它加密 | 商户签 v3 请求,微信支付验签;部分 v2 接口(退款、撤销、下载资金账单)需携带 |
常见的低级错误是把 apiclient_cert.pem 当成私钥,或者拿 wechatpay.pem(平台证书公钥)当商户私钥算签名,结果就是 401 / 签名错误。
平台证书下载接口返回的证书是加密的,处理链条有四步,前两步是必须的:
- 用 APIv3 密钥解密报文中的证书(必须);
- 用解密得到的证书验证报文签名(必须);
- 核对颁发者为
Tenpay.com Root CA(强烈推荐); - 通过信任链验证(强烈推荐)。
三、证书生成与轮换的时间规则
官方常见问题口径:
- 微信侧在平台证书过期前 210 天自动生成新平台证书。此时商户平台已经可见,但调用下载接口拿不到新证书。
- 从过期前 180 天开始,调用下载接口能获取全部有效期内的证书,即新旧两份都在。
- 临过期但近 90 天未曾调用过平台证书的,不会自动生成新平台证书。
- 新证书生成后、旧证书有效期进入 180 天窗口时,商户能取到新旧两份证书,但微信支付签名时默认仍使用旧证书。
- 灰度 100% 后,微信支付签名全部改用新证书,旧证书到期自动下线。
这意味着任何实现了自动下载证书的服务,必须同时兼容新旧两份证书。只取列表第一条、或者缓存里只留一份的实现,会在轮换窗口期用错证书。
四、灰度期两条进度条,控制方不同
回调使用公钥的比例由微信支付控制,自动灰度:第一天 0.1%、第二天 1%、第三天 5%、第四天 10%、第五天 20%、第六天 50%、第七天 100%。灰度期间按比例一部分用公钥签名、剩下的仍用平台证书签名。所以商户端不能假设「一切到公钥就全是公钥」,必须根据回调请求头 Wechatpay-Serial 判断本次该用哪个密钥验签。
应答使用公钥的比例由商户自己控制,取决于请求头 Wechatpay-Serial 带的是公钥 ID 还是平台证书序列号:
比例 = 近 7 天用微信支付公钥调接口次数 / 近 7 天 v3 接口总调用次数
这是近 7 天的滑动窗口,数据 1 小时刷新一次。所以即便代码已经全量改成公钥,比例也要等 7 天才会爬到 100%。
几个容易踩的点:
- 请求不带
Wechatpay-Serial头,微信支付就用平台证书签名。 - 公钥 ID 带
PUB_KEY_ID_前缀,例如:
-H "Wechatpay-Serial: PUB_KEY_ID_00000000000000000000000000000000"
- 申请公钥后默认不开启公钥切换,必须在商户平台「账户中心 - API安全 - 微信支付公钥 - 管理公钥」点击开启公钥切换。未开启时,即使请求里传了公钥 ID,微信侧仍用平台证书返回。(商户平台按钮操作属于文档口径,未实测。)
- 平台证书切换公钥与「灰度更换新平台证书」不能同时进行。正在灰度更换新证书的,需要先停止,才能继续切换流程。
- 公钥灰度和切换按商户号维度进行。同一个商户号在多个项目里使用时,所有项目都支持公钥模式后才能点「开始更换」。
- 灰度完成后在商户平台点「确认切换」,此后不能再用平台证书。完成切换后,除敏感字段加解密场景外,回调与应答的 HTTP 请求头可以不携带
Wechatpay-Serial。 - 需要暂停灰度时,商户平台点「停止更换」,全部切回平台证书。
Java SDK 侧有一个具体的坑:原 RSAAutoCertificateConfig.Builder 换成 RSAPublicKeyConfig.Builder,publicKeyId 填公钥 ID;回调在平滑切换期用 RSACombinedNotificationConfig,它会持续自动下载平台证书。回调比例到 100% 后必须换成 RSAPublicKeyNotificationConfig,否则应答比例里始终混着自动下载平台证书的流量,比例卡在 100% 以下上不去。
另外,平台证书过期时间和灰度时间要一起算,避免出现证书已过期、公钥灰度还没走完的窗口。
五、回调验签:不要用 startswith("PUB_KEY_ID_") 二分
一种流传很广的写法是在回调处理里做前缀判断:
if (str_starts_with($serial, 'PUB_KEY_ID_')) {
// 用公钥验签
} else {
// 用平台证书验签
}
这段代码在灰度期和证书轮换期会挂。原因有二:灰度期同一个商户号的回调会在两种密钥之间按比例来回切;证书轮换期又会同时存在新旧两份平台证书。前缀二分只能区分「公钥 vs 平台证书」,区分不了「哪一把平台证书」。
正确做法是忽略这个字段值的构成,按 key/value Map 加载密钥实例,用 Wechatpay-Serial 去查:
$platformPublicKeyMap = [
'7132D72A03E93CDDF8C03BBD1F37EEDF********' => $platformCertificateInstance,
'PUB_KEY_ID_00000000000000000000000000000000' => $platformPublicKeyInstance,
];
if (!array_key_exists($inWechatpaySerial, $platformPublicKeyMap)) {
// 响应非 20x 状态码
throw new \UnexpectedValueException('未知的 wechatpay-serial 请求头');
}
$verifyKey = Rsa::from($platformPublicKeyMap[$inWechatpaySerial], Rsa::KEY_TYPE_PUBLIC);
未命中的 serial 一律返回 4xx/5xx,不要猜、不要 fallback 到某个默认证书。
构造客户端时,certs 就是一个「证书序列号 或 公钥 ID → 公钥实例」的 map:
$platformCertificateOrPublicKeyFilePath = 'file:///path/to/wechatpay/certificate_or_publickey.pem';
$instance = Builder::factory([
'mchid' => ...,
'serial' => ...,
'privateKey' => ...,
'certs' => [$platformCertificateSerialOrPublicKeyId => $platformPublicKeyInstance],
]);
Go SDK 的对应写法(文档口径示例,未实测):
wechatpayPublicKey, _ = utils.LoadPublicKeyWithPath("/path/to/wechatpay/pub_key.pem")
opts := []core.ClientOption{
option.WithWechatPayPublicKeyAuthCipher(
mchID, mchCertificateSerialNumber, mchPrivateKey,
wechatpayPublicKeyID, wechatpayPublicKey,
),
}
client, err := core.NewClient(ctx, opts...)
handler := notify.NewNotifyHandler(
mchAPIv3Key,
verifiers.NewSHA256WithRSAPubkeyVerifier(wechatpayPublicKeyID, *wechatPayPublicKey),
)
六、手工排查命令
验签串由三段拼成(应答场景):应答时间戳 + \n + 应答随机串 + \n + 应答报文主体 + \n,最后一行也要换行。回调场景对应的时间戳头是 Wechatpay-Timestamp,随机串头是 Wechatpay-Nonce。body 必须用原始报文,任何框架对 body 的改写(反序列化再序列化、去掉空白)都会导致验签失败。
时间戳偏差建议允许最多 5 分钟,超出直接拒绝以防重放,机器需要 NTP 同步。
从平台证书导出公钥:
openssl x509 -in 1900009191_wxp_cert.pem -pubkey -noout > 1900009191_wxp_pub.pem
查平台证书有效期:
openssl x509 -in wechatpay_xxx.pem -noout -dates
openssl x509 -in wechatpay_xxx.pem -noout -enddate
查证书序列号(商户 API 证书和平台证书同理):
openssl x509 -in apiclient_cert.pem -noout -serial
查证书里带的商户号:
openssl x509 -in apiclient_cert.pem -noout -text | grep -o 'CN=[0-9]*' | sed 's/CN=//'
base64 解码签名:
openssl base64 -d signature.txt
手工验签:
openssl dgst -sha256 -verify 1900009191_wxp_pub.pem -signature signature.txt
- 常见问题_平台证书:
- 如何使用平台证书验签名:
- 平台证书/公钥常见问题(服务商):