跳转到内容
HexaTransfer
返回博客
加密与安全

客户端加密教程:从零开始构建

在Web应用中实现客户端加密的分步教程。在文件离开用户设备之前在浏览器中加密。

浏览器中的客户端文件加密,使用 Web Crypto API 大约需要 80 行 JavaScript。整个模式是:在浏览器中生成 AES-256-GCM 密钥,使用随机 96 位 nonce 加密文件,通过 HTTPS/TLS 1.3 上传密文,然后将密钥嵌入 URL 片段标识符(#key=...)——浏览器永远不会将片段发送给服务器——来共享生成的链接。接收者使用该片段在浏览器中解密。本教程通过一个完整可工作的实现来展示整个过程,包括大文件的分块处理、通过 PBKDF2(60 万次迭代)派生密码密钥,以及首次实现时必然踩到的坑。

架构概览

[发送方浏览器]                      [服务器]                    [接收方浏览器]
  读取文件 → AES 密钥(随机)        接受 POST                    GET 密文
  用 AES-256-GCM 加密               存储密文数据块                从 URL 片段解析密钥
  POST 密文                         无密钥,无明文                在浏览器中解密
  构建带 #key=... 的 URL             返回下载 URL                  保存文件到磁盘

服务器只是一个哑存储。它只看到密文,无法解密。解密密钥存在于 URL 片段中,浏览器特殊处理片段:它从不出现在 HTTP 请求行中。这是包括 HexaTransfer 在内的每一个零知识文件传输服务的基础。

第一步:生成对称密钥

async function generateKey() {
  return await crypto.subtle.generateKey(
    { name: "AES-GCM", length: 256 },
    true, // 可提取,这样才能导出到 URL
    ["encrypt", "decrypt"]
  );
}

extractable: true 标志是必要的,因为我们需要将密钥序列化到 URL 片段中。如果构建的是密钥只存在于内存中的流程(例如粘贴发送工具),则设置为 false

第二步:将文件读取为 ArrayBuffer

async function readFile(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.onerror = () => reject(reader.error);
    reader.readAsArrayBuffer(file);
  });
}

这将整个文件加载到内存中。500 MB 以下的文件没有问题。对于更大的文件,跳至下文的流式处理部分。

第三步:加密缓冲区

async function encryptFile(key, plaintext) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    key,
    plaintext
  );
  // 将 IV 前置到密文,这样接收者才能提取它
  const combined = new Uint8Array(iv.length + ciphertext.byteLength);
  combined.set(iv, 0);
  combined.set(new Uint8Array(ciphertext), iv.length);
  return combined.buffer;
}

Nonce(IV)按 NIST SP 800-38D 规定为 96 位(12 字节)。它不保密,但必须在同一密钥下唯一。由于每个文件生成新密钥,随机 nonce 在这里是安全的。将 IV 前置到密文是常见惯例;接收者在解密前将其拆分出来。

第四步:上传密文

async function uploadCiphertext(ciphertext) {
  const response = await fetch("/api/upload", {
    method: "POST",
    body: ciphertext,
    headers: { "Content-Type": "application/octet-stream" },
  });
  const { fileId } = await response.json();
  return fileId;
}

服务器接收二进制数据块,分配 ID,存储后返回该 ID。没有请求头泄露文件名,没有查询参数携带密钥。即使服务器的硬盘明天被盗,攻击者看到的也只是乱码。

第五步:构建带密钥在片段中的共享 URL

async function buildShareURL(fileId, key) {
  const rawKey = await crypto.subtle.exportKey("raw", key);
  const keyBase64 = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  return `${location.origin}/f/${fileId}#${keyBase64}`;
}

base64url 编码(用 -_ 分别替换 +/)避免 URL 转义问题,去掉 = 填充是为了美观。

片段(#...)在这里很关键。当接收者加载 URL 时,浏览器将片段保持在客户端。/f/{fileId} 的 HTTP GET 请求不包含 #keyBase64,因此服务器永远不会获知密钥。自己验证:在任何含片段的 URL 上打开浏览器开发者工具,观察网络标签页。

第六步:接收方侧解密

async function downloadAndDecrypt() {
  const fileId = location.pathname.split("/").pop();
  const keyBase64 = location.hash.slice(1);
  const rawKey = Uint8Array.from(
    atob(keyBase64.replace(/-/g, "+").replace(/_/g, "/")),
    c => c.charCodeAt(0)
  );
  const key = await crypto.subtle.importKey(
    "raw", rawKey, "AES-GCM", false, ["decrypt"]
  );
  const response = await fetch(`/api/download/${fileId}`);
  const combined = new Uint8Array(await response.arrayBuffer());
  const iv = combined.slice(0, 12);
  const ciphertext = combined.slice(12);
  const plaintext = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv }, key, ciphertext
  );
  const blob = new Blob([plaintext]);
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "downloaded-file";
  a.click();
}

GCM 的认证标签在 decrypt() 期间被检查。如果密文被篡改,调用会抛出 OperationError,这是一个干净的失败模式。

通过 PBKDF2 派生密码密钥

如果用户提供密码而非随机密钥,通过 PBKDF2 派生 AES 密钥:

async function deriveKey(password, salt) {
  const passwordKey = await crypto.subtle.importKey(
    "raw", new TextEncoder().encode(password),
    "PBKDF2", false, ["deriveKey"]
  );
  return await crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt,
      iterations: 600000,
      hash: "SHA-256",
    },
    passwordKey,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );
}

60 万次 PBKDF2-SHA-256 迭代是 OWASP 2023 基线。盐值必须是 16 个随机字节,并与密文一起存储(非保密,但必须唯一)。对于新代码,考虑通过 argon2-browser 等库使用 Argon2id——它抵抗 GPU 攻击的能力远超 PBKDF2。

大文件的流式处理

500 MB 以上的文件应进行分块。通过 File.stream() 读取,依次加密每块并上传:

async function encryptStream(file, key) {
  const reader = file.stream().getReader();
  const chunks = [];
  let chunkIndex = 0;
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const iv = new Uint8Array(12);
    // 将块索引编码到 nonce 中以保证唯一性
    new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
    const ct = await crypto.subtle.encrypt(
      { name: "AES-GCM", iv }, key, value
    );
    chunks.push({ iv, ct });
  }
  return chunks;
}

从块索引派生 nonce 在不跟踪状态的情况下保证唯一性。接收方按顺序解密各块并拼接。

对于真正的流式 AEAD,libsodium 的 crypto_secretstream_xchacha20poly1305(通过 libsodium.js)更为简洁,并能检测截断攻击。Web Crypto 在 2026 年尚无等效原语。

测试与常见错误

需要避免的常见错误:

  • 使用 Math.random() 生成密钥或 nonce:始终使用 crypto.getRandomValues()
  • 在同一密钥下重用 nonce:会破坏 GCM 安全性。每文件随机密钥使其安全;分块流程需要每块唯一 nonce。
  • 未检查是否为 HTTPScrypto.subtle 在不安全来源上未定义。开发时在 localhost 或用自签名证书测试。
  • 将密钥存储在 localStorage:来源上的任何 XSS 均可读取。改用 URL 片段模式,或不可提取密钥。
  • 忘记将 IV 与密文一起包含:解密会失败且无有用错误信息。始终前置或序列化在一起。
  • 对片段处理不当:不要将 URL(含片段)意外发布到第三方服务。如果片段内容敏感,只通过端对端渠道共享。

服务器端的职责

在客户端加密架构中,服务器的职责很小:接受 POST,存储数据块,返回 ID,为数据块提供 GET,到期时删除。不涉及任何加密。服务器在存储之外应做的:

  • 执行文件大小限制(防止滥用)
  • 对上传和下载进行速率限制
  • 设置较短的保留期(7 天是合理的默认值,与 HexaTransfer 相同)
  • 只记录必要内容(上传时间戳,如果优先考虑隐私则不记录 IP)
  • 以 TLS 1.3 加 HSTS 提供服务
  • 如果 API 仅从你的域名调用,设置限制来源的 CORS 头

整合在一起

一个最简工作应用可放在一个 HTML 文件加 50 行 Express 后端中。总依赖:客户端零依赖(Web Crypto 是原生的),服务器端 Express 加 multer。加密强度与 AES-256-GCM 原语本身相同,因为那正是你使用的算法。没有需要搞错的秘密算法,只有需要正确使用的原语。

最难的部分是边缘情况:大文件、密码到密钥的流程、解密失败时的接收方用户体验、优雅处理过期链接。核心密码学本身很直接。

在 hexatransfer.com 上试试 — 免费、无需注册、最多 10 GB。

通过端到端加密安全发送大文件

通过端到端加密免费传输最大10GB的文件。无需注册账户。文件在上传前在浏览器中加密,其他人无法读取。

发送文件