☰
EncryptionUtils 实战:AES、RSA、Base64 跨端联调
2026/9/29 1:19:17 网站建设 项目流程

每个项目做到第三个月,libs 目录下大概率会长出一个叫EncryptionUtils的类。它最开始通常只有一个md5(String),然后产品说要传密码,于是加了个 AES;再然后接口联调方说签名对不上,于是又塞进 RSA 和 SHA-256;等到某天有人在群里问“为什么我这解出来是乱码”,你打开这个文件一看,已经八百多行,方法名从encrypt1排到encrypt7,每个方法的字符集、填充模式都不一样,改一处崩三处。我这几年参与过支付、IoT 设备通信、内部管理后台几类项目,几乎每个都逃不开这件事,最后沉淀下来的那套写法其实高度相似:分层清晰、参数固定、异常信息明确,并且坚决不把“字符编码”和“业务编码”混进来。下面我就按自己实际落地的顺序,把Util.EncryptionUtils这个工具类从选型、编码、对称加解密、非对称加解密到排查技巧完整讲一遍,代码以 Java/JCE 为主,思路对其他语言同样成立。不管你是刚接触javax.crypto的新手,还是已经写过几轮但总被联调方追着问“你是不是填充方式不对”的老手,应该都能从这里抄到能直接用的东西。

1. 先给工具类划边界,别让编码和加密挤在一起

1.1 三个被混在一起的“编码”

“编码”这个词在我们日常沟通里至少背着三种互不相干的含义,混起来是EncryptionUtils失控的头号原因。第一种是字符编码,也就是 UTF-8、GBK、ISO-8859-1 这些把字符映射成字节的规则,它管的是“中文为什么变成问号”;第二种是二进制到文本的编码,典型是 Base64 和 Hex,把不可打印的字节流变成 ASCII 字符串,方便塞进 JSON、URL、数据库字段,它管的是“密文怎么传输”;第三种是业务字典编码,比如省市区编码、天气城市编码、物流公司编码,本质是一张查找表,它管的是“101 代表哪个区”。

这三种东西放在同一个类里,短期看不出问题,一旦有人接手就会出事。我就见过把 Base64 当成加密用的场景:前端把用户手机号 Base64 之后传给后端,团队里有人默认“这是编码,不是加密,无所谓”,结果日志里直接打印出了明文手机号。也见过反过来,业务方要求“加密”身份证号,开发同学直接调了Base64.encode,上线后被安全扫描直接打回。我的做法是在类注释第一行就写清楚:本类只处理第一、第二种编码,业务字典编码请放到单独的 CodeTable 工具中。这条边界一划,类的职责立刻清爽,后面加方法的时候也会自然地先问一句“这属于加密还是属于查表”。

顺带说一句,热搜里提到的“地理编码”“省市区编码和名称 js 数据”这类内容,跟加密解密完全不是一个领域。它们更适合放在数据字典模块,用静态 Map 或者加载 JSON 资源的方式维护,硬塞进EncryptionUtils只会让这个类的搜索命中率变得极差——别人搜“加密”结果搜出来一堆城市编码,反过来也一样。

1.2 主干选型:AES 加 SHA-256 加 Base64

划定边界之后就是选型。工具类的选型原则和业务代码不一样,业务代码可以追新,工具类必须稳定、通用、跨端可复现。我的默认组合是:

用途首选说明
数据加密AES-128/256,CBC 模式对称、快、各语言原生支持
密钥交换/签名RSA-2048,OAEP 或 PSS非对称,只用来保护对称密钥和做签名
摘要SHA-256校验完整性、做签名预处理
兼容遗留MD5、SHA-1只读场景保留,新逻辑不用
字节到文本Base64(URL 安全变体)传输、入库统一用它

为什么不选国密或者其他小众算法作为默认?因为跨端一致性成本。移动端、服务端、嵌入式设备、第三方对接方,各自的语言生态对算法的支持程度差别很大。AES 和 RSA 属于“你把参数写清楚,对方照着写基本能对上”的级别,而一些小众算法在不同库里的默认参数经常不一致,联调时间会成倍增加。这不是说小众算法不好,而是说工具类的第一职责是降低沟通成本,特殊算法应该单独开一个类去承载。

另一个关键决定是:工具类里的方法默认不返回可变的密钥对象,也不暴露 Cipher 实例。我早期写过一个getCipher()方法,本意是方便调用方复用,结果三个月后有人拿着这个 Cipher 去做了另一个算法初始化,导致并发场景下解密随机失败,查了整整两天。后来我把所有 Cipher 实例都封在方法内部,每次调用新建,调用方拿到的只有字符串或者字节数组。这个改动看起来“性能差一点”,但换来的是没有状态、没有并发问题,非常划算。

2. 编码解码层:Base64 与 Hex 的坑比想象中多

2.1 Base64 的三种变体和换行陷阱

Base64 看起来最简单,实际是联调事故的高发区,核心原因是它有多个变体。RFC 4648 定义了标准字母表(+和/)、URL 安全字母表(-和_),MIME 变体则额外要求每 76 个字符插入换行。Java 的java.util.Base64正好对应这三种:getEncoder()、getUrlEncoder()、getMimeEncoder()。

最常见的坑是把 Base64 密文放进 URL 参数或者 JWT 里,用了标准编码器,结果+在 URL 解码时被当成空格,/被某些网关当成路径分隔符。表现就是“我本地测好好的,一上线就解密失败”。解决方式只有一条:只要这段 Base64 会出现在 URL、文件名、HTTP Header 里,就用 URL 安全变体。

第二个坑是换行。Android 的android.util.Base64.encodeToString默认带Base64.DEFAULT标志,它会在末尾自动追加换行符\n,也会每 76 字符折行。服务端如果直接把这个字符串拿去解码,末尾的换行不影响,但折行在某些严格解析器上会报错。所以我习惯在 Android 侧显式传Base64.NO_WRAP,在服务端解码前先做一次replaceAll("\\s", "")兜底。这个兜底几乎零成本,但能省下很多次“你传过来的字符串是不是带换行了”的来回扯皮。

第三个坑是填充符。标准 Base64 用=补齐到 4 的倍数,URL 安全变体在部分实现里会省略=。如果两端一个带填充一个不带,Java 的Base64.getDecoder()会直接抛IllegalArgumentException。稳妥做法是:编码时统一带填充,解码时先用 MIME 或宽松解析器兼容无填充的情况,或者干脆自己补=到长度为 4 的倍数,这个逻辑不超过五行。

2.2 Hex 实现与大小写统一

Hex 常用于把摘要值展示成人能读的字符串,比如e10adc3949ba59abbe56e057f20f883e就是我们熟悉的那个 MD5 结果。它的坑集中在大小写和前缀上。有的实现对每个字节用Integer.toHexString之后不补零,遇到0x0A这种字节就会输出成a而不是0a,导致长度变成奇数,这在做定长比较时是致命的。

我在工具类里固定用查表法实现,两张表分别对应大写和小写,逻辑清晰且没有补零问题:

private static final char[] HEX_LOWER = "0123456789abcdef".toCharArray(); public static String toHex(byte[] data) { if (data == null) return null; char[] out = new char[data.length * 2]; for (int i = 0; i < data.length; i++) { int v = data[i] & 0xFF; out[i * 2] = HEX_LOWER[v >>> 4]; out[i * 2 + 1] = HEX_LOWER[v & 0x0F]; } return new String(out); }

注意data[i] & 0xFF这一步不能省。Java 的 byte 是有符号的,-1会被Integer.toHexString转成ffffffff而不是ff,这是新手最常踩的一脚。解码方向同样要注意:奇数长度的字符串必须直接拒绝,否则会得到一个静默错误的字节数组,后面解密时抛出莫名其妙的BadPaddingException,排查方向会被彻底带偏。

2.3 字符集必须显式声明

String.getBytes()不带参数时使用平台默认字符集。同一个程序在 Windows 开发机上跑出 GBK,在 Linux 服务器上跑出 UTF-8,同一个中文密码算出来的摘要完全不同。这个问题在 IDEA 里几乎不会暴露,因为 IDEA 默认设-Dfile.encoding=UTF-8,一打包部署就翻车。

所以我对自己的要求是:工具类里任何getBytes()和new String(byte[])都必须带字符集参数,一个都不许省。常用字符集定义一个常量CHARSET = StandardCharsets.UTF_8,Android 低版本上StandardCharsets需要 API 19 以上,如果是老项目就用Charset.forName("UTF-8")缓存成静态常量,避免每次调用都去查表。

这里还有一个隐蔽的坑:从十六进制字符串还原字节时,如果中间经过了new String(bytes, "UTF-8"),那些不构成合法 UTF-8 序列的字节会被替换成U+FFFD(也就是那个菱形问号),这个替换是不可逆的。所以字节数组要么全程保持字节形态,要么用 Hex 或 Base64 这种可逆方式转换,绝对不要中途用字符串“过一手”。

3. 摘要算法:MD5 放在什么位置才合适

3.1 MD5 今天还能干什么

先说结论:MD5 不能再用于任何安全相关的场景,包括密码存储和签名。它已经被证明存在实用化的碰撞攻击,也就是说攻击者可以构造两个内容不同但 MD5 相同的文件。用于密码存储时,一张彩虹表就能把常见的弱密码反查出来;用于签名时,碰撞意味着可以伪造内容。

但 MD5 也不是一无是处。它在这些场景仍然可用:文件去重时算内容指纹、断点续传时校验分片、缓存键生成、日志关联 ID。判断标准很简单——这个值被篡改之后有没有人能获利。如果篡改只会导致缓存多打一次,那用 MD5 无所谓;如果篡改能让攻击者免登录或者改金额,那必须换 SHA-256。

热搜里“md5解密”这类词其实存在一个认知误区:MD5 本身是不可逆的,所谓“解密”本质是查表或者暴力枚举,也就是拿一个巨大的字典去试,看哪个输入算出来的哈希对得上。这从侧面说明了为什么单纯的 MD5 不足以保护密码——只要密码够短够常见,就一定在某个表里。补充一句,网上那些“MD5 在线解密”站点,你输进去的不是密文,是你想反查的目标哈希,站点返回的是它的字典命中结果,不是你算错了。理解这一点,也就理解了为什么密码必须加盐。

3.2 加盐与慢哈希的取舍

如果一定要在一段时间内继续用 MD5 存密码(比如维护老系统),至少要加盐,并且盐要每个用户独立、随机生成、和密码一起存储。实现上就是把salt + password拼起来再算摘要,验证时用同一个盐重算。这能挡住彩虹表,但挡不住 GPU 暴力枚举。

更正规的做法是用专为密码设计的慢哈希:PBKDF2、bcrypt、scrypt、Argon2。它们的共同特点是计算一次要几十到几百毫秒,正常登录感觉不到,但攻击者想枚举一亿次就要付出极大的时间成本。Java 8 自带PBKDF2WithHmacSHA256,不需要额外依赖,是我在纯 JDK 项目里的首选。参数上我一般取迭代 10 万次、输出 256 位,这个数值在两年前的普通服务器上大约 50 到 100 毫秒,可以接受。如果服务器配置低,可以酌情降到 5 万,但不要再低了。

这里有个很容易忽略的细节:慢哈希的迭代次数应该随硬件升级而提高,并且要存在数据库里。存储格式建议用类似pbkdf2$100000$salt$hash的字符串,把算法、迭代次数、盐、结果打包在一起。这样以后升级迭代次数时,老用户下次登录验证成功后可以顺手重新计算并更新,实现平滑迁移,不用强制所有人改密码。

3.3 摘要工具的实现要点

摘要工具的核心方法就两个:digest(byte[])和digestHex(String)。实现上的注意点有三个。第一,MessageDigest实例不是线程安全的,不要缓存成静态字段复用,每次getInstance新建,开销可以忽略。第二,入参为 null 时要明确抛异常还是返回 null,我倾向于抛IllegalArgumentException,因为摘要 null 基本意味着调用方代码写错了,静默返回 null 会把错误推迟到很远的地方。第三,比较两个摘要值时要使用常量时间比较,MessageDigest.isEqual(a, b)在 JDK 内部做了防时序攻击的优化,比Arrays.equals更合适。

public static byte[] sha256(byte[] data) { if (data == null) throw new IllegalArgumentException("data must not be null"); try { return MessageDigest.getInstance("SHA-256").digest(data); } catch (NoSuchAlgorithmException e) { // SHA-256 是 JDK 强制要求支持的算法,走到这里说明运行环境异常 throw new IllegalStateException("SHA-256 not available", e); } }

关于异常处理,NoSuchAlgorithmException对于 SHA-256 和 AES 这类标准算法来说几乎不可能发生,因为 JLS 要求所有 Java 平台实现都必须支持。所以我在工具类里把它包成IllegalStateException直接往上抛,而不是在签名里声明受检异常从而污染所有调用方。这一点在工具类设计上很重要:受检异常只应该出现在调用方真正能够处理的地方,工具类里的环境性异常包装成运行时异常更合适。

4. 对称加密:AES 的参数定下来就别动了

4.1 分组模式与填充方式的选择逻辑

AES 是分组密码,分组长度固定 128 位(16 字节)。光有算法还不够,必须同时确定模式和填充方式,这三者合起来才是一个完整的加密方案,Java 里的写法是AES/CBC/PKCS5Padding这种三段式字符串。

模式决定了多个分组之间怎么关联,常见的有:

模式特点是否推荐
ECB每个分组独立加密,相同明文块产生相同密文块不推荐,会泄漏数据图案
CBC每个分组先和前一密文块异或,需要 IV推荐,兼容性最好
CTR把分组密码当流密码用,不需要填充可用,但 IV 绝不能重复
PCBC传播型分组链接,一个块出错影响后续全部极少用,仅历史系统

ECB 的问题是“同样的明文加密出来永远一样”。经典例子是加密一张位图,用 ECB 模式加密后,图像轮廓依然清晰可见,因为大片同色区域的密文块完全相同。所以只要数据超过一个分组,就不要用 ECB。我第一次做设备通信协议时图省事用了 ECB,被安全评审直接指出这个问题,后来全部换成了 CBC。

PCBC 在热搜里出现过,这里简单说明:它是为 Kerberos v4 设计的一种模式,特点是明文和密文都参与下一块的异或,一块出错会传播到后面所有块。它的加密和解密运算不对称,实现时极易写错,而且 Java 标准库的 JCE 并不提供 PCBC,需要引入额外密码库或者自己实现。除非你在对接一个已经上线多年、参数无法更改的老系统,否则没有理由选它。我在实际项目里只遇到过一次 PCBC,是给一个上世纪遗留系统做适配,最后是拿 BouncyCastle 解决的,自己手写太容易在边界处理上出错。

填充方式上,CBC 模式要求数据长度是 16 字节的整数倍,不够就补。PKCS5Padding在 Java 里对 AES 实际执行的是 PKCS#7 的逻辑,补n个值为n的字节,如果正好整除则补满 16 个字节,这样解密时才能无歧义地去掉填充。NoPadding意味着你必须自己在业务层保证长度对齐,一般只用于已经自己做了填充的场景。CTR 模式则可以配NoPadding。

4.2 密钥长度、IV 与传输方案

AES 支持 128、192、256 三种密钥长度。128 位(16 字节)已经足够安全,256 位在面对量子计算的讨论中更受青睐,但要注意一些历史版本 JDK 对 256 位有出口限制,需要安装额外的策略文件。JDK 8u161 之后默认放开,如果项目还在用更早的版本,部署时会抛InvalidKeyException: Illegal key size,这个报错信息很容易被误判成密钥本身有问题。

IV(初始化向量)在 CBC 模式下是必须的,它不需要保密,但必须每次加密都随机生成,且不可预测。固定 IV 会导致相同明文产生相同密文,等于把 ECB 的问题又带回来了。生成方式用SecureRandom:

private static final int IV_LENGTH = 16; private static byte[] generateIv() { byte[] iv = new byte[IV_LENGTH]; new SecureRandom().nextBytes(iv); return iv; }

IV 的传输是新手最容易卡住的地方。常见的三种做法:一是把 IV 拼在密文前面一起传输,解密方按固定长度切分;二是用同一个主密钥通过 KDF 从固定盐派生出固定的 IV(安全性差一些,但实现简单);三是把 IV 作为协议字段单独传。第一种最省事也最常用,格式就是IV(16字节) + 密文,接收方读前 16 字节当 IV。注意不要用Random而是用SecureRandom,Random的种子可预测,用它生成的 IV 在安全上等于没用。

密钥本身的来源也要认真对待。绝对不要把密钥硬编码在代码里提交到版本库。我见过的做法里比较靠谱的是:密钥存在配置中心或密钥管理服务,应用启动时拉取;如果是小项目,至少放在环境变量里,配合部署脚本注入。用一个固定的字符串常量当密钥,一旦代码仓库泄露或者被离职人员带走,所有历史数据都等于明文。

4.3 完整实现:CBC 加 PKCS5Padding

把上面几点拼起来,一个可用的对称加解密就是下面这样。为了便于传输,我把 IV 拼在密文前,整体做 Base64 输出:

public static String aesEncrypt(String plain, byte[] key) throws GeneralSecurityException { if (plain == null) return null; byte[] iv = generateIv(); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(iv)); byte[] cipherBytes = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); byte[] combined = new byte[iv.length + cipherBytes.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(cipherBytes, 0, combined, iv.length, cipherBytes.length); return Base64.getUrlEncoder().withoutPadding().encodeToString(combined); } public static String aesDecrypt(String encoded, byte[] key) throws GeneralSecurityException { if (encoded == null) return null; byte[] combined = Base64.getUrlDecoder().decode(encoded); if (combined.length <= 16) throw new IllegalArgumentException("cipher text too short"); byte[] iv = Arrays.copyOfRange(combined, 0, 16); byte[] cipherBytes = Arrays.copyOfRange(combined, 16, combined.length); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(iv)); return new String(cipher.doFinal(cipherBytes), StandardCharsets.UTF_8); }

几个细节说明。第一,这里用Base64.getUrlEncoder().withoutPadding(),是为了让结果安全地出现在 URL 和 Token 里;解码端用对应的getUrlDecoder(),它能兼容无填充输入。第二,combined.length <= 16的校验不能省,否则短输入会走到IvParameterSpec构造里抛出一个含义模糊的异常。第三,方法签名抛GeneralSecurityException,这是Cipher.init和doFinal的共同父类异常,调用方可以在这里统一转换成业务异常。

注意:这段代码的密钥长度如果是 16 字节就是 AES-128,32 字节就是 AES-256。长度必须是 16、24、32 之一,其他长度构造SecretKeySpec时就会报错。

5. 非对称加密:RSA 分段与签名

5.1 RSA 一次到底能加密多少字节

RSA 常被误认为可以替代 AES 直接加密业务数据,实际上它有两个硬性限制:速度慢和单次可加密长度有限。以 2048 位密钥(256 字节)为例,不同填充方案下能加密的明文长度完全不同:

填充方案单次最大明文长度计算方式
RSA/ECB/PKCS1Padding245 字节256 - 11
RSA/ECB/OAEPWithSHA-256AndMGF1Padding190 字节256 - 2×32 - 2
RSA/ECB/NoPadding256 字节256

这个计算背后的道理是:RSA 本质是对一个大整数做模幂运算,密钥长度决定了模数的大小,而填充方案需要占用一部分空间来保证安全。PKCS#1 v1.5 占用 11 字节固定开销,OAEP 的开销和哈希长度相关,用 SHA-256 时是 2×32 + 2 = 66 字节。如果不清楚这个规则,写代码时传入 300 字节的明文,就会收到javax.crypto.IllegalBlockSizeException: Data must not be longer than 245 bytes。看到这个报错,你马上就知道是密文超长,而不是密钥错误。

那怎么加密长数据?答案是混合加密:随机生成一个 AES 密钥,用它加密业务数据,再用 RSA 公钥加密这个 AES 密钥,两者一起传输。接收方先用 RSA 私钥解出 AES 密钥,再解业务数据。这几乎是所有正经协议(包括 TLS)的做法。如果实在懒得做混合加密,也可以写分段逻辑,把长数据切成 245 字节一段分别加密,但这样做的效率远低于混合方案。

5.2 分段加密的实现

有些老协议确实要求纯 RSA 分段,比如某些硬件设备只支持这一种方式。分段逻辑本身不复杂,关键是要把段长度和解密端严格对齐:

private static final int RSA_BLOCK = 245; // 2048 位密钥 + PKCS1Padding public static String rsaEncryptBySegment(byte[] data, PublicKey publicKey) throws GeneralSecurityException { Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); cipher.init(Cipher.ENCRYPT_MODE, publicKey); ByteArrayOutputStream out = new ByteArrayOutputStream(); for (int offset = 0; offset < data.length; offset += RSA_BLOCK) { int len = Math.min(RSA_BLOCK, data.length - offset); byte[] chunk = cipher.doFinal(data, offset, len); out.write(chunk, 0, chunk.length); } return Base64.getEncoder().encodeToString(out.toByteArray()); }

解密端要注意一个容易忽略的问题:分段解密时不能简单地按 256 字节切分再调用一次doFinal,因为每一段密文长度是密钥长度(这里是 256 字节),明文长度才是变化的。所以解密循环要按 256 切,逐段doFinal,把明文结果依次写出来。

这里我必须提醒一个很多人栽过的坑:如果你先cipher.init(DECRYPT_MODE)然后循环调用doFinal处理每一段,这是可以的;但如果中途混用update和doFinal,会得到错误的结果。RSA 是分组密码,这里的语义就是“每一段独立处理一遍”,不要想象成流式。

5.3 签名与验签:比加密更常用的场景

实际项目里 RSA 用得最多的其实是签名而不是加密。签名解决的是“这个数据确实来自持有私钥的一方,且没有被篡改”,典型场景是回调通知、支付请求、开放平台接口。标准做法是:把参数按字典序排序,拼成k1=v1&k2=v2形式,对拼接结果做 SHA-256 摘要,再用私钥对这个摘要签名。

签名的填充方案建议用SHA256withRSA,如果对方系统支持,优先上RSASSA-PSS。验签端一定要用公钥验签,并且要处理 Base64 解码失败、签名长度不对等异常,这些异常通常意味着对方参数组装方式和你理解的不一致,而不是密钥配错了。我在对接某次回调时排查过一下午,最后发现是对方把参数拼接时的空值参数也带上了,而我这边的 SDK 过滤了空值,两边拼出来的待签字符串不一样。这类问题有个很实用的排查手法:把待签名的原始字符串也写进日志(脱敏后),然后和对方日志逐字节对比,比任何猜测都快。

6. 常见异常与跨端联调排查速查表

6.1 异常信息对照表

加密相关的异常信息普遍比较晦涩,我把这些年遇到的高频异常整理成一张表,遇到问题时先对照定位,能省掉大量试错时间。

异常信息关键词常见原因处理方向
BadPaddingException密钥或 IV 不一致、密文被截断、填充方式不同检查两端密钥字节和填充设置
IllegalBlockSizeException密文长度不是分组大小整数倍、RSA 明文超长确认是否漏了 Base64 解码或做了分段
InvalidKeyException: Illegal key sizeJDK 版本限制 256 位密钥升级 JDK 或更换密钥长度
InvalidAlgorithmParameterExceptionIV 长度不是 16 字节、GCM 的 IV 长度不符检查 IV 生成和切分逻辑
IllegalArgumentException: Illegal base64 character用了标准编码但传输中被转义改用 URL 安全变体
NoSuchAlgorithmException算法名拼写错误或运行环境裁剪核对三段式字符串写法

这张表里最常见的是BadPaddingException。它有一个特点:几乎所有参数不一致的问题最后都表现成它。密钥错、IV 错、密文截断、字符集不同、填充方式不同,报出来的都是同一个异常。这意味着你没法靠异常信息定位,只能一项一项排除。我的习惯是写一个联调自检方法,用固定的测试向量在两端分别跑一遍,把密钥的 SHA-256、IV 的 Hex、明文的字节数组都打出来对比,逐项对上之后再来排查业务数据,效率会高很多。

6.2 跨端对齐的六个检查项

跨语言跨端联调失败,99% 出在下面六个点上,我把它做成 checklist,每次对接新系统先过一遍:

  1. 算法全名:是AES/CBC/PKCS5Padding还是AES/CBC/PKCS7Padding,在一些语言里PKCS5和PKCS7是两个不同实现,Java 里写PKCS5Padding实际按 PKCS7 执行。
  2. 密钥的字节来源:是一个 16 字符的字符串直接用getBytes(),还是把 32 位十六进制字符串先 Hex 解码再当密钥?这两种做法得到的字节完全不同,是最高频的不一致来源。
  3. IV 是否参与传输:如果参与,约定好拼在前还是拼在后,还是单独字段。
  4. 字符集:明文转字节用什么字符集,中文场景必须是 UTF-8。
  5. Base64 变体:标准还是 URL 安全,带不带填充和换行。
  6. 时间戳和随机数:如果协议里有这两个字段,检查单位是秒还是毫秒,随机数位数是否一致。

6.3 我踩过的几个真实坑

第一个坑跟 HTTP 请求编码有关。前端用表单方式提交,浏览器默认按application/x-www-form-urlencoded编码,+会被解析成空格。我们把 Base64 密文放在表单字段里,服务端收到的字符串里所有+都变成了空格,解密全部失败。解决方式有两种:前端做一次encodeURIComponent,或者服务端在解码前把空格换回+。我选了前者,因为后者容易误伤本来就合法的空格。

第二个坑跟密钥轮换有关。业务要求每季度换一次密钥,但老数据是用老密钥加密的。如果直接替换密钥常量,所有历史数据的解密都会炸。正确做法是在密文里带一个密钥版本号,格式类似v2:base64内容,解密时按版本号选对应密钥。这个设计一开始加进去几乎零成本,事后补救却要写数据迁移脚本,非常麻烦。

第三个坑是关于日志的。有一段时间我们的日志里会打印加密前的明文和加密后的密文,用于排查问题。上线后被安全扫描指出这等于把敏感信息落盘了。后来改成了只打印密文的 SHA-256 前八位,既能关联一次请求,又不泄漏内容。这个做法推荐给大家:排查用哈希前缀做追踪 ID,别打印原文。

7. 线程安全、性能与上线前的收尾清单

7.1 Cipher 实例到底能不能复用

Cipher不是线程安全的,这一点和MessageDigest一样。有人会想用ThreadLocal<Cipher>来避免重复getInstance的开销,这个思路在极端高频场景下是成立的,但要注意每次使用前必须重新init,并且doFinal之后内部状态会被重置,跨线程传递会出问题。我个人的判断标准是:如果单机 QPS 在几千以内,直接每次新建完全够用,getInstance的开销在纳秒级别,不值得为它引入 ThreadLocal 的复杂度和内存泄漏风险。

真正需要关注性能的是慢哈希的选择和大数据的处理方式。PBKDF2 迭代十万次是故意的慢,不要在批量导入用户时对每个用户都算一遍,那种场景应该先收集数据再统一处理。大文件加解密则应该用流式接口CipherInputStream和CipherOutputStream,避免把整个文件读进内存,我见过有人用readAllBytes处理几百兆的文件,直接把服务搞 OOM。

7.2 密钥管理不能省的那几件事

工具类写得再漂亮,密钥管理出问题等于白做。三件事必须做到。第一,密钥不进代码仓库,用配置中心、环境变量或者密钥管理服务。第二,不同环境(开发、测试、生产)用不同密钥,绝不允许共用。我见过测试环境密钥泄漏导致生产数据被解的情况,虽然场景听起来离谱,但确实发生过。第三,密钥要有轮换机制和版本标识,前面提到的v2:前缀方案就是为此准备的。

还有一个容易被忽略的点:加密和解密的密钥权限应该分开。在微服务架构里,写数据的服务需要加密权限,读数据的服务需要解密权限,如果所有服务都持有同一把密钥,一处被攻破就全线失守。更进一步,用 KMS 这类服务托管密钥,应用只拿到一个临时的数据密钥,用完即弃,安全性会高一个量级。

7.3 上线前我会走一遍的清单

最后一节,把我自己每次上线前会核对的内容列出来,你可以直接拿去当模板:

  • 所有getBytes()和new String()是否都带了字符集参数
  • 是否还有 ECB 模式在用,如果有,评估能不能换 CBC
  • 密钥是否从代码或配置文件里移出去了
  • IV 是否每次随机生成,是否用的是SecureRandom
  • Base64 变体是否和对接方对齐,URL 场景是否用了 URL 安全变体
  • 摘要比较是否用了常量时间比较
  • 日志里是否还在打印明文或原始密钥
  • 是否加了密钥版本号,历史数据的解密路径是否验证过
  • 单元测试里是否覆盖了空串、中文、超长数据、非法 Base64 这四类边界输入

这几条里,我认为最容易被忽略的是最后一条。空串和不合法输入这类边界情况,在正常流程里永远走不到,但一旦有人恶意构造请求,就会是第一个被利用的点。我在工具类里对 null 输入统一抛IllegalArgumentException,对空字符串则允许通过(因为空字符串加密后是一个合法的填充块),这个策略在项目里跑下来没有出过问题。

回头看我写过的几版EncryptionUtils,从最开始一个方法打天下,到后来按用途拆成DigestUtils、SymmetricUtils、AsymmetricUtils三个类,变化最大的其实不是代码量,而是参数有没有被固定下来。工具类的价值不在于算法多高级,而在于任何一个人接手之后,不需要重新理解一遍你这套参数是怎么定的,照着调用就能跑通。我现在的习惯是把所有默认参数写成类顶部的常量,配上注释说明为什么选这个值,比如为什么用 CBC 不用 ECB、为什么迭代十万次、为什么 IV 拼在前面。这些注释在半年后自己回头看的时候,比代码本身更有用。

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

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

立即咨询