客户端加密教程:从零开始构建
在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。
- 未检查是否为 HTTPS:
crypto.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。