1. 先从一道高频面试题说起:两个分类维度经常被搞混
刚入行那会儿,我在项目里写了一个“加密工具类”,把Base64编码、MD5摘要、AES加密全部塞进了一个叫EncryptUtil的类里,接口名就一个encode和decode。当时觉得“反正都是把数据变乱码”,直到后来做登录模块,被同事指着代码问“你这个MD5怎么解密?”我才意识到,很多人(包括当时的我)对加密算法最基本的分类逻辑都没搞清楚。
这其实也是这个标题最有意思的地方:对称加密与非对称加密,是按“加密和解密用不用同一个密钥”来分的;可逆加密与不可逆加密,是按“密文能不能还原成明文”来分的。这两个维度是相互交错的,并不是并列关系。
拿具体的算法去套:
- AES、DES、SM4,既属于对称加密,也属于可逆加密;
- RSA、ECC、SM2,既属于非对称加密,也属于可逆加密;
- MD5、SHA-256、bcrypt,属于不可逆加密,也就是我们常说的哈希摘要算法。
换句话说,“可逆/不可逆”是站在结果角度看的,“对称/非对称”是站在密钥管理角度看的。搞清楚这个交叉关系,比死记硬背一堆算法名字有用得多。
这篇文章我会从这两条主线展开,逐个把常用的算法拿出来讲明白:它是什么、怎么工作的、在真实项目里一般用在哪儿、有哪些坑。内容会贴合后端开发、接口设计、安全加固这些实际场景,如果你是刚接触安全这块的开发者,或者准备系统整理加密知识,可以参考整条梳理思路。
先说结论:对称加密胜在速度,非对称加密胜在密钥分发,哈希胜在不可逆。真实项目里几乎没有只用一种算法就能解决所有问题的场景,组合使用才是常态。
理解了这句话,后面所有细节都好办了。
2. 对称加密:一把钥匙开门,速度快但钥匙怎么给是个问题
2.1 算法运行的直观过程
对称加密是所有加密体系里最直观的。你可以把它想象成一把物理挂锁:用这把锁锁上箱子,再用同一把钥匙打开。发送方和接收方手里拿的是同一把密钥,加密用的密钥和解密用的密钥完全一致。
流程拆开看就是四步:
- 发送方用密钥K对明文做加密运算,得到密文;
- 密文通过网络传输给接收方;
- 接收方用同一个密钥K做解密运算;
- 得到原始明文。
这里有一个关键问题:发送方是怎么把密钥K安全地告诉接收方的?如果通过网络直接传,那密钥一旦被截获,加密就形同虚设。这是对称加密最大的痛点,也是后面非对称加密存在的理由之一。
2.2 三个主流算法的核心对比
对称加密算法很多,但真正在工程里频繁出现的其实就那几个。
DES,数据加密标准,1977年由IBM设计,密钥长度只有56位。放到今天的硬件条件下,穷举攻击已经可以做到在数小时甚至数分钟内破译。我见过很多老系统里还在用DES,通常是因为历史包袱太重,改造成本高。但新项目里再碰DES,基本属于给自己挖坑。
3DES,就是DES做了三次加密,有效密钥长度提升到了112位或168位。安全性比DES好得多,但性能只有DES的三分之一左右,而且块大小还是64位,在现代应用中已经逐渐被AES取代。它最大的存在意义在于兼容旧系统——比如一些金融支付场景的老协议还在用3DES。
AES,高级加密标准,是目前全球使用最广泛的对称加密算法。它支持128位、192位、256位三种密钥长度,分组大小固定128位。AES-256的密钥空间是2的256次方,这个数字大到什么程度呢?哪怕用全球最快的超级计算机去穷举,也要远超宇宙年龄的时间才能遍历完。所以从工程层面讲,AES-256的暴力破解基本可以忽略不计。
除了这三位,国内还有一种需要熟悉:SM4,国密算法中的对称加密标准。分组长度128位,密钥长度128位,算法结构公开,在国内政府和金融行业的合规项目里经常是硬性要求。如果你的系统需要过等保或信创相关的审查,SM4大概率绕不开。
2.3 分组模式:为什么同样的明文会得到不同的密文
AES和DES都是分组密码,也就是说它们把明文分成固定长度的块,一块一块地加密。但是,如果你的数据有重复的块,直接逐块加密就会导致“相同明文块对应相同密文块”的问题。于是出现了分组模式。
常用模式有这几种:
- ECB(电子密码本模式):最简单的模式,每个块独立加密,可以并行计算。但相同明文组会产生相同密文组,明文的统计规律会直接暴露在密文中。比如加密一张头像图片,用ECB模式加密后密文仍然能看出人脸的轮廓,因为颜色分布规律被保留了下来。所以这个模式我建议在生产环境直接禁用。
- CBC(密码分组链接模式):每个明文块先与前一个密文块异或,再加密。第一个块需要和一个随机生成的初始向量IV做异或。同一个明文,只要IV不同,密文就不同。CBC的缺点是加密过程是串行的,不能并行,而且解密时需要先解出前一个块才能解当前块。
- GCM(伽罗瓦计数器模式):基于CTR计数器模式,并行度高,同时自带认证能力(GMAC)。也就是说,GCM不仅能加密数据,还能校验数据有没有被篡改。这是目前我在所有新项目里的首选模式。
2.4 实际编码中的完整示例
下面这段Java代码展示了最标准、最安全的AES-GCM用法,包含随机IV、密钥建议、密文附带认证标签的做法。
import javax.crypto.Cipher; import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; import javax.crypto.spec.GCMParameterSpec; import java.security.SecureRandom; import java.util.Base64; public class AesGcmDemo { // AES-GCM加密,返回 Base64 字符串:IV + 密文 + 认证标签 public static String encrypt(String plaintext, SecretKey key) throws Exception { byte[] iv = new byte[12]; // GCM 推荐 12 字节的 IV SecureRandom random = new SecureRandom(); random.nextBytes(iv); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv)); byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); byte[] result = new byte[iv.length + ciphertext.length]; System.arraycopy(iv, 0, result, 0, iv.length); System.arraycopy(ciphertext, 0, result, iv.length, ciphertext.length); return Base64.getEncoder().encodeToString(result); } // 解密:先从 Base64 中拆出 IV 和密文 public static String decrypt(String data, SecretKey key) throws Exception { byte[] decoded = Base64.getDecoder().decode(data); byte[] iv = Arrays.copyOfRange(decoded, 0, 12); byte[] ciphertext = Arrays.copyOfRange(decoded, 12, decoded.length); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, iv)); byte[] plaintext = cipher.doFinal(ciphertext); return new String(plaintext, StandardCharsets.UTF_8); } public static void main(String[] args) throws Exception { KeyGenerator kg = KeyGenerator.getInstance("AES"); kg.init(256); SecretKey key = kg.generateKey(); String encrypted = encrypt("这是一个需要保护的敏感信息", key); System.out.println("密文:" + encrypted); String decrypted = decrypt(encrypted, key); System.out.println("解密:" + decrypted); } }几个容易犯的错,我在代码里已经刻意规避了:
- IV不能固定,必须随机生成,并在密文里一起传递。如果IV重复,GCM的安全性会崩塌;
- GCM的认证标签长度建议128位,解密时如果数据被篡改,
doFinal会直接抛AEADBadTagException,这个异常一定要捕获处理; - 不要把IV和密钥混淆。密钥是双方事先约定好的、保密的;IV是随机的、可以公开的。
2.5 密钥长度和Key派生:一句话说清
AES密钥就是一段随机字节,16字节=128位,32字节=256位。但日常开发中,我们拿到的往往是一个用户输入的口令(比如字符串),直接拿字符串当密钥是危险的,因为口令的熵通常不够。
正确的做法是用PBKDF2、bcrypt或HKDF等密钥派生函数,把口令转换成固定长度的高质量密钥。比如:
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); KeySpec spec = new PBEKeySpec(password.toCharArray(), salt, 10000, 256); SecretKey derivedKey = factory.generateSecret(spec);这里salt是随机盐,10000是迭代次数。迭代次数越高,暴力破解成本越大。生产环境建议看着硬件配置调到几万甚至几十万。
3. 非对称加密:两把钥匙各司其职,解决信任和分发难题
3.1 公钥和私钥的设计思想
非对称加密的思路,我一般用“信箱”来类比。公共信箱的投递口,所有人都能往里投信(用公钥加密),但只有拿着信箱钥匙的人能打开取信(用私钥解密)。
在这个体系里,公钥是公开的,任何人都可以拿到;私钥是私密的,只有你自己知道。用公钥加密的数据,只有对应的私钥能解密;反过来,用私钥签名的数据,大家都可以用公钥验证签名确实是你发出的。
这解决了对称加密最头疼的“密钥分发”问题。比如客户端和服务端通信,服务端把自己的公钥发给客户端,客户端用公钥加密一个随机会话密钥,然后传给服务端,服务端用私钥解密得到会话密钥。之后双方就用这个会话密钥进行对称加密通信。这个流程就是HTTPS握手和混合加密的基本逻辑。
3.2 RSA和ECC家族:两种主流的数学根基
RSA是目前最普及的非对称加密算法,核心基于大整数因子分解难题。两个大素数相乘很容易,但从乘积反推出是哪两个素数却极其困难。RSA的密钥长度一般有1024位、2048位、4096位。1024位已经被认为不安全,新项目至少要用2048位。
ECC(椭圆曲线加密)则基于椭圆曲线离散对数难题,在相同安全强度下,ECC的密钥长度比RSA短得多。举个直观数字:256位的ECC约等于3072位的RSA。密钥短带来的直接好处是计算量小、存储空间小、带宽占用低,这在移动端和物联网场景里优势尤其明显。
ECC在工程里通常以两种形式出现:
- ECDHE:用于密钥交换,前端知名框架的HTTPS握手常用它实现向前保密;
- ECDSA:用于数字签名,很多区块链和Token场景里都在用。
国密标准里对应的非对称算法是SM2,同样基于椭圆曲线,在合规项目里和SM4配合使用。
3.3 RSA和ECC的参数差异对照
拿几个实用维度做个表,方便选型时快速决策:
| 对比维度 | RSA | ECC |
|---|---|---|
| 推荐密钥长度 | 2048位起 | 256位起 |
| 同等安全强度 | 密钥长,运算重 | 密钥短,运算快 |
| 典型场景 | 兼容旧系统、签名、密钥传输 | 移动端、IoT、实时交互 |
| 运算开销 | 相对较大 | 相对较小 |
| 算法复杂度 | 大整数运算实现简单 | 椭圆曲线运算实现稍复杂 |
实践中还有一点需要特别注意:非对称加密不适合加密大块数据。RSA-2048最多只能加密约245字节的明文(2048位减去填充开销),超过这个长度就需要分段加密,不仅慢而且脆弱。所以生产项目里非对称加密通常只用来加密“密钥”本身,而不是“数据”本身。
3.4 RSA在Java里的一段签名代码
签名和非对称加密是两个方向:加密是“用公钥加、用私钥解”,签名是“用私钥签、用公钥验”。下面是一个标准的SHA256withRSA签名流程:
import java.security.*; import java.util.Base64; public class RsaSignDemo { public static void main(String[] args) throws Exception { KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); gen.initialize(2048); KeyPair pair = gen.generateKeyPair(); PrivateKey privateKey = pair.getPrivate(); PublicKey publicKey = pair.getPublic(); String content = "订单金额=100,订单号=20250101"; // 私钥签名 Signature signer = Signature.getInstance("SHA256withRSA"); signer.initSign(privateKey); signer.update(content.getBytes(StandardCharsets.UTF_8)); byte[] signature = signer.sign(); String signed = Base64.getEncoder().encodeToString(signature); System.out.println("签名:" + signed); // 公钥验签 Signature verifier = Signature.getInstance("SHA256withRSA"); verifier.initVerify(publicKey); verifier.update(content.getBytes(StandardCharsets.UTF_8)); boolean verified = verifier.verify(Base64.getDecoder().decode(signed)); System.out.println("验签结果:" + verified); } }签名的意义不是保密,而是防篡改+防抵赖。对方用自己的私钥签了名,就无法否认这个动作;接收方用公钥验签,就能确定数据在传输中没有被改过。在支付回调、开放平台接口、版本发布这些对真实性和完整性要求高的场景里,签名是标配。
3.5 混合加密:一次完整的HTTPS简化流程
很多读者会问我:既然非对称加密解决了密钥传输问题,那直接用RSA加密所有数据不就行了?性能不允许。
公钥加密做一次几百字节的操作,耗时可能是毫秒级;同样时间AES能加密几个量级的数据。所以实际工程里普遍采用“混合加密”:
- 客户端生成一个随机的AES会话密钥(临时密钥);
- 用服务端公钥(RSA或ECC)加密这个会话密钥;
- 服务端用私钥解密,得到会话密钥;
- 后续所有业务数据都用AES-GCM加密,配合会话密钥传输。
这个设计兼顾了非对称加密的安全分发能力和对称加密的高吞吐量。理解了混合加密,HTTPS中间人攻击怎么防、证书是干什么的,也就顺带通了。
4. 不可逆加密:单向散列与密码存储,为什么说“哈希不是加密”
4.1 核心特性:为什么哈希不可“解密”
不可逆加密的正规名字叫密码散列函数,它接受任意长度的输入,输出固定长度的摘要。最核心的特性有三个:
- 单向性:从输出无法推导出输入;
- 抗碰撞性:很难找到两个不同的输入产生相同的输出;
- 雪崩效应:输入哪怕只改一个比特,输出也会面目全非。
因为不可逆,所以哈希不能用来做数据加解密,它只能用来做“完整性校验”和“口令验证”。每次用户输入口令时,系统把输入做同样的哈希运算,然后对比库里存的摘要是否一致。即使数据库泄露,攻击者拿到的也是密文摘要,没法直接还原成明文密码。
但这里有个前提:如果用户密码本身就是弱密码(如123456),攻击者可以预先算好海量常见密码的哈希值,组成一张彩虹表,然后反查摘要。所以拿MD5直接存用户密码,在今天是高危行为。
4.2 从MD5到SHA-256的演进逻辑
MD5曾经是最流行的摘要算法,输出128位(32个十六进制字符),至今很多老系统还在用。2004年前后,学者已经发现了非常高效的碰撞方法,用普通PC就能构造两个内容不同但MD5相同的文件。今天再做文件校验,我是不建议用MD5的,除非只是给数据打一个简易指纹。
SHA-1的输出是160位,前几年Google团队发布了SHAttered碰撞攻击,真实地生成了两个不同的PDF文件但SHA-1值相同。所以SHA-1也基本被判了死刑,主流浏览器在HTTPS证书里已经彻底放弃SHA-1。
SHA-2家族(SHA-224、SHA-256、SHA-384、SHA-512)是目前应用最广泛的。SHA-256输出256位,碰撞攻击在可预见的时间内都不现实。比特币的工作量证明用的就是SHA-256衍生算法。SHA-3是新一代标准,设计结构和SHA-2完全不同,目前主要作为未来储备,日常场景用SHA-256就够了。
4.3 慢哈希:为什么存密码要用bcrypt而不是SHA-256
很多人有一个误区:SHA-256不可逆,那用它存密码是不是就够了?
不够。因为普通哈希算法是追求速度的,每秒能算几十亿次。攻击者拿到数据库后,可以用GPU高速跑字典攻击,弱密码瞬间就能试出来。所以存储密码的正确做法是用慢哈希算法,也就是故意让哈希计算变得很慢、很耗资源。
主流的三个选项:
- bcrypt:广泛用于后端框架,自带随机盐,输出固定60字符,算法中有代价因子可以调整运算轮数;
- PBKDF2:基于HMAC的迭代口令派生函数,可以在不同HMAC算法上调优,是各类标准体系里最常见的;
- Argon2:2015年密码哈希竞赛冠军,内存占用高,对GPU攻击的抵抗力更强,Java和Go生态都有实现。
下面是一份选型建议:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| Web应用用户密码存储 | bcrypt | 成熟、生态好、自动带盐 |
| 老系统升级兼容 | PBKDF2 | 迭代次数可调,兼容性好 |
| 高安全或新系统 | Argon2 | 抗GPU更优,内存参数可控 |
| 一般完整性校验 | SHA-256 / SHA-512 | 速度快,用于文件、内容摘要 |
4.4 加盐的正确姿势
盐(Salt)是一段随机数据,在哈希前和口令拼在一起。作用很简单:让相同密码的哈希值也完全不同,从而彻底打掉彩虹表的复用性。
加盐的三个实践原则,每一条都是我踩过坑后总结的:
- 盐要足够长,至少16字节,最好是密码学安全随机数生成器生成;
- 每个用户用独立的盐,不能全系统共用一个盐;
- 盐不需要保密,直接和哈希值一起存数据库即可,攻击者拿到盐也推不出原始口令。
用一个通用伪代码说明正确流程:
1. 用户注册时输入 password 2. salt = random(16字节) 3. hash = bcrypt_hash(password, salt, cost=12) 4. 数据库存储:salt + hash 5. 用户登录时输入 password 6. 从数据库中取出 salt 和 hash 7. 用同样的 cost 参数重新计算 bcrypt_hash 8. 如果结果一致则通过有几个细节会坑到新手:cost参数太高会让登录接口明显变慢(CPU耗尽),太低又不够安全;Java里BCrypt.gensalt(12),Go里是bcrypt.GenerateFromPassword([]byte(pwd), cost),Node里用bcrypt.hash(pwd, 12),反正核心都是“让计算慢到暴力破解难以承受,但又不至于影响用户体验”。
5. 真实项目里的选型思路和避坑清单
5.1 按场景而不是按算法选型
我在调研了几十个项目后悟出一个道理:不要先选算法,要先定场景。不同的安全需求对应完全不同的方案组合。下面对照知乎上常见的开发场景做个整理:
| 业务场景 | 推荐方案 | 理由 |
|---|---|---|
| 用户密码存储 | bcrypt / Argon2 | 不可逆 + 慢哈希 + 加盐 |
| 接口传输数据加密 | AES-256-GCM | 速度快,自带完整性认证 |
| 会话密钥协商 / 握手 | ECDHE或RSA2048 | 解决公钥分发,可前向保密 |
| 开放平台签名 | RSA-SHA256或ECDSA | 防篡改、抗抵赖 |
| 数据库敏感字段 | AES-256-GCM(应用层加密) | 兼顾安全性和可用性 |
| 文件完整性校验 | SHA-256 | 快速,碰撞难度可接受 |
| 国密合规项目 | SM2 + SM3 + SM4 | 国内政企合规要求 |
如果拿不准某个具体场景,我的保守建议是:**所有可逆加密一律AES-GCM,所有签名一律RSA-2048或ECC-256,所有密码存储一律bcrypt或Argon2。**这套组合在现代工程里基本不会出大错。
5.2 我见过的五类典型翻车现场
第一类:用ECB模式加密业务数据。某公司内部后台把用户手机号用AES-ECB加密存库,结果相同手机号产生了相同密文,导致数据去重分析时可以直接按密文分组,整个加密形同虚设。解决方法是迁移到GCM模式,同时把旧的密文做一次批量解密再迁移。
第二类:用固定IV初始化AES-CBC。每个请求都用同一个IV,密文的前16字节完全一致,针对https这类协议流量,观察者就能根据重复密文头判断请求来源。正确做法是每次生成随机IV,并随密文一起传输。
第三类:用MD5或者SHA-256直接存用户密码。这种设计在数据库泄露事件里极其被动,攻击者拿到后用彩虹表一查,一大半弱密码直接还原。宁愿启动时多花100毫秒,也要换成bcrypt。存量数据可以考虑做一轮哈希升级:用户下次登录时自动用新算法重新存储。
第四类:RSA只生成512位密钥。这类代码通常是从网上抄的Demo,根本没有考虑安全强度。RSA-512已经被证明能被快速分解,任何时候都不要在生产代码里出现小于2048位的RSA密钥。
第五类:以为Base64就是加密。这是一个更基本的概念混淆。Base64只是一种编码方式,把二进制数据转换成可打印字符,它没有任何保密能力,任何人拿到Base64字符串都能解码还原。编码是可逆的、公开的,加密是可逆的、需要密钥的,这两件事要分清楚。
5.3 HMAC:容易被忽略的完整性校验工具
最后补一个很多文章不提、但工程里非常常用的工具:HMAC(基于哈希的消息认证码)。它不是用来加密的,而是用来验证消息完整性和来源真实性的。
HMAC与普通哈希的最大区别是:普通哈希任何人都能算,HMAC需要密钥。只有持有相同密钥的双方才能计算和验证MAC。所以HMAC常用于接口签名、Webhook回调校验、开放平台防篡改等场景。
比如某开放平台的回调通知,服务端用AppSecret作为HMAC密钥,对请求体按约定拼接后计算HMAC-SHA256,客户端收到后同样计算并对比。只要两边结果一致,就能确认消息确实是该服务端发出的,且在传输过程中没有被修改。
HMAC-SHA256在隐私合规和数据防篡改里非常常用,面试中经常作为区分“懂不懂工程安全”的隐藏考点。
5.4 最后的实操体会
说实话,加密这块内容多且杂,我过去也走过“什么流行用什么”的弯路。后来项目做得多了,慢慢形成了一套自己的判断顺序:先确认“这个数据能不能脱敏”“这个数据需不需要可逆”“谁持有密钥”,然后再去选算法和模式。尤其是对称加密的分组模式和密码存储的慢哈希选择,这两块是最容易踩坑、也最影响安全结果的地方。
如果你现在正要给一个新系统做安全设计,我的建议是:把这篇文章里的对照表打印出来贴在工位,先从场景出发,回过来再对照算法特性。加密不是算法堆得越多越安全,而是把每把锁用在它该用的地方。这一点想明白了,安全问题其实就解决了一大半。