EncryptionUtils 实战:AES、RSA、Base64 跨端联调
发布时间:2026/9/29 1:19:18来源:尧图网络
每个项目做到第三个月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/256CBC 模式对称、快、各语言原生支持密钥交换/签名RSA-2048OAEP 或 PSS非对称只用来保护对称密钥和做签名摘要SHA-256校验完整性、做签名预处理兼容遗留MD5、SHA-1只读场景保留新逻辑不用字节到文本Base64URL 安全变体传输、入库统一用它为什么不选国密或者其他小众算法作为默认因为跨端一致性成本。移动端、服务端、嵌入式设备、第三方对接方各自的语言生态对算法的支持程度差别很大。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.encodingUTF-8一打包部署就翻车。所以我对自己的要求是工具类里任何getBytes()和new String(byte[])都必须带字符集参数一个都不许省。常用字符集定义一个常量CHARSET StandardCharsets.UTF_8Android 低版本上StandardCharsets需要 API 19 以上如果是老项目就用Charset.forName(UTF-8)缓存成静态常量避免每次调用都去查表。这里还有一个隐蔽的坑从十六进制字符串还原字节时如果中间经过了new String(bytes, UTF-8)那些不构成合法 UTF-8 序列的字节会被替换成UFFFD也就是那个菱形问号这个替换是不可逆的。所以字节数组要么全程保持字节形态要么用 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 的问题又带回来了。生成方式用SecureRandomprivate 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而是用SecureRandomRandom的种子可预测用它生成的 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-12832 字节就是 AES-256。长度必须是 16、24、32 之一其他长度构造SecretKeySpec时就会报错。5. 非对称加密RSA 分段与签名5.1 RSA 一次到底能加密多少字节RSA 常被误认为可以替代 AES 直接加密业务数据实际上它有两个硬性限制速度慢和单次可加密长度有限。以 2048 位密钥256 字节为例不同填充方案下能加密的明文长度完全不同填充方案单次最大明文长度计算方式RSA/ECB/PKCS1Padding245 字节256 - 11RSA/ECB/OAEPWithSHA-256AndMGF1Padding190 字节256 - 2×32 - 2RSA/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 用得最多的其实是签名而不是加密。签名解决的是“这个数据确实来自持有私钥的一方且没有被篡改”典型场景是回调通知、支付请求、开放平台接口。标准做法是把参数按字典序排序拼成k1v1k2v2形式对拼接结果做 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每次对接新系统先过一遍算法全名是AES/CBC/PKCS5Padding还是AES/CBC/PKCS7Padding在一些语言里PKCS5和PKCS7是两个不同实现Java 里写PKCS5Padding实际按 PKCS7 执行。密钥的字节来源是一个 16 字符的字符串直接用getBytes()还是把 32 位十六进制字符串先 Hex 解码再当密钥这两种做法得到的字节完全不同是最高频的不一致来源。IV 是否参与传输如果参与约定好拼在前还是拼在后还是单独字段。字符集明文转字节用什么字符集中文场景必须是 UTF-8。Base64 变体标准还是 URL 安全带不带填充和换行。时间戳和随机数如果协议里有这两个字段检查单位是秒还是毫秒随机数位数是否一致。6.3 我踩过的几个真实坑第一个坑跟 HTTP 请求编码有关。前端用表单方式提交浏览器默认按application/x-www-form-urlencoded编码会被解析成空格。我们把 Base64 密文放在表单字段里服务端收到的字符串里所有都变成了空格解密全部失败。解决方式有两种前端做一次encodeURIComponent或者服务端在解码前把空格换回。我选了前者因为后者容易误伤本来就合法的空格。第二个坑跟密钥轮换有关。业务要求每季度换一次密钥但老数据是用老密钥加密的。如果直接替换密钥常量所有历史数据的解密都会炸。正确做法是在密文里带一个密钥版本号格式类似v2:base64内容解密时按版本号选对应密钥。这个设计一开始加进去几乎零成本事后补救却要写数据迁移脚本非常麻烦。第三个坑是关于日志的。有一段时间我们的日志里会打印加密前的明文和加密后的密文用于排查问题。上线后被安全扫描指出这等于把敏感信息落盘了。后来改成了只打印密文的 SHA-256 前八位既能关联一次请求又不泄漏内容。这个做法推荐给大家排查用哈希前缀做追踪 ID别打印原文。7. 线程安全、性能与上线前的收尾清单7.1 Cipher 实例到底能不能复用Cipher不是线程安全的这一点和MessageDigest一样。有人会想用ThreadLocalCipher来避免重复getInstance的开销这个思路在极端高频场景下是成立的但要注意每次使用前必须重新init并且doFinal之后内部状态会被重置跨线程传递会出问题。我个人的判断标准是如果单机 QPS 在几千以内直接每次新建完全够用getInstance的开销在纳秒级别不值得为它引入 ThreadLocal 的复杂度和内存泄漏风险。真正需要关注性能的是慢哈希的选择和大数据的处理方式。PBKDF2 迭代十万次是故意的慢不要在批量导入用户时对每个用户都算一遍那种场景应该先收集数据再统一处理。大文件加解密则应该用流式接口CipherInputStream和CipherOutputStream避免把整个文件读进内存我见过有人用readAllBytes处理几百兆的文件直接把服务搞 OOM。7.2 密钥管理不能省的那几件事工具类写得再漂亮密钥管理出问题等于白做。三件事必须做到。第一密钥不进代码仓库用配置中心、环境变量或者密钥管理服务。第二不同环境开发、测试、生产用不同密钥绝不允许共用。我见过测试环境密钥泄漏导致生产数据被解的情况虽然场景听起来离谱但确实发生过。第三密钥要有轮换机制和版本标识前面提到的v2:前缀方案就是为此准备的。还有一个容易被忽略的点加密和解密的密钥权限应该分开。在微服务架构里写数据的服务需要加密权限读数据的服务需要解密权限如果所有服务都持有同一把密钥一处被攻破就全线失守。更进一步用 KMS 这类服务托管密钥应用只拿到一个临时的数据密钥用完即弃安全性会高一个量级。7.3 上线前我会走一遍的清单最后一节把我自己每次上线前会核对的内容列出来你可以直接拿去当模板所有getBytes()和new String()是否都带了字符集参数是否还有 ECB 模式在用如果有评估能不能换 CBC密钥是否从代码或配置文件里移出去了IV 是否每次随机生成是否用的是SecureRandomBase64 变体是否和对接方对齐URL 场景是否用了 URL 安全变体摘要比较是否用了常量时间比较日志里是否还在打印明文或原始密钥是否加了密钥版本号历史数据的解密路径是否验证过单元测试里是否覆盖了空串、中文、超长数据、非法 Base64 这四类边界输入这几条里我认为最容易被忽略的是最后一条。空串和不合法输入这类边界情况在正常流程里永远走不到但一旦有人恶意构造请求就会是第一个被利用的点。我在工具类里对 null 输入统一抛IllegalArgumentException对空字符串则允许通过因为空字符串加密后是一个合法的填充块这个策略在项目里跑下来没有出过问题。回头看我写过的几版EncryptionUtils从最开始一个方法打天下到后来按用途拆成DigestUtils、SymmetricUtils、AsymmetricUtils三个类变化最大的其实不是代码量而是参数有没有被固定下来。工具类的价值不在于算法多高级而在于任何一个人接手之后不需要重新理解一遍你这套参数是怎么定的照着调用就能跑通。我现在的习惯是把所有默认参数写成类顶部的常量配上注释说明为什么选这个值比如为什么用 CBC 不用 ECB、为什么迭代十万次、为什么 IV 拼在前面。这些注释在半年后自己回头看的时候比代码本身更有用。
网站建设高端定制企业官网