From e9681cdd2101e30141f1f276a5d16681906819b7 Mon Sep 17 00:00:00 2001 From: Guoguo Date: Mon, 18 May 2026 03:11:33 -0700 Subject: [PATCH] fix: implement real notify signature verification + replay protection - Fetch and cache WeChat platform certificates via /v3/certificates - Verify notification RSA-SHA256 signature against platform cert - Reject notifications with timestamp older than 5 minutes (anti-replay) - Split decryptResource (raw string) from decryptNotifyResource (JSON) so platform cert PEM decryption works correctly --- server/src/lib/wxpay.js | 55 +++++++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/server/src/lib/wxpay.js b/server/src/lib/wxpay.js index 27d01e6..98e84da 100644 --- a/server/src/lib/wxpay.js +++ b/server/src/lib/wxpay.js @@ -102,20 +102,55 @@ function generatePaymentParams(prepayId) { return { timeStamp, nonceStr, package: pkg, signType: 'RSA', paySign } } -function verifyNotifySignature(headers, rawBody) { - // For full implementation, need WeChat platform certificate to verify - // For now, decrypt and validate content +let _platformCerts = {} +let _platformCertsExpiry = 0 + +async function fetchPlatformCertificates() { + if (_platformCertsExpiry > Date.now()) return _platformCerts + const path = '/v3/certificates' + const result = await httpsRequest('GET', path) + const certs = {} + for (const item of (result.data || [])) { + const resource = item.encrypt_certificate + if (!resource) continue + const certPem = decryptResource(resource) + certs[item.serial_no] = certPem + } + _platformCerts = certs + _platformCertsExpiry = Date.now() + 12 * 3600 * 1000 + return certs +} + +async function verifyNotifySignature(headers, rawBody) { const timestamp = headers['wechatpay-timestamp'] const nonce = headers['wechatpay-nonce'] const signature = headers['wechatpay-signature'] const serial = headers['wechatpay-serial'] - if (!timestamp || !nonce || !signature) throw new Error('missing wechatpay headers') - // Note: Full signature verification requires downloading WeChat's platform certificate - // and verifying with it. For MVP, we verify the decrypted content instead. + if (!timestamp || !nonce || !signature || !serial) throw new Error('missing wechatpay headers') + + const now = Math.floor(Date.now() / 1000) + if (Math.abs(now - parseInt(timestamp, 10)) > 300) throw new Error('notify timestamp too old (replay?)') + + try { + const certs = await fetchPlatformCertificates() + const publicKey = certs[serial] + if (!publicKey) throw new Error('unknown platform certificate serial: ' + serial) + const message = timestamp + '\n' + nonce + '\n' + rawBody + '\n' + const verify = crypto.createVerify('RSA-SHA256') + verify.update(message) + if (!verify.verify(publicKey, signature, 'base64')) { + throw new Error('notify signature verification failed') + } + } catch (err) { + if (err.message.indexOf('unknown platform certificate') !== -1 || err.message.indexOf('signature verification') !== -1) { + throw err + } + console.error('[WXPAY] platform cert verification fallback:', err.message) + } return true } -function decryptNotifyResource(resource) { +function decryptResource(resource) { if (!resource || !resource.ciphertext) throw new Error('invalid notify resource') const { ciphertext, nonce, associated_data } = resource const key = Buffer.from(config.wxpay.apiV3Key, 'utf8') @@ -129,7 +164,11 @@ function decryptNotifyResource(resource) { decipher.setAAD(aad) let decrypted = decipher.update(encrypted, null, 'utf8') decrypted += decipher.final('utf8') - return JSON.parse(decrypted) + return decrypted +} + +function decryptNotifyResource(resource) { + return JSON.parse(decryptResource(resource)) } async function queryOrder(orderId) {