☰
前后端AES加密实战:敏感字段加密与解密方案全解析
2026/10/19 14:48:58 网站建设 项目流程

这次的项目需求,就是在一个前后端分离的系统里,把登录密码、手机号这类敏感字段用AES加密之后再传给后端接口,后端拿到密文再解密,整个实战过程我踩了不少坑,也整理出了一套可以直接复用的代码。简单说,就是把此前明文提交的敏感字段,改成前端先加密、后端再解密的模式。不管你是刚接触加密的新手,还是正在为“密码不能明文进接口”这种安全需求发愁,这篇都值得看完再动手。

事情源于我们内部的一次安全自查。管理后台的登录接口被指出存在风险:请求体里的密码字段是明文,虽然系统早就上了HTTPS,可检查方明确指出,“链路加密不代表应用层安全”。一开始我也觉得这是在挑刺,但把落日志、网关抓包、问题排查这几个场景一摆,就明白了——敏感字段在到达业务代码之前,经过的中间环节太多了。今天这篇文章,我把完整的参数约定、前端加密代码、Java后端解密代码、三端对齐验证方法,以及联调时踩过的坑全部整理出来,给同样被这个需求卡住的朋友作参考。

1. 为什么前后端要自己做AES加密

1.1 需求现场:一条安全整改项引发的改造

我们管理后台的登录请求,原来长这样:前端把用户名和密码塞进表单,POST 到后端,后端拿到后校验。整条链路有 HTTPS 保护,浏览器到服务器之间的网络传输部分是加密的。但问题出在请求到达服务器之后。

服务端项目普遍有访问日志、操作日志、链路追踪。当时我们排查发现,如果日志框架把请求体完整打出来,密码字段就是明晃晃的明文。抓包工具在网络层看不到密码,但服务端一解包,日志一记录,密码就暴露了。还有网关、消息队列、数据同步工具,只要中间某一环把请求内容打印了,明文就会落到日志文件里,而日志文件的访问权限往往比数据库宽得多。

安全整改项的核心诉求就一句话:敏感字段在请求体里不能是明文。这跟链路层是否加密没关系,要求的是应用层再给数据加一层保护。

1.2 应用层加密和 HTTPS 到底有什么不同

拿快递打个比方。HTTPS 保证的是“包裹在运输途中不被拆开”,从发出到接收,中间人看到的是密封的包裹。但包裹到了驿站、仓库、门卫手里,里面装的是什么,链路上并不关心。应用层 AES 加密相当于在包裹里面又加了一道锁,即使某个中间环节把箱子打开了,看到的也是一堆密文,拿不到真正的敏感信息。

具体落到实现上,就是前端在用户输入密码之后、把数据放进请求体之前,先调用加密函数,把“123456”变成一串类似F79q2cOd...的 Base64 字符串。后端接口收到这串密文后,先调用解密工具类,还原成明文再走原来业务逻辑。这样做的好处是:日志里记录的、网关里转发的、第三方系统里落地的,全都是密文,明文只存在于前端用户输入的那一瞬间和后端业务处理的内存里。

这个思路不只是密码能用,身份证号、手机号、银行卡号、订单金额,凡是需要在接口里传输的敏感字段,都可以套同一套方案。

1.3 对称加密选型:AES 为什么是首选

选定加密算法时,我对比过几条路线。

DES 和 3DES 属于上一个时代的产物。密钥长度短、运算性能一般,行业安全标准里早把这类算法标记为不推荐。3DES 虽然比 DES 强,但块大小、运算效率都不如 AES,新项目完全没有理由再选它。

RSA 是非对称加密,公钥加密、私钥解密,安全性没问题,但用在接口加解密上有两个短板。一是性能比对称加密慢几个量级,同样的数据量,AES 加密耗时几乎可以忽略,RSA 有明显的计算开销。二是明文长度受密钥长度限制,比如 1024 位的 RSA 密钥一次最多只能加密 117 字节,稍微长一点的业务字段就没法直接用。

AES 是对称加密,128 位密钥的强度已经足够,加解密速度极快,浏览器、Java、Python、Go 都有现成的实现,几乎成了“前后端敏感字段加密”这个场景的标准答案。所以最后方案没有悬念:算法选 AES,接下来只需要把模式、填充、密钥长度这些细节定清楚。

2. 方案整体设计与参数约定

2.1 架构流程设计

动手写代码之前,我先把整个流程画了一遍:

  1. 前端封装统一的加密工具函数,在提交表单前把加密结果放入请求体。
  2. 后端接口收到密文字段,调用工具类解密,还原出明文后走原有校验逻辑。
  3. 加密解密逻辑集中在各自端的工具类里,业务代码不感知加解密细节。

把加解密集中在工具类而不是散落在业务代码里,是为了后续好维护。比如某天要换密钥、换加密模式,只在工具类里改一行,所有业务接口同时生效。如果每个接口都自己写一遍 AES 调用,那后续排查和升级都是灾难。

后端我再强调一个设计点:解密动作尽量不要散在 Controller 里。更优雅的做法是在参数解析层统一处理,比如自定义注解配合拦截器,或者直接在一个解密服务里处理。我们团队当时没有选太重的方案,因为涉及改造的接口就那几个,集中在工具类里调就够了。接口数量多的话,还是建议做一个切面统一解密。

2.2 模式选型:CBC 有它的理由

AES 本身是分组加密算法,一次处理 16 字节。多个分组怎么串联,就是模式的事。我当时在 ECB、CBC、GCM 之间权衡了一下。

ECB 模式最简单,每个分组独立加密,相同明文块永远得到相同密文块。问题就出在这个“相同”上:数据规律性会反映到密文里,攻击者可以通过观察重复块来猜测明文结构。比如加密一张带大色块的图片,ECB 模式的密文肉眼就能看出图片轮廓。这种模式在安全性要求高的场景是不可接受的。

CBC 模式引入了 IV,每个明文分组先跟上一个密文分组异或,再执行加密。第一个分组跟 IV 异或。只要 IV 不同,相同明文加密出来的密文就完全不同,规律性被打破。这是当时我们选它的主要原因。

我也认真考虑过 GCM。GCM 是目前推荐度更高的模式,自带认证能力,密文被篡改后能发现。但当时前端用的是 CryptoJS,GCM 在 CryptoJS 里的认证标签处理比较绕,需要多写不少代码。考虑到团队熟悉度和整体改造成本,老项目选择了 CBC。新项目我强烈建议直接上 GCM,这个放到最后再细说。

2.3 密钥、IV、填充:最容易埋雷的“三件套”约定

前后端能成功加解密,秘密不在算法本身,而在参数完全对齐。我把整张参数约定表贴在下面,这条表就是我这次实战里最重要的产出:

参数约定值说明
算法AES对称加密
密钥长度128 位密钥字符串正好是 16 字节
工作模式CBC需要 IV,同一个明文在不同 IV 下密文不同
填充前端 Pkcs7 / 后端 PKCS5PaddingAES 块大小为 16 字节,二者实际等价
明文编码UTF-8统一按 UTF-8 取字节,防止中文乱码
IV16 字节字符串演示用固定 IV;生产环境建议随机生成
密文编码Base64方便放进 JSON 和日志

这里要解释一个常见疑惑:PKCS5 和 PKCS7 到底是不是同一个东西。严格说,PKCS5 是 8 字节块算法的填充标准,PKCS7 是通用填充标准。但在 AES 场景下,块大小固定 16 字节,Java 里的PKCS5Padding底层实现和 PKCS7 完全一致:差几个字节就补几个字节,补的字节值就是补的个数。所以前端写CryptoJS.pad.Pkcs7,后端写AES/CBC/PKCS5Padding,两边能正常对上,就是这个原因。

密钥长度对应关系也得记牢:AES-128 密钥 16 字节,AES-192 密钥 24 字节,AES-256 密钥 32 字节。前后端一旦有一端把密钥长度搞错,比如前端用 16 字节、后端用 32 字节,两边实际上在跑不同的 AES 变体,解密出来必然是乱码或直接报错。

IV 是另一个细节。CBC 模式里 IV 不需要保密,但必须保证唯一性和两端一致。演示代码里我会用固定 IV,方便复现。生产环境建议每次加密随机生成 IV,随密文一起传给后端,JSON 结构类似{ "iv": "xxxx", "data": "xxxx" }。这样同一个密码每次加密出来的密文都不一样,安全性会好很多。

3. 前端加密实战:CryptoJS 实现

3.1 CryptoJS 的引入与按需加载

前端选型没有太多悬念,CryptoJS 是使用最广的 JavaScript 加密库。浏览器场景可以直接通过 CDN 引入脚本文件,也可以在工程化项目里用 npm 安装:

npm install crypto-js

如果在意打包体积,可以用按需引入的方式:

import AES from 'crypto-js/aes'; import CryptoJS from 'crypto-js/core'; import modeCBC from 'crypto-js/mode-cbc'; import padPkcs7 from 'crypto-js/pad-pkcs7';

这种方式只把 AES 相关模块打进包里,比整体引入小不少。项目里如果对包体积不敏感,直接import CryptoJS from 'crypto-js'也没问题。

3.2 完整前端加解密工具函数

直接上可以复用的代码。密钥和 IV 先用固定值演示,方便和三端对齐验证:

const AES_KEY = '2024BlogAesKey!'; const AES_IV = '2024BlogInitVec!'; // AES-128-CBC 加密,返回 Base64 密文 function aesEncrypt(plainText) { const key = CryptoJS.enc.Utf8.parse(AES_KEY); const iv = CryptoJS.enc.Utf8.parse(AES_IV); const encrypted = CryptoJS.AES.encrypt( CryptoJS.enc.Utf8.parse(plainText), key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); // 关键:取真正的密文部分,再转 Base64 return encrypted.ciphertext.toString(CryptoJS.enc.Base64); } // AES-128-CBC 解密,输入 Base64 密文,返回明文字符串 function aesDecrypt(cipherBase64) { const key = CryptoJS.enc.Utf8.parse(AES_KEY); const iv = CryptoJS.enc.Utf8.parse(AES_IV); const decrypted = CryptoJS.AES.decrypt( cipherBase64, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); return decrypted.toString(CryptoJS.enc.Utf8); } // 使用示例 const cipher = aesEncrypt('hello blog, AES is working!'); console.log(cipher); // Base64 密文 console.log(aesDecrypt(cipher)); // 还原明文

有几个点要特别说明。明文必须先用CryptoJS.enc.Utf8.parse()转成 WordArray,这一步是为了确保中文字符按 UTF-8 编码。如果直接把字符串传进去,在绝大多数浏览器里加密结果一致,但为了严谨和跨端稳定,显式转换是最稳妥的做法。

3.3 CryptoJS 两个容易踩的大坑

第一个坑是密钥字符串被当成“口令”而不是密钥。CryptoJS 的 API 有个隐藏逻辑:如果传给AES.encrypt()的第二个参数是普通字符串,它不会把这个字符串的字节直接当密钥,而是把它当成一个 passphrase,走 OpenSSL 的密钥派生算法 EVP_BytesToKey 去生成真正的密钥,同时还会生成一个随机 salt。这意味着你写着“密钥是 abcdef123456”,实际加密用的密钥根本不是这串字节,而是拿它派生出来的另一组字节。后端如果用固定字节做 AES 解密,永远对不上。解决办法就是前面代码里的写法:用CryptoJS.enc.Utf8.parse()把密钥字符串转成 WordArray,再传进去。

第二个坑是默认toString()输出格式。CryptoJS.AES.encrypt()返回的是一个 CipherParams 对象,直接对它调用toString(),输出的是 OpenSSL 兼容格式的字符串,开头带Salted__前缀和 salt 字节。如果直接把这份内容交给后端,后端会把它当成标准密文去解,解密时会报Given final block not properly padded,因为这个格式多出来的头部根本不是密文的一部分。正确做法是取encrypted.ciphertext,再用 Base64 编码输出,也就是上面代码里encrypted.ciphertext.toString(CryptoJS.enc.Base64)这一步。

顺带提一个容易被忽视的问题:密文长度会膨胀。AES 是分组加密,明文长度必须按 16 字节对齐,不足的要补,所以密文长度一定是 16 的倍数,再经过 Base64 编码还会再增加约三分之一。比如 6 位数字密码,明文只有 6 字节,加密后密文是 16 字节,Base64 之后变成 24 个字符,数据库字段如果只给密码留 10 个字符,解密没问题但存储会放不下。这个在表结构设计阶段就要想到。

4. 后端解密实战:Java 实现与三端对齐

4.1 Java 后端解密工具类

后端我用的是 Java,Spring Boot 环境。核心依赖是 JDK 自带的 JCE,不需要额外引入加密库。工具类的完整代码如下:

import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.util.Base64; public class AesUtils { private static final String AES_KEY = "2024BlogAesKey!"; private static final String AES_IV = "2024BlogInitVec!"; public static String decrypt(String cipherBase64) throws Exception { // 1. Base64 解码,得到密文字节 byte[] cipherBytes = Base64.getDecoder().decode(cipherBase64); // 2. 构造密钥对象 SecretKeySpec keySpec = new SecretKeySpec( AES_KEY.getBytes(StandardCharsets.UTF_8), "AES"); // 3. 构造 IV 对象 IvParameterSpec ivSpec = new IvParameterSpec( AES_IV.getBytes(StandardCharsets.UTF_8)); // 4. 初始化 Cipher:算法/模式/填充 Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); // 5. 解密 byte[] plainBytes = cipher.doFinal(cipherBytes); return new String(plainBytes, StandardCharsets.UTF_8); } public static String encrypt(String plainText) throws Exception { Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); SecretKeySpec keySpec = new SecretKeySpec( AES_KEY.getBytes(StandardCharsets.UTF_8), "AES"); IvParameterSpec ivSpec = new IvParameterSpec( AES_IV.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] cipherBytes = cipher.doFinal( plainText.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(cipherBytes); } }

注意几个细节。密钥和 IV 取字节时必须指定 UTF-8 字符集,直接调getBytes()会依赖服务器默认字符集,一旦环境从 Windows 换成 Linux 可能就出问题。Java 的Base64.getDecoder()对应的是标准 Base64,跟前端CryptoJS.enc.Base64一致。

4.2 三端对齐的自测方法

AES 算法是确定性的。同样的明文、密钥、IV、模式、填充,在任何语言、任何实现下加密出来的字节完全一致。这个特性是做三端对齐验证的基础。

我的自测方法是准备一组固定数据:

明文:hello blog, AES is working! 密钥:2024BlogAesKey! IV :2024BlogInitVec!

在前端加密一次,得到 Base64 密文,然后拿到 Java 后端解一次,能还原成原明文,链路就通了。反过来再测一下:Java 加密,前端解密。

后端可以写一个单元测试来固化这个结果:

@Test void testFrontendCipherCanBeDecrypted() throws Exception { // 这里放前端加密后产生的 Base64 密文 String cipherFromFrontend = "前端生成的密文"; String plain = AesUtils.decrypt(cipherFromFrontend); assertEquals("hello blog, AES is working!", plain); }

要注意的是,固定密钥和固定 IV 时,同一个明文每次加密出来的密文是固定的,所以测试用例里的断言值确定、可复现。如果改成随机 IV,那就要改成先传 IV 再解密的模式。

4.3 Python 辅助工具串场

联调阶段我写了一个 Python 脚本,用来独立验证加解密,排查是哪一端的参数没对齐。项目中不一定需要 Python,只是提一下这个思路:当两端对不上时,用第三个语言做基准判断,往往很快能定位问题。

from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad import base64 KEY = b'2024BlogAesKey!' IV = b'2024BlogInitVec!' class AesHelper: @staticmethod def encrypt(plain_text: str) -> str: raw = pad(plain_text.encode('utf-8'), AES.block_size) cipher = AES.new(KEY, AES.MODE_CBC, IV) return base64.b64encode(cipher.encrypt(raw)).decode('ascii') @staticmethod def decrypt(cipher_base64: str) -> str: raw = base64.b64decode(cipher_base64) cipher = AES.new(KEY, AES.MODE_CBC, IV) plain = unpad(cipher.decrypt(raw), AES.block_size) return plain.decode('utf-8')

安装依赖用pip install pycryptodome。这个类只用于测试和造数据,生产代码不需要。CBC 模式解密时,unpad会校验填充值,如果密文不对、填充解析失败,它会直接抛异常,这本身就是一种很好的错误信号。

5. 实战踩坑记录与问题速查

5.1 真实踩坑案例回顾

这次联调,我记录了一批典型问题,每一个都对应一版真实的报错现场。

第一个坑是 OpenSSL 格式密文。同事用的是前端默认的encrypted.toString()把结果传给我,结果 Java 端报Given final block not properly padded。当时第一反应是密钥不对,排查半天才发现密文 Base64 解码后开头是Salted__8 个字节,这不是标准密文。这个就是第 3.3 节说的第二个坑,非常隐蔽。

第二个坑是密钥长度错位。有一次后端同事自行修改了密钥,把 16 字节换成了 24 字节,前端还是 16 字节。后端实际跑的是 AES-192,前端跑的是 AES-128,两边密文全都对不上。这种问题不会立刻报错,表现出来就是“解密成功但全是乱码”或者“偶发解出乱码”。

第三个坑是 IV 编码不一致。CBC 解密时,IV 只影响第一个明文分组。如果 IV 错误,解密过程不会报错,但是第一块解出来是乱的,后面的块正常。当时的场景是前端用 UTF-8 字符串做 IV,后端某次重构误用了 Hex 编码的 IV,结果就是密文开头 16 字节永远乱码。这种问题比整体乱码更迷惑人,排查时要特别留意。

第四个坑发生在 URL 传参场景。前端把 Base64 密文直接拼到 URL 上,传输过程中+被解析成空格,后端收参后解密直接报last unit does not have enough valid bits。Base64 字符集里包含+和/,这两个字符在 URL 里属于特殊字符。解决方案是改用 URL-safe Base64,或者在前端对密文做一次encodeURIComponent,后端取参时做对应解码。

5.2 问题速查表

把典型现象和对应解法整理成速查表,联调卡住时先对一遍:

现象大概率原因处理方式
解密报Given final block not properly padded密文带 Salted__ 前缀 / 密文被截断 / 填充不一致确认前端取 ciphertext,不取默认 toString
解密成功但第一块乱码IV 不一致对比两端的 IV 编码方式
解密成功但全部乱码密钥不一致或密钥长度不一致逐字节比对密钥,确认 16/24/32 字节
报Illegal key size密钥超过 128 位且 JDK 策略受限用 128 位或升级新版 JDK
URL 传参后解密失败Base64 中+被转义成空格用 URL-safe Base64 或 encodeURIComponent
中文解密后乱码明文编码未统一全程指定 UTF-8
同一个明文每次密文都相同固定 IV 的正常现象生产环境改用随机 IV

排查这类问题的核心方法论是“逐项对齐”:密钥字节、IV 字节、模式、填充、密文编码,一个参数一个参数核对。如果还找不出来,就用 Python 脚本造一组基准密文,分别在前端和后端解密跑一遍,哪边对不上就是哪边的参数问题。这个方法建议直接收藏,能省大量联调时间。

6. 生产环境的安全加固与建议

6.1 密钥管理:别高估前端静态密钥的局限

用固定密钥写在 JavaScript 里的方案,对日志和网关场景有效,但要说清楚它的边界:前端代码是会被用户拿到的,打开开发者工具就能看到密钥字符串。因此前端的 AES 加密解决的不是“对抗黑客”,而是“避免敏感字段在中间环节明文落地”。想清楚这个定位,才能合理设计生产环境的密钥管理。

我按安全级别把方案分成三档。低敏感业务,比如内部系统的登录密码,用静态 AES 密钥配合 HTTPS,成本最低,够用。中敏感业务,密钥由后端配置中心下发,支持版本化和定期轮换,前端不直接硬编码密钥。高敏感业务,就需要上非对称 + 对称的混合加密,也就是下面这种方案。

6.2 混合加密(RSA + AES)的典型做法

混合加密的思路是用 RSA 保护 AES 密钥,用 AES 加密业务数据。典型流程是这样:

  1. 前端请求一个密钥接口,后端生成 RSA 密钥对,把公钥返回,私钥留在内存中并设置短有效期。
  2. 前端随机生成一次性的 AES 密钥和 IV,用 RSA 公钥加密 AES 密钥和 IV,得到信封密钥。
  3. 前端用 AES 密钥加密敏感字段。
  4. 请求体里同时携带信封密钥、IV、密文、时间戳。
  5. 后端先用 RSA 私钥解开信封密钥,再用 AES 解出明文。

这个方案解决了 AES 密钥静态暴露的问题,代价是每次会话多一组 RSA 加解密计算。适合登录、改密、支付这类低频敏感接口,不适合每个请求都走一遍。再加一层防重放措施会更稳妥:请求体里放时间戳和随机 nonce,后端校验时间窗,比如 5 分钟内有效,并通过 nonce 判重。

6.3 新项目我更推荐直接上 GCM

如果是从零开始的新项目,建议直接选 AES-GCM 而不是 CBC。GCM 属于 AEAD 认证加密,除了保密性,还提供完整性校验:密文在传输过程中被篡改,解密端会直接校验失败。CBC 模式本身没有认证能力,密文被改了不一定能发现,需要额外搭配 MAC 校验才能补上。

Java 后端用 GCM 的写法很简洁:

Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES"); GCMParameterSpec gcmSpec = new GCMParameterSpec(128, ivBytes); cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); byte[] plainBytes = cipher.doFinal(encryptedBytes);

这里GCMParameterSpec(128, ivBytes)里的 128 是认证标签长度,单位是位。Java 的doFinal会自动校验认证标签,加密时会把 tag 拼在密文后面,解密时一起提交即可。

前端这块,新项目我不太建议再用 CryptoJS 做 GCM,因为认证标签需要自己拼接处理,容易出问题。浏览器原生 Web Crypto API 对 GCM 支持很好,代码大概是这个形态:

const key = await crypto.subtle.importKey( 'raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt'] ); const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, plainBytes ); // encrypted 的内容 = 密文 + 认证标签 // 需要把 iv 和 encrypted 一起传给后端

有一点需要提前知道:Web Crypto API 只在安全上下文中可用,也就是 HTTPS 页面或localhost环境,纯 HTTP 的内网页面调用crypto.subtle会拿不到对象。内网部署时要先确认能不能跑通,这是我在验证时踩到的一个环境限制。

如果你正在做类似的改造,我的建议是别急着抄代码,先把第 2 节的参数表逐项定死,再动手写功能。参数对齐这件事,真的是一行字符都不能差。密钥写死在前端只能算入门方案,生产环境至少要走到配置中心下发,敏感业务再上混合加密。加解密本身不难,难的是两端把每一项约定都落实到位。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询