SubtleCrypto 的非加密用途
本文将重点介绍 digest 方法的用法,该方法属于 SubtleCrypto 接口。在 Web Crypto API 中的许多其他方法都有非常具体的加密用例,但创建内容的哈希(这正是 digest 方法所做的)有许多非常有用的用途。
本文不讨论 SubtleCrypto 接口 的加密用途。本文要强调的最重要的一点是:不要在生产环境的加密用途中使用此 API,因为它功能强大且底层。要正确使用它,您需要采取许多特定于上下文的步骤来正确完成加密任务。如果其中任何一个步骤出错,最坏的情况是您的代码无法运行,最好是代码会运行,而您却会在不知不觉中让用户面临不安全产品的风险。
您甚至可能根本不需要使用 Web Crypto API。您想要用加密来解决的许多问题,Web 平台已经提供了解决方案。例如,如果您担心中间人攻击(例如 Wi-Fi 热点读取客户端和服务器之间的信息),通过确保正确使用 HTTPS 即可解决。您想安全地在用户之间发送信息吗?那么您可以通过 WebRTC 数据通道 建立用户之间的数据连接,该连接在标准中是加密的。
SubtleCrypto 接口 提供了用于处理加密的底层原语,但使用这些工具实现系统是一项复杂的任务。错误很难被发现,结果可能意味着您的用户数据不像您想象的那么安全。如果您的用户正在共享敏感或有价值的数据,这可能会带来灾难性的后果。
如有疑问,请不要自己尝试,聘请有经验的人员,并确保您的软件经过安全专家的审计。
哈希文件
这是您可以使用 Web Crypto API 完成的最简单的有用操作。它不涉及生成密钥或证书,并且只有一个步骤。
哈希 是一种将一长串字节转换为一串较短字符串的技术,其中长字符串的微小变化会导致短字符串的巨大变化。此技术对于在不检查两个文件的每个字节的情况下标识两个相同的文件非常有用。这非常有用,因为您有一个简单的字符串进行比较。需要明确的是,哈希是一个单向操作。您无法从哈希值生成原始字节字符串。
如果生成的两个哈希相同,但用于生成它们的文件不同,则称为哈希冲突,这是一种偶然发生的概率极低的事件,对于 SHA256 这样的安全哈希函数,几乎不可能人为制造。因此,如果两个字符串相同,您可以合理地确信两个原始文件是相同的。
在本文发布时,SHA256 是哈希文件的常用选择,但在 SubtleCrypto 接口中还有 更高级的哈希函数 可用。SHA256 哈希最常见的表示形式是 64 个十六进制数字组成的字符串。十六进制意味着它只使用 0-9 和 a-f 这些字符,代表 4 位信息。简而言之,SHA256 哈希将任意长度的数据转换为几乎唯一的 256 位数据。
这项技术通常被允许您下载可执行文件的网站使用,以确保下载的文件与作者意图的文件匹配。这可以确保您的用户不会安装恶意软件。最常见的方法是:
- 记下文件名和网站提供的 SHA256 校验和。
- 下载可执行文件。
- 在终端中运行
sha256sum /path/to/the/file来生成您自己的代码。如果您使用的是 Mac,可能需要 单独安装。 - 比较这两个字符串 - 它们应该匹配,除非文件已被篡改。

SubtleCrypto 的 digest() 方法对此很有用。要生成文件的校验和,可以这样做:
首先,我们添加一些 HTML 元素来加载文件并显示 SHA-256 输出。
<h3>Demonstration of hashing a file with SHA256</h3>
<label
>Choose file(s) to hash <input type="file" id="file" name="file" multiple
/></label>
<output></output>
接下来,我们使用 SubtleCrypto 接口来处理它们。这通过以下方式工作:
- 使用
File对象的arrayBuffer()方法将文件读取到ArrayBuffer中。 - 使用
crypto.subtle.digest('SHA-256', arrayBuffer)来处理 ArrayBuffer。 - 将生成的哈希(另一个 ArrayBuffer)转换为字符串,以便显示。
const output = document.querySelector("output");
const file = document.getElementById("file");
// Run the hashing function when the user selects one or more file
file.addEventListener("change", hashTheseFiles);
// The digest function is asynchronous, it returns a promise
// We use the async/await syntax to simplify the code.
async function fileHash(file) {
const arrayBuffer = await file.arrayBuffer();
// Use the subtle crypto API to perform a SHA256 Sum of the file's
// Array Buffer. The resulting hash is stored in an array buffer
const hashAsArrayBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer);
// To display it as a string we will get the hexadecimal value of
// each byte of the array buffer. This gets us an array where each byte
// of the array buffer becomes one item in the array
const uint8ViewOfHash = new Uint8Array(hashAsArrayBuffer);
// We then convert it to a regular array so we can convert each item
// to hexadecimal strings, where characters of 0-9 or a-f represent
// a number between 0 and 15, containing 4 bits of information,
// so 2 of them is 8 bits (1 byte).
const hashAsString = Array.from(uint8ViewOfHash)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return hashAsString;
}
async function hashTheseFiles(e) {
let outHTML = "";
// iterate over each file in file select input
for (const file of this.files) {
// calculate its hash and list it in the output element.
outHTML += `${file.name} ${await fileHash(file)}\n`;
}
output.innerText = outHTML;
}
您会在哪里使用它?
此时,您可能会想:“我可以在自己的网站上使用它,当用户下载文件时,我们可以确保哈希值匹配,以向用户保证他们的下载是安全的”。不幸的是,这有两个立即想到的问题:
-
可执行文件的下载始终应该通过 HTTPS 进行。这可以防止中间方执行此类攻击,因此是多余的。
-
如果攻击者能够替换原始服务器上的下载文件,那么他们也可以简单地替换调用 SubtleCrypto 接口的代码来绕过它,并声称一切正常。可能是一些偷偷摸摸的替换,例如 严格相等性,这在您自己的代码中可能很难发现。
diff--- if (checksum === correctCheckSum) return true; +++ if (checksum = correctCheckSum) return true;
一个可能值得尝试的地方是,如果您想测试来自您不控制的第三方下载源的文件。前提是下载位置具有 CORS 标头,允许您在将文件提供给用户之前对其进行扫描。不幸的是,默认情况下很少有服务器启用 CORS。
什么是“哈希加盐”?
您可能以前听过一句话:“给哈希加盐”。它与我们当前的主题关系不大,但了解一下是好的。
注意:本节讨论的是密码安全,SubtleCrypto 提供的哈希函数不适用于此用例。对于这些目的,您需要昂贵且缓慢的哈希函数,如 scrypt 和 bcrypt。SHA 的设计初衷是相当快速和高效的,这使其不适合密码哈希。本节仅供您参考 — 请勿在客户端使用 Web Crypto API 来哈希密码。
哈希的一个流行用例是密码。您绝不应该以明文形式存储用户的密码,这简直是个糟糕的主意。相反,您存储用户密码的哈希值,这样,如果黑客获取了您的用户名和密码数据库,也无法恢复原始密码。眼尖的读者可能会注意到,通过将已知密码列表中的哈希值与获取的密码哈希列表进行比较,仍然可以推断出原始密码。将一个字符串连接到密码会改变哈希值,使其不再匹配。这被称为加盐。另一个棘手的问题是,如果您为每个密码使用相同的盐,那么具有匹配哈希值的密码也具有相同的原始密码。因此,如果您知道一个,那么您就知道所有匹配的密码。
为了解决这个问题,您执行所谓的给哈希加盐。对于每个密码,您生成一个盐(随机字符字符串),然后将其与密码字符串连接起来。然后,您将哈希值和盐存储在同一个数据库中,以便在用户稍后登录时进行匹配检查。这意味着,如果两个用户使用相同的密码,它们的哈希值也会不同。因此,您需要一个昂贵的加密函数,因为使用常用密码列表来找出原始密码会耗费大量时间。
使用 SHA 的哈希表
您可以使用 SHA1 快速生成非加密安全的哈希。这些对于将任意数据转换为稍后可用于查找的键非常有用。
例如,如果您想拥有一个数据库,其中一行中的一个字段包含大量数据块。这会降低数据库的效率,因为其中一个字段必须是可变长度的,或者足够大以存储可能的最大数据块。另一种解决方案是生成数据块的哈希值,并将其存储在一个单独的查找表中,使用哈希值作为索引。然后,您可以在原始数据库中只存储哈希值,这是一个固定长度的好方法。
SHA1 哈希的可能变体数量极其庞大。多到偶然产生两个具有相同 SHA1 哈希值的数据块几乎是不可能的。因为 SHA1 不是加密安全的,所以可能有意产生两个具有相同 SHA1 哈希值的文件。因此,理论上,恶意用户可以生成一个数据块来替换数据库中的原始数据块,而由于哈希值相同,因此不会被检测到。这是一个值得注意的攻击向量。
Git 如何存储文件
Git 使用 SHA1 哈希,这里是一个很好的例子,它在两个有趣的方式中使用哈希。当文件存储在 git 中时,它们通过它们的 SHA1 哈希进行引用。这使得 git 能够快速查找数据并恢复文件。
它不只是使用文件内容进行哈希,还会先加上 UTF8 字符串 "blob ",然后是文件大小(以字节为单位,以十进制表示),最后是 null 字符(在 JavaScript 中可以写成 "\0")。您可以使用 Encoding API 的 TextEncoder 接口 来编码 UTF8 文本,因为 JavaScript 中的字符串是 UTF16。
下面的代码与我们的 SHA256 示例一样,可以用来从文件中生成这些哈希。用于上传文件的 HTML 保持不变,但我们会做一些额外的工作来像 git 一样添加大小信息。
<h3>Demonstration of how git uses SHA1 for files</h3>
<label
>Choose file(s) to hash <input type="file" id="file" name="file" multiple
/></label>
<output></output>
const output = document.querySelector("output");
const file = document.getElementById("file");
file.addEventListener("change", hashTheseFiles);
async function fileHash(file) {
const arrayBuffer = await file.arrayBuffer();
// Git prepends the null terminated text 'blob 1234' where 1234
// represents the file size before hashing so we are going to reproduce that
// first we work out the Byte length of the file
const uint8View = new Uint8Array(arrayBuffer);
const length = uint8View.length;
// Git in the terminal uses UTF8 for its strings; the Web uses UTF16.
// We need to use an encoder because different binary representations
// of the letters in our message will result in different hashes
const encoder = new TextEncoder();
// Null-terminated means the string ends in the null character which
// in JavaScript is '\0'
const view = encoder.encode(`blob ${length}\0`);
// We then combine the 2 Array Buffers together into a new Array Buffer.
const newBlob = new Blob([view.buffer, arrayBuffer], {
type: "text/plain",
});
const arrayBufferToHash = await newBlob.arrayBuffer();
// Finally we perform the hash this time as SHA1 which is what Git uses.
// Then we return it as a string to be displayed.
return hashToString(await crypto.subtle.digest("SHA-1", arrayBufferToHash));
}
function hashToString(arrayBuffer) {
const uint8View = new Uint8Array(arrayBuffer);
return Array.from(uint8View)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
// like before we iterate over the files
async function hashTheseFiles(e) {
let outHTML = "";
for (const file of this.files) {
outHTML += `${file.name} ${await fileHash(file)}\n`;
}
output.innerText = outHTML;
}
请注意,它如何使用 Encoding API 来生成头部,该头部与原始 ArrayBuffer 连接以生成要哈希的字符串。
Git 如何生成提交哈希
有趣的是,git 也以类似的方式基于多个信息生成提交哈希。这些可能包括前一个提交哈希和提交消息,它们共同构成一个新的哈希。这可以用来引用基于多个唯一标识符的提交。
终端命令是: (printf "commit %s\0" $(git --no-replace-objects cat-file commit HEAD | wc -c); git cat-file commit HEAD) | sha1sum
本质上是 UTF8 字符串(null 字符写成 \0)
commit [size in bytes as decimal of this info]\0tree [tree hash] parent [parent commit hash] author [author info] [timestamp] committer [committer info] [timestamp] commit message
这很好,因为没有单个字段保证是唯一的,但组合在一起可以为单个提交提供一个唯一的指针。然而,整个字符串太长且难以处理。因此,通过哈希它,您可以得到一个新的唯一字符串,它足够短,可以方便地从多个字段共享。
这就是为什么如果您曾经修改过提交,即使您没有对消息进行任何更改,哈希值也会改变。提交的时间戳已更改,即使只是一个字符的更改,也足以完全更改新的哈希值。
由此得出的结论是,当您想为某些数据添加一个键,但任何单个信息都不够独特时,将多个字符串连接起来并对其进行哈希处理是一种生成有用键的好方法。
希望这些示例能鼓励您查看这个强大而新的 API。请记住,不要自己重新创建加密的东西。了解这些工具的存在就足够了,其中一些工具,如 crypto.digest() 函数,是您日常开发中有用的工具。