🔐 前后端文件加密解密实战:从坑到方案全记录

🔐 前后端文件加密解密实战:从坑到方案全记录

一次前端 + 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 的加密细节,也为团队建立了一套可复用的安全通信机制。

如果你也在实现前端加密、保护用户隐私的遗传上,希望这篇文章能帮你少走弯路!


相关推荐
二闹5 分钟前
三个注解,到底该用哪一个?别再傻傻分不清了!
后端
用户490558160812516 分钟前
当控制面更新一条 ACL 规则时,如何更新给数据面
后端
林太白18 分钟前
Nuxt.js搭建一个官网如何简单
前端·javascript·后端
码事漫谈20 分钟前
VS Code 终端完全指南
后端
该用户已不存在1 小时前
OpenJDK、Temurin、GraalVM...到底该装哪个?
java·后端
怀刃1 小时前
内存监控对应解决方案
后端
码事漫谈1 小时前
VS Code Copilot 内联聊天与提示词技巧指南
后端
Moonbit1 小时前
MoonBit Perals Vol.06: MoonBit 与 LLVM 共舞 (上):编译前端实现
后端·算法·编程语言
Moonbit1 小时前
MoonBit Perals Vol.06: MoonBit 与 LLVM 共舞(下):llvm IR 代码生成
后端·程序员·代码规范
Moonbit2 小时前
MoonBit Pearls Vol.05: 函数式里的依赖注入:Reader Monad
后端·rust·编程语言