前端加密解密实战:从原理到项目应用的完整指南

令狐淑宁 前端 阅读 2,429
赞 17 收藏
二维码
手机扫码查看
反馈

我的写法,亲测靠谱

加密解密这东西,前端能躲就躲。但有些项目你就是绕不开——比如要存用户敏感信息到 localStorage,又不想裸奔;或者和后端传数据时要求前端先做一层对称加密。这时候别慌,我一般用 CryptoJS + 自定义封装,虽然不是最牛的方案,但稳定、简单、不容易出事。

前端加密解密实战:从原理到项目应用的完整指南

核心逻辑是:只做对称加密(AES),密钥由环境变量注入或动态生成,绝不硬编码在代码里。为什么?后面会讲一个我踩过的巨坑。

下面是我常用的封装方式:

import CryptoJS from 'crypto-js';

// 密钥来源必须灵活,不能写死
const getEncryptionKey = () => {
  // 比如从登录后返回的 token 中提取 key,或者从环境变量读取
  return process.env.REACT_APP_CRYPTO_KEY || 'fallback-temp-key-for-dev';
};

export const encrypt = (data) => {
  try {
    const key = getEncryptionKey();
    const ciphertext = CryptoJS.AES.encrypt(JSON.stringify(data), key).toString();
    return ciphertext;
  } catch (error) {
    console.warn('加密失败', error);
    return null;
  }
};

export const decrypt = (ciphertext) => {
  try {
    const key = getEncryptionKey();
    const bytes = CryptoJS.AES.decrypt(ciphertext, key);
    const originalText = bytes.toString(CryptoJS.enc.Utf8);
    if (!originalText) return null;
    return JSON.parse(originalText);
  } catch (error) {
    console.warn('解密失败,可能是密钥不对或数据损坏', error);
    return null;
  }
};

这段代码我在三个项目里都用过,最大的好处是:出问题有兜底(try/catch),不会因为一条数据解密失败直接崩页面。而且用了 JSON.stringify 和 parse,支持对象加密,比单纯加解字符串实用得多。

这里注意,我踩过好几次坑的是密钥管理。最早一次我把密钥直接写成字符串常量放在 utils 文件里,结果 build 完代码被人反编译一眼看出来,测试账号密码全被导出来了……后来改成了运行时注入,至少不会明文躺在 js 包里。

这几种错误写法,别再踩坑了

见过太多人乱搞加密,我列几个典型反面案例:

  • 密钥写死在代码里:像这样 const KEY = '123456',然后传给 AES.encrypt。拜托,打包完 source map 一开,谁都能看到。就算你混淆了变量名,静态分析工具分分钟扒出来。
  • 用 Base64 当加密:有人把敏感数据 btoa(JSON.stringify(data)) 就当“加密”了,实际上这只是编码,连加密都不是。稍微懂点前端的复制粘贴就能 decode 回去。
  • 本地存储加密数据却不处理异常:比如 localStorage.setItem(‘user’, encrypt(user)),但 decrypt 的时候没考虑 key 变了怎么办。结果用户一升级版本,key 更新了,老数据全废,直接报错卡登录页。
  • 在 URL 参数里传加密串还带等号:AES 加密出来的字符串包含 /, +, =,直接拼在 URL 上,不 encodeURI 就跳转,后端收不到完整参数。折腾了半天发现是 + 被当成空格处理了……

上面这些问题我都亲自踩过,尤其是最后一个,排查了两个小时才定位到是 URL 编码问题。建议处理办法是加密后用 base64url 编码再传输:

function base64UrlEncode(str) {
  return str.replace(/+/g, '-').replace(///g, '_').replace(/=+$/, '');
}

function base64UrlDecode(str) {
  str = (str + '===').slice(0, str.length + (str.length % 4));
  return str.replace(/-/g, '+').replace(/_/g, '/');
}

这样加密后的字符串才能安全放进 query string 或 cookie。

实际项目中的坑

上个月我接手一个旧项目,里面有个需求是保存用户的支付信息到本地,方便快速填写。原开发用了 CryptoJS 加密,但有个致命问题:每次启动应用生成不同的随机 key,加密完存进 localStorage。结果重启之后根本没法解密之前的数据!

我当时一看代码人都傻了,这不是等于没加密吗?数据每次都是新的,老的永远读不出来。修复办法很简单:key 必须持久化或者可推导,不能随机。

最后我们改成用用户 ID 做 salt,拼接固定 secret 生成 key:

const deriveKeyFromUserId = (userId) => {
  const baseKey = process.env.REACT_APP_ENCRYPTION_SALT;
  return CryptoJS.SHA256(baseKey + userId).toString().substr(0, 32); // AES-256 需要 32 字节
};

这样同一个用户每次进来看到的 key 是一致的,又能保证不同用户之间隔离。当然,前提是用户 ID 不变。

还有一个细节:不要忽略浏览器兼容性。CryptoJS 在 IE11 上基本没问题,但如果你用 Web Crypto API(原生 crypto.subtle),那得小心,IE 全系列不支持,Safari 有些方法行为也不一样。所以中小型项目我还是推荐 CryptoJS,兼容性稳得多。

还有个小技巧:你可以加个版本前缀来标识加密格式,方便以后升级算法。

export const encrypt = (data) => {
  try {
    const key = getEncryptionKey();
    const rawStr = JSON.stringify(data);
    const ciphertext = CryptoJS.AES.encrypt(rawStr, key).toString();
    return v1:${ciphertext}; // 加个版本号
  } catch (e) {
    return null;
  }
};

export const decrypt = (cipherWithVersion) => {
  if (!cipherWithVersion) return null;

  let ciphertext;
  if (cipherWithVersion.startsWith('v1:')) {
    ciphertext = cipherWithVersion.slice(3);
  } else {
    // 兼容老数据
    ciphertext = cipherWithVersion;
  }

  try {
    const key = getEncryptionKey();
    const bytes = CryptoJS.AES.decrypt(ciphertext, key);
    const result = bytes.toString(CryptoJS.enc.Utf8);
    return result ? JSON.parse(result) : null;
  } catch {
    return null;
  }
};

这样未来如果要换算法(比如改 ChaCha20-Poly1305),可以直接出 v2 版本,不影响老用户。

API 请求里的加密也要小心

有些项目要求前端把整个请求体加密发给后端。比如这样:

fetch('https://jztheme.com/api/submit', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: encrypt({ name: '张三', phone: '13888888888' })
})

看着没啥问题,但要注意两点:

  • 加密后的 body 已经不是 JSON 了,Content-Type 应该改成 text/plain 或自定义类型,否则后端 parser 可能误解析。
  • 如果用了 HTTPS,其实没必要再加这一层加密。除非你们公司合规要求“双重保护”,不然纯属增加复杂度。

我自己更倾向于:HTTPS + 后端字段级加密,而不是让前端背这个锅。毕竟前端环境不可控,真想偷数据的人早就 hook 掉 decrypt 方法了。

总结一下我怎么防翻车

最后说说我现在的标准操作流程:

  • 只在必要场景用前端加密:比如临时缓存敏感信息,或对接特定第三方协议。
  • 绝对不用前端做身份认证类的加密逻辑(比如算签名登录),交给后端。
  • 密钥不硬编码,通过安全渠道下发或派生。
  • 所有加密操作包 try/catch,失败降级为空或提示重新输入。
  • 本地存储加密数据时,加上时间戳或版本标识,便于清理或迁移。
  • 优先使用经过考验的库(CryptoJS),不自己造轮子。

改完后仍有一两个小问题,比如不同设备同步数据时密钥不一致,但这属于产品设计层面的问题,技术上已经做到可控了。

以上是我踩坑后的总结,希望对你有帮助。有更好的方案欢迎评论区交流,比如有人用 WebAssembly 跑加密算法的也可以聊聊,我一直没敢在生产环境试。这个技巧的拓展用法还有很多,后续会继续分享这类实战博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论