两个文件看起来一模一样,SHA-256 为什么不同?用 Web Crypto 实现浏览器本地文件校验
前言
在下载软件安装包、备份文件或传输资料时,我们经常会看到一串 SHA-256 校验值。
例如:
ba7816bf8f01cfea414140de5dae2223
b00361a396177a9cb410ff61f20015ad
这串字符看起来很复杂,但它其实可以用来检查文件内容是否发生变化。
最近在研究浏览器原生 API 时,我想到一个问题:
能不能不安装任何依赖、不上传文件到服务器,直接在网页中计算文件的 SHA-256?
答案是可以的。
现代浏览器提供了 Web Crypto API,可以在客户端完成 SHA-256 哈希计算。
本文从基本原理出发,使用 HTML 和 JavaScript 实现一个简单的本地文件校验工具,同时分析几个开发中容易遇到的问题。
一、SHA-256 是什么?
SHA-256 是 SHA-2 家族中的一种密码学哈希算法。
它可以将任意长度的输入数据转换为固定长度的摘要。
SHA-256 的输出为 256 位,通常使用 64 个十六进制字符表示。
例如,字符串 abc 按 UTF-8 编码后的 SHA-256 为:
ba7816bf8f01cfea414140de5dae2223
b00361a396177a9cb410ff61f20015ad
哈希算法有几个重要特点:
第一,相同的字节内容会产生相同的哈希值。
第二,只要输入字节发生变化,哈希值通常就会明显不同。
第三,无法通过 SHA-256 摘要直接还原原始文件。
因此,SHA-256 常用于文件完整性校验、内容寻址和其他需要密码学摘要的场景。
不过需要注意:SHA-256 可以帮助验证文件是否与预期内容一致,但它本身不能证明文件来源可信,也不等于病毒扫描。
二、为什么两个看起来一样的文件,SHA-256 会不同?
这是一个很容易遇到的问题。
假设我们有两个文本文件。
第一个内容是:
Hello World
第二个内容看起来也是:
Hello World
但是计算 SHA-256 后,结果却不一样。
原因可能并不是哈希算法出了问题,而是文件实际字节不同。
常见情况包括:
| 原因 | 说明 |
|---|---|
| 换行符不同 | Windows 常见 CRLF,Linux 常见 LF |
| 文件编码不同 | UTF-8、UTF-16 等编码产生不同字节 |
| UTF-8 BOM | 有无 BOM 会影响文件内容 |
| 末尾空格或换行 | 不容易被肉眼发现 |
| 图片重新保存 | 编码参数或元数据可能发生变化 |
例如:
const a = new TextEncoder().encode("Hello");
const b = new TextEncoder().encode("Hello\n");
这两段数据只差一个换行符,但它们的 SHA-256 会不同。
所以,文件校验时真正比较的是字节,而不是屏幕上显示的文字。
三、浏览器如何计算 SHA-256?
现代浏览器提供了 crypto.subtle.digest() 方法。
它可以对输入数据计算密码学摘要。
例如:
async function sha256(text) {
const data = new TextEncoder().encode(text);
const buffer = await crypto.subtle.digest(
"SHA-256",
data
);
return Array.from(new Uint8Array(buffer))
.map(byte => byte.toString(16).padStart(2, "0"))
.join("");
}
sha256("abc").then(console.log);
运行后,可以得到前面提到的标准 SHA-256 测试值。
其中:
TextEncoder 用于把字符串编码为 UTF-8 字节。
crypto.subtle.digest() 负责计算 SHA-256。
Uint8Array 用于读取摘要字节。
最后将字节转换为十六进制字符串,方便显示和比较。
整个过程不需要额外安装 JavaScript 加密库。
四、实战:做一个本地文件 SHA-256 校验器
接下来实现一个简单网页。
功能包括选择文件、计算 SHA-256、填写预期哈希值并进行比较。
为了便于演示,这里把 HTML 和 JavaScript 写在同一个文件中。
创建 index.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1">
<title>SHA-256 文件校验</title>
</head>
<body>
<h1>SHA-256 文件校验助手</h1>
<label for="fileInput">选择文件</label>
<input type="file" id="fileInput">
<button id="calculateBtn" type="button">
计算 SHA-256
</button>
<p>计算结果:</p>
<output id="hashResult"></output>
<p>
<label for="expectedHash">
预期 SHA-256
</label>
</p>
<input id="expectedHash"
placeholder="输入64位十六进制校验值">
<button id="compareBtn" type="button">
比较校验值
</button>
<p id="status" role="status"></p>
<script>
const fileInput =
document.getElementById("fileInput");
const hashResult =
document.getElementById("hashResult");
const expectedHash =
document.getElementById("expectedHash");
const status =
document.getElementById("status");
let currentHash = "";
fileInput.addEventListener("change", () => {
currentHash = "";
hashResult.textContent = "";
status.textContent = "";
});
document.getElementById("calculateBtn")
.addEventListener("click", async () => {
const file = fileInput.files[0];
if (!file) {
status.textContent = "请先选择文件";
return;
}
if (!globalThis.crypto?.subtle) {
status.textContent =
"当前环境不支持 Web Crypto,请使用 HTTPS 或 localhost";
return;
}
if (file.size > 20 * 1024 * 1024) {
status.textContent =
"示例限制文件大小不超过20MB";
return;
}
status.textContent = "正在计算...";
currentHash = "";
hashResult.textContent = "";
try {
const data = await file.arrayBuffer();
const digest = await crypto.subtle.digest(
"SHA-256",
data
);
currentHash = Array.from(
new Uint8Array(digest)
).map(
b => b.toString(16).padStart(2, "0")
).join("");
hashResult.textContent = currentHash;
status.textContent = "计算完成";
} catch {
status.textContent = "计算失败,请重试";
}
});
document.getElementById("compareBtn")
.addEventListener("click", () => {
const expected =
expectedHash.value.trim().toLowerCase();
if (!currentHash) {
status.textContent = "请先计算文件哈希";
return;
}
if (!/^[0-9a-f]{64}$/.test(expected)) {
status.textContent =
"请输入有效的64位SHA-256值";
return;
}
status.textContent =
currentHash === expected
? "校验通过:哈希值一致"
: "校验失败:哈希值不一致";
});
</script>
</body>
</html>
这个示例使用浏览器本地读取文件,并没有把文件上传到远程服务器。
为了避免演示程序一次读取过大的文件,代码将文件大小限制为 20MB。
实际项目可以根据使用场景调整限制,但不应该直接认为浏览器适合一次性处理任意大小的文件。
五、为什么 Web Crypto 有时会提示不可用?
在实际开发中,还有一个容易遇到的问题:
本地测试时 SHA-256 可以正常计算,换一个访问地址后却报错。
其中一个重要原因是 Web Crypto 的安全上下文要求。
crypto.subtle 通常只在安全上下文中可用。
例如:
https://example.com
通常可以使用。
本地开发时,以下地址一般也可以:
http://localhost:3000
http://127.0.0.1:3000
但如果通过普通 HTTP 的远程地址访问,浏览器可能不提供对应 API。
因此,开发这类工具时,建议使用 HTTPS 或浏览器认可的本地开发地址。
不要通过引入不可信的外部哈希服务绕过这个问题,否则反而可能破坏本地文件处理的隐私优势。
六、大文件计算还有什么限制?
上面的示例使用:
const data = await file.arrayBuffer();
这会把整个文件读入内存。
对于小型文件,使用起来很方便。
但如果用户选择一个几 GB 的文件,浏览器就可能面临较大的内存压力。
另外,标准 Web Crypto 的 subtle.digest() 并不提供通用的增量式流式哈希接口。
如果业务需要处理超大文件,可以考虑成熟的增量哈希实现,并结合 Web Worker 避免长时间占用主线程。
但对于普通小型文件校验工具,直接使用浏览器原生 Web Crypto 通常已经足够。
技术方案应当根据真实需求选择,而不是为了复杂而复杂。
七、SHA-256 一致,就代表文件绝对安全吗?
这是另一个需要特别澄清的问题。
假设我们下载了一个安装包。
下载页面提供了 SHA-256 值。
我们计算后发现两个值完全一致。
这能够说明:在 SHA-256 的安全假设下,下载文件与该参考摘要对应的内容一致。
但是,如果下载页面本身遭到篡改,攻击者同时替换了文件和页面上的哈希值,那么简单的哈希比较仍然可能显示一致。
因此,对于安全要求较高的软件分发场景,除了完整性校验,还需要关注:
-
哈希值是否来自可信渠道。
-
下载来源是否可靠。
-
软件是否提供可信的数字签名。
-
安装包是否经过必要的安全检查。
SHA-256 解决的是内容完整性问题,不是全部的软件安全问题。
八、总结
通过浏览器原生 Web Crypto API,我们可以很方便地实现一个不依赖后端的文件 SHA-256 校验工具。
这个过程也让我们理解了几个重要的开发知识点:
第一,SHA-256 比较的是文件实际字节,而不是文件名称或视觉内容。
第二,文本编码、换行符和文件元数据变化都可能导致哈希不同。
第三,Web Crypto 可以完成浏览器本地哈希计算,但需要满足安全上下文要求。
第四,对于大文件,应考虑内存占用和分块处理方案。
第五,哈希值一致可以提供完整性验证,但不能单独证明文件来源安全。
我认为,对于一些功能明确的小工具,优先利用浏览器原生 API,往往能够减少依赖安装、构建环境和后期维护的复杂度。
一个好的开发方案,不一定需要复杂框架。有时候,浏览器已经提供了完成任务所需要的全部能力。
- 点赞
- 收藏
- 关注作者
评论(0)