前后端算的 MD5、SHA-256 对不上?编码、换行、BOM、HMAC、JSON 顺序,八个原因逐个实测

接口签名校验失败、文件秒传对不上、两边日志里的 MD5 怎么看都不一样,这类问题我前后排过不下十次。最后发现,哈希算法本身从来没错过,错的都是「喂进去的字节」和「吐出来的格式」。

下面八个原因按我碰到的频率排,每个都在 Node 25.8 上实跑过,输出原样贴出来,你可以直接复制去对。

先记住一句话:哈希算的是字节,不是字符串。字符串到字节之间的每一步转换,都是一个对不上的机会。

1. 字符编码:同一个「你好」,三种 MD5

JS 里的字符串是 UTF-16,Java 的 getBytes() 不传参数时用平台默认编码,PHP 拿到的是什么取决于文件和请求本身的编码。只要两边不是同一种编码,结果就不一样。

js 复制代码
import { createHash } from 'node:crypto';
const md5 = (data) => createHash('md5').update(data).digest('hex');

md5('你好');                                // 字符串默认按 UTF-8
md5(Buffer.from('你好', 'latin1'));        // 被当成 latin1 截断
md5(Buffer.from('你好', 'utf16le'));       // UTF-16 小端

输出:

复制代码
7eca689f0d3389d9dea66ae112e5cfd7   UTF-8
52a90a58a24523da1b9d09124b1f3861   latin1
bda2556346d512a3d529d0e88725cff5   UTF-16LE

终端里 printf '你好' | md5 得到的也是 7eca689f...,和 UTF-8 那行一致。约定很简单:两边都显式转成 UTF-8 字节再算 。Java 写 getBytes(StandardCharsets.UTF_8),浏览器用 new TextEncoder().encode(s)。

2. 结尾多了一个换行

这是命令行验证时最常见的坑。echo 默认在末尾加 \n:

bash 复制代码
echo -n abc | shasum -a 256
# ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
echo abc | shasum -a 256
# edeaaff3f1774ad2888673770c6d64097e391bc362d7d6fb34982ddf0efd18cb

第一行才是 sha256("abc") 的标准值。从文件读密钥、从配置读盐值的时候也一样,编辑器保存时自动补的末尾换行会悄悄混进去。读出来先 trim(),或者干脆约定不允许首尾空白。

3. Windows 换行 CRLF

同一段多行文本,在 Windows 上编辑过一次,\n 就可能变成 \r\n:

js 复制代码
md5('a\nb');    // 8cdeb44417f3c26826595d5820cf5700
md5('a\r\nb');  // 65d5f03c46e62e3f2babbe712d2ce464

Git 的 core.autocrlf 也会干这件事:仓库里是 LF,Windows 同事检出来是 CRLF,同一个文件两边算出的哈希不同。对文本做签名时,先统一 s.replace(/\r\n/g, '\n')。

4. 看不见的 BOM

有些编辑器保存 UTF-8 文件时会在开头加 EF BB BF 三个字节(BOM),读进 JS 后是一个  字符,打印出来什么都看不到:

js 复制代码
md5('abc');  // 53a492debae1c7abf6615813b4b5ca99
md5('abc');        // 900150983cd24fb0d6963f7d28e17f72

日志里两边的字符串看起来一模一样,长度却差 1。排查时先打 s.length 和 Buffer.from(s).toString('hex'),比肉眼对字符可靠得多。

5. 输出格式:hex、大小写、Base64、Base64URL

算法和输入都对了,最后一步「怎么把 32 个字节写成文本」还能再错一次:

js 复制代码
const sha = createHash('sha256').update('abc');
sha.copy().digest('hex');        // ba7816bf8f01cfea...f20015ad
sha.copy().digest('base64');     // ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=
sha.copy().digest('base64url');  // ungWv48Bz-pBQUDeXa4iI7ADYaOWF3qctBD_YfIAFa0

几个常见的不一致:

现象 原因
一边全小写一边全大写 hex 大小写约定不同,比较前统一 toLowerCase()
一边 64 位一边 44 位 一边 hex 一边 Base64
只差 + / = 这几个字符 一边是 Base64,一边是 Base64URL
Base64 里的 + 变成了空格 放进 URL 查询参数时没编码

最后一条在签名放进 query string 的场景下特别多,服务端拿到的已经不是原来那串了。

6. HMAC 不是「把密钥拼在前面再哈希」

接口签名文档写的是 HMAC-SHA256,有人实现成 sha256(key + data),结果当然对不上:

js 复制代码
import { createHmac } from 'node:crypto';
createHmac('sha256', 'key').update('abc').digest('hex');
// 9c196e32dc0175f86f4b1cb89289d6619de6bee699e4c378e68309ed97a1a6ab
createHash('sha256').update('key' + 'abc').digest('hex');
// 1141e2212b08bb07b3c9558bb7a90f4a99b1c6f0c97e970c279df64e7f0875ed

HMAC 内部要对密钥做两次填充、算两轮哈希,和简单拼接是两回事。拼接写法还有长度扩展攻击的问题,签名场景别用。

7. JSON 字段顺序

对一个对象签名,最省事的写法是 md5(JSON.stringify(obj)),但字段顺序一变,字符串就变了:

js 复制代码
md5(JSON.stringify({ a: 1, b: 2 }));  // {"a":1,"b":2} → 608de49a4600dbb5b173492759792e4a
md5(JSON.stringify({ b: 2, a: 1 }));  // {"b":2,"a":1} → 9915965eb40d343a8fe26e4e341d1a05

后端用 Go 的 map 序列化会按 key 排序,用 Java 的 HashMap 顺序不保证,前端对象按插入顺序。数字也有坑:JSON.stringify({p: 1.10}) 输出 {"p":1.1},另一边如果按字符串 "1.10" 拼就对不上。

签名要定一个规范化 规则:参数按 key 字典序排好,用 k=v&k=v 拼起来,数字按约定格式转字符串,再去算。各家支付平台的签名文档都是这么规定的,原因就在这。

8. Unicode 规范化:看起来一样的 é

é 可以是一个码点(U+00E9),也可以是 e 加一个组合重音符(U+0065 U+0301)。macOS 文件系统、部分输入法会产生后一种:

js 复制代码
const nfc = 'é'.normalize('NFC');  // length 1
const nfd = 'é'.normalize('NFD');  // length 2
md5(nfc);  // 66ddcd97cfdeabb2f6fb8a999b4bc76f
md5(nfd);  // 5526861fbb1e71a1bda6ac364310a807

用户名、文件名参与签名时偶尔会撞到。两边都先 normalize('NFC') 再算。

附:浏览器里没有 MD5

浏览器原生的 crypto.subtle.digest 支持 SHA-1、SHA-256、SHA-384、SHA-512,不支持 MD5:

js 复制代码
await crypto.subtle.digest('SHA-256', new TextEncoder().encode('abc'));
// → ba7816bf...f20015ad,和 Node 一致
await crypto.subtle.digest('MD5', new Uint8Array(1));
// NotSupportedError: Unrecognized algorithm name

前端要算 MD5 只能用 spark-md5、crypto-js 这类库。另外 crypto.subtle 只在 HTTPS 和 localhost 下可用,内网 http 地址上调用会拿到 undefined,这也是「本地好好的,测试环境报错」的常见来源。

排查顺序

我现在遇到哈希对不上,固定按这个顺序查:

  1. 两边都把输入 打成 hex(Buffer.from(s).toString('hex')),先比字节,不比哈希。字节不一样,就回去看编码、换行、BOM、规范化、JSON 顺序。
  2. 字节一样,再比算法:是不是一边 HMAC 一边普通哈希,SHA-256 有没有写成 SHA-1。
  3. 算法也一样,最后比输出格式:hex 大小写、Base64 还是 Base64URL、URL 里有没有被转义。

做第一步时需要一个随手能用的对照工具。我平时用自己做的 福兮的哈希计算工具,贴一段文本,MD5、SHA-1、SHA-256、SHA-384、SHA-512、RIPEMD160 六种一起按 UTF-8 算出来,可以切十六进制或 Base64 输出,拿来和代码里的结果对一下很快。比如输入「你好」,MD5 是 7eca689f...,和第 1 节 UTF-8 那一行一致:

它的局限也说清楚:只能算文本 ,不能拖文件进去算文件哈希;HMAC 在站上是另一个工具,这个页面里没有。要核对大文件的哈希,还是用命令行 shasum -a 256 文件名 或者 certutil -hashfile 文件名 SHA256(Windows),这两个读的是原始字节,不会有上面这些编码问题。

相关推荐
一木 之林1 小时前
DeepSeek Agent 开发
java·前端·人工智能
Java后端的Ai之路1 小时前
Python 进阶探索25 - difflib模块之文本对比
开发语言·python·django·哈希算法·difflib
王霸天1 小时前
Three.js 模型体积优化:Draco/Meshopt 压缩与 DRACOLoader 配置的 4 个步骤
java·前端·javascript
AAA程序技术1 小时前
用户点击按钮时,发生了什么
前端
花旗蜕变1 小时前
前端转 NestJS 全栈实践:从表单页面到微信业务系统
前端·typescript·nestjs
开开心心就好1 小时前
二维码批量生成导出工具,离线可用完全免费
java·前端·人工智能·智能手机·github·excel·visual studio
web打印社区2 小时前
Lodop 提示未安装或请升级:Chrome 里先分清该装哪套
开发语言·前端·javascript·chrome·websocket·http
重生之我复员之后重当黄毛2 小时前
vscode配置c/c++环境
c语言·前端·visual studio
COOLMO研究AI2 小时前
企业官网服务页的信息架构怎么设计:从用户问题到语义化 HTML
前端·css·html