🔐 前后端文件加密解密实战:从坑到方案全记录
一次前端 + Node.js 的加密集成实践总结,涉及 WebCrypto、RSA、AES、文件结构规范、兼容性问题,以及最佳实践。
👉 背景与目标
在一个需求中,我们需要前端对用户上传到亚马逊S3桶的文件进行加密处理,确保合规安全。加密方案如下:
- 前端使用 RSA 公钥加密随机生成的 AES 密钥
- 使用 AES-CBC 对文件进行对称加密
- 后端使用私钥解密密钥,再解密文件内容
最终达成目标:前端加密、后端解密,双向安全通信
⚠️ 遇到的坑一:WebCrypto 不支持 RSAES-PKCS1-v1_5
我们最初在 Node.js 中使用:
php
crypto.publicEncrypt({
key: pubKey,
padding: crypto.constants.RSA_PKCS1_PADDING
}, aesKey);
这在 Node.js 中很常见(即 RSAES-PKCS1-v1_5 加密),但前端使用 WebCrypto API 时:
csharp
await crypto.subtle.importKey("spki", keyData, {
name: "RSAES-PKCS1-v1_5"
}, false, ["encrypt"]);
🚨 直接报错:
makefile
NotSupportedError: Algorithm: Unrecognized name
✅ 解决方案一:统一使用 RSA-OAEP(推荐标准)
我们决定统一使用现代、更安全的 RSA-OAEP
:
✅ 前端使用 WebCrypto:
php
await crypto.subtle.importKey("spki", keyData, {
name: "RSA-OAEP",
hash: "SHA-256"
}, false, ["encrypt"]);
✅ 后端 Node.js 使用:
php
crypto.privateDecrypt({
key: privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256"
}, encryptedKey);
📌 优点:
- 更安全
- 浏览器标准支持良好
- 与 Node.js 可顺利配合
📁 自定义文件格式设计
为了确保前后端处理一致,我们设计了一个规范的加密文件结构:
scss
[0-1] Business Group ID (2 bytes)
[2-3] Version (2 bytes)
[4-5] Encrypted AES Key Length (2 bytes)
[6-N] Encrypted AES Key
[N-M] AES-CBC Encrypted Data
示例代码片段:
ini
const header = Buffer.alloc(6);
header.writeUInt16BE(groupId, 0);
header.writeUInt16BE(version, 2);
header.writeUInt16BE(encryptedKey.length, 4);
🔐 AES-CBC 加密细节
我们使用 AES-256-CBC 对文件内容进行对称加密。IV 固定:
ini
const iv = Buffer.from('426E6B65723230313200000000000000', 'hex');
虽然固定 IV 不是最佳实践,但在一些场景下用于传输协议内嵌可控数据是可接受的。
🧰 前端自动加密并下载
通过 Web 前端完成加密并自动触发下载:
ini
const blob = new Blob([finalData], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name.replace(/(.[^/.]+)$/, "_encrypted$1");
a.click();
URL.revokeObjectURL(url);
📌 亮点:
- 保留原始文件后缀
- 支持任意格式文件
- 浏览器端即加即下,安全 & 快速
🔄 解密端(Node.js)一键还原
Node.js 中对应的解密脚本能根据文件结构自动还原加密数据:
ini
const header = encryptedFile.slice(0, 6);
const encryptedKey = encryptedFile.slice(6, 6 + aesKeyLength);
const aesKey = crypto.privateDecrypt(...);
const content = crypto.createDecipheriv('aes-256-cbc', aesKey, iv);
🔁 完美还原前端加密文件!
⚙️ 实用附加工具
- ✅ 密钥批量生成脚本:快速创建多个测试公私钥对
js
const fs = require('fs');
const path = require('path');
const { generateKeyPairSync } = require('crypto');
const outputDir = './generated_keys';
const totalKeys = 5; // 修改这里控制生成几对密钥
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir);
}
for (let i = 1; i <= totalKeys; i++) {
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
});
fs.writeFileSync(path.join(outputDir, `public_${i}.pem`), publicKey);
fs.writeFileSync(path.join(outputDir, `private_${i}.pem`), privateKey);
console.log(`🔐 第 ${i} 对密钥已生成 ✅`);
}
console.log(`🎉 所有 ${totalKeys} 对密钥已保存在 ${outputDir}`);
- ✅ nodejs 加解密
js
// nodejs 加密文件
const fs = require('fs');
const crypto = require('crypto');
const pubKey = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsmzqkxMQrYpNGHhLzSFG
CY4NLLlS66Hr2OIq0JbF9A4jcyFgKcknrMQoHXiO9a50MkzcUo3uXMdJmt5wS/KR
FR1RXWU2AylXWomrzvNShPH2rD3sfi6syfzVLNrb1CSxHNyYESdQHzUaVGMQ7hgZ
Q+ome8uur+FZPtYkQqf4qiYm7yDGv5wf+LzQMTmoYBfME793qPLpVmX7S7bjzLvb
cLXPgBDvpmO0TMCtfkWHoYbjr1fJSX3oql20VoHu9/fWogxItGHIm+Fk5j7jz6x8
0An9wBIeqV3AD3rIRW4LbtcfO+V9nYsLWppedzSYX1csWnIDwHrmHDWyRDhNpP5h
iQIDAQAB
-----END PUBLIC KEY-----`
const file_name = '222'
const fileType = '.jpeg'
const inputFile = `./files/${file_name}${fileType}`; // 要加密的源文件
const outputFile = `./files/${file_name}-jiami${fileType}`; // 输出加密后的文件
// 固定 IV(16 字节)
const iv = Buffer.from('426E6B65723230313200000000000000', 'hex');
// 读取原始数据
const sourceBytes = fs.readFileSync(inputFile);
// 生成 AES 密钥
const aesKey = crypto.randomBytes(32); // AES-256
// 使用 RSA 公钥加密 AES 密钥(使用 RSA-OAEP + SHA256)
const encryptedAesKey = crypto.publicEncrypt(
{
key: pubKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha256'
},
aesKey
);
// 使用 AES-CBC 加密原始文件
const cipher = crypto.createCipheriv('aes-256-cbc', aesKey, iv);
let encryptedData = cipher.update(sourceBytes);
encryptedData = Buffer.concat([encryptedData, cipher.final()]);
// 构建 header(6 字节:业务ID、版本、密钥长度)
const businessGroupId = 0; // 可自定义
const version = 1; // 可自定义
const aesKeyLength = encryptedAesKey.length;
const header = Buffer.alloc(6);
header.writeUInt16BE(businessGroupId, 0);
header.writeUInt16BE(version, 2);
header.writeUInt16BE(aesKeyLength, 4);
// 拼接最终数据包
const finalData = Buffer.concat([header, encryptedAesKey, encryptedData]);
// 写入加密文件
fs.writeFileSync(outputFile, finalData);
console.log(`✅ 文件加密完成: ${outputFile}`);
js
// nodejs 解密文件
const fs = require('fs');
const crypto = require('crypto');
const privateKey = `-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCybOqTExCtik0Y
eEvNIUYJjg0suVLroevY4irQlsX0DiNzIWApySesxCgdeI71rnQyTNxSje5cx0ma
3nBL8pEVHVFdZTYDKVdaiavO81KE8fasPex+LqzJ/NUs2tvUJLEc3JgRJ1AfNRpU
YxDuGBlD6iZ7y66v4Vk+1iRCp/iqJibvIMa/nB/4vNAxOahgF8wTv3eo8ulWZftL
tuPMu9twtc+AEO+mY7RMwK1+RYehhuOvV8lJfeiqXbRWge7399aiDEi0Ycib4WTm
PuPPrHzQCf3AEh6pXcAPeshFbgtu1x875X2diwtaml53NJhfVyxacgPAeuYcNbJE
OE2k/mGJAgMBAAECggEAJux9y/H3zHQEV5c+qhRdLA5yL1SKk17yjZDhWDncG17O
OvqH+PWqExWDztITFeOl86cy1UujOVg8ZW2rP9Y98kZADsjanJvzM4wfD/mjAd+r
OW4U3n4eMb5XXv6rwD706F/zSKKz5ur1wH0QH3DgaiF1ncIUbMgTaRLcPDJam/tz
AskqbaFiUce9r3bcIp+T8jNUAdYTi2xxv1wxrzTL59QqkgX0Y1WakUj+mziZLxkG
1BvL6+2whNwEl+G+kEl78xotNwA3NH10vnvGCrPndEKwDKAvf6KuIPNsbtiXuLb8
I7BkoirZYgiTkncTciFllrys/FBrszCNaEo7aXf4ZQKBgQDsGTWzdi2OY4VHLP84
ie7y8UW0E9v631Xh+Y1KgjAe2PRywYsrOOsm9ciiqIH2XlDG3JHn6JZcwPhomuUu
nOx2W9BR33BR9FvXFkD9Gd/k2DMT4RHQRb9eHFCfIgBzxpMQF855RJzWMEIpAeuG
HDz3xIXTNcMsGr5Iq5ZT7ErEgwKBgQDBdyzbjLCNesBBiF0cGaJLHAG4LGR+KyRr
Qvq4cLHSCGZW9Q3qghk9skvu5eS4yUTL9UQwD9DaD5jf3qp6KgRxOH+JAxQyeWJm
i8d1QAiKJFCq9lQMtrLabVOR78BX1ddtS7lT6TUNIUjxM0XZkYhnL2goJOpmX9yR
4yClllVcAwKBgQC6LpMjh2EbdS2n2DsXeeZd3JfsDvEdX7tZLTMXe5y45ru2Nxwp
JISDv7TNWHbMMsoeV9gTel4AnQgHbYangIYUPAkGV146sqkt71Wmgh4GV9vHpGgo
xqfHbirBos2afxB/01Y2WT4YxguWTPZPVrtZY7dovG/BuwEtS2qLmb3IQwKBgD/+
WbotqWDDD8EdiB75Y7OQigkPthX900gfmatUq50b5i4xVO8TJaJAzdkx9hwhhz4n
3OxA7waSTjEPCDjsmReSZq09dXrUp/XfmpRwH+MB7CUA4gBqlnKl4xTMx0TGmUGv
4Jee9ktnjScrnJlBCGuJRNyyiU5fuygOhwf+2DFdAoGBALfQT6HjfopMhm+bgs0J
ydgztUa0GhUVk76Dkk+dNrQ01iiSFtnrpobrA4J7n4rQMzY6KYBGN1iawfdaSegQ
yqB4ea+9tjp11J3LQ9pSuyvDAR3qfieJbT1DumThT+lJp3Ly4dL3RcHuMW3VCE1y
mx8TQ8ah1/qlIj4dZ4N8/Try
-----END PRIVATE KEY-----`
const file_name = '222-jiami'
const fileType = '.jpeg'
const inputFilePath = `./files/${file_name}${fileType}`; // 要解密的源文件
const outputFilePath = `./files/${file_name}-jiemi${fileType}`; // 输出解密后的文件
// 固定的 IV(和加密端保持一致)
const iv = Buffer.from('426E6B65723230313200000000000000', 'hex');
function decryptFile() {
const encryptedFile = fs.readFileSync(inputFilePath);
// 读取 header
const dataFormat = encryptedFile.slice(0, 6);
const aesKeyLength = dataFormat.readUInt16BE(4);
// 解析出加密的 AES 密钥
const encryptedAesKey = encryptedFile.slice(6, 6 + aesKeyLength);
// 解密 AES 密钥(使用 RSA-OAEP)
const aesKey = crypto.privateDecrypt(
{
key: privateKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: "sha256"
},
encryptedAesKey
);
// 解密 AES 内容
const encryptedContent = encryptedFile.slice(6 + aesKeyLength);
const decipher = crypto.createDecipheriv('aes-256-cbc', aesKey, iv);
let decrypted = decipher.update(encryptedContent);
decrypted = Buffer.concat([decrypted, decipher.final()]);
// 写出解密后的文件
fs.writeFileSync(outputFilePath, decrypted);
console.log(`✅ 解密完成,输出路径: ${outputFilePath}`);
}
decryptFile();
- ✅ Web 前端 HTML 示例:文件选择、RSA 加密、AES 加密、自动下载
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>文件加密并下载</title>
</head>
<body>
<h2>文件加密并下载</h2>
<input type="file" id="fileInput" />
<button onclick="encryptFile()">加密并下载</button>
<pre id="output"></pre>
<script>
const publicKeyPem = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsmzqkxMQrYpNGHhLzSFG
CY4NLLlS66Hr2OIq0JbF9A4jcyFgKcknrMQoHXiO9a50MkzcUo3uXMdJmt5wS/KR
FR1RXWU2AylXWomrzvNShPH2rD3sfi6syfzVLNrb1CSxHNyYESdQHzUaVGMQ7hgZ
Q+ome8uur+FZPtYkQqf4qiYm7yDGv5wf+LzQMTmoYBfME793qPLpVmX7S7bjzLvb
cLXPgBDvpmO0TMCtfkWHoYbjr1fJSX3oql20VoHu9/fWogxItGHIm+Fk5j7jz6x8
0An9wBIeqV3AD3rIRW4LbtcfO+V9nYsLWppedzSYX1csWnIDwHrmHDWyRDhNpP5h
iQIDAQAB
-----END PUBLIC KEY-----`;
async function encryptFile() {
const fileInput = document.getElementById("fileInput");
const file = fileInput.files[0];
if (!file) {
alert("请选择一个文件");
return;
}
const fileBytes = new Uint8Array(await file.arrayBuffer());
const randomKey = window.crypto.getRandomValues(new Uint8Array(32));
const iv = new Uint8Array([
0x41, 0x6E, 0x6B, 0x65, 0x72, 0x32, 0x30, 0x31,
0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]);
// 导入 RSA 公钥
const rsaKey = await window.crypto.subtle.importKey(
"spki",
pemToArrayBuffer(publicKeyPem),
{
name: "RSA-OAEP",
hash: "SHA-256"
},
false,
["encrypt"]
);
// 加密 AES 密钥
const encryptedKey = new Uint8Array(await window.crypto.subtle.encrypt(
{ name: "RSA-OAEP" },
rsaKey,
randomKey
));
// 加密文件内容
const aesKey = await window.crypto.subtle.importKey(
"raw",
randomKey,
{ name: "AES-CBC" },
false,
["encrypt"]
);
const encryptedContent = new Uint8Array(await window.crypto.subtle.encrypt(
{ name: "AES-CBC", iv },
aesKey,
fileBytes
));
// 拼接头部(dataFormat)
const dataFormat = new Uint8Array(6);
const keyLen = encryptedKey.length;
dataFormat[0] = 0; dataFormat[1] = 0; // businessGroupIdHex
dataFormat[2] = 0; dataFormat[3] = 1; // versionHex
dataFormat[4] = (keyLen >> 8) & 0xff;
dataFormat[5] = keyLen & 0xff;
// 拼接最终文件
const totalLength = dataFormat.length + encryptedKey.length + encryptedContent.length;
const finalData = new Uint8Array(totalLength);
finalData.set(dataFormat, 0);
finalData.set(encryptedKey, dataFormat.length);
finalData.set(encryptedContent, dataFormat.length + encryptedKey.length);
// 下载
const blob = new Blob([finalData], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name.replace(/(\.[^/.]+)$/, "_encrypted$1");
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// 显示摘要
const hexPreview = Array.from(finalData.slice(0, 64))
.map(b => b.toString(16).padStart(2, '0')).join(' ');
document.getElementById("output").textContent =
`✅ 加密并下载成功!数据预览(前64字节):\n${hexPreview} ...`;
}
function pemToArrayBuffer(pem) {
const b64 = pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
</script>
</body>
</html>
🧐 总结:跨端加密的最佳实践
维度 | 推荐做法 |
---|---|
加密算法 | RSA-OAEP + AES-256-CBC |
密钥管理 | 后端生成 & 存储私钥 |
前端兼容性 | 使用 WebCrypto + SPKI PEM |
文件结构 | 设计可解析 Header + Body |
安全性提示 | 切勿硬编码私钥在前端 |
文件后缀处理 | 保留原始文件类型 |
💬 结言
前后端加密不是"选项",而是数据安全的必需品。通过这次实战,我们不仅摸清了浏览器和 Node.js 的加密细节,也为团队建立了一套可复用的安全通信机制。
如果你也在实现前端加密、保护用户隐私的遗传上,希望这篇文章能帮你少走弯路!