修复微信支付通知公钥验签

解析微信平台 SPKI 公钥并提取 PKCS#1 RSA 公钥。
复用生产 RSA-SHA256 验签路径处理支付通知。
补充标准 SPKI 公钥与真实签名向量回归测试。
同步微信支付验签排障文档和解析依赖。
This commit is contained in:
2026-07-13 00:45:20 +08:00
parent 35568eeef4
commit cde9b597bc
5 changed files with 83 additions and 11 deletions
@@ -2060,10 +2060,10 @@
- 现象:微信小程序支付下单能返回 `prepay_id`,但真实支付通知验签失败,或者本地实现误把商户 API 私钥当作回调验签 key。
- 原因:商户私钥只用于商户请求微信支付和生成小程序 `paySign`;微信支付通知的 `Wechatpay-Signature` 需要使用微信支付平台公钥或平台证书公钥验签,并按通知头里的平台序列号匹配。
- 处理:api-server 真实微信支付配置同时需要商户私钥与微信平台公钥:`WECHAT_PAY_PRIVATE_KEY_*` 用于签名,`WECHAT_PAY_PLATFORM_PUBLIC_KEY_*``WECHAT_PAY_PLATFORM_SERIAL_NO` 用于通知验签,`WECHAT_PAY_API_V3_KEY` 只用于解密通知 resource。支付成功后只通过通知里的 `out_trade_no` 确认本地 pending 订单,并保存 `transaction_id``profile_recharge_order.provider_transaction_id`
- 处理:api-server 真实微信支付配置同时需要商户私钥与微信平台公钥:`WECHAT_PAY_PRIVATE_KEY_*` 用于签名,`WECHAT_PAY_PLATFORM_PUBLIC_KEY_*``WECHAT_PAY_PLATFORM_SERIAL_NO` 用于通知验签,`WECHAT_PAY_API_V3_KEY` 只用于解密通知 resource。微信平台 `PUBLIC KEY` PEM 的 DER 内容是 SPKI `SubjectPublicKeyInfo`,初始化时必须解析并提取其中的 PKCS#1 `RSAPublicKey` DER 后再交给 `ring::RSA_PKCS1_2048_8192_SHA256`;不能把整段 SPKI DER 直接传给 `ring`支付成功后只通过通知里的 `out_trade_no` 确认本地 pending 订单,并保存 `transaction_id``profile_recharge_order.provider_transaction_id`
- APIv3 通知成功应答使用 HTTP `204 No Content`,不要沿用 V2 XML 成功报文;失败仍返回 4XX/5XX 让微信重试。
- 验证:mock 通知测试只能覆盖本地回调推进;真实环境还需用微信支付平台公钥、真实通知头和 API v3 密钥验证签名与解密链路。
- 关联:`server-rs/crates/api-server/src/wechat_pay.rs``docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`
- 验证:mock 通知测试只能覆盖本地回调推进;`platform-wechat` 必须用标准 SPKI `PUBLIC KEY` 和匹配私钥生成真实 RSA-SHA256 签名,覆盖 SPKI 到 PKCS#1 的解析与生产验签 helper。真实环境还需用微信支付平台公钥、真实通知头和 API v3 密钥验证签名与解密链路。
- 关联:`server-rs/crates/platform-wechat/src/pay.rs``server-rs/crates/api-server/src/wechat/pay.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 微信支付 JSAPI 下单必须显式带 User-Agent
+1
View File
@@ -4550,6 +4550,7 @@ dependencies = [
"tracing",
"url",
"urlencoding",
"x509-parser",
]
[[package]]
+1
View File
@@ -153,6 +153,7 @@ url = "2"
urlencoding = "2"
uuid = "1"
webp = "0.3"
x509-parser = "0.16"
zip = { version = "2", default-features = false }
[profile.dev]
@@ -20,3 +20,4 @@ time = { workspace = true, features = ["formatting"] }
tracing = { workspace = true }
url = { workspace = true }
urlencoding = { workspace = true }
x509-parser = { workspace = true }
+77 -8
View File
@@ -27,6 +27,11 @@ use std::convert::TryInto;
use time::{Duration as TimeDuration, OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::warn;
use url::Url;
use x509_parser::{
prelude::FromDer,
public_key::{PublicKey, RSAPublicKey},
x509::SubjectPublicKeyInfo,
};
const WECHAT_PAY_PROVIDER_MOCK: &str = "mock";
const WECHAT_PAY_PROVIDER_REAL: &str = "real";
@@ -874,13 +879,7 @@ impl RealWechatPayClient {
let signature_bytes = BASE64_STANDARD.decode(signature).map_err(|_| {
WechatPayError::InvalidSignature("微信支付通知签名 base64 无效".to_string())
})?;
let public_key = signature::UnparsedPublicKey::new(
&signature::RSA_PKCS1_2048_8192_SHA256,
&self.platform_public_key_der,
);
public_key
.verify(&message, &signature_bytes)
.map_err(|_| WechatPayError::InvalidSignature("微信支付通知签名验签失败".to_string()))
verify_rsa_sha256_signature(&self.platform_public_key_der, &message, &signature_bytes)
}
fn sign_message(&self, message: &str) -> Result<String, WechatPayError> {
@@ -1539,7 +1538,40 @@ fn parse_public_key_pem(pem: &str) -> Result<Vec<u8>, WechatPayError> {
"微信支付平台公钥必须是 PUBLIC KEY PEM".to_string(),
));
}
Ok(der)
let (remaining, subject_public_key_info) =
SubjectPublicKeyInfo::from_der(&der).map_err(|_| {
WechatPayError::InvalidConfig("微信支付平台 PUBLIC KEY SPKI 解析失败".to_string())
})?;
if !remaining.is_empty() || subject_public_key_info.subject_public_key.unused_bits != 0 {
return Err(WechatPayError::InvalidConfig(
"微信支付平台 PUBLIC KEY SPKI 格式非法".to_string(),
));
}
if !matches!(subject_public_key_info.parsed(), Ok(PublicKey::RSA(_))) {
return Err(WechatPayError::InvalidConfig(
"微信支付平台 PUBLIC KEY 必须使用 RSA 算法".to_string(),
));
}
let rsa_public_key_der = subject_public_key_info.subject_public_key.data.as_ref();
let (remaining, _) = RSAPublicKey::from_der(rsa_public_key_der).map_err(|_| {
WechatPayError::InvalidConfig("微信支付平台公钥不是有效的 RSA 公钥".to_string())
})?;
if !remaining.is_empty() {
return Err(WechatPayError::InvalidConfig(
"微信支付平台 RSA 公钥包含多余数据".to_string(),
));
}
Ok(rsa_public_key_der.to_vec())
}
fn verify_rsa_sha256_signature(
public_key_der: &[u8],
message: &[u8],
signature_bytes: &[u8],
) -> Result<(), WechatPayError> {
signature::UnparsedPublicKey::new(&signature::RSA_PKCS1_2048_8192_SHA256, public_key_der)
.verify(message, signature_bytes)
.map_err(|_| WechatPayError::InvalidSignature("微信支付通知签名验签失败".to_string()))
}
fn parse_single_pem_block(pem: &str) -> Result<(String, Vec<u8>), WechatPayError> {
@@ -1666,6 +1698,18 @@ mod tests {
use cbc::cipher::{BlockEncryptMut, block_padding::NoPadding};
use serde_json::json;
const TEST_RSA_PUBLIC_KEY_SPKI_PEM: &str = r#"-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0MGrqlP1eWB08o5BsF1y
cMjr6oicQQjO3veTfYRdBPJYmluoVAM8Q6bRvRuayqmhKt0/HeKbFwm4hbXqyvlE
yyNbG/OQpcq++bS+2FBlCrr5j/G+YpltOmuIZbo5vmvuWxjoxpf5zvdvTPtonahJ
VprIAzq8+NsTpgRbBI/GI1KaFEg12pk/vjCMKgVLTH76bYjhAXaIvYpYJaDxzTlC
VxGe8+KWe/e3brg2CP84sFgCw4JRBRkrRqoJKhWnaj1CbaMEkWmebP48tWa/SMpk
QDeqguoVrBMCVVV75GSCSaGlxT8XxPr03T+H1M+xnQZaqD+0EyN5CABmuMisP9ql
KwIDAQAB
-----END PUBLIC KEY-----"#;
const TEST_RSA_SIGNATURE_BASE64: &str = "cuwiNfJ/Ck0UiG1xZl6T1tyAtap91hAXrfpH4FPI+8nzrbnX9NHj5T5DiUdGeuR+Y1BQ+N+y4M4SOih4g2oArdmWeGgfoKE/N7O61fN3SaEGfhSqSedrtTc02j3Yvk/2HeBtsdcgaLG8xb/ZFGifVTHAeGaHpT4Yy1tmP6V+Kd6FUoMVJsXdyDBxRRzWqssIKfEIfBO0gCZ/j34Hqrt6KLYeBu6hMW77YCe0ShpZO4An3MxpjcYAlkeF8fuhWdQsPz4DcF00mtoJWYg2ncTb6OCsCc4YeUC2dKHWo6S7vxsnr9qwp2XvRnRuYp9kHN7oTOCfs851lYmTYJJfMvjlLg==";
#[test]
fn mock_pay_params_use_request_payment_shape() {
let params = build_mock_pay_params("recharge:user:1:points_60");
@@ -1897,6 +1941,31 @@ mod tests {
);
}
#[test]
fn spki_platform_public_key_verifies_real_rsa_sha256_signature() {
let public_key_der = parse_public_key_pem(TEST_RSA_PUBLIC_KEY_SPKI_PEM)
.expect("SPKI platform public key should parse as PKCS#1 RSA key");
let message = build_notify_signature_message(
b"1778759600",
b"nonce-real-signature",
br#"{"id":"notify-1","event_type":"TRANSACTION.SUCCESS"}"#,
);
let signature_bytes = BASE64_STANDARD
.decode(TEST_RSA_SIGNATURE_BASE64)
.expect("test RSA signature should decode");
verify_rsa_sha256_signature(&public_key_der, &message, &signature_bytes)
.expect("SPKI-derived PKCS#1 public key should verify notification signature");
let mut tampered_message = message;
tampered_message.push(b' ');
assert!(
verify_rsa_sha256_signature(&public_key_der, &tampered_message, &signature_bytes)
.is_err(),
"tampered notification must fail signature verification"
);
}
#[test]
fn parse_mock_notify_defaults_success_state() {
let notify =