From cde9b597bc81d3e63480915f70c3cfa12b47e7b5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 13 Jul 2026 00:45:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BE=AE=E4=BF=A1=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E9=80=9A=E7=9F=A5=E5=85=AC=E9=92=A5=E9=AA=8C=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解析微信平台 SPKI 公钥并提取 PKCS#1 RSA 公钥。 复用生产 RSA-SHA256 验签路径处理支付通知。 补充标准 SPKI 公钥与真实签名向量回归测试。 同步微信支付验签排障文档和解析依赖。 --- docs/project-memory/shared-memory/pitfalls.md | 6 +- server-rs/Cargo.lock | 1 + server-rs/Cargo.toml | 1 + server-rs/crates/platform-wechat/Cargo.toml | 1 + server-rs/crates/platform-wechat/src/pay.rs | 85 +++++++++++++++++-- 5 files changed, 83 insertions(+), 11 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a05b47abf..5821346b1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 0cf7b126f..8333ba60b 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -4550,6 +4550,7 @@ dependencies = [ "tracing", "url", "urlencoding", + "x509-parser", ] [[package]] diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index d02fd74d1..fd22210ca 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -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] diff --git a/server-rs/crates/platform-wechat/Cargo.toml b/server-rs/crates/platform-wechat/Cargo.toml index 760bb2387..821b11968 100644 --- a/server-rs/crates/platform-wechat/Cargo.toml +++ b/server-rs/crates/platform-wechat/Cargo.toml @@ -20,3 +20,4 @@ time = { workspace = true, features = ["formatting"] } tracing = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } +x509-parser = { workspace = true } diff --git a/server-rs/crates/platform-wechat/src/pay.rs b/server-rs/crates/platform-wechat/src/pay.rs index 193ba4dde..ea8b77083 100644 --- a/server-rs/crates/platform-wechat/src/pay.rs +++ b/server-rs/crates/platform-wechat/src/pay.rs @@ -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 { @@ -1539,7 +1538,40 @@ fn parse_public_key_pem(pem: &str) -> Result, 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), 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 =