ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

微信小程序MD5中文加密不一致?先搞定UTF-8字符编码转换

微信小程序MD5中文加密不一致?先搞定UTF-8字符编码转换 说实话微信小程序里和MD5中文相关的这个bug我在今年做的一个电商小程序项目里又踩了一遍。当时是接口签名校验通不过后端用Java实现前端在小程序里对同样的参数做MD5两边算出来的值就是不一样。最让人抓狂的是参数全是英文和数字的时候一切正常只要昵称、地址这类字段里混入中文签名立刻挂掉。排查到最后问题根子不在MD5算法本身而在字符编码。这篇内容我把它写成一份排查记录不光是给结论也会把为什么会出现这个问题的底层逻辑完整拆开。对于正被前后端签名对不上、MD5加密结果不一致折磨的同学这篇至少能帮你少走我一整天的弯路。无论你用的是自己写的md5函数、网上抄的精简实现还是blueimp-md5、crypto-js这类成熟库这篇文章的思路都适用。1. MD5中文Bug长什么样签名对不上、加密串不一致1.1 一个典型场景登录接口的参数签名在微信小程序里最常遇到MD5的场景就是接口签名。客户端把时间戳、随机字符串、请求参数按约定顺序拼成一个字符串然后做MD5得到sign值跟随请求一起发给后端。后端拿到参数后用同样的规则拼一次、算一次然后比对sign。看起来很简单但一旦参数里有中文两边算出来的MD5经常不一样。我当时的排查流程是先检查拼接顺序再看分隔符、空格最后打印出前后端拼接后的原串发现两边肉眼看起来完全一样。可MD5就是不一样。后来我把前端和后端算出来的MD5都打出来放在一起对比才发现一个规律只要字符串里全是英文字母和数字两边结果一致一旦有中文结果必定不一致。而且不止是登录接口凡是把用户昵称、收货地址、备注信息这类中文内容拼进字符串做MD5的接口全军覆没。1.2 影响范围不只是签名凡是对中文做MD5都可能踩这个问题的影响面比很多人想象得大。除了接口签名还有几种常见场景同样会踩登录密码加密存储。老系统常用MD5对密码做摘要用户在注册时输入中文密码的场景虽然少但一旦有前后端不一致会让登录永远失败。本地缓存key的生成。用请求内容或文件名做MD5当成缓存key如果缓存写入端和读取端有一端对中文处理不一致就会出现缓存永远命不中的诡异问题。文件校验。小程序端上传文件时先算MD5给后端做秒传校验文件名、文件内容里如果包含中文元信息很容易出问题。数据签名防篡改。内容字段含中文时签名不一致导致校验失败。说白了只要MD5的输入是字符串而这个字符串里有中文编码问题就可能冒出来。1.3 为什么这个坑到现在还高频出现这个坑之所以现在还在频繁出现原因有三第一很多MD5实现是从老博客、老源码里Copy过来的那些代码写得早默认使用者只传英文字符压根没考虑中文编码第二微信小程序的开发者工具、基础库、真机JavaScript引擎一直在变让很多人误以为是环境差异导致的问题第三新手和经验丰富的开发者在排查时惯性思维都是先对参数、对拼接规则很少有人第一时间想到编码层面。搜索平台上“最新已解决”这个标签说明这个坑至今还在批量制造开发者所以专门写一篇讲编码成因和完整修复方案的文章确实有必要。2. 根因拆解MD5处理的是字节不是“字符串”2.1 MD5算法的输入输出要理解这个bug先得想清楚MD5算法到底在算什么。MD5算法接收的是一串比特流或字节数组经过填充、分组、压缩等步骤最终输出一个128位的散列值通常表示为32位十六进制字符串。它并不关心输入内容是人话还是机器码只关心输入的二进制内容。所以问题就来了字符串只是一种抽象的“字符序列”计算机在真正处理它的时候必须把它编码成字节。同一个字符串用不同的编码方式会得到完全不同的字节序列而字节序列一变MD5结果自然就变了。2.2 字符编码码点和字节序列这里需要引入两个基础概念码点和编码。Unicode给每个字符分配了一个唯一的编号这个编号叫码点。比如“微”这个字的码点是U5FAE。但码点只是编号存到内存、写入文件、网络传输时需要把它编码成实际的字节序列。同样是“微”这个字UTF-8编码是3个字节E5 BE AEUTF-16编码是2个字节5F AEGBK编码是2个字节CE A2同一个字在不同编码下长得完全不一样。如果A程序按UTF-8读取B程序按GBK读取两边拿到的字节不同MD5自然不同。2.3 JavaScript字符串的坑内部存储是UTF-16JavaScript字符串在内存中统一按UTF-16存储微信小程序也不例外。这意味着当你在小程序里写中文它在引擎内部是UTF-16的字节序列。MD5库在计算时如果它直接把字符串里的每个字符取出来用charCodeAt拿到数值再当成字节去处理那么它拿到的是UTF-16编码下的码元数值而不是UTF-8编码后的字节值。举个例子charCodeAt(微)返回的是0x5FAE也就是24494这个值和UTF-8编码下的E5 BE AE完全是两码事。把0x5FAE塞进MD5算法和把E5 BE AE塞进MD5算法算出来的摘要绝对不同。这就是中文MD5出错的根本原因。2.4 为什么英文数字没问题这个现象实际上反过来验证了上面这个原因。ASCII字符的码点范围是0到127而UTF-8编码对0到127的字符就是原样编码一个字符刚好一个字节数值等于码点本身。比如字母a的码点是97UTF-8编码后也还是97。在这种场景下不管MD5库是按UTF-16码元处理还是按UTF-8编码处理拿到的字节数值都一样结果自然一致。这就像两个人在同一个路口按照同一份地图走只要道路没有分叉谁都发现不了地图其实不一样。所以纯英文和数字参数永远正常只有中文一出现就“水土不服”。3. 修复前的自查你用的MD5库属于哪一派3.1 会用UTF-8处理的库有些MD5库在设计时就考虑到了编码问题内部会先把字符串按UTF-8编码成字节序列再计算MD5。这类库的典型代表是crypto-js。crypto-js的使用方式和直接传字符串略有不同需要显式调用UTF-8解析const CryptoJS require(crypto-js); const msg 中文测试; const hash CryptoJS.MD5(CryptoJS.enc.Utf8.parse(msg)).toString(); console.log(hash);注意这里我用的是CryptoJS.enc.Utf8.parse(msg)先把字符串转成UTF-8编码的WordArray再交给MD5计算。如果你图省事直接CryptoJS.MD5(msg)在某些版本里结果也可能是对的因为它内部默认做了一次UTF-8编码但为了明确语义、避免版本差异建议显式包一层。3.2 不会自动处理UTF-8的库另一类库不负责编码转换它们只负责把传入的字符串当字节序列处理。这类库的代表是blueimp-md5以及大量网上流传的精简MD5实现。blueimp-md5内部通过charCodeAt逐个取字符数值它假设你传入的字符串已经是“每个字符对应一个字节”的二进制字符串状态。如果你直接传中文它拿到的是UTF-16码元数值结果就是错的。网上copy的精简MD5实现更是重灾区。很多老代码写于十年前当时的应用场景主要是英文网页根本没有考虑中文输入这类代码的错误版式五花八门有的高位直接丢弃有的按GBK处理有的甚至bytes越界变成负数。我把常见的MD5库在小程序里的表现整理成一个对照表方便你自查库是否显式处理UTF-8小程序中建议用法crypto-js支持需显式调用Utf8.parseCryptoJS.MD5(CryptoJS.enc.Utf8.parse(str)).toString()blueimp-md5不支持需要先转字节手动转成UTF-8字节序列或二进制字符串SparkMD5部分推荐传ArrayBuffer先转ArrayBuffer再增量计算网上自写MD5多数不支持建议直接换库或者自己补UTF-8编码函数3.3 微信小程序运行环境的特殊差异微信小程序的运行环境比浏览器更复杂。开发者工具跑在Chromium上iOS真机跑在JavaScriptCore上Android真机在不同系统版本上可能跑V8或JavaScriptCore。这些JavaScript引擎对字符串的存储方式一样但对一些新API的支持程度不同。比如TextEncoder这个API在部分基础库版本和部分系统上不存在。如果你在代码里直接使用new TextEncoder().encode(中文)可能在开发者工具里正常一到某些真机直接报TextEncoder is not defined。这也是为什么我建议在小程序场景下优先使用手动实现的UTF-8编码函数不依赖宿主环境API跨端最稳。4. 核心修复方案先把中文变成UTF-8字节数组再做MD54.1 修复思路一句话不管你是哪个派系的MD5库修复思路都只有一句话先把JavaScript字符串编码成UTF-8的字节数组再把字节数组交给MD5算法。编码一致性是跨语言MD5比对的基础。后端Java、Python、Go这些语言处理字符串时默认就是按UTF-8编码获取字节的只要前端也走UTF-8结果必然一致。4.2 方案一用encodeURIComponent转百分号编码再还原这是一个取巧但很实用的办法。encodeURIComponent会将中文字符转成%XX%XX%XX形式的百分号编码其中XX就是UTF-8编码后的字节十六进制值。解析回来即可得到完整的字节数组。function stringToUtf8Bytes(str) { const encoded encodeURIComponent(str); const bytes []; for (let i 0; i encoded.length; i) { if (encoded[i] %) { bytes.push(parseInt(encoded.substr(i 1, 2), 16)); i 2; } else { bytes.push(encoded.charCodeAt(i)); } } return bytes; }这里有一个细节必须注意encodeURIComponent并不会编码A-Z a-z 0-9 - _ . ! ~ * ( )这些字符。也就是说它们不会被转成%XX格式而是原样出现在结果里。所以else分支不能丢必须把未编码字符的charCodeAt也 push 进去。之前有同事抄了这个思路却漏了else分支结果英文字母和数字全丢了MD5又对不上。4.3 方案二自己实现UTF-8编码函数彻底可控我个人最推荐的是自己实现一个UTF-8编码函数原因很简单不依赖任何库、不依赖任何平台API在任何JS环境下行为一致而且能正确处理emoji等四字节字符。function utf8Encode(str) { const bytes []; for (let i 0; i str.length; i) { let code str.charCodeAt(i); // 处理代理对比如 emoji 表情 if (code 0xD800 code 0xDBFF) { const low str.charCodeAt(i 1); if (low 0xDC00 low 0xDFFF) { code 0x10000 ((code - 0xD800) 10) (low - 0xDC00); i; } } if (code 0x80) { // 单字节ASCII 范围 bytes.push(code); } else if (code 0x800) { // 双字节覆盖大部分 European 和中东文字 bytes.push( 0xC0 | (code 6), 0x80 | (code 0x3F) ); } else if (code 0x10000) { // 三字节覆盖 CJK 中文、日文、韩文等 bytes.push( 0xE0 | (code 12), 0x80 | ((code 6) 0x3F), 0x80 | (code 0x3F) ); } else { // 四字节覆盖 emoji 和生僻字 bytes.push( 0xF0 | (code 18), 0x80 | ((code 12) 0x3F), 0x80 | ((code 6) 0x3F), 0x80 | (code 0x3F) ); } } return bytes; }这段代码的逻辑其实是照着UTF-8编码规则逐条实现的。UTF-8是变长编码ASCII范围用一个字节其他字符根据码点大小分别用两到四个字节。每个多字节序列的第一个字节是控制头后面的字节都带10开头的前缀所以0x80 | (code 0x3F)这样的位运算就是取出低位6位并加上前缀。理解了这一点即使是陌生的字符你也能知道代码在做什么。4.4 方案三TextEncoder的兼容性问题如果你只想写最少代码可以用new TextEncoder().encode(str)它在较新的微信基础库版本里能正常工作。但前面说过部分真机和旧基础库不支持所以稳妥做法是做一个能力检测function utf8EncodeSafe(str) { if (typeof TextEncoder ! undefined) { return new TextEncoder().encode(str); } // 回退到手动实现 return utf8Encode(str); }能力检测的意思是优先用系统API系统没有就回退到自己写的函数。这个模式在微信小程序开发里非常实用任何涉及跨端兼容的API都可以这么处理。4.5 放进项目里的完整封装把上面的能力整合到一起就是可以直接抄进项目的工具函数。以blueimp-md5为例const md5 require(blueimp-md5); function utf8Encode(str) { // 这里放 4.3 节里的完整实现 } function md5Utf8(str) { const bytes utf8Encode(str); // 转成二进制字符串确保每个字符恰好对应一个字节 let binary ; for (let i 0; i bytes.length; i) { binary String.fromCharCode(bytes[i]); } return md5(binary); } module.exports { md5Utf8 };这里为什么要转成二进制字符串因为blueimp-md5这类库的入口只接受字符串内部会逐字符取charCodeAt。我们通过String.fromCharCode(bytes[i])创建的字符串每个字符的码点刚好是0到255和UTF-8字节数组一一对应这样MD5库拿到手的就是字节序列不会再发生编码错乱。如果你用的是crypto-js封装更简单const CryptoJS require(crypto-js); function md5Utf8(str) { return CryptoJS.MD5(CryptoJS.enc.Utf8.parse(str)).toString(); }5. 实测验证同一串中文前后端必须算出同一个值5.1 先定一个测试串验证方法很简单关键是要选一个有代表性的测试串。我建议不要只测纯中文而是混合ASCII、中文、特殊标点和emoji比如Hello中文abc123_-.这个串里既有单字节的ASCII也有三字节的中文和中文标点还有四字节的emoji如果代码能正确处理覆盖面就比较全了。5.2 后端对照方法前端算出来的值对不对最终要以某个“标准答案”为准。最直接的标准答案就是后端语言算出的UTF-8编码MD5。以下是几种常见后端验证方式macOS或Linux终端echo -n Hello中文abc123_-. | md5sum注意这里的-n参数它表示输出字符串后不换行。如果去掉换行符MD5会把换行符也计算进去结果就不对了。很多人在这里翻过车。Python验证import hashlib s Hello中文abc123_-. print(hashlib.md5(s.encode(utf-8)).hexdigest())Java验证import java.security.MessageDigest; import java.nio.charset.StandardCharsets; public class Md5Test { public static void main(String[] args) throws Exception { String s Hello中文abc123_-.; MessageDigest md MessageDigest.getInstance(MD5); byte[] digest md.digest(s.getBytes(StandardCharsets.UTF_8)); StringBuilder sb new StringBuilder(); for (byte b : digest) { sb.append(String.format(%02x, b)); } System.out.println(sb.toString()); } }5.3 修复前后对比按照上方的标准答案在微信小程序里跑同一段代码结果应该是这样的输入串错误实现直接传字符串给MD5库修复后先UTF-8编码后端标准答案Hello中文abc123_-.与后端不一致与后端一致以md5sum为准我在实际项目里见过的一个错误例子是同样的输入错误实现算出来的是32位小写十六进制但和后端值完全没有规律性差异不是少一位多一位而是从第1位开始就不一样。这是典型的编码错乱不是大小写或拼接问题。5.4 自动化自测防止回归修复之后建议在项目中留一个自测用例把标准答案写死到测试代码里。以后任何人改动工具函数只要跑一遍用例就能发现编码问题又回来了const { md5Utf8 } require(./utils/md5); const input Hello中文abc123_-.; const expected 把后端算出的标准答案填在这里; const actual md5Utf8(input); console.log(actual expected ? PASS : FAIL); if (actual ! expected) { throw new Error(MD5 UTF-8 编码结果不一致); }这类回归测试在团队协作时特别有用。我见过不止一次因为某个同学“优化”了工具函数把UTF-8编码步骤去掉了导致整个项目签名大面积失败。有了这个自测用例代码提交前跑一下问题根本不会流到线上。6. 比编码更隐蔽的几个拦路虎大小写、特殊字符、隐形换行统统踩过6.1 MD5结果的大小写不一致这个坑和编码无关但特别容易和中文bug搅在一起。很多MD5库默认输出小写十六进制而后端Java代码如果用了String.format(%X, b)就会输出大写。两边比较的时候一个转成小写、一个转成大写比对必挂。站在工程角度我建议在生成签名的时候统一转成小写因为后端生态里小写十六进制更普遍。如果后端坚持大写前端就toUpperCase()关键是两边必须约定同一个标准。6.2 特殊字符在签名串里的转义问题当你要签名的内容里有、/、这类URL特殊字符时需要注意它们在传输过程中的变化。比如你用encodeURIComponent编码后会变成%2B但如果你在生成签名时用的是未编码的原始字符串ab而后端收到的是解码后的a b空格两边的签名原串就不一样了。这种问题看表象和中文MD5bug一模一样都是“前端对不上后端”但根子在URL编码不在字符编码。排查时记得把拼接后的签名原串打出来逐字符比对。6.3 隐形空格和换行我在项目里排过最哭笑不得的一个问题前端从输入框拿到用户昵称后调用trim去掉了首尾空格但数据库里存的名字后面跟了一个不可见的零宽空格。后端从数据库取值拼签名时把这个零宽空格也拼进去了两边MD5不一致。这类“看不见的字符”最坑人。排查技巧是把签名原串转成十六进制或Unicode码点打印出来人眼看不见的空格在十六进制下面藏不住。比如\u00a0是不换行空格\u200b是零宽空格这些字符从界面上完全看不出来。6.4 接口层二次编码导致中文内容变了还有一种情况前端在拼接签名时用的是中文原始值但请求发出前对参数做了encodeURIComponent于是后端收到的参数是解码前的%E4%B8%AD%E6%96%87还是解码后的中文取决于服务端框架的配置。如果签名串里拼的是原始值而后端校验时拼的是解码后的值或者反过来就会出现只有中文参数才触发的签名失败。解决思路和编码问题是同一个方向约定清楚“参与签名的字符串”到底经过没经过URL编码然后在前后端同时执行同样规则。6.5 大文件和数据量大的MD5计算要考虑增量如果你用MD5不是为了签名字符串而是为了校验上传文件那还需要考虑增量计算问题。整个文件一次性读进内存算MD5在微信小程序里不现实文件稍大就可能内存溢出。这时要选支持增量计算的库比如SparkMD5。它的用法是分片读取ArrayBuffer不断调用append最后end得到结果。因为ArrayBuffer本身就是二进制数据不涉及字符串编码问题所以这一场景反而是最不容易踩中文坑的。但要注意分片大小和append顺序前后端必须按同样的分片顺序计算。最后再说个实在的建议如果你在项目里已经踩过一次这个坑不妨把修复后的工具函数沉淀下来并且配套写一个后端对照用例防止哪天换了个MD5库或者有同事改代码又把编码问题带回来。我自己现在做小程序项目只要涉及MD5计算第一件事就是确认参与计算的字符串走的是不是UTF-8字节而不是等出问题再去补。希望这篇能把你的排查时间从一个下午压缩到十分钟。
返回列表