☰
Java AES128加解密实战:密钥模式填充与踩坑排查全解析
2026/10/15 22:26:31 网站建设 项目流程

刚在项目里给一批存量接口做敏感字段加密时,被 Java 的 AES128 折腾了一圈。网上关于这块的帖子不少,但大多只丢一段代码,既不解释为什么这么写,也没说清楚密钥、模式、填充这些参数凑不对会出什么事故。这篇文章就把我实际踩过的坑、最终落地的写法、以及为什么这么写的逻辑一起整理出来,给后面要用 AES128 做加解密的同学做个参考。

先说结论:AES 本身在 Java 里用起来很简单,JDK 自带,不需要引入任何第三方依赖。真正容易出问题的全是周边细节——密钥怎么转成合法长度、用什么模式、用什么填充、最后用 Base64 还是十六进制输出,这些定不下来,加密出来的数据换个环境就解不开。这篇文章主要覆盖四块内容:AES128 的选型理由、可直接抄作业的加解密工具类、密钥和 Base64 这些周边细节的深挖,以及我在实际项目里遇到的连环问题排查过程。

1. 为什么偏偏是 AES128:一个关于平衡的选择

在动笔写代码之前,我花了一些时间把加密算法的选择逻辑理了一遍。因为在真实项目里,选哪个算法、哪个密钥长度,往往不是技术偏好问题,而是由合规要求、性能预算、平台兼容性共同决定的。

1.1 安全强度与性能的平衡点

AES 的块大小是固定的 128 位(即 16 字节),密钥长度则可以是 128、192 或 256 位。AES128 意味着密钥是 128 位,也就是 16 个字节。很多人第一反应是“密钥越长越安全”,但实际业务系统里,AES128 和 AES256 的差距并没有直觉上那么大。AES128 的暴力破解需要 2 的 128 次方次尝试,这个量级在今天的算力下已经是天文数字。对绝大多数内部系统、接口传输加密、数据库字段加密场景,AES128 的安全余量完全足够。

我选择 AES128 更重要的原因是性能和密钥管理的成本。AES256 在加解密时需要进行更多轮数的轮变换(14 轮 vs 10 轮),虽然单次耗时差距在毫秒级别,但在高并发的网关解密场景里,每一微秒都会被放大。更麻烦的是,256 位密钥对很多旧系统的硬件加密模块支持不友好,密钥长度翻倍也意味着密钥在配置、存储、传输时的成本更高。这种“够用就好”的选择,恰恰是工程实践的常态。

1.2 对称加密和非对称加密的定位差异

有读者可能会问:为什么不用 RSA?原因是 AES 是对称加密,加密解密用同一把密钥,计算开销小,适合大数据量的加解密;RSA 是非对称加密,加密用公钥、解密用私钥,安全模型不同,但性能差了几个数量级,通常只用来加密密钥本身,而不是业务数据。

在我做的这个项目里,对接方要求对手机号、身份证号进行加密后落库,同时需要支持根据密文反查明文。这种场景天然适合对称加密:密钥掌握在自己手里,加解密都是自己完成。非对称加密当然也可以,但没必要为几百字节的敏感字段付出几倍的计算成本和密钥管理复杂度。

1.3 AES 在世界范围内的实际地位

AES 从 2001 年被确定为标准算法后,到今天仍然是使用最广泛的对称加密算法。无论是数据库透明加密、传输层安全协议,还是各类支付系统的敏感数据加密,底层几乎都是 AES 算法本身或其变体。选择一个被业界反复验证过、有海量安全审计记录的算法,本身就是一种风险管理策略。相比自己发明加密算法或者使用小众算法,AES128 意味着你站在了最成熟的技术路径上。

2. 可直接落地的 AES128 加解密工具类

有了选型理由做铺垫,下面就是实际写代码的部分。我直接给出我在项目里最终使用的工具类,然后逐段解释关键逻辑。

2.1 基础版本:CBC 模式 + PKCS5Padding

很多教程上来就跑一个最简版本(ECB 模式),但真正做业务系统,我不会建议你直接用 ECB。下面这个是我的基础推荐版本,用 AES/CBC/PKCS5Padding,带上了初始化向量 IV:

import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Base64; public class Aes128Util { /** * 加密,返回 Base64 字符串 */ public static String encrypt(String plaintext, String key, String iv) throws Exception { byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8); byte[] ivBytes = iv.getBytes(StandardCharsets.UTF_8); SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, "AES"); IvParameterSpec ivParameterSpec = new IvParameterSpec(ivBytes); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); byte[] encryptedBytes = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encryptedBytes); } /** * 解密,从 Base64 字符串还原明文 */ public static String decrypt(String ciphertext, String key, String iv) throws Exception { byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8); byte[] ivBytes = iv.getBytes(StandardCharsets.UTF_8); SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, "AES"); IvParameterSpec ivParameterSpec = new IvParameterSpec(ivBytes); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(ciphertext)); return new String(decryptedBytes, StandardCharsets.UTF_8); } }

这一段是我认为“可以直接抄”的版本。但看了注释还不够,有几个地方得拆开讲清楚,不然很容易在部署时出幺蛾子。

2.2 密钥和 IV 的长度校验逻辑

AES128 要求密钥必须是 16 个字节。很多新人第一次跑通代码用的密钥是任意字符串,比如"mykey123",长度 8 个字节,结果运行时报错:Invalid AES key length。我后来在工具类里加了一个前置校验,避免误用:

public static void validateKeyAndIv(String key, String iv) { byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8); byte[] ivBytes = iv.getBytes(StandardCharsets.UTF_8); if (keyBytes.length != 16) { throw new IllegalArgumentException("AES128 要求密钥长度必须为 16 字节,当前为 " + keyBytes.length); } if (ivBytes.length != 16) { throw new IllegalArgumentException("IV 长度必须为 16 字节,当前为 " + ivBytes.length); } }

这个校验的作用很直接:与其等到加密时抛出一堆看不懂的异常,不如在入口就把问题暴露出来。实际部署时,很多人会把密钥放在配置文件里,如果不小心从网上复制了一个多字节的密钥(比如带中文的),这个校验能帮忙第一时间发现。

2.3 为什么不推荐 ECB 模式

网上很多 AES 示例用的是AES/ECB/PKCS5Padding,因为代码最少、最简单。但 ECB 有一个非常经典的安全缺陷:相同的明文块会被加密成相同的密文块。直观地理解,这就好比给一个图形上了马赛克,虽然细节被模糊了,但如果原图颜色分布有规律,马赛克下依然能看出轮廓。对于手机号、身份证号这类高熵值但长度有限、前缀可能相同的字段,ECB 模式可能会泄露部分统计特征。

CBC 模式则不同,它让每个明文块先与前一个密文块异或,再加密,这样即使明文有重复块,密文也不会表现出相同模式。代价是必须引入一个 IV(初始化向量),稍显繁琐,但对业务系统,这点繁琐远小于安全风险。我的默认建议是:能用 CBC 就别用 ECB,除非你有非常明确的性能理由,且已经评估过数据特征。

2.4 更强的推荐:GCM 模式

如果你问我实际项目里最终用的是什么,答案是 GCM 模式。严格来说,GCM 属于 AEAD(带关联数据的认证加密)模式,它同时提供机密性和完整性校验。如果你的需求不仅是不让别人看到明文,还要防止密文被篡改,GCM 是比 CBC 更合适的选择。

public static String encryptGcm(String plaintext, String key) throws Exception { byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8); byte[] ivBytes = new byte[12]; // GCM 推荐 12 字节的随机 IV SecureRandom random = new SecureRandom(); random.nextBytes(ivBytes); SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, "AES"); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, new GCMParameterSpec(128, ivBytes)); byte[] encryptedBytes = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); // 将 IV 和密文拼在一起输出,方便后续解密提取 byte[] combined = new byte[ivBytes.length + encryptedBytes.length]; System.arraycopy(ivBytes, 0, combined, 0, ivBytes.length); System.arraycopy(encryptedBytes, 0, combined, ivBytes.length, encryptedBytes.length); return Base64.getEncoder().encodeToString(combined); } public static String decryptGcm(String ciphertext, String key) throws Exception { byte[] combinedBytes = Base64.getDecoder().decode(ciphertext); byte[] ivBytes = new byte[12]; byte[] encryptedBytes = new byte[combinedBytes.length - 12]; System.arraycopy(combinedBytes, 0, ivBytes, 0, ivBytes.length); System.arraycopy(combinedBytes, 12, encryptedBytes, 0, encryptedBytes.length); SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES"); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, new GCMParameterSpec(128, ivBytes)); byte[] decryptedBytes = cipher.doFinal(encryptedBytes); return new String(decryptedBytes, StandardCharsets.UTF_8); }

这里有个细节值得注意:GCM 模式下不需要额外选填充方式,所以用NoPadding。另外,GCM 的 IV 一般用 12 字节,而且必须是随机生成、每次加密都不同。如果 IV 复用,攻击者有可能恢复出密钥流,这是 GCM 模式最需要避开的坑。

3. 密钥、IV、Base64:三个必须弄明白的周边细节

很多代码跑不通或者跨系统解密失败,问题往往不出在 Cipher 本身,而是出在外围的数据处理上。这一节单独把这些细节展开聊。

3.1 密钥的本质:字符串不是密钥,字节数组才是

AES 算法操作的密钥本质是一段固定长度的随机字节序列。我们平时写的"abc123..."之类字符串,必须通过getBytes()转换为字节数组,并且这个字节数组的长度正好等于 16(对 AES128 而言),算法才能正常工作。

这意味着同一个字符串,用不同的字符集转换,会得到不同的字节。最常见的问题是 Windows 和 Linux 默认字符集不一致,一个是 GBK,一个是 UTF-8,同一个密钥字符串在两个环境下转出来的字节不同,解密自然失败。我在项目里统一强制使用StandardCharsets.UTF_8,从源头上消灭这类环境差异。

3.2 初始化向量 IV 的作用与生成方式

CBC 模式下,IV 的作用是让同一个明文、同一把密钥,在不同次加密中产生不同的密文。比如你要加密两个相同的手机号,如果 IV 固定不变,两次加密的结果就完全相同,对手可能直接通过密文比对推断出两份数据属于同一个人。如果每次加密使用随机 IV,相同的明文也会得到不同的密文。

IV 是否需要保密?答案是否定的。IV 的官方术语叫“公开向量”,可以随密文一起传输。但它的核心禁忌是:加密时不能重复使用同一个 IV。实际项目中,有些人把 IV 写死在配置文件里,这虽然不影响安全性,但失去了 CBC 模式“相同明文不同密文”的意义。如果条件允许,建议每次加密生成随机 IV,并把 IV 和密文一起存进数据库或传给对端。GCM 模式里我就是这么做的。

3.3 Base64 到底是干什么的

加密得到的原始结果是一串二进制字节,里面可能包含任意值,包括 0 字节、换行符、不可见字符。直接把字节转为字符串或者放进 JSON、XML 里传输,会出各种奇奇怪怪的编码错误。Base64 的作用就是把任意二进制数据转换成由 64 个可见字符组成的文本,这样就能作为字符串安全存储或传输。

Java 中的Base64.getEncoder()是标准实现,不需要像早期版本一样用sun.misc.BASE64Encoder(这个类在新版本里已经不可用,而且有性能问题)。解密时调用Base64.getDecoder().decode(ciphertext)把它转回原始字节。这里要特别注意:不同语言的 Base64 细节略有差异(比如换行符处理),跨语言对接时最好统一用标准 Base64,不要用 URL-safe 变体或 MIME 变体,除非双方约定一致。

3.4 填充模式:为什么有 PKCS5 和 PKCS7 的区别

AES 的块大小是 16 字节,但你要加密的原文长度不一定正好是 16 的倍数。填充的作用就是把最后一块补齐。Java 的标准选项里写的是PKCS5Padding,但严格来说,PKCS5 只定义了 8 字节块的填充,AES 用的 16 字节块应该叫 PKCS7。两者算法几乎相同:需要补 n 个字节就补 n 个值为 n 的字节。你要是需要跟其他语言(比如 Python 的 AES 库、Go 的加密包)对接,对方通常写PKCS7,Java 这边写PKCS5Padding,效果其实是一致的。

有一种容易踩的坑是:如果加密用 PKCS5Padding,解密时却用了 NoPadding,或者反过来,可能能解开部分数据,但末尾会残留填充字节。所以加解密两端的填充配置必须保持完全一致。我遇到过对接方把模式写错、解密出来的数据末尾多了一堆\u0001这类字符的问题,就是填充模式没有对齐导致的。

3.5 SM4 等国产算法的补充对比

如果你所在的业务对合规有特殊要求,可能会遇到"必须使用国产算法"的场景,比如 SM4。SM4 的分组长度也是 128 位,密钥长度同样支持 128 位,用法跟 AES128 在结构上有相似之处。但从工程成熟度看,AES 在各类语言、各版本 JDK 中的支持是开箱即用的,SM4 需要依赖第三方库或定制包。在没有明确合规指令的情况下,AES128 仍然是通用性最好的选择。如果项目确实有国密要求,建议把这一层算法选择封装在统一的加密服务接口后面,而不是散落在业务代码里。

4. 实际项目里那场连环坑排查:从“解不开”到数据损坏

这一节记录我在真实项目里为了让 AES128 加解密稳定运行而排除的一连串问题。这些问题单看都不复杂,但连在一起会让人怀疑人生。

4.1 第一坑:密钥长度看着是 16,实际不是 16

最开始我的密钥配置是这样的:

secretKey=Abc1234567890e

肉眼数一下,14 个字符。说好的 16 字节呢?因为密钥字符串我没做任何长度校验,直接拿去加密,结果第一条数据就报Invalid AES key length。我立刻就意识到是密钥长度的问题,但问题没有当场解决——我项目里同时引入了 Spring Boot 的配置加密插件,配置项里的密钥在运行时被自动解密,最终注入到代码里的值和我写进配置文件的原文已经不是同一个了。

这个坑的教训是:永远在代码入口校验实际拿到的密钥长度,而不是相信配置值。我把 2.2 节里的validateKeyAndIv加入到所有加密入口之后,这类问题才真正被拦住。

4.2 第二坑:解出的明文末尾带着诡异符号

修完密钥长度后,加密没问题了,但解密出来的字符串末尾偶尔会带\u0010或一堆不可见字符。这种现象通常是加密方和解密方使用的填充不一致导致的。我排查发现,我的环境下加密用的是AES/CBC/PKCS5Padding,但某个历史接口在调用时直接 new 了一个Cipher.getInstance("AES"),相当于什么都不指定,走的是 JDK 默认 Provider 的默认模式。两个模式不一致,解密时就会出现尾部残留字节。

解决办法很简单:把项目中所有Cipher.getInstance的调用统一成显式指定完整算法字符串,不允许只写AES。我养成了一个新的代码审查习惯,凡是看到只写AES而不带模式/填充的,直接打回。

4.3 第三坑:加密结果每次都不一样,导致数据库索引失效

切换到 CBC 并加入随机 IV 后,安全强度上来了,但问题也来了:业务方要求手机号加密后存储,同时要求能按手机号精确查询。由于我每次加密都用随机 IV,同一个手机号每次加密出的密文都不同,数据库的普通索引直接失效,查询性能直线下降。

这个场景后来是用“索引列单独生成可检索密文”的方案解决的:除了完整密文列,额外存一列使用固定 IV 或固定加密方式生成的确定性密文,专门用于等值查询。双方安全团队认可这个方案后,才真正定稿。这也是一个很重要的经验:加密方案设计不能只考虑算法层面,要考虑到下游存储和检索模式。AES128 本身只负责加密,但方案落到工程上,需要你提前想好数据怎么查、怎么比、怎么保证唯一性。

4.4 第四坑:跨语言解密时,Java 和对方的 Base64 变体不一致

系统对接时,对方用的是另一种语言。他们加密后交给我们的数据,用我的解密工具解出来总是乱码。排查到最后发现,对方用的 Base64 编码是 URL-safe 变体,会把+和/替换成-和_,而 Java 的Base64.getDecoder()默认按标准 Base64 解码,遇到这些字符直接不解。后来统一约定使用标准 Base64,并且两端都写死,不再依赖默认配置。跨语言对接,除了算法本身,字符编码和 Base64 变体也要作为联调清单的固定检查项。

4.5 排查思路的价值:加解密问题要先分内外

这一连串坑排完之后,我自己总结了一套快速定位加解密问题的思路,分享给大家:

  1. 先确认加解密两端使用完全一致的算法字符串(算法/模式/填充各字段都要一致)。
  2. 再确认密钥和 IV 的字节长度和来源环境。
  3. 确认密文的文本编码方式和 Base64 变体是否一致。
  4. 用一个已知的常量明文做往返验算,加密再解密,看是否能恢复原文。
  5. 如果涉及跨语言,双方各准备一组带固定 IV 的测试向量,互相对比结果。

这套排查流程我后来直接写进了项目交接文档里。处理加解密问题,最忌一上来就盯着代码看半天,先对齐这些外部参数,问题大概率已经解决了一大半。

5. 可复用的秘钥管理与安全建议

前面讲的都是加解密逻辑本身,但一个完整的加密落地方案,密钥怎么管才是真正的头号问题。代码写得再好,密钥泄露就等于裸奔。

5.1 密钥不要出现在代码和配置明文里

初级做法是把密钥写在 Java 类文件里,或者把密钥明文放在application.yml里。这种做法在内部工具里也许能跑,但一旦代码仓库泄露,所有加密数据全部玩完。我所在项目的密钥实际上是通过环境变量启动时注入的,配置中心保存密文版本,代码里只保留占位符。如果你的系统已经有密钥管理服务,应该优先集成它,而不是自己维护一套密钥存储。

5.2 密钥轮换机制

密钥长时间不变更,泄露的风险会随时间累积。建议至少每 90 天轮换一次密钥。但轮换并不简单,需要处理存量数据:用旧密钥加密的数据,在新密钥上线后必须能解密。一种通用的做法是密文头部加一个密钥版本号,格式类似:

密文 = 密钥版本号(2字节) + IV(12字节) + 密文数据

解密时先读版本号,再选择对应的密钥。我的工具类已经把 IV 拼在密文前面了,顺带把版本号也拼进去。这样以后切密钥,只需要发布新版本号对应的密钥,旧数据仍然能解。

5.3 密钥存储的环境分离

开发环境、测试环境、生产环境必须使用不同的密钥。不然测试人员或者开发人员在本地就能解开生产数据,安全边界形同虚设。我自己曾经在处理一个线上事故时,因为误用了测试环境密钥去解生产库,直接解出来乱码,最后才发现是环境变量没有隔离开。这个问题的风险不止是加解密失败,更在于它会让团队成员误以为生产数据的秘钥可以被随意获取,这种认知本身就是安全漏洞。

5.4 加密后的数据要防篡改吗

前面说过 GCM 模式自带完整性校验。如果你使用 CBC,不提供认证和完整性保护,攻击者可以翻转密文中的某些比特,导致解密出来的明文变成不可预期的值,但解密过程本身不会报错。这就是所谓的“比特翻转攻击”。对贷款金额、状态字段这类数据,这是非常现实的威胁。我的建议是:涉及完整性要求较高场景,直接用 GCM;如果必须用 CBC,最好额外加一个 HMAC 对密文做签名校验。

5.5 Java 版本差异带来的兼容性问题

JDK 8 和更高版本的 JDK 在 AES 相关类上差别不大,但如果用的是某些精简版 JRE,可能缺少Java Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy。以前默认还不支持 256 位密钥,AES128 并不受影响。如果项目确实要上 AES256,需要确认 JDK 版本和策略包配置。不过绝大多数项目早已升级到 JDK 11 或 17,这个问题基本不存在了。对 AES128 来说,JDK 8 到 21 都能顺畅运行,这也是我推荐它的一个现实理由。

最后分享一个小细节。AES128 的“128”指的是密钥长度,但它也容易让人误以为和加密结果长度有关。加密结果长度由明文长度加填充决定,跟密钥长度没有直接关系。比如一个 1 字节的明文,CBC 模式下加密结果是 16 字节;16 字节明文加密结果也是 16 字节;17 字节明文加密结果是 32 字节。这个规律在预估数据库字段长度和接口传输耗时时非常有用,特别建议做技术方案阶段就把这个算进去,免得上线前临时提工单改字段长度。

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

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

立即咨询