q-koa 13.8.38 → 13.8.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -91,7 +91,13 @@ const sendWxFail = (ctx, msg = 'FAIL') => {
91
91
  * @param {string} options.md5Key MD5 签名密钥(signType=MD5 时使用)
92
92
  * @returns {boolean}
93
93
  */
94
- const validateUnionNotifySign = ({ notifyData, signType, publicKey, md5Key }) => {
94
+ const validateUnionNotifySign = ({
95
+ notifyData,
96
+ signType,
97
+ publicKey,
98
+ md5Key,
99
+ fallbackPlatformPublicKey,
100
+ }) => {
95
101
  if (!notifyData || !notifyData.sign) return false
96
102
  const params = { ...notifyData }
97
103
  const sign = String(params.sign)
@@ -121,28 +127,47 @@ const validateUnionNotifySign = ({ notifyData, signType, publicKey, md5Key }) =>
121
127
  // RSA 双算法兜底:先试 RSA-SHA256(通联 2048-bit 新平台证书),失败再退 RSA-SHA1(1024-bit 老平台证书)
122
128
  // 两份证书的签名/密钥长度天然不同,1024-bit 公钥永远验不出 256 字节签名,2048-bit 公钥也验不出 128 字节签名,
123
129
  // 所以顺序上即使「错算法试一次」也不会误判,只是多一次 CPU crypto 调用(微秒级,可忽略)。
124
- if (!publicKey) return false
125
- // PEM 标准化:无头尾则包 SPKI 头尾 + 内容单换行;已有头尾若一行无换行,补换行让 OpenSSL 稳定识别
126
- let pem = String(publicKey)
127
- if (!pem.includes('-----BEGIN')) {
128
- pem = `-----BEGIN PUBLIC KEY-----\n${pem}\n-----END PUBLIC KEY-----`
129
- } else if (!pem.includes('\n')) {
130
- pem = pem
131
- .replace('-----BEGIN PUBLIC KEY-----', '-----BEGIN PUBLIC KEY-----\n')
132
- .replace('-----END PUBLIC KEY-----', '\n-----END PUBLIC KEY-----')
130
+ // 尝试用户配置的 union_publicKey(兼容无头尾/带PEM一行/带PEM多行;SHA256 先 SHA1 后)
131
+ if (publicKey) {
132
+ let pem = String(publicKey)
133
+ if (!pem.includes('-----BEGIN')) {
134
+ pem = `-----BEGIN PUBLIC KEY-----\n${pem}\n-----END PUBLIC KEY-----`
135
+ } else if (!pem.includes('\n')) {
136
+ pem = pem
137
+ .replace('-----BEGIN PUBLIC KEY-----', '-----BEGIN PUBLIC KEY-----\n')
138
+ .replace('-----END PUBLIC KEY-----', '\n-----END PUBLIC KEY-----')
139
+ }
140
+ for (const alg of ['RSA-SHA256', 'RSA-SHA1']) {
141
+ try {
142
+ const verify = crypto.createVerify(alg)
143
+ verify.update(signStr, 'utf8')
144
+ if (verify.verify(pem, sign, 'base64')) return true
145
+ } catch (e) {}
146
+ }
133
147
  }
134
- for (const alg of ['RSA-SHA256', 'RSA-SHA1']) {
148
+ // 兜底:通联官方生产平台公钥(1024-bit)+ RSA-SHA1
149
+ // 这是因为 union_publicKey 配置通常是商户自己生成的应用公钥,用于商户端请求签名的「请求方向」,
150
+ // 而通联平台异步通知的「回调方向」签名用的是通联自己掌握的另一对密钥(老平台是 1024-bit SHA1)。
151
+ if (
152
+ fallbackPlatformPublicKey &&
153
+ (!publicKey || String(publicKey) !== String(fallbackPlatformPublicKey))
154
+ ) {
135
155
  try {
136
- const verify = crypto.createVerify(alg)
156
+ const pem = `-----BEGIN PUBLIC KEY-----\n${fallbackPlatformPublicKey}\n-----END PUBLIC KEY-----`
157
+ const verify = crypto.createVerify('RSA-SHA1')
137
158
  verify.update(signStr, 'utf8')
138
159
  if (verify.verify(pem, sign, 'base64')) return true
139
- } catch (e) {
140
- // key 跟算法不兼容(比如 1024-bit 公钥试 SHA256 在极旧 Node 下可能 throw),静默下一轮
141
- }
160
+ } catch (e) {}
142
161
  }
143
162
  return false
144
163
  }
145
164
 
165
+ // 通联支付官方生产环境 1024-bit 平台公钥(用于回调验签兜底,与 allinpay.js 内置 publicKey.production 保持一致)
166
+ // 商户的 union_publicKey 配置通常是自己生成的 2048-bit 应用公钥(给商户侧请求签名用),和通联平台用于
167
+ // 异步通知签名的私钥不是同一对,所以当 union_publicKey 验签失败时,再用这张通联官方平台公钥补一轮。
168
+ const UNION_PROD_PLATFORM_PUBLIC_KEY =
169
+ 'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCm9OV6zH5DYH/ZnAVYHscEELdCNfNTHGuBv1nYYEY9FrOzE0/4kLl9f7Y9dkWHlc2ocDwbrFSm0Vqz0q2rJPxXUYBCQl5yW3jzuKSXif7q1yOwkFVtJXvuhf5WRy+1X5FOFoMvS7538No0RpnLzmNi3ktmiqmhpcY/1pmt20FHQQIDAQAB'
170
+
146
171
  const check = ({ timestamp, nonce, signature, token }) => {
147
172
  const tmp = [token, timestamp, nonce].sort().join('')
148
173
  const currSign = crypto.createHash('sha1').update(tmp).digest('hex')
@@ -1514,12 +1539,6 @@ exports.union_notify = async (ctx) => {
1514
1539
  try {
1515
1540
  const notifyData = ctx.request.body
1516
1541
 
1517
- // TODO: 临时观察日志,上线后删除
1518
- app.service.log.push({
1519
- app,
1520
- message: `通联融合回调union_notify报文: ${JSON.stringify(notifyData || {})}`,
1521
- })
1522
-
1523
1542
  if (!notifyData || typeof notifyData !== 'object') {
1524
1543
  ctx.status = 400
1525
1544
  ctx.body = 'FAIL'
@@ -1563,50 +1582,20 @@ exports.union_notify = async (ctx) => {
1563
1582
  }
1564
1583
 
1565
1584
  if (hasSign) {
1566
- // 生成签名原文用于诊断日志(跟 validateUnionNotifySign 内 build 保持一致)
1567
- const paramsForSignStr = { ...notifyData }
1568
- delete paramsForSignStr.sign
1569
- const sortedKeysForSign = Object.keys(paramsForSignStr).sort()
1570
- let diagsignStr = ''
1571
- for (const k of sortedKeysForSign) {
1572
- const v = paramsForSignStr[k]
1573
- if (v === undefined || v === null || v === '') continue
1574
- if (diagsignStr) diagsignStr += '&'
1575
- diagsignStr += `${k}=${v}`
1576
- }
1577
1585
  const signOk = validateUnionNotifySign({
1578
1586
  notifyData,
1579
1587
  signType: rawSignType,
1580
1588
  publicKey: union_publicKey,
1581
1589
  md5Key: union_md5Key,
1590
+ fallbackPlatformPublicKey: UNION_PROD_PLATFORM_PUBLIC_KEY,
1582
1591
  })
1583
1592
  if (!signOk) {
1584
- // ===== 诊断信息:帮助定位到底是 publicKey 错、签名串错、还是配置根本没拿到值 =====
1585
- const keyTypeInfo = union_publicKey
1586
- ? (String(union_publicKey).includes('-----BEGIN')
1587
- ? `pem-with-head,len=${String(union_publicKey).length},head=${String(union_publicKey).slice(0, 24)}`
1588
- : `pem-body-only,len=${String(union_publicKey).length},head=${String(union_publicKey).slice(0, 24)}`)
1589
- : 'NULL/UNDEFINED'
1590
1593
  app.service.log.push({
1591
1594
  app,
1592
- message: `通联回调验签失败【详细诊断】type=${rawSignType} cusid=${cusid} appid=${notifyData.appid} trxid=${notifyData.trxid} | union_publicKey=${keyTypeInfo} | union_publicKey_isSet=!!${!!union_publicKey} | union_md5Key_isSet=!!${!!union_md5Key} | signStr_len=${diagsignStr.length} | signStr=${diagsignStr} | raw_data=${JSON.stringify(notifyData)}`,
1595
+ message: `通联回调验签失败(type=${rawSignType}): ${JSON.stringify(notifyData)}`,
1593
1596
  })
1594
- // TODO: 2026-09-02 临时绕过(上线确认验签算法+公钥正确后删除):
1595
- // 只有当「商户号绑定正确」+「trxstatus=0000」+「存在 sign」时才允许绕过,
1596
- // 避免无 sign 的伪造报文直接进业务。
1597
- if (
1598
- trxstatus === '0000' &&
1599
- union_mchId &&
1600
- cusid === union_mchId
1601
- ) {
1602
- app.service.log.push({
1603
- app,
1604
- message: `通联回调验签临时BYPASS: trxid=${notifyData.trxid} out_trade_no=${notifyData.cusorderid} (24h内确认后删除此逻辑)`,
1605
- })
1606
- } else {
1607
- ctx.body = 'FAIL'
1608
- return
1609
- }
1597
+ ctx.body = 'FAIL'
1598
+ return
1610
1599
  }
1611
1600
  }
1612
1601
 
@@ -1703,11 +1692,6 @@ exports.union_notify = async (ctx) => {
1703
1692
  exports.notify = async (ctx) => {
1704
1693
  const { app } = getAppByCtx(ctx)
1705
1694
  const result = ctx.request.xmlBody
1706
- // TODO: 临时观察日志,上线后删除
1707
- app.service.log.push({
1708
- app,
1709
- message: `微信支付回调notify报文: ${JSON.stringify(result || {})}`,
1710
- })
1711
1695
  if (!result || !result.xml) return sendWxFail(ctx, 'INVALID_BODY')
1712
1696
 
1713
1697
  // ===== 微信 V2 回调验签 + mch_id 匹配 =====
@@ -1982,11 +1966,6 @@ const getRefundResultJson = async ({ req_info, app_key }) => {
1982
1966
  exports.refund_notify = async (ctx) => {
1983
1967
  const { app } = getAppByCtx(ctx)
1984
1968
  const result = ctx.request.xmlBody
1985
- // TODO: 临时观察日志,上线后删除
1986
- app.service.log.push({
1987
- app,
1988
- message: `微信退款回调refund_notify报文: ${JSON.stringify(result || {})}`,
1989
- })
1990
1969
  if (!result || !result.xml) return sendWxFail(ctx, 'INVALID_BODY')
1991
1970
 
1992
1971
  const pay_config = ctx.params.sub || 'weixin_pay'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "q-koa",
3
- "version": "13.8.38",
3
+ "version": "13.8.40",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {