文件
shanghaipeiwan/server/tests/wechat-pay-v3.spec.ts

213 行
7.7 KiB
TypeScript

import crypto from 'crypto';
import {
buildAuthorization,
buildJsapiPayParams,
verifyNotifySignature,
decryptNotifyResource,
resolvePlatformKey,
WechatPayConfig,
} from '../src/core/pay/wechat-pay-v3';
/**
* 微信支付 V3 加密管道行为测试
* 通过内存生成 RSA 密钥对,离线验证:请求签名 / 回调验签 / APIv3 解密 / 小程序支付参数签名。
* 这些是「真实收款对账」的安全基石,必须可被公钥验证且篡改必然失败。
*/
describe('WechatPayV3 (支付加密管道)', () => {
let merchantPriv: string;
let merchantPub: string;
let platformPriv: string;
let platformPub: string;
let cfg: WechatPayConfig;
beforeAll(() => {
const merchant = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
merchantPriv = merchant.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
merchantPub = merchant.publicKey.export({ type: 'spki', format: 'pem' }).toString();
const platform = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
platformPriv = platform.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
platformPub = platform.publicKey.export({ type: 'spki', format: 'pem' }).toString();
cfg = {
appId: 'wx_test_app',
mchId: '1600000000',
apiV3Key: 'A'.repeat(32),
serialNo: 'MERCHANTSERIAL001',
privateKey: merchantPriv,
};
});
describe('1. 商户请求签名 (buildAuthorization)', () => {
it('生成的 Authorization 头包含 mchid/序列号 且签名可被商户公钥验证', () => {
const body = JSON.stringify({ appid: cfg.appId, out_trade_no: 'TEST_1' });
const auth = buildAuthorization({
method: 'POST',
urlPath: '/v3/pay/transactions/jsapi',
body,
cfg,
});
const m = auth.match(
/mchid="([^"]+)",nonce_str="([^"]+)",signature="([^"]+)",timestamp="([^"]+)",serial_no="([^"]+)"/
);
expect(m).not.toBeNull();
expect(m![1]).toBe(cfg.mchId);
expect(m![5]).toBe(cfg.serialNo);
const message = `POST\n/v3/pay/transactions/jsapi\n${m![4]}\n${m![2]}\n${body}\n`;
const ok = crypto
.createVerify('RSA-SHA256')
.update(message)
.verify(merchantPub, m![3], 'base64');
expect(ok).toBe(true);
});
it('报文体被篡改后验签必然失败', () => {
const body = JSON.stringify({ out_trade_no: 'TEST_1' });
const auth = buildAuthorization({
method: 'POST',
urlPath: '/v3/pay/transactions/jsapi',
body,
cfg,
});
const m = auth.match(/nonce_str="([^"]+)",signature="([^"]+)",timestamp="([^"]+)"/)!;
const tamperedMsg = `POST\n/v3/pay/transactions/jsapi\n${m[3]}\n${m[1]}\n${JSON.stringify({ out_trade_no: 'TEST_2' })}\n`;
const ok = crypto
.createVerify('RSA-SHA256')
.update(tamperedMsg)
.verify(merchantPub, m[2], 'base64');
expect(ok).toBe(false);
});
});
describe('2. 回调验签 (verifyNotifySignature)', () => {
it('平台私钥签名 + 平台公钥验签 通过', () => {
const pcfg: WechatPayConfig = { ...cfg, platformPublicKey: platformPub };
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = 'nonce123456';
const body = JSON.stringify({ id: 'evt_1', event_type: 'TRANSACTION.SUCCESS' });
const signature = crypto
.createSign('RSA-SHA256')
.update(`${timestamp}\n${nonce}\n${body}\n`)
.sign(platformPriv, 'base64');
expect(
verifyNotifySignature({ cfg: pcfg, timestamp, nonce, body, signature, serial: 'P' })
).toBe(true);
});
it('报文被篡改后验签失败', () => {
const pcfg: WechatPayConfig = { ...cfg, platformPublicKey: platformPub };
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = 'nonce123456';
const body = JSON.stringify({ id: 'evt_1' });
const signature = crypto
.createSign('RSA-SHA256')
.update(`${timestamp}\n${nonce}\n${body}\n`)
.sign(platformPriv, 'base64');
expect(
verifyNotifySignature({ cfg: pcfg, timestamp, nonce, body: body + 'x', signature, serial: 'P' })
).toBe(false);
});
it('时间戳超出 5 分钟容差时验签失败 (防重放)', () => {
const pcfg: WechatPayConfig = { ...cfg, platformPublicKey: platformPub };
const timestamp = String(Math.floor(Date.now() / 1000) - 1000);
const nonce = 'nonce123456';
const body = JSON.stringify({ id: 'evt_1' });
const signature = crypto
.createSign('RSA-SHA256')
.update(`${timestamp}\n${nonce}\n${body}\n`)
.sign(platformPriv, 'base64');
expect(
verifyNotifySignature({ cfg: pcfg, timestamp, nonce, body, signature, serial: 'P' })
).toBe(false);
});
it('未配置平台公钥时失败关闭 (fail-closed)', () => {
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = 'nonce123456';
const body = '{}';
const signature = 'anything';
expect(
verifyNotifySignature({ cfg, timestamp, nonce, body, signature, serial: 'P' })
).toBe(false);
});
});
describe('3. APIv3 回调报文解密 (decryptNotifyResource)', () => {
const apiV3Key = 'abcdefghijklmnopqrstuvwxyz123456';
const nonce = 'randnonce12';
const aad = 'transaction';
const encrypt = (plain: string) => {
const cipher = crypto.createCipheriv(
'aes-256-gcm',
Buffer.from(apiV3Key),
Buffer.from(nonce)
);
cipher.setAAD(Buffer.from(aad));
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
return Buffer.concat([enc, cipher.getAuthTag()]).toString('base64');
};
it('AES-256-GCM 解密还原原始业务报文', () => {
const plain = JSON.stringify({
out_trade_no: 'WX_RECHARGE_1',
trade_state: 'SUCCESS',
transaction_id: '42000',
amount: { total: 1000 },
});
const decrypted = decryptNotifyResource(apiV3Key, {
ciphertext: encrypt(plain),
nonce,
associated_data: aad,
});
expect(decrypted.out_trade_no).toBe('WX_RECHARGE_1');
expect(decrypted.trade_state).toBe('SUCCESS');
expect(decrypted.amount.total).toBe(1000);
});
it('密钥错误时解密抛错,绝不返回伪造数据', () => {
const plain = JSON.stringify({ out_trade_no: 'X' });
const ciphertext = encrypt(plain);
expect(() =>
decryptNotifyResource('z'.repeat(32), { ciphertext, nonce, associated_data: aad })
).toThrow();
});
it('APIv3 密钥长度非 32 位时拒绝解密', () => {
expect(() =>
decryptNotifyResource('short', { ciphertext: 'AAAA', nonce, associated_data: aad })
).toThrow();
});
});
describe('4. 小程序支付参数签名 (buildJsapiPayParams)', () => {
it('paySign 可被商户公钥验证', () => {
const params = buildJsapiPayParams(cfg, 'wx_test_123');
expect(params.signType).toBe('RSA');
expect(params.package).toBe('prepay_id=wx_test_123');
const message = `${cfg.appId}\n${params.timeStamp}\n${params.nonceStr}\n${params.package}\n`;
const ok = crypto
.createVerify('RSA-SHA256')
.update(message)
.verify(merchantPub, params.paySign, 'base64');
expect(ok).toBe(true);
});
});
describe('5. 平台公钥解析 (resolvePlatformKey)', () => {
it('合法的公钥 PEM 可解析', () => {
expect(resolvePlatformKey(platformPub)).not.toBeNull();
});
it('空值与非法内容返回 null', () => {
expect(resolvePlatformKey('')).toBeNull();
expect(resolvePlatformKey('not-a-pem')).toBeNull();
});
});
});