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

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

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

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


相关推荐
异常君6 分钟前
HTTP头中的Accept-Encoding与Content-Encoding深度剖析
后端·nginx·http
异常君13 分钟前
MySQL重复数据克星:7种高效处理方案全解析
java·后端·mysql
异常君17 分钟前
Spring 定时任务执行一次后不再触发?5 大原因与解决方案全解析
java·后端·spring
异常君19 分钟前
Java 序列化工具:@JSONField 注解实战解析与应用技巧
java·后端·json
菜鸟谢34 分钟前
c# 文件系统
后端
写bug写bug1 小时前
Java并发编程:什么是线程组?它有什么作用?
java·后端
Andya_net1 小时前
SpringBoot | 构建客户树及其关联关系的设计思路和实践Demo
java·spring boot·后端
南囝coding2 小时前
关于我的第一个产品!
前端·后端·产品
北漂老男孩2 小时前
Spring Boot 自动配置深度解析:从源码结构到设计哲学
java·spring boot·后端
陈明勇2 小时前
MCP 实战:用 Go 语言开发一个查询 IP 信息的 MCP 服务器
人工智能·后端·mcp