fix: 支付安全审核修复(支付宝+微信)
支付宝: - 回调增加 app_id 校验,防止跨商户通知 - 回调增加 sign_type 过滤,仅接受 RSA2 - 退款增加 out_request_no 保证幂等 - 金额解析增加精度保护 - timestamp 改用 CST 时区 微信: - 自行实现 AES-GCM 解密替代库的 decipher_gcm(修复 AuthTag 未验证) - WXPAY_PUBLIC_KEY_ID 改为必填 - serial 匹配检查改为强制 - 时间戳校验移到签名验证之前 - nonce 改用 crypto.randomBytes - publicKey 不允许空 Buffer fallback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ function getCommonParams(appId: string): Record<string, string> {
|
|||||||
format: 'JSON',
|
format: 'JSON',
|
||||||
charset: 'utf-8',
|
charset: 'utf-8',
|
||||||
sign_type: 'RSA2',
|
sign_type: 'RSA2',
|
||||||
timestamp: new Date().toISOString().replace('T', ' ').substring(0, 19),
|
timestamp: new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' }).replace('T', ' '),
|
||||||
version: '1.0',
|
version: '1.0',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export class AlipayProvider implements PaymentProvider {
|
|||||||
return {
|
return {
|
||||||
tradeNo: result.trade_no || tradeNo,
|
tradeNo: result.trade_no || tradeNo,
|
||||||
status,
|
status,
|
||||||
amount: parseFloat(result.total_amount || '0'),
|
amount: Math.round(parseFloat(result.total_amount || '0') * 100) / 100,
|
||||||
paidAt: result.send_pay_date ? new Date(result.send_pay_date) : undefined,
|
paidAt: result.send_pay_date ? new Date(result.send_pay_date) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -90,15 +90,25 @@ export class AlipayProvider implements PaymentProvider {
|
|||||||
params[key] = value;
|
params[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sign_type 过滤:仅接受 RSA2
|
||||||
|
if (params.sign_type && params.sign_type !== 'RSA2') {
|
||||||
|
throw new Error('Unsupported sign_type, only RSA2 is accepted');
|
||||||
|
}
|
||||||
|
|
||||||
const sign = params.sign || '';
|
const sign = params.sign || '';
|
||||||
if (!env.ALIPAY_PUBLIC_KEY || !verifySign(params, env.ALIPAY_PUBLIC_KEY, sign)) {
|
if (!env.ALIPAY_PUBLIC_KEY || !verifySign(params, env.ALIPAY_PUBLIC_KEY, sign)) {
|
||||||
throw new Error('Alipay notification signature verification failed');
|
throw new Error('Alipay notification signature verification failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// app_id 校验
|
||||||
|
if (params.app_id !== env.ALIPAY_APP_ID) {
|
||||||
|
throw new Error('Alipay notification app_id mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tradeNo: params.trade_no || '',
|
tradeNo: params.trade_no || '',
|
||||||
orderId: params.out_trade_no || '',
|
orderId: params.out_trade_no || '',
|
||||||
amount: parseFloat(params.total_amount || '0'),
|
amount: Math.round(parseFloat(params.total_amount || '0') * 100) / 100,
|
||||||
status:
|
status:
|
||||||
params.trade_status === 'TRADE_SUCCESS' || params.trade_status === 'TRADE_FINISHED' ? 'success' : 'failed',
|
params.trade_status === 'TRADE_SUCCESS' || params.trade_status === 'TRADE_FINISHED' ? 'success' : 'failed',
|
||||||
rawData: params,
|
rawData: params,
|
||||||
@@ -110,6 +120,7 @@ export class AlipayProvider implements PaymentProvider {
|
|||||||
out_trade_no: request.orderId,
|
out_trade_no: request.orderId,
|
||||||
refund_amount: request.amount.toFixed(2),
|
refund_amount: request.amount.toFixed(2),
|
||||||
refund_reason: request.reason || '',
|
refund_reason: request.reason || '',
|
||||||
|
out_request_no: request.orderId + '-refund',
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -49,11 +49,12 @@ export function initPaymentProviders(): void {
|
|||||||
!env.WXPAY_PRIVATE_KEY ||
|
!env.WXPAY_PRIVATE_KEY ||
|
||||||
!env.WXPAY_API_V3_KEY ||
|
!env.WXPAY_API_V3_KEY ||
|
||||||
!env.WXPAY_PUBLIC_KEY ||
|
!env.WXPAY_PUBLIC_KEY ||
|
||||||
|
!env.WXPAY_PUBLIC_KEY_ID ||
|
||||||
!env.WXPAY_CERT_SERIAL ||
|
!env.WXPAY_CERT_SERIAL ||
|
||||||
!env.WXPAY_NOTIFY_URL
|
!env.WXPAY_NOTIFY_URL
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'PAYMENT_PROVIDERS includes wxpay but required env vars are missing: WXPAY_APP_ID, WXPAY_MCH_ID, WXPAY_PRIVATE_KEY, WXPAY_API_V3_KEY, WXPAY_PUBLIC_KEY, WXPAY_CERT_SERIAL, WXPAY_NOTIFY_URL',
|
'PAYMENT_PROVIDERS includes wxpay but required env vars are missing: WXPAY_APP_ID, WXPAY_MCH_ID, WXPAY_PRIVATE_KEY, WXPAY_API_V3_KEY, WXPAY_PUBLIC_KEY, WXPAY_PUBLIC_KEY_ID, WXPAY_CERT_SERIAL, WXPAY_NOTIFY_URL',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
paymentRegistry.register(new WxpayProvider());
|
paymentRegistry.register(new WxpayProvider());
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ function getPayInstance(): WxPay {
|
|||||||
const env = assertWxpayEnv(getEnv());
|
const env = assertWxpayEnv(getEnv());
|
||||||
|
|
||||||
const privateKey = Buffer.from(env.WXPAY_PRIVATE_KEY);
|
const privateKey = Buffer.from(env.WXPAY_PRIVATE_KEY);
|
||||||
const publicKey = env.WXPAY_PUBLIC_KEY ? Buffer.from(env.WXPAY_PUBLIC_KEY) : Buffer.alloc(0);
|
if (!env.WXPAY_PUBLIC_KEY) {
|
||||||
|
throw new Error('WXPAY_PUBLIC_KEY is required');
|
||||||
|
}
|
||||||
|
const publicKey = Buffer.from(env.WXPAY_PUBLIC_KEY);
|
||||||
|
|
||||||
payInstance = new WxPay({
|
payInstance = new WxPay({
|
||||||
appid: env.WXPAY_APP_ID,
|
appid: env.WXPAY_APP_ID,
|
||||||
@@ -45,7 +48,7 @@ function yuanToFen(yuan: number): number {
|
|||||||
|
|
||||||
async function request<T>(method: string, url: string, body?: Record<string, unknown>): Promise<T> {
|
async function request<T>(method: string, url: string, body?: Record<string, unknown>): Promise<T> {
|
||||||
const pay = getPayInstance();
|
const pay = getPayInstance();
|
||||||
const nonce_str = Math.random().toString(36).substring(2, 15);
|
const nonce_str = crypto.randomBytes(16).toString('hex');
|
||||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||||
|
|
||||||
const signature = pay.getSignature(method, nonce_str, timestamp, url, body ? JSON.stringify(body) : '');
|
const signature = pay.getSignature(method, nonce_str, timestamp, url, body ? JSON.stringify(body) : '');
|
||||||
@@ -134,8 +137,17 @@ export async function createRefund(params: WxpayRefundParams): Promise<Record<st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function decipherNotify<T>(ciphertext: string, associatedData: string, nonce: string): T {
|
export function decipherNotify<T>(ciphertext: string, associatedData: string, nonce: string): T {
|
||||||
const pay = getPayInstance();
|
const env = assertWxpayEnv(getEnv());
|
||||||
return pay.decipher_gcm<T>(ciphertext, associatedData, nonce);
|
const key = env.WXPAY_API_V3_KEY;
|
||||||
|
const ciphertextBuf = Buffer.from(ciphertext, 'base64');
|
||||||
|
// AES-GCM 最后 16 字节是 AuthTag
|
||||||
|
const authTag = ciphertextBuf.subarray(ciphertextBuf.length - 16);
|
||||||
|
const data = ciphertextBuf.subarray(0, ciphertextBuf.length - 16);
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
decipher.setAAD(Buffer.from(associatedData));
|
||||||
|
const decoded = Buffer.concat([decipher.update(data), decipher.final()]);
|
||||||
|
return JSON.parse(decoded.toString('utf-8')) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyNotifySign(params: {
|
export async function verifyNotifySign(params: {
|
||||||
|
|||||||
@@ -112,20 +112,20 @@ export class WxpayProvider implements PaymentProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证 serial 匹配我们配置的公钥 ID
|
// 验证 serial 匹配我们配置的公钥 ID
|
||||||
if (env.WXPAY_PUBLIC_KEY_ID && serial !== env.WXPAY_PUBLIC_KEY_ID) {
|
if (serial !== env.WXPAY_PUBLIC_KEY_ID) {
|
||||||
throw new Error(`Wxpay serial mismatch: expected ${env.WXPAY_PUBLIC_KEY_ID}, got ${serial}`);
|
throw new Error(`Wxpay serial mismatch: expected ${env.WXPAY_PUBLIC_KEY_ID}, got ${serial}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await verifyNotifySign({ timestamp, nonce, body, serial, signature });
|
|
||||||
if (!valid) {
|
|
||||||
throw new Error('Wxpay notification signature verification failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
if (Math.abs(now - Number(timestamp)) > 300) {
|
if (Math.abs(now - Number(timestamp)) > 300) {
|
||||||
throw new Error('Wechatpay notification timestamp expired');
|
throw new Error('Wechatpay notification timestamp expired');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const valid = await verifyNotifySign({ timestamp, nonce, body, serial, signature });
|
||||||
|
if (!valid) {
|
||||||
|
throw new Error('Wxpay notification signature verification failed');
|
||||||
|
}
|
||||||
|
|
||||||
const payload: WxpayNotifyPayload = JSON.parse(body);
|
const payload: WxpayNotifyPayload = JSON.parse(body);
|
||||||
|
|
||||||
if (payload.event_type !== 'TRANSACTION.SUCCESS') {
|
if (payload.event_type !== 'TRANSACTION.SUCCESS') {
|
||||||
|
|||||||
Reference in New Issue
Block a user