跳转到内容
HexaTransfer
返回博客
技术深度解析

Build an Encrypted 文件共享 App from Scratch

Step-by-step tutorial to build an encrypted 文件共享 application. Frontend 加密, 安全 backend, and deployment walkthrough.

《个人信息保护法》(PIPL)第五十一条要求处理者采取"加密、去标识化等安全技术措施"。构建加密文件共享应用最彻底的实现,是将加密逻辑放在浏览器端而非服务器端——服务器永远只看到密文,从根本上满足"技术保护措施"要求。最简技术栈:使用 Web Crypto API 通过 AES-256-GCM 加密的 Vite + React 前端,将每次上传视为不透明数据块的 Fastify 后端,S3 兼容存储(Cloudflare R2 或 Backblaze B2),以及解密密钥位于 # 之后永远不到达服务器的短分享链接。约 400 行代码即可交付可用版本,每月托管成本 5 美元以内。

真正重要的架构决策

最重要的单一决策:加密密钥存放在哪里。若它碰触过服务器,那就不是端到端加密,只是带额外步骤的服务端加密。正确模式:在浏览器中生成随机 256 位密钥,用它加密文件,上传密文,将密钥放入 URL 片段(https://yourapp.com/f/abc123#k=base64key)。浏览器永远不会在 HTTP 请求中发送片段,密钥因此留在客户端。

第二个决策:分块上传。超过 100 MB 的文件需要可断点续传的分片上传,否则任何不稳定的 Wi-Fi 都会中断传输。S3 的分片 API 支持最小 5 MB 的块和最多 10,000 个分片,上限为 50 GB。从第一天起就为此做规划。

前端加密流水线

生成密钥,用 AES-256-GCM 加密,生成密文 Blob 和用于 URL 片段的 base64 密钥:

async function encryptFile(file: File) {
  const key = await crypto.subtle.generateKey(
    { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']
  );
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const plaintext = await file.arrayBuffer();
  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv }, key, plaintext
  );
  const rawKey = await crypto.subtle.exportKey('raw', key);
  const blob = new Blob([iv, new Uint8Array(ciphertext)]);
  return { blob, keyBase64: toBase64(rawKey) };
}

超过 50 MB 的文件,用流式版本替代——对 4 MB 块加密并追加到 ReadableStream。移动端 Safari 的堆内存在单个 ArrayBuffer 超过约 400 MB 时会崩溃。

上传端点设计

后端应对内容一无所知。接收带密文的 POST 请求,生成随机 16 字符的 URL 安全 ID,将其与过期时间存入 SQLite,并将请求体直接流式传输到 S3:

fastify.post('/upload', async (req, reply) => {
  const id = nanoid(16);
  const key = `blobs/${id}`;
  const upload = new Upload({
    client: s3,
    params: { Bucket: 'hexa-transfers', Key: key, Body: req.raw }
  });
  await upload.done();
  db.prepare('INSERT INTO shares VALUES (?, ?, ?, ?, 0, ?)').run(
    id, key, req.headers['content-length'], Date.now() + 7*86400*1000, 10
  );
  return { id };
});

默认 7 天过期,最多 10 次下载。设置积极的默认值,因为放任不管的代价是存储量无限增长。使用定时任务每夜删除过期 Blob。

带片段密钥的分享链接

上传完成后,在客户端构建分享 URL:

const { id } = await uploadResponse.json();
const shareUrl = `${location.origin}/f/${id}#k=${keyBase64}`;

该片段永远不会离开用户的浏览器。接收方点击链接时,React 应用读取 window.location.hash,解析密钥,获取密文,并在本地解密。服务器没有获取明文的路径,除非向用户推送恶意 JavaScript——这正是为什么需要严格的内容安全策略并固定子资源哈希。

实现下载流程

在下载页面,将密文作为流拉取并按块对齐解密:

const keyRaw = base64ToBytes(location.hash.slice(3));
const key = await crypto.subtle.importKey(
  'raw', keyRaw, { name: 'AES-GCM' }, false, ['decrypt']
);
const resp = await fetch(`/blob/${id}`);
const iv = new Uint8Array(await resp.body.getReader().read()
  .then(r => r.value.slice(0, 12)));
const ct = await resp.arrayBuffer();
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct.slice(12));
const url = URL.createObjectURL(new Blob([pt]));

对于大文件,使用 StreamSaver.js 或 File System Access API,让解密字节直接写入磁盘而非内存。

默认符合法规的设计

由于服务器只看到密文,PIPL 和《数据安全法》(DSL)意义上几乎没有需要保护的个人数据。尽管如此,仍需记录数据处理关系(R2 和 DigitalOcean),设置 30 天最长 Blob 保留期,并明确告知用户分享链接包含密钥,应通过发送方信任的渠道传输。对每个 IP 每小时 50 次上传进行速率限制,不记录用户身份的前提下阻止滥用。

HexaTransfer 基本上就是这套架构——浏览器端 Web Crypto,不透明 Blob 存储,密钥在 URL 片段中,服务器上零明文。立即体验:https://hexatransfer.com——免费,无需注册,最大支持 10 GB。

MVP 上线后的优先增量功能

基础流程上线后,价值最高的功能依次是:在片段密钥之上添加密码保护(PBKDF2,600,000 次迭代)、通过临时邮箱发送每次下载通知,以及允许举报密文哈希而不暴露内容的滥用举报机制。除非有明确理由,避免添加账户功能——它会在一夜之间将应用从隐私工具变成数据合规负担。保持功能面小,让加密来做繁重工作。

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

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

发送文件