构建一个加密备份服务,最大的挑战不是文件传输,而是如何在不信任服务端的情况下保持密钥安全。DeBackup 的思路是把加密放在客户端完成,服务端只负责存储密文和元数据。本文使用 Node.js 的 crypto、fs 和 stream 模块搭建最小可用的加密备份流程,重点说明 AES-256-GCM 认证加密、scrypt 密钥派生和流式管道处理。

为什么选择 AES-256-GCM 和客户端加密
备份服务的架构通常有三种:增量备份、全量备份和差异备份。DeBackup 的加密层如果放在服务端,密钥也会被服务端持有,威胁模型里内部人员或数据库拖库会导致数据泄露。客户端加密意味着备份数据在上传之前已经变成密文,服务端只看到不可读的二进制流。Node.js 的 crypto 模块内置了 OpenSSL 支持的多种算法,AES-256-GCM 是目前最合适的选择之一。GCM 模式是认证加密模式,它在加密的同时生成一个认证标签 authTag,接收方可以用它确认密文没有被篡改。这样就不需要再单独执行 HMAC 计算,也避免了拼接 MAC 时可能出现的顺序错误。
AES-256-GCM 的密钥长度为 32 字节,IV 推荐 12 字节随机值。GCM 对 IV 的重用非常敏感,如果同一个密钥配合相同的 IV 加密不同文件,攻击者可以恢复出认证密钥,进而伪造密文。因此代码中必须使用 crypto.randomBytes(12) 生成随机 IV,并把 IV 以明文形式附加在密文文件头部。IV 不是秘密,但必须唯一。Node.js 的 createCipheriv 方法要求显式提供 key 和 iv,这比已经废弃的 createCipher 更安全,因为后者会从密码自动派生不安全的密钥和 IV。生产环境中应避免使用 createCipher。
客户端加密的另一层含义是密钥不落盘。用户口令只在内存中存在,通过 scrypt 派生为 32 字节的主密钥。scrypt 是内存困难型函数,攻击者暴力破解时需要消耗大量内存和时间。Node.js 从 v10 开始提供 promisify 化的 crypto.scrypt,便于在异步流程中调用。下面代码展示了如何派生密钥和初始化加密器。
const crypto = require('crypto');
const { promisify } = require('util');
const scrypt = promisify(crypto.scrypt);
async function deriveKey(password, salt) {
const key = await scrypt(password, salt, 32);
return key;
}
async function createCipherContext(key) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
return { cipher, iv };
}
Node.js 的流式加密上传实现
直接把整个文件读入内存再加密在大文件场景下会导致内存占用激增。Node.js 的 stream 模块天然适合处理这类顺序读写任务。fs.createReadStream 以块的形式读取文件,cipher 作为 Transform 流对每个 chunk 加密,fs.createWriteStream 负责把密文写入目标文件。使用 stream.pipeline 而不是手动 pipe 可以自动处理背压和错误清理,避免 write 端返回 false 时继续写入造成内存积压。
上传流程中还需要先把 IV 写入输出流。由于 IV 长度固定为 12 字节,可以先用 writeStream.write(iv) 写入,再将剩余的流通过 pipeline 连接。加密完成后调用 cipher.getAuthTag() 取出 16 字节的认证标签,这个标签需要和密文分开存储或放在文件末尾。实际 DeBackup 服务中,服务端会为每个备份对象保存 authTag 和盐值等元数据,但永远不接触密钥和明文。以下代码实现了完整的加密文件函数。
const fs = require('fs');
const crypto = require('crypto');
const { pipeline } = require('stream');
const { promisify } = require('util');
const pipe = promisify(pipeline);
async function encryptFile(inputPath, outputPath, key) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const readStream = fs.createReadStream(inputPath);
const writeStream = fs.createWriteStream(outputPath);
writeStream.write(iv);
await pipe(readStream, cipher, writeStream);
return cipher.getAuthTag();
}
如果文件较大,例如超过 1GB,同步版本的 crypto 变换流会按块处理,不会占用额外的大块内存。不过默认的 highWaterMark 是 16KB,可以根据磁盘 IO 调整到 64KB 或更高,但不宜过大,否则单次 buffer 分配过多。Node.js 的流式处理还可以和 HTTP 上传结合,把 cipher 的出口直接接到 http.request 的 write 端,实现边加密边上传,减少临时文件。DeBackup 原型中可以先用本地文件演示,再替换为网络流。
密钥派生与恢复下载的完整链路
恢复流程比加密多一步 authTag 校验。下载密文后,先读出文件头部的 12 字节 IV,再用同一个密钥和 IV 创建 decipher,调用 decipher.setAuthTag(authTag) 设置认证标签。如果密文在传输或存储中被修改,解密过程会抛出错误并中止,不会输出被篡改的明文。这个特性让 DeBackup 不需要额外存储哈希值就能保证完整性。
密钥派生必须在每次备份或恢复时重新计算。将用户口令和盐值传入 scrypt,得到 32 字节密钥。盐值应该随机生成并和密文元数据一起保存。如果所有备份共用一个盐值,攻击者可以预先计算彩虹表加速破解,因此每个备份对象应有独立盐值。代码中可以使用 crypto.randomBytes(16) 生成盐值,然后存储为十六进制字符串。恢复下载时先读取盐值,再结合用户输入口令重新派生密钥。
下面展示一个解密恢复函数的骨架,它从文件流中读取 IV,设置 authTag,并用 pipeline 将解密的明文写入输出文件。完整工程中还需要处理文件路径穿越、临时文件清理和并发限制。Node.js 的事件循环适合同时处理多个备份任务,但磁盘和网络 IO 是瓶颈,建议配合队列控制并发数。
const fs = require('fs');
const crypto = require('crypto');
const { pipeline } = require('stream');
const { promisify } = require('util');
const pipe = promisify(pipeline);
async function decryptFile(inputPath, outputPath, key, authTag) {
const readStream = fs.createReadStream(inputPath);
const ivBuffer = Buffer.alloc(12);
await new Promise((resolve, reject) => {
readStream.once('readable', () => {
const chunk = readStream.read(12);
if (!chunk || chunk.length !== 12) {
reject(new Error('invalid iv'));
return;
}
chunk.copy(ivBuffer);
resolve();
});
});
const decipher = crypto.createDecipheriv('aes-256-gcm', key, ivBuffer);
decipher.setAuthTag(authTag);
const writeStream = fs.createWriteStream(outputPath);
await pipe(readStream, decipher, writeStream);
}
DeBackup 的加密备份链路至此形成完整闭环:客户端通过 scrypt 派生密钥,用 AES-256-GCM 流式加密文件,IV 和 authTag 随元数据保存,恢复时先验证认证标签再解密。把加密模块独立出来之后,业务代码只需要关注备份调度和存储位置,安全边界清晰,后续接入对象存储或私有云也不会破坏机密性。