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 style="display:block;font-family:monospace;"></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 "
,后面跟着以十进制表示的文件大小(以字节为单位),最后是空字符(在 JavaScript 中可以用 "\0"
表示)。可以使用 TextEncoder 接口 的 编码 API 来编码 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 style="display:block;font-family:monospace;"></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;
}
注意它如何使用 编码 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 字符串(空字符写为 \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()
函数)对你日常开发工作很有帮助,就足够了。