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(); }); }); });