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 位数据。

此技术通常由允许您下载可执行文件的网站使用,以确保下载的文件与作者的意图相符。这可以确保您的用户不会安装恶意软件。最常见的方法是

  1. 记下文件名和网站提供的 SHA256 校验和。
  2. 下载可执行文件。
  3. 在终端中运行 sha256sum /path/to/the/file 以生成您自己的代码。如果您使用的是 Mac,您可能需要 单独安装它
  4. 比较这两个字符串 - 除非文件被破坏,否则它们应该匹配。

Examples of SHA256 from the download for the software "Blender". These look like 64 hexadecimal digits followed by a file name like "blender.zip"

SubtleCrypto 的 digest() 方法对此很有用。要生成文件的校验和,您可以按照以下步骤进行

首先,我们添加一些 HTML 元素用于加载一些文件并显示 SHA-256 输出

html
<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)转换为字符串,以便可以显示它
js
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 提供的哈希函数不适合此用例。出于这些目的,您需要昂贵且速度慢的哈希函数,例如 scryptbcrypt。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 中的大小信息。

html
<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>
js
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

来源:git 提交 sha1 如何形成

本质上,它是 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() 函数)对你日常开发工作很有帮助,就足够了。