ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java EncryptionUtils 实战:分清编码、摘要与加密

Java EncryptionUtils 实战:分清编码、摘要与加密 接到一个叫Util.EncryptionUtils的工具类多数人的第一反应是不就是 Base64 转一下、MD5 摘要一下。我接手过一个跑了六年的老项目那个类里塞了二十多个静态方法命名从encrypt、encode、digest、sign、enDes到getMd5Password全混在一起。最要命的是里面那个叫encrypt的方法返回值其实是 MD5 十六进制串而它被用来存用户密码;另一个encode方法在 AES 加密之后又套了一层 Base64结果前端解码时又用 URL 解码一次线上偶发登录失败查了三天。这篇文章我想把编码、加密、摘要这三件事彻底掰开然后把一个真正能打的EncryptionUtils该怎么做讲清楚——AES 的模式选择、IV 生成、RSA 的分段逻辑、Base64 与字符集的坑、密钥管理、线程安全、跨语言联调全部落到能直接抄的代码和参数上。适合正在写工具类的中高级后端、需要和前端或客户端联调的开发也适合刚入行、被加密解密这四个字搞混的新人。1. 先分清三件事编码、加密、摘要不是一回事先说一个现象你在搜索引擎里敲编码两个字出来的结果会非常魔幻。有人问 CTC 解码怎么做有人问 AAC 单帧解码长度是多少有人要下载省市区编码 JS 数据、天气城市编码表还有人问地理编码怎么用。这些都叫编码但它们和EncryptionUtils里的编码几乎没有任何关系。这种一词多义是很多新人混乱的源头所以我习惯在工具类的注释里第一行就把边界写死本类只处理字节序列与字符串之间的可逆表示转换以及基于密钥的机密性与完整性保护不涉及音视频编解码、不涉及字符集的地理信息映射。把范围划清楚之后剩下的事情其实只有三块编码Encoding可逆、无密钥、摘要Digest/Hash不可逆、无密钥、加密Encryption可逆、有密钥。这三者的区别不是学术洁癖它直接决定了你该选哪个方法、性能开销多大、以及出了事能不能补救。1.1 Base64 的定位它只是换个写法别拿它当锁Base64 的本质是把 3 个字节24 位重新切成 4 个 6 位单元每个单元映射到 64 个可打印字符之一。因为 6 位能表示 063所以字母表是A-Z、a-z、0-9再加上和/一共 64 个。原文长度不是 3 的倍数时末尾用补齐。所以 Base64 的膨胀率是固定的约 4/3即 33%1KB 数据编码后大约 1366 字节这个数是可预测的做接口报文长度估算时很有用。它的定位是让二进制数据能在只支持文本的通道里安全穿行比如 JSON 字段、URL 参数、邮件正文、HTTP Header。它不提供任何机密性——任何人拿到 Base64 字符串一行命令就能还原。我见过检测报告里把接口返回了 Base64 编码的身份证号定性为高危这不是误判因为那确实等同于明文。实战里 Base64 有三个变体必须分清用错了就是线上事故变体字母表差异补齐典型场景标准 Base64/通用二进制转文本URL-Safe Base64-_通常保留URL 参数、文件名、JWTMIME Base64标准字母表按 76 字符折行邮件附件URL-Safe 之所以要换掉和/是因为这两个字符在 URL 查询串里有特殊含义会被解析成空格/会和路径分隔混淆。JWT 用的就是 URL-Safe 且去掉的版本所以你在解析 JWT 时如果直接拿标准解码器去解遇到-和_就会抛IllegalArgumentException。Java 8 之后java.util.Base64已经内置了这三种不用再引第三方库byte[] raw hello world.getBytes(StandardCharsets.UTF_8); String std Base64.getEncoder().encodeToString(raw); String url Base64.getUrlEncoder().withoutPadding().encodeToString(raw); String mime Base64.getMimeEncoder().encodeToString(raw); byte[] back Base64.getUrlDecoder().decode(url);注意一个不对称的地方编码时你可以调用withoutPadding()但解码时getUrlDecoder()本身就能容忍缺失的。所以解析 JWT 时用getUrlDecoder()是安全的不用先补等号。1.2 MD5 解密到底在解什么摘要算法没有逆运算搜索MD5 解密的量非常大这个说法本身就是错的。MD5 是单向散列函数输入任意长度输出固定 128 位16 字节设计目标就是不可逆。它之所以能解是因为攻击者预先算好了海量常见字符串的摘要值做成一张巨大的映射表彩虹表你提交的摘要值恰好命中表里某一项时就查出了原文。这本质是字典命中不是数学解密。这意味着两件事。第一只要原文足够随机、足够长彩虹表就命中不了。第二加盐能有效破坏预计算因为MD5(password)和MD5(password salt)的摘要值完全不同攻击者必须针对每一个盐重新建表成本线性上升。顺带说清楚算法强度MD5 和 SHA-1 都已经被证实存在实用的碰撞构造也就是说攻击者能造出两个不同内容但摘要相同的输入。这在校验文件完整性场景下是致命的——攻击者可以替换文件内容而让你的校验通过。现在做摘要校验至少用 SHA-256做消息认证用 HMAC-SHA256。// 摘要只做完整性校验不做密码存储 MessageDigest md MessageDigest.getInstance(SHA-256); byte[] digest md.digest(payload.getBytes(StandardCharsets.UTF_8)); String hex HexFormat.of().formatHex(digest); // JDK 17 // 消息认证带密钥的摘要能防篡改 Mac mac Mac.getInstance(HmacSHA256); mac.init(new SecretKeySpec(secretKeyBytes, HmacSHA256)); byte[] tag mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));HMAC 和先摘要再拼密钥是两码事后者存在长度扩展攻击风险自己拼是典型的自制密码学不要做。1.3 密码存储别自己造从 PBKDF2 到 BCrypt 的选择如果EncryptionUtils里有一个方法叫encryptPassword那它至少应该满足三个条件单向、慢、每个用户独立盐。慢是特性不是缺点因为攻击者拿到的是一批摘要值慢意味着他爆破每一个都要付出 CPU 代价。MD5 快得离谱一张中端显卡每秒能算上百亿次这正好是攻击者想要的。我的选择顺序是优先用框架自带的BCryptPasswordEncoderSpring Security 提供它把盐和成本因子直接编码在输出串里形如$2a$10$...其中10是迭代轮数的指数改成本只需调一个参数存量数据不受影响。如果环境不允许引入 Spring就用 JDK 自带的 PBKDF2int iterations 210_000; // OWASP 对 PBKDF2-HMAC-SHA256 的建议下限 int keyLength 256; byte[] salt new byte[16]; new SecureRandom().nextBytes(salt); KeySpec spec new PBEKeySpec(password.toCharArray(), salt, iterations, keyLength); SecretKeyFactory f SecretKeyFactory.getInstance(PBKDF2WithHmacSHA256); byte[] hash f.generateSecret(spec).getEncoded();存的时候把iterations、salt、hash三段拼起来存比如iterations:saltBase64:hashBase64这样以后想提高迭代轮数仍然能验证老用户并且在用户下次登录时静默升级。这个版本化存储的思路比一次性把所有人都换掉要平滑得多。2. EncryptionUtils 的接口该长什么样工具类的接口设计比实现更考验经验因为实现写错了可以改接口写歪了会传染整个项目。我见过最典型的反模式是所有方法都叫encryptXxx/decryptXxx参数都是String返回值也都是String。这种设计把三个不同层次的概念压成了一条线用的人根本不知道自己在做编码还是加密也不知道要不要传密钥。2.1 命名分层encode/decode 与 encrypt/decrypt 必须分开我给自己定的规则是死板的凡是可逆且不需要密钥的方法名用encode/decode凡是需要密钥的用encrypt/decrypt凡是不可逆的用digest/hash/sign。这一条规则看起来啰嗦但它能让代码评审时一眼看出问题——当你看到userService.encryptId(id)而方法签名里根本没有密钥参数那它八成是 Base64 或者十六进制转换而这两个东西不该出现在加密的位置上。配套的还有一条不要在工具类里隐藏密钥来源。要么显式传SecretKey/byte[] key要么由调用方注入一个KeyProvider。绝对不要写成AES.encrypt(data)然后在方法体里private static final String KEY 1234567890123456。硬编码密钥的问题不只是泄露更是运维灾难——你没有任何办法在不改代码、不发版的情况下轮换密钥而密钥轮换是安全运营里最高频的动作之一。2.2 输入输出的类型选择字符集是最容易翻车的地方关于参数类型我的结论是二进制输入输出用byte[]需要展示或落库时再在边界上转 Base64。不要在加密方法内部偷偷做 Base64因为那样你就失去了灵活性——有些通道更适合十六进制比如某些硬件指令要求的定长编码有些通道要求 URL 安全变体这些应该由调用方决定。更关键的是字符集。String.getBytes()不带参数时使用平台默认字符集这个默认值在 Linux 上通常是 UTF-8在某些 Windows 环境里可能是 GBK在容器镜像里还可能因为缺少 locale 而回退到 POSIX/ASCII。这意味同一段代码在开发机和服务器上算出来的密文可能不同而密文不同就意味着无法互相解密。所以每一处字符串与字节的转换都必须显式写StandardCharsets.UTF_8。// 错误依赖平台默认字符集 byte[] bad text.getBytes(); // 正确显式指定行为在任何环境一致 byte[] good text.getBytes(StandardCharsets.UTF_8);这个坑的隐蔽之处在于它不会报错只会在跨环境时安静地出错。我自己定了一条规矩只要看到getBytes()或new String(byte[])后面没有字符集参数一律打回。2.3 异常处理包装成运行时异常但要保留可判别的原因加密工具类的方法签名如果写成throws Exception调用方就只能写try { ... } catch (Exception e) {}这是信息丢失最严重的一种写法。我的做法是定义一个业务异常并把底层原因作为cause传进去同时在需要区分密钥错误和密文被篡改时用不同的异常类型或错误码标记。还有一个细节解密失败的异常信息不要回显给终端用户。像Given final block not properly padded这种信息对攻击者有价值——它能区分填充错误和密钥错误在某些分组模式如 CBC下会被利用来做填充预言攻击。正确做法是统一返回数据校验失败详细原因只写进服务端日志而且日志里不要带明文和密钥。public final class CryptoException extends RuntimeException { private final ErrorCode code; public CryptoException(ErrorCode code, String msg, Throwable cause) { super(msg, cause); this.code code; } public ErrorCode getCode() { return code; } }3. 对称加密的落地细节AES 的模式、填充与 IV对称加密是EncryptionUtils里出镜率最高的部分也是参数最多、最容易配错的部分。一个完整的 AES 配置由三段组成算法 / 模式 / 填充在 Java 里写成一个字符串比如AES/CBC/PKCS5Padding。这三段里任何一段写错后果都不一样。3.1 ECB 为什么被从默认选项里踢出去ECBElectronic Codebook是最简单的模式把明文切成 16 字节一块每块独立加密。它的致命缺陷是相同明文块产生相同密文块于是数据结构会从密文里泄露出来。最经典的演示是那张企鹅图用 ECB 加密一张轮廓明显的图片加密后的图像依然能清楚看出企鹅的形状只是因为颜色分布被打乱而变得斑驳。放到业务里这个问题的表现形式是如果你的明文是结构化的比如一批固定格式的身份证号、一批字段值相同的 JSON攻击者不需要密钥只看密文里哪些块重复就能推断出哪些记录的哪些字段相同。这属于机密性泄露而且是无密钥泄露非常危险。所以我的立场很明确ECB 不应该出现在任何新代码里。唯一还算合理的场景是加密纯随机数据本身无结构也就无泄露但既然都是随机数据换 CBC 也没有额外成本。3.2 CBC 与 PKCS5PaddingIV 必须是随机的而且要跟着密文走CBCCipher Block Chaining的做法是每块先用前一块的密文做异或再加密第一块没有前一块就用 IV初始化向量代替。这样相同明文块在不同位置会得到不同密文ECB 的结构泄露问题就解决了。有两个关于 IV 的细节写错了安全性直接归零第一IV 必须是随机的不能固定。我在项目里见过private static final byte[] IV 0000000000000000.getBytes()这样做的效果是同一个密钥加密同一段明文每次结果都一样。这等于放弃了 CBC 的语义安全攻击者可以通过比对密文判断两条记录是否内容相同。正确做法是每加密一次生成一个新的随机 IV。第二IV 不需要保密但必须和解密方一致所以要跟着密文一起存。常见做法是把 IV 拼在密文前面传输或存储时是IV(16字节) || 密文解密时先切前 16 字节做 IV剩下的做数据。因为 IV 对每个密文都是随机的所以它本质上是一个参数而不是秘密明文传输没有风险。public static byte[] aesCbcEncrypt(byte[] plain, SecretKey key) { byte[] iv new byte[16]; new SecureRandom().nextBytes(iv); // 每次都要新的 Cipher c Cipher.getInstance(AES/CBC/PKCS5Padding); c.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv)); byte[] ct c.doFinal(plain); byte[] out new byte[iv.length ct.length]; System.arraycopy(iv, 0, out, 0, iv.length); System.arraycopy(ct, 0, out, iv.length, ct.length); return out; }关于PKCS5Padding这个命名有个小知识值得记一下Java 里写PKCS5Padding当分组长度是 16 字节时它实际执行的是 PKCS7 填充填充值等于填充字节数范围 116。这是因为 PKCS5 标准原本只定义了 8 字节分组。所以和 Python、Node、Go 等语言联调时对方写PKCS7你写PKCS5在 AES 这种 16 字节分组下是等价的不用改。3.3 GCM 与 PCBC什么时候该换模式CBC 只保证机密性不保证完整性。也就是说攻击者可以翻转密文里的某些比特解密后明文会相应变化而你无法察觉实际上在 CBC 下攻击者甚至能可控地改变明文的某些位。要同时拿到机密性和完整性应该用 AEAD 模式也就是AES-GCM。GCM 的特点是在密文之外额外生成一个 16 字节的认证标签Tag解密时如果 Tag 校验不通过直接抛异常数据一律丢弃。它还支持附加认证数据AAD也就是不加密但要参与校验的字段非常适合把协议头、租户 ID 这类信息绑进完整性保护里防止被篡改。用 GCM 有几个参数要注意IV 推荐 12 字节不是 16 字节。Java 里 GCM 默认接受 12 字节 IV这是标准推荐长度性能最好。同一个密钥下 IV 绝对不能重复。GCM 对 IV 重用极其敏感重复使用会直接导致认证密钥泄露。所以要么用随机 12 字节 IV配足够大的随机源要么用确定性计数器方案。Tag 长度默认 128 位不要为了省字节调到很短。Cipher c Cipher.getInstance(AES/GCM/NoPadding); byte[] iv new byte[12]; new SecureRandom().nextBytes(iv); c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv)); c.updateAAD(headers.getBytes(StandardCharsets.UTF_8)); byte[] ct c.doFinal(plain);至于 PCBCPropagating CBC它在文献里存在但工程上几乎没有正当理由去用它——它既没有像 GCM 那样提供认证又比不上 CBC 的通用性连互通性都很差多数语言的标准库不提供。如果某个需求方点名要 PCBC我的建议是先问清楚他真正想解决的问题是什么十有八九他要的是防篡改那就直接上 GCM。3.4 密钥长度、Provider 选择与合规要求AES 支持 128、192、256 位三种密钥长度。旧版 JDK8u151 之前默认限制为 128 位用 256 位会抛InvalidKeyException: Illegal key size需要额外装策略文件8u161 之后默认就放开了不需要再折腾。如果你的运行环境可能有老 JDK启动时打印一次Cipher.getMaxAllowedKeyLength(AES)作为环境自检能省掉很多排查时间。另外Cipher.getInstance()是按 Provider 顺序查找的。当项目里同时引入了 BouncyCastle 之类的第三方 Provider 时同一个算法名可能被不同 Provider 实现行为细节比如对 IV 长度的容忍度会有差异。生产环境最好显式指定 Provider或者在启动时用Cipher.getInstance(transform, providerName)锁定避免因为依赖顺序变化导致行为漂移。部分行业合规场景会要求使用 SM4 这类分组算法用法结构和 AES 完全一样只是算法名和分组长度16 字节需要注意密钥长度固定 128 位。封装的时候建议把算法族作为参数抽出来而不是写死 AES这样以后换算法不用重写工具类。4. 非对称加密与分段这件事RSA 在EncryptionUtils里的角色和 AES 完全不同它慢、能加密的数据量极小但解决了密钥怎么安全传递这个根本问题。很多同学第一次用 RSA 加密一段稍长的字符串就直接抛异常问题就出在能加密的数据量这个概念上。4.1 RSA 一次到底能加密多少字节把账算出来RSA 的安全性建立在模数 N 的因式分解难度上工程上 N 的字节长度就等于密钥长度除以 8。比如 2048 位密钥模数占 256 字节。而 RSA 加密不是直接对所有字节做运算它需要把明文按标准封装成结构PKCS#1 v1.5 或 OAEP封装过程要占用若干字节剩下的才是你能塞数据的空间。计算公式很简单填充方案单次最大明文长度2048 位密钥下的值PKCS#1 v1.5N - 11 字节245 字节OAEP SHA-1N - 2×20 - 2214 字节OAEP SHA-256N - 2×32 - 2190 字节N 为模数字节数2048 位时 N 256所以用 2048 位密钥配 PKCS#1 v1.5一次最多加密 245 字节,超过就必须分段。这个数字很多人是靠试出来的我建议直接算清楚因为它决定了你分段代码里每块的 chunkSize。4.2 分段加密与分段解密的实现要点分段的逻辑其实很朴素加密时把明文按最大长度切片逐块doFinal把结果拼接起来解密时反过来按模数长度256 字节切片逐块解密拼回。关键在于两端的切块尺寸不一样private static final int KEY_SIZE_BITS 2048; private static final int MODULUS_BYTES KEY_SIZE_BITS / 8; // 256 public static byte[] rsaEncrypt(byte[] data, PublicKey pub) throws Exception { Cipher c Cipher.getInstance(RSA/ECB/PKCS1Padding); c.init(Cipher.ENCRYPT_MODE, pub); int chunk MODULUS_BYTES - 11; // 245 ByteArrayOutputStream out new ByteArrayOutputStream(); for (int off 0; off data.length; off chunk) { int len Math.min(chunk, data.length - off); out.write(c.doFinal(data, off, len)); // 每块输出 256 字节 } return out.toByteArray(); } public static byte[] rsaDecrypt(byte[] cipher, PrivateKey priv) throws Exception { Cipher c Cipher.getInstance(RSA/ECB/PKCS1Padding); c.init(Cipher.DECRYPT_MODE, priv); ByteArrayOutputStream out new ByteArrayOutputStream(); for (int off 0; off cipher.length; off MODULUS_BYTES) { out.write(c.doFinal(cipher, off, MODULUS_BYTES)); // 每块输入 256 字节 } return out.toByteArray(); }如果你用的是 C# 侧代码注意它默认的RSACryptoServiceProvider在不同版本上行为差异较大有的版本必须显式指定fOAEP false才能和 Java 的PKCS1Padding对上。跨语言联调时最常见的失败就是填充方案不一致加密侧用 OAEP、解密侧用 PKCS#1报错信息通常只是含糊的解密失败。提示分段拼接时不要用字符串相加RSA 的每一块输出都是定长的二进制转成字符串再拼会引入编码问题。老老实实用ByteArrayOutputStream。4.3 RSA 不该拿来加密大文件那它到底该干什么理解了 2048 位密钥一次只能装 245 字节你就能明白为什么 RSA 不适合加密业务数据。它真正的用法是配合对称加密使用用随机生成的 AES 密钥加密业务数据再用 RSA 的公钥加密这把 AES 密钥一并传给接收方。这就是数字信封的思路。这样既有对称加密的速度又有非对称加密的密钥分发能力。RSA 的另一个主战场是签名方向和加密正好相反私钥签名、公钥验证。签名不加密数据只证明这段数据确实是由持有私钥的人发出的且未被篡改。签名前要先对数据做摘要因为 RSA 运算慢且长度受限所以完整的签名算法描述是SHA256withRSASignature s Signature.getInstance(SHA256withRSA); s.initSign(privateKey); s.update(data); byte[] sig s.sign(); s.initVerify(publicKey); s.update(data); boolean ok s.verify(sig);注意一点SHA256withRSA内部已经做了摘要你不要再自己先算一遍 SHA-256 再送进去那会变成对摘要值再做一次摘要两端做法不一致就验证失败。5. 那些年 EncryptionUtils 踩过的坑前面讲的是该怎么写这一节讲哪里会翻车。这几条都是我在真实项目里排查过的有些花了整整一天。5.1 Cipher 不是线程安全的静态单例复用会出乱码Cipher对象是有状态的init()会设置密钥、IV、模式update()/doFinal()会推进内部缓冲区。多线程共享同一个Cipher实例时线程 A 的init可能被线程 B 的doFinal覆盖表现为偶发的解密乱码或者IllegalBlockSizeException而且只在并发量上来时才出现本地压测很难复现。解决方案有两种每次调用都Cipher.getInstance()新建实例推荐开销其实很小或者用ThreadLocalCipher缓存。我倾向第一种因为代码简单、没有内存泄漏风险。同样的问题也存在于MessageDigest和Mac。它们的对象开销都不大不要为了性能优化把它们做成静态共享。5.2 Base64 的换行、URL 安全字符与 JWT 里的坑前面提过 URL-Safe 变了字母表这里补充一个更隐蔽的问题标准 Base64 本身不产生换行但 MIME 编码器会产生。MIME Base64 按 76 字符折行并在每行末尾加\r\n。如果你用getMimeEncoder()编码后把结果塞进 JSON 或 HTTP Header那些换行符会导致解析失败而且在日志里看起来字符串是对的肉眼很难发现。还有一种情况是把 Base64 字符串放进 URL 的查询参数在查询串里会被解码成空格/也可能被路径处理器吃掉。这时候要么用 URL-Safe 变体要么对结果再做一次 URL 编码但两层编码会让长度膨胀且容易被前端漏解一层——我开头说的那个线上事故就是这么来的。我的建议是确定性地为每个场景选一种编码并写在接口文档里。跨端传输统一用 URL-Safe 无填充落库存储可以用标准 Base64绝对不要这里顺手 URL 编码一下。5.3 GBK 与 UTF-8 混用那个著名的乱码是怎么产生的你可能见过锟斤拷这三个字它不是随便乱码而是有明确成因的UTF-8 解码器遇到非法字节序列时会输出替换字符UFFFD这个字符用 UTF-8 编码是EF BF BD。如果这段字节又被错误地按 GBK 解码EF BF在 GBK 里对应锟BD EF对应斤BF BD对应拷连续几个替换字符就拼出了锟斤拷。这个现象的根因就是同一个字节流被不同的字符集解读。在EncryptionUtils里它最常出现在密文传输环节A 端用 UTF-8 把 Base64 字符串转字节B 端用默认字符集可能是 GBK解回字符串中间任何一次不一致都会导致后续解密失败。防住的唯一办法是在链路的每一个环节都显式声明字符集HTTP 的Content-Type: application/json; charsetUTF-8、数据库连接的characterEncodingUTF-8、new String(bytes, StandardCharsets.UTF_8)。不要依赖任何默认值。5.4 密钥硬编码与日志泄密两个最容易被忽略的失分点这一条放在最后因为它最不技术但后果最严重。密钥硬编码的具体表现密钥写在常量里、写在配置文件里并提交到代码仓库、写在 Dockerfile 里、写在前端 JS 里。前端 JS 里放密钥尤其糟糕因为前端代码对用户完全可见等于把密钥公开。正确做法是密钥由密钥管理服务下发或者至少通过环境变量注入并且支持轮换。日志泄密的具体表现在加密失败的catch块里打log.error(解密失败, 密文{}, 密钥{}, cipherText, key)。开发环境图方便上了生产就是灾难。我的做法是给工具类加一个安全日志约定打印密文时只打前 8 位加省略号密钥一律不打印明文一律不打印。还有一个细节是比较操作要用常量时间。验证签名或者校验摘要时用或者String.equals逐字节比较会在第一个不同字节处提前返回攻击者可以通过测量响应时间逐字节猜出正确值。JDK 提供了MessageDigest.isEqual()它内部是常量时间实现// 不安全 if (expected.equals(actual)) { ... } // 安全 if (MessageDigest.isEqual(expected, actual)) { ... }6. 怎么验证你的工具类是对的写完工具类不算完事你还得能证明它是对的。我在项目里推行过一个很轻量的做法给EncryptionUtils配一组固定的单元测试覆盖各类边界并且这组测试在任何环境下都必须通过。6.1 用固定密钥和固定 IV 做回归测试生产代码里 IV 必须随机但测试代码里 IV 应该固定否则每次运行结果都不同断言没法写。做法是给工具类留一个包可见的重载版本接受外部传入的 IV仅供测试使用。除去每次结果一样这一点还要覆盖这些边界明文长度为 0、1、15、16、17 字节正好卡在分组边界前后明文长度是 16 的倍数时密文长度应该多出 16 字节整块填充密文被改动一个字节后GCM 解密必须抛异常Base64 解码非法字符时应抛异常而不是静默返回空数组长度超过 245 字节的数据走 RSA 分段解密后与原文逐字节相等6.2 跨语言联调前先对齐这五件事和前端、客户端或者其他语言的服务联调时八成的问题都出在下面的差异上。我的习惯是先填一张表双方确认后再写代码项目需要对齐的内容常见分歧算法与模式如 AES/CBC/PKCS7一方用 ECB 一方用 CBCIV 传递方式拼在密文前的第 16 字节一方固定 IV编码格式Base64 还是 Hex一方 Base64 一方 Hex字符集统一 UTF-8一方用默认字符集密钥格式原始字节还是 Base64 串一方把 Base64 串的字符当字节用最后一条特别容易踩密钥在配置里是 Base64 字符串k3J...Java 侧要Base64.getDecoder().decode(keyStr)得到真实的 16/32 字节而有些人直接把字符串getBytes()当密钥用。这时候密钥长度会变成 24 或 44 字节AES 会直接抛InvalidKeyException如果碰巧长度合法那就更糟——两端算出来的密钥不同解密永远失败。6.3 一个可以直接抄的自检清单我在每次新项目的加密模块上线前会过一遍这个清单。它不是形式主义每一条都对应过一次真实故障所有getBytes()/new String()是否都显式指定了字符集是否存在 ECB 模式的使用IV 是否每次随机生成并随密文一起传输GCM 的 IV 是否为 12 字节且不存在同密钥复用密钥是否来自配置或密钥服务而非代码常量Cipher/MessageDigest/Mac 是否每次新建或使用 ThreadLocal日志中是否可能出现明文、密钥或完整密文比较摘要或签名是否使用了常量时间比较RSA 分段的最大明文长度是否按填充方案正确计算是否有覆盖分组边界、篡改检测、长数据分段的单元测试说到底一个EncryptionUtils的价值不在于它有多少个方法而在于它的每一行都能被解释清楚为什么这么写。我后来重构那个老项目时把二十多个方法砍到了十一个每个方法上面都有一行注释说明这是编码/这是摘要/这是加密密钥从哪来。接口变少了出问题的概率反而低了很多——因为用的人不用再猜了。
返回列表