写过不少项目,踩过不少加解密的坑,才体会到这个领域光看文档远远不够。很多开发初期容易混淆一个点:同样是“加密”,登录密码的处理、接口报文的签名、数据库敏感字段的落库,底层用的根本不是一回事。对称加密、非对称加密、可逆加密、不可逆加密,这四个词单独拎出来都能说上几句,但一旦要在具体业务里做技术选型,经常就分不清该用哪个。
这篇文章就专门梳理开发中常用的加密算法,重点讲对称与非对称、可逆与不可逆这两组分类怎么理解、怎么选型、怎么落地。适合后端开发、客户端开发、以及做架构设计或技术方案评审的同学参考。看完之后你至少能回答三个问题:为什么密码不能直接AES加密存储?HTTPS为什么既要非对称又要对称?接口签名到底该用HMAC还是RSA?
1. 先搞清楚加密算法的两条分类主线
1.1 四类算法的核心定义与边界
开发中遇到的“加密”相关需求,其实可以按两个维度切成四块。第一个维度是“能不能还原”,第二个维度是“加密和解密用的密钥是否相同”。
可逆加密,指的是密文能够通过某个过程还原成原始明文。对称加密和非对称加密都属于可逆加密,区别在于加解密密钥的关系。对称加密用同一个密钥,就好比你家保险柜只有一把钥匙,锁进去和打开都用它。非对称加密则有一对密钥,公钥加密、私钥解密,或者私钥签名、公钥验签,相当于一个带投信口的信箱:任何人都能从投信口塞信进去,但只有持有信箱钥匙的人能取出来。
不可逆加密,在学术上更准确的名字是散列算法或摘要算法。它对任意长度的输入计算出一个固定长度的输出,这个过程没有解密函数。你可以把它想象成碎纸机:纸张进去变成碎条,碎条拼不回去,但同一份文件碎出来的结果是一样的。典型代表是MD5、SHA-1、SHA-256、SHA-3。
这两条主线交叉之后,就形成了技术方案选择的底层框架:要保真还原,走可逆加密;不要还原、只要校验,走不可逆摘要;单机或内部系统共享密钥,走对称;跨系统、需要分发公钥的场景,走非对称。
1.2 为什么可逆与不可逆的划分比想象中重要
很多开发事故的根源,就是把不可逆的场景做成了可逆。最典型的例子是用户密码存储。有些系统为了“安全”,把用户密码用AES加密后存进数据库,还自认为比明文强。问题是:既然系统能解密,那任何拿到数据库权限和密钥的攻击者都能还原所有用户的密码,这和明文存储没有本质区别。
密码校验的根本诉求是“我能不能验证用户输入的密码正确”,而不是“我能不能把密码取回来”。所以正确做法是用不可逆的哈希算法,配合随机盐和迭代计算,把密码变换成一个无法还原的摘要值。验证时用同样的参数重新计算,比较结果是否一致。
理解清楚这条边界,后面所有选型都不会跑偏。凡是说“需要展示原文、需要解密还原”的场景,才考虑AES、RSA这类可逆加密;凡是说“只需校验是否一致、不需要还原”的场景,一律往摘要算法方向走。
2. 对称加密实战:AES算法与工作模式选择
2.1 对称加密为什么在性能上碾压非对称
对称加密的核心优势是快。AES(高级加密标准)在硬件和软件层面都有高度优化,现代CPU甚至自带AES指令集,加解密大文件、大数据流基本没有压力。相比之下,RSA这类非对称加密基于大数运算,性能要慢几个数量级。所以实际工程里,大量业务数据的加密几乎清一色选择对称加密。
AES的密钥长度有128、192、256比特三种。密钥越长,暴力破解的代价越高。AES-128在当前算力下已经具备足够安全强度,AES-256则把安全余量拉得更高。在预算允许的情况下,新项目直接选AES-256没有毛病。
但AES本身只是一个分组密码算法,它把明文分成固定128比特的块,逐块加密。单块加密的安全性和“块与块之间怎么关联”完全无关,后者由工作模式决定。选模式是AES实战里第一个需要认真对待的决策点。
2.2 ECB、CBC、GCM三种模式怎么选
ECB模式最简单,每块明文独立加密,相同的明文块产生相同的密文块。这种特性会导致密文暴露出明文的结构信息,比如一张图片用ECB加密后,轮廓可能依然可见。所以除了极个别兼容性需求,ECB应该直接拉黑。
CBC模式引入了前一个密文块参与当前块的加密,相同的明文块在不同位置会产生不同的密文。它还需要一个初始向量IV,第一个块和前一个“虚拟块”异或。CBC的问题在于密文无法并行计算,而且如果IV固定或可预测,会带来选择明文攻击风险。
GCM模式是目前最推荐的选择。它是CTR模式与GHASH认证的组合,既能加密也能生成完整性认证标签。也就是说,GCM同时保证了机密性和完整性,任何密文被篡改,解密时认证都会失败。GCM的另一个实用优势是除了加密数据,还能关联认证一些不需要加密的附加数据,比如业务流水号。综合来看,新项目做对称加密,默认选AES/GCM/NoPadding几乎不会出错。
2.3 AES/GCM在Java中的落地示例
GCM模式的标准IV长度是96比特,也就是12字节,这是效率与安全之间的折中。密钥长度用256比特。代码里必须显式设置认证标签长度,默认128比特。下面是一段可直接参考的Java实现:
import javax.crypto.*; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.security.SecureRandom; import java.util.Base64; public class AesGcmDemo { // 密钥必须是16、24或32字节,这里用32字节(AES-256) private static final int KEY_LENGTH_BYTES = 32; private static final int IV_LENGTH_BYTES = 12; private static final int TAG_LENGTH_BITS = 128; public static String encrypt(String plainText, byte[] key) throws Exception { byte[] iv = new byte[IV_LENGTH_BYTES]; SecureRandom random = new SecureRandom(); random.nextBytes(iv); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); SecretKeySpec keySpec = new SecretKeySpec(key, "AES"); GCMParameterSpec gcmSpec = new GCMParameterSpec(TAG_LENGTH_BITS, iv); cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec); byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); // 惯例:IV和密文一起返回,避免解密时无法还原IV byte[] result = new byte[iv.length + encrypted.length]; System.arraycopy(iv, 0, result, 0, iv.length); System.arraycopy(encrypted, 0, result, iv.length, encrypted.length); return Base64.getEncoder().encodeToString(result); } public static String decrypt(String base64CipherText, byte[] key) throws Exception { byte[] all = Base64.getDecoder().decode(base64CipherText); byte[] iv = new byte[IV_LENGTH_BYTES]; byte[] encrypted = new byte[all.length - IV_LENGTH_BYTES]; System.arraycopy(all, 0, iv, 0, iv.length); System.arraycopy(all, iv.length, encrypted, 0, encrypted.length); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); SecretKeySpec keySpec = new SecretKeySpec(key, "AES"); GCMParameterSpec gcmSpec = new GCMParameterSpec(TAG_LENGTH_BITS, iv); cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); byte[] decrypted = cipher.doFinal(encrypted); return new String(decrypted, StandardCharsets.UTF_8); } }两个细节值得强调。第一,加密结果里一定要带上IV,解密时要用同一个IV,否则解密失败。第二,GCM解密时如果传入的密文被篡改过,doFinal会直接抛认证异常,业务层要捕获并当作数据异常处理,而不是继续往下走。
2.4 对称加密在业务里的典型落点
对称加密适合用到这些场景:数据库字段加密,比如手机号、身份证号、银行卡号;本地配置文件里的敏感信息,比如第三方API的密钥;大文件加密,比如上云前的数据加密包。凡是数据量大、要求高吞吐、密钥可控的场景,优先对称加密。
关于密钥管理,有个容易忽略的点:AES本身很安全,但密钥如果硬编码在代码里或放在Git仓库里,整个加密体系形同虚设。新项目应该把密钥放到独立的密钥管理服务或环境变量配置中心,至少也要做到按环境区分、定期轮换。别为了一时省事,把整个加密链路的安全水位拉低到零。
3. 非对称加密实战:RSA与ECC的关键决策
3.1 非对称加密到底在解决什么问题
非对称加密解决的是密钥分发的信任问题。对称加密要求双方先约定同一个密钥,但在网络上传递密钥本身就是风险点。非对称加密允许公钥公开分发,私钥由一方独占,于是不安全的信道也能建立加密通信。
RSA是基于大整数分解难题的算法,ECC(椭圆曲线密码)基于椭圆曲线离散对数难题。两者的区别简单说:ECC用更短的密钥达到相同安全强度。256比特的ECC密钥,安全性大致相当于3072比特的RSA密钥。密钥短意味着计算快、存储小,在移动端和低功耗设备上优势尤其明显。
工程里非对称加密最典型的两个用途:一是加密数据,用公钥加密,私钥解密;二是数字签名,用私钥签名,公钥验签。这两个用途方向相反,不能搞混。
3.2 RSA的三个关键工程细节
RSA实战里第一个坑就是密钥长度。目前最低底线是2048比特,强烈推荐3072或4096比特。密钥长度直接决定能加密的明文长度上限,因为RSA的加密运算是把明文转换成一个小于模数的大整数。如果使用RSA/ECB/OAEPWithSHA-256AndMGF1Padding,2048比特的密钥大约只能加密190字节左右的数据,超出就会报错。所以RSA只适合加密短数据,比如对称密钥、随机数、手机号这类小字段。真要加密大内容,正确姿势是“混合加密”:用RSA加密一个临时生成的AES密钥,再用这个AES密钥加密业务数据。
第二个关键点是填充方式。RSA不能直接用RSA/ECB/NoPadding,因为没有填充的RSA会暴露大量数学结构,极易被攻击。推荐使用OAEP填充,它内嵌了哈希运算和随机盐,安全性远高于PKCS1 v1.5填充。Java里对应的是RSA/ECB/OAEPWithSHA-256AndMGF1Padding。
第三个细节是签名和加密不要混用同一套理解。RSA加密用公钥、解密用私钥;签名用私钥、验签用公钥。有些封装类库在签名场景会直接调用加密接口,写代码时一定要分清楚,注释写明白这一段是加密还是签名,避免后续维护的人反向使用密钥。
3.3 RSA加解密与签名的Java示例
加解密示例:
import javax.crypto.Cipher; import java.security.*; import java.util.Base64; public class RsaDemo { public static KeyPair generateKeyPair() throws Exception { KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); generator.initialize(2048); return generator.generateKeyPair(); } public static String encryptWithPublicKey(byte[] data, PublicKey publicKey) throws Exception { Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); cipher.init(Cipher.ENCRYPT_MODE, publicKey); return Base64.getEncoder().encodeToString(cipher.doFinal(data)); } public static byte[] decryptWithPrivateKey(String base64Data, PrivateKey privateKey) throws Exception { Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); cipher.init(Cipher.DECRYPT_MODE, privateKey); return cipher.doFinal(Base64.getDecoder().decode(base64Data)); } }签名与验签示例:
import java.security.*; public class RsaSignatureDemo { public static String sign(byte[] data, PrivateKey privateKey) throws Exception { Signature signature = Signature.getInstance("SHA256withRSA"); signature.initSign(privateKey); signature.update(data); return Base64.getEncoder().encodeToString(signature.sign()); } public static boolean verify(byte[] data, String base64Signature, PublicKey publicKey) throws Exception { Signature signature = Signature.getInstance("SHA256withRSA"); signature.initVerify(publicKey); signature.update(data); return signature.verify(Base64.getDecoder().decode(base64Signature)); } }签名时记得先对原文做规范化处理,比如把字段排序、拼接成固定格式的字符串,再做签名。接收方校验时要用完全相同的格式拼接,任何空格、换行、字段顺序的差异都会导致验签失败。这是接口联调里最常见的返工原因之一。
3.4 ECC、SM2和Ed25519:新的主流趋势
如果项目对性能和密钥长度敏感,建议优先考虑ECC系算法。ECDSA是基于椭圆曲线的签名算法,密钥短、速度快;Ed25519则是更现代的选择,签名固定64字节,性能和安全性都很出色。国内的一些商用场景也会用到SM2算法,同样基于椭圆曲线,工程实现上与ECC系类似。
这些算法的共同趋势是新项目逐渐从RSA迁移到ECC系。原因不只是性能,还包括更短密钥带来的传输带宽优势。如果从零开始设计一套接口签名体系,直接选择Ed25519或ECDSA是比较省心的路线,前提是各端的密码库支持度要提前确认。
4. 不可逆加密的真相:摘要、加盐与密码存储
4.1 摘要算法是怎么做到不可逆的
摘要算法把任意长度的输入压缩成固定长度的输出,比如SHA-256输出256比特。这个过程是单向的,理论上你只能通过穷举输入来猜测原始值,无法从摘要反推明文。
MD5曾是应用最广的摘要算法,输出128比特。但它已被证明存在碰撞攻击,即可以构造两个不同内容却拥有相同MD5值。因此MD5不能用于任何对抗恶意攻击的场景。但也别一听MD5就全盘否定,在内部数据校验、文件分片校验、缓存Key计算这类低对抗场景,MD5仍然有性价比优势,只是要清楚它的边界。
SHA-1同样存在理论碰撞风险,不建议用于安全场景。SHA-256属于SHA-2家族,是目前最稳妥的通用选择。SHA-3作为新一代标准,设计思路与SHA-2完全不同,安全性有更好的结构保证,不过工程普及度不如SHA-2。
4.2 密码存储为什么必须加盐
如果直接把用户密码做SHA-256摘要存储,会有两个问题。第一,相同的密码会产生相同的摘要,攻击者可以预先计算常见密码的摘要表,也就是彩虹表,反查得到密码。第二,摘要计算极快,暴力破解成本很低。
解决方案是加盐。盐是一段随机生成的字符串,每个用户各不相同。存储时把盐和密码拼接后计算摘要,数据库中同时保存盐和最终的摘要值。校验时取出该用户的盐,拼上输入密码重新计算,与存储的摘要比较。加盐之后,即使两个用户密码相同,摘要也完全不同,彩虹表直接失效。
不过,普通SHA-256加盐对暴力破解的抵抗力仍不够。现代密码存储更推荐使用专门设计的慢哈希算法,例如BCrypt、scrypt、Argon2。这类算法通过大量迭代和内存占用,故意把单次计算拉慢到毫秒甚至更高级别,攻击者批量破解的成本呈指数级上升。BCrypt加盐逻辑内建在算法本身,使用非常简单:
// 伪代码示例,实际项目可用成熟的开源BCrypt库 String salt = BCrypt.gensalt(12); // 12是代价因子,越大越慢 String hash = BCrypt.hashpw(password, salt); boolean ok = BCrypt.checkpw(inputPassword, hash);代价因子建议从10起步,根据服务器性能适当上调。代价因子上调后,老用户已有哈希不会自动适配新强度,通常做法是在登录校验时检测到哈希代价因子低于当前阈值,就顺手用新代价重新生成哈希,再更新数据库记录。
4.3 HMAC:带密钥的不可逆校验
HMAC是在摘要算法基础上引入密钥的一种消息认证码。它和普通摘要的区别在于,只有知道密钥的人才能计算和验证摘要。接口签名场景里,HMAC比纯摘要更安全,因为攻击者无法伪造签名,除非密钥泄露。
HMAC的底床算法可以选SHA-256,也就是HmacSHA256。计算时需要约定好消息拼接规则,常见做法是把请求参数按字典序排序,拼接成k1=v1&k2=v2之类的字符串,再追加时间戳或随机数,最后一起做HMAC。接收方用同一条规则计算并比对,防止参数被篡改。时间戳的作用是防止重放攻击,通常允许5分钟或10分钟内的窗口。
4.4 数字签名:不可逆摘要与非对称加密的组合
数字签名本质上把摘要算法和非对称加密组合起来:对原文做SHA-256摘要,再用私钥对摘要做加密。验签时用公钥解开摘要,重新对原文计算摘要,两者一致则签名有效。这样做的好处是签名只针对摘要而非全量数据,计算量小,同时又能验证数据完整性和来源真实性。
所以“不可逆加密”这个说法其实包含两类:一类是纯摘要的非对称签名,一类是带密钥的HMAC。选型时注意应用场景:双方共享密钥就用HMAC,有公钥分发需求或需要抗抵赖,就走数字签名。
5. 不同业务场景下的加密方案选型对照
5.1 传输通道:TLS混合加密是怎么设计的
HTTPS所依赖的TLS协议,内部就采用了混合加密方案:握手阶段用非对称算法完成身份认证和密钥协商,双方各自生成会话密钥后,业务数据全程走对称加密。这正是前面提到的“非对称分发密钥、对称加密数据”的标准组合。
如果你自己设计安全通信方案,也建议沿用这条路线:客户端用服务端公钥加密一个临时对称密钥,后续通信用该对称密钥做AES-GCM加解密。别试图用RSA加密所有业务报文,性能和密文长度都不可接受。
5.2 接口签名选型:HMAC还是RSA
内部系统接口优先考虑HMAC,因为双方各自持有同一个密钥,实现简单、性能好。对外开放的API接口优先考虑RSA或ECDSA签名,因为不需要在第三方系统里分发共享密钥,只需公开验签公钥即可。
一个容易踩的坑是:签名覆盖的字段范围不统一。定义签名规则时,一定要明确哪些字段参与签名、哪些字段不参与,比如文件流、日志字段通常排除在外。还要约定空值和缺省字段的处理方式,否则同一份数据在两端拼出来的规则字符串不一致,验签必挂。
5.3 敏感数据加密与密码存储的最终结论
归纳一下选型逻辑:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 用户密码校验 | BCrypt / scrypt / Argon2 | 不可逆、慢哈希、抗彩虹表 |
| 接口参数防篡改 | HMAC-SHA256 | 共享密钥、计算简单、可加重放防护 |
| 对外接口签名 | RSA-SHA256 / ECDSA / Ed25519 | 公钥公开、私钥唯一、可抗抵赖 |
| 大字段加密存储 | AES-256-GCM | 性能好、带完整性认证 |
| 小字段加密交换 | RSA(OAEP填充) | 密钥分发方便、只适合短数据 |
| 文件完整性校验 | SHA-256 | 快速可靠、无密钥管理负担 |
5.4 密钥长度与算法强度的速查基数
选算法时,还要关注密钥长度的安全余量。下面给出当前认可的推荐基线:
| 算法 | 最小推荐密钥长度 | 备注 |
|---|---|---|
| AES | 128位 | 新项目建议直接256位 |
| RSA | 2048位 | 建议3072位以上 |
| ECDSA / Ed25519 | 256位 | 安全强度对标RSA-3072 |
| SHA-256 | 256位摘要 | 通用默认选择 |
| BCrypt | 代价因子10以上 | 根据硬件性能调整 |
6. 常见问题与排查技巧实录
6.1 “非法密钥长度”或“密钥长度错误”的报错
这个问题在JDK 8较低版本时很常见,原因是JDK默认的JCE策略限制AES密钥最长128位,RSA最长2048位。解决方案要么升级JDK到8u161以上(默认放开),要么安装无限制权限策略文件。如果你维护老系统,先确认运行时JDK版本,再确认Cipher.getMaxAllowedKeyLength("AES")的返回值。
6.2 Base64编解码导致解密失败
加解密结果经常用Base64传输或存储。常见问题是字符串里有换行符、URL安全的Base64变体、或者Base64补齐符处理不一致。排查时先确认两端用的是同一套Base64规则,以及是否在传输过程中被截断或转义。加密后的Base64字符串不要在日志里打全量,避免敏感信息扩散。
6.3 AES/GCM解密报AEADBadTagException
这个异常说明认证失败,密文被篡改或密钥、IV不匹配。逐个排查:密钥是否一致、IV是否复用或错位、Base64解码后的字节数组是否完整、密文传输过程中有无数据丢失。注意检查拼接顺序,加密时先放IV再放密文,解密时也要按相同顺序拆开。
6.4 验签一直失败,查了很久才发现是字符串拼接不一致
最多发的验签问题不是密码学问题,而是签名原文的规范不统一。解决思路是写一个独立的签名工具类,生成签名和验证签名都调用同一套“构造待签名原文”的方法,并且用单元测试固定几组样例输入。联调阶段让双方各自构造一组样例数据,交叉验证签名和验签结果,比联调时互相猜快得多。
6.5 误把RSA当作万能算法,加密长文本导致异常
RSA不是用来加密大数据的。如果你要加密的字段可能超过几百字节,直接切换到AES,然后用RSA保护AES密钥。这个混合方案是大量生产系统的通用做法,性能和安全性兼顾。
6.6 密钥硬编码在代码里
代码仓库一旦泄露,所有硬编码密钥全部暴露。至少做到:配置文件外置、按环境隔离、密钥生命周期管理、定期轮换。生产环境密钥与测试环境密钥必须不同。如果条件允许,接入云上的密钥管理服务,把密钥的读写权限纳入统一审计。
踩过几次加解密的坑之后,我个人的体会是:加密算法的选型不是越复杂越好,而是越合适越好。搞清楚场景需要可逆还是不可逆、共享密钥还是公钥体系,再决定具体算法和参数,基本就不会出大问题。安全方案没有一步到位的终局,算法会过时,密钥长度要求会不断提高,保持对行业基准的关注,定期审视现有系统的密码学配置,才是更长久的安全姿态。