213 行
7.7 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|