修复微信支付通知公钥验签
解析微信平台 SPKI 公钥并提取 PKCS#1 RSA 公钥。 复用生产 RSA-SHA256 验签路径处理支付通知。 补充标准 SPKI 公钥与真实签名向量回归测试。 同步微信支付验签排障文档和解析依赖。
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Generated
+1
@@ -4550,6 +4550,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user