背景知识

AES-GCM是微信支付APIv3的加解密方案之一,定义可见rfc5116,v3使用的是aead_aes_256_gcm。稍微补充一个aead的的描述,aead加密方式与其他对称加密方式主要不同的地方就是:

它每一段密文必定有对应的校验码,通过核对校验码来判断密文是否完整

APIv3回调通知和平台证书下载文档上有介绍AES-GCM的使用场景。nodejs原生crypto模块,在处理GCM模式解密时,从变更历史上看,Node11加入了强制校验auth_tag(authentication tag)长度规则,Node10目前全系列还没有合并这个向前兼容规则,详情可见 https://github.com/nodejs/node/pull/20039

测试代码

先上一段测试用js代码,来复现 nodejs#20039 上连带反馈的问题:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
const crypto = require('crypto')
const decrypt = (ciphertext, key, iv, aad = '') => {
  const buf = Buffer.from(ciphertext, 'base64')
  const tag = buf.slice(-16)
  const payload = buf.slice(0, -16)

  const decipher = crypto.createDecipheriv(
    'aes-256-gcm', key, iv
  ).setAuthTag(tag).setAAD(Buffer.from(aad))

  return Buffer.concat([
    decipher.update(payload, 'hex'),
    decipher.final()
  ]).toString('utf8')
}

const mockupIv = 'abcdef0123456789'
const mockupKey = 'abcdef0123456789abcdef0123456789'

try {
  decrypt('', mockupKey, mockupIv)
} catch {}

上述代码,在node10.15-10.24,均抛出如下不可捕获的错误(fatal error),程序会直接挂掉,在12-15之间,可以正常运行。

错误日志

类似如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
node[97219]: ../src/node_crypto.cc:3047:CipherBase::UpdateResult node::crypto::CipherBase::Update(const char *, int, unsigned char **, int *): Assertion `MaybePassAuthTagToOpenSSL()' failed.
 1: 0x100d69661 node::Abort() (.cold.1) [/Users/james/.nvm/versions/node/v10.24.0/bin/node]
 2: 0x10003aeb4 node_module_register [/Users/james/.nvm/versions/node/v10.24.0/bin/node]
 3: 0x100039fb9 node::AddEnvironmentCleanupHook(v8::Isolate*, void (*)(void*), void*) [/Users/james/.nvm/versions/node/v10.24.0/bin/node]
 4: 0x100112fae node::StringBytes::InlineDecoder::Decode(node::Environment*, v8::Local<v8::String>, v8::Local<v8::Value>, node::encoding) [/Users/james/.nvm/versions/node/v10.24.0/bin/node]
 5: 0x1001119dc node::crypto::CipherBase::Update(v8::FunctionCallbackInfo<v8::Value> const&) [/Users/james/.nvm/versions/node/v10.24.0/bin/node]
 6: 0x1002386c3 v8::internal::FunctionCallbackArguments::Call(v8::internal::CallHandlerInfo*) [/Users/james/.nvm/versions/node/v10.24.0/bin/node]
 7: 0x100237bae v8::internal::MaybeHandle<v8::internal::Object> v8::internal::(anonymous namespace)::HandleApiCallHelper<false>(v8::internal::Isolate*, v8::internal::Handle<v8::internal::HeapObject>, v8::internal::Handle<v8::internal::HeapObject>, v8::internal::Handle<v8::internal::FunctionTemplateInfo>, v8::internal::Handle<v8::internal::Object>, v8::internal::BuiltinArguments) [/Users/james/.nvm/versions/node/v10.24.0/bin/node]
 8: 0x10023728a v8::internal::Builtin_Impl_HandleApiCall(v8::internal::BuiltinArguments, v8::internal::Isolate*) [/Users/james/.nvm/versions/node/v10.24.0/bin/node]
 9: 0x37d3d8d5bf3d
10: 0x37d3d8d118d5
11: 0x37d3d8d0a5c3
12: 0x37d3d8d118d5
13: 0x37d3d8d0a5c3
[1]    97218 abort      npm test

上述错误日志,发生在我本地的Node10环境中。我花了几个小时,翻了好几遍github issues,最后找到了 nodejs#20039 pull requests,通读下来并反复测试了10.15-10.24版本,均无法正常捕获,这应该是上述pr没合并至Node10系列所致。

产生条件

稍微分析一下,可能产生致命错误的条件:

密文为空字符串时,程序会崩

密文为 Cg==(base64空字符串) CLI会有 Warning DEP0090 弹出

(node:987) [DEP0090] DeprecationWarning: Permitting authentication tag lengths of 1 bytes is deprecated. Valid GCM tag lengths are 4, 8, 12, 13, 14, 15, 16.

微信支付官方文档在解密示例代码 常量定义了这个auth_tag长度为128位16字节,匹配rfc5116规范并且取的是最大值。

这下问题来了,万一无法正常获取到待解密字符串或者获取到的是空字符串,GCM模式校验码位又必须是16字节,业务逻辑又强依赖解密后字符串(验签证书是v3通讯强依赖)这崩掉了,着急上火的可真就是摊上事儿了!

向前兼容方案

找到问题关键点,那就打个业务逻辑补丁:应用端,对输入待解密字符串,做长度校验,长度为0的,不进入解密函数;或者可以采用如下向前兼容js patch补丁

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-    ).setAuthTag(tag).setAAD(Buffer.from(aad))
+    )
+
+    // Restrict valid GCM tag length, patches for Node < 11.0.0
+    // more @see https://github.com/nodejs/node/pull/20039
+    const tagLen = tag.length
+    if (tagLen > 16 || (tagLen < 12 && tagLen != 8 && tagLen != 4)) {
+      let backport = new TypeError(`Invalid authentication tag length: ${tagLen}`)
+      backport.code = 'ERR_CRYPTO_INVALID_AUTH_TAG'
+      throw backport
+    }
+    decipher.setAuthTag(tag).setAAD(Buffer.from(aad))

上述代码取自 wechatpay-axios-plugin@aa36a56,也已随源码用例覆盖Node10-Node15版本,均达预期,可安全使用。

可能的影响面

小程序云开发标配目前是Node10,不清楚云开发团队在处理消息通知及关键信息解密时,是否采用的是轻量化如nodejs原生crypto这样的解决方案,这个就需要云产品团队相关的同学进来看看,评估一下有无风险点了。

对自主对接云开发的开发者来说,建议尽快给打下业务逻辑补丁或者程序解密补丁,避免不可预期的错误发生(虽然极小概率,但支付的事,可真不是小事儿)。

题外话

建议云开发平台,能够升级一下Node10至最新lts运行时,一并建议能同时支持Node12Node14运行时。