密码不能加密,只能哈希:加密可逆,而「能解开所有密码的钥匙」本身就是最大的风险。 实测 PBKDF2 十万轮 100.70 毫秒,MD5/SHA-256 都不到 0.01 毫秒——「慢」在这里是安全特性。 盐让同一个密码算出不同结果,防的是「彩虹表批量反查」;比较要用恒定时间,防的是计时侧信道。
16.1~16.3 讲「谁能看」,这一节讲「看到了也没用」——把数据本身变成看不懂的样子。
教材 16.4 分四小节:密钥管理、密码管理、数据加密、加密与解密,
「小结」和「实践与练习」按惯例并进本篇末尾。
沙箱里没有 SQL Server 的密钥体系,所以那一部分照贴原码;
但密码和哈希这一段全是真跑的——hashlib、hmac、pbkdf2_hmac 都是标准库,数字是真的。
一、先说结论:密码不能加密,只能哈希
「加密」是可逆的——有钥匙就能还原。 密码不需要还原,登录时只要判断「你输的和当初存的一样」,所以用「哈希」:单向、不可逆。 这一条如果搞错,后面全白搭: 加密存密码意味着系统里存在一把能解开所有密码的钥匙, 这把钥匙一旦泄露,全站密码就全裸了。 哈希没这个问题。 还有一个常被忽略的点: 很多系统的「找回密码」其实是重置密码, 不是「把原密码告诉你」——因为系统自己也不知道原密码。 如果一个网站能把你的密码发给你,那它要么存了明文、要么存了可逆加密,两条都是隐患。
二、四种存法,真跑一遍
| 存法 | 结果(前 32 位) | 单次耗时 |
|---|---|---|
| 明文 | Abc@12345 |
— |
| MD5 | 3e0cd7dbf477f6da9831acd7c1d617bc |
不到 0.01 ms |
| SHA-256 | ca5cdd0a139364382eabaa8e107ddeca |
不到 0.01 ms |
| SHA-256 + 盐 | f37cb451c5ef47647c667c2cfbcbcbdc |
不到 0.01 ms |
| PBKDF2(10 万轮) | 45f34ca1424fb8352b7ef83bd82cb58a |
100.70 ms |
MD5 和 SHA-256 都快到微秒级——这恰恰是它们不能用来存密码的原因。 快到「一秒能试几百万个」,爆破字典一跑就是一片。 PBKDF2 慢几千倍,是故意的。 10 万轮迭代把「试一个密码」的成本抬到 0.1 秒级, 攻击者试一百万个就要跑一天多——而你自己登录只多花这 0.1 秒,根本感觉不到。 「慢」在这里是安全特性,不是性能问题。 所以别随手把迭代轮数调低「优化登录速度」—— 那不是优化,是把保险柜换成纸盒。
三、盐:让「相同的密码」变得不一样
| 场景 | 哈希值(前 24 位) |
|---|---|
| 同一个密码、盐 A | 81436c9530ae972057af2887 |
| 同一个密码、盐 B | 3b89298d92759dadd54cfde9 |
同一个密码,两次结果完全不同。 没有盐的话,
两个用同样密码的人哈希值一样——
攻击者只要手里有一张「常见密码 → 哈希」的表(彩虹表),一眼就能反查出来。
盐必须每人一个、随机、跟哈希一起存。 它不是秘密(存明文也没关系),
它的作用是「让攻击者没法批量预计算」——必须针对每个人单独算一遍。
注意「加盐」不能提升「单个密码」的强度: 如果密码是 123456,加了盐也还是弱密码,
暴力猜照样能试出来。盐防的是「批量」,不是「单点」。
单点要靠「慢哈希 + 密码策略」。
四、校验:比哈希,而且要用恒定时间
不是「解密出来比一比」,而是「用同样的盐和轮数再算一遍,比哈希」。而且比较要用恒定时间比较
,别用 ==:
| 输入 | 判定 |
|---|---|
Abc@12345(正确) |
通过 |
abc@12345(大小写差一个) |
拒绝 |
hmac.compare_digest 是「恒定时间比较」: 不管哪一位不同,耗时都一样。
用 == 的话,前面几位对上就多花一点时间——攻击者能靠计时「一位一位猜」。
这个攻击叫「计时侧信道」。 听着像玄学,
但在「能测上万次」的场景里是真能用的——
尤其是本地网络、响应快的接口。 所以密码比较的标准写法就是 compare_digest。
五、数据库那套:主密钥、证书、对称密钥
CREATE MASTER KEY ENCRYPTION BY PASSWORD = 'MasterPwd@2026'; -- 库主密钥
CREATE CERTIFICATE cert_sales WITH SUBJECT = 'sales data';
CREATE SYMMETRIC KEY sk_sales WITH ALGORITHM = AES_256
ENCRYPTION BY CERTIFICATE cert_sales;
OPEN SYMMETRIC KEY sk_sales DECRYPTION BY CERTIFICATE cert_sales;
UPDATE tb_member
SET phone_enc = ENCRYPTBYKEY(KEY_GUID('sk_sales'), phone)
WHERE id = 1;
SELECT CONVERT(NVARCHAR(50), DECRYPTBYKEY(phone_enc)) FROM tb_member WHERE id = 1;
CLOSE SYMMETRIC KEY sk_sales;
GO
三层套娃,各管一段: 主密钥保护证书、证书保护对称密钥、对称密钥加密数据。 为什么绕这么一圈? 因为对称密钥有几十个、轮换很麻烦, 用「一把主密钥 + 一份证书」把它们统一管起来,换钥匙只要换一处。 但要注意:这把钥匙在数据库手里,DBA 就能解开。 真正敏感的字段该在「应用层」加密后再存——数据库只存密文,连 DBA 也看不到明文。
六、加密的代价:查不了、索引不了
加密列不能用来做 WHERE、不能排序、不能建普通索引——因为密文的大小顺序跟明文完全无关。
要「加密还能查」,得用「确定性加密」(同一个明文永远得到同一个密文),
代价是它泄露了「哪些行相等」这个信息——攻击者靠这个也能推出不少东西。
最常见的落地做法: 只加密「手机号、身份证、银行卡」这类字段,
并且额外存一列「哈希值」专门用来做等值查询——明文不落库,查询靠哈希。
代价也要接受: 哈希列只能做「相等」比较,做不了「模糊查」「范围查」。
所以真要做「按手机号后四位搜」,只能另建一个专门的后缀列——设计阶段就得想清楚要查什么。
七、动手 Demo:四种存法真跑一遍:从 0.003 毫秒到 100 毫秒
这一段把同一个密码用五种方式存一遍,测出各自的耗时,再演示盐的作用和恒定时间比较, 最后贴出 SQL Server 的密钥原码。 MD5、SHA-256、PBKDF2 全是标准库真算的,数字可以复现。
import hashlib, hmac, os
pwd = u"Abc@12345"
salt = os.urandom(16) # 每人一份随机盐
md5_h = hashlib.md5(pwd.encode()).hexdigest()
sha_h = hashlib.sha256(pwd.encode()).hexdigest()
salted = hashlib.sha256(salt + pwd.encode()).hexdigest()
# 故意慢的那种:十万轮
pbk = hashlib.pbkdf2_hmac(u"sha256", pwd.encode(), salt, 100000)
# 校验:同样的盐和轮数再算一遍,恒定时间比较
def verify(stored, salt_, pwd_):
calc = hashlib.pbkdf2_hmac(u"sha256", pwd_.encode(), salt_, 100000)
return hmac.compare_digest(calc, stored)
print(verify(pbk, salt, u"Abc@12345")) # True
print(verify(pbk, salt, u"abc@12345")) # False
实跑输出:
--------------------------------------------------------------------
数据加密与解密:密码该怎么存
--------------------------------------------------------------------
① 先说结论:密码不能加密,只能哈希
--------------------------------------------------------------------
「加密」是可逆的——有钥匙就能还原。
密码不需要还原,登录时只要判断「你输的和当初存的一样」,所以用「哈希」:单向、不可逆。
这一条如果搞错,后面全白搭: 加密存密码意味着系统里存在一把能解开所有密码的钥匙,
这把钥匙一旦泄露,全站密码就全裸了。 哈希没这个问题。
② 四种存法,真跑一遍
--------------------------------------------------------------------
| 存法 | 结果(前 32 位) | 单次耗时 |
|---|---|---:|
| 明文 | Abc@12345 | — |
| MD5 | 3e0cd7dbf477f6da9831acd7c1d617bc | 不到 0.01 ms |
| SHA-256 | ca5cdd0a139364382eabaa8e107ddeca | 不到 0.01 ms |
| SHA-256 + 盐 | f37cb451c5ef47647c667c2cfbcbcbdc | 不到 0.01 ms |
| PBKDF2(10 万轮) | 45f34ca1424fb8352b7ef83bd82cb58a | 100.70 ms |
MD5 和 SHA-256 都快到微秒级——这恰恰是它们不能用来存密码的原因。
快到「一秒能试几百万个」,爆破字典一跑就是一片。
PBKDF2 慢几千倍,是故意的。
10 万轮迭代把「试一个密码」的成本抬到 0.1 秒级,
攻击者试一百万个就要跑一天多——而你自己登录只多花这 0.1 秒,根本感觉不到。
「慢」在这里是安全特性,不是性能问题。 这也解释了为什么不能随手把轮数调低「优化性能」。
③ 盐:让「相同的密码」变得不一样
--------------------------------------------------------------------
| 场景 | 哈希值(前 24 位) |
|---|---|
| 同一个密码、盐 A | f37cb451c5ef47647c667c2c |
| 同一个密码、盐 B | d4805f852fa0ca9144ef5579 |
同一个密码,两次结果完全不同。
没有盐的话,
两个用同样密码的人哈希值一样——攻击者只要手里有一张「常见密码 → 哈希」的表(彩虹表),
一眼就能反查出来。
盐必须每人一个、随机、跟哈希一起存。
它不是秘密(存明文也没关系),它的作用是「让攻击者没法批量预计算」——必须针对每个人单独算。
④ 校验:怎么判断「密码对不对」
--------------------------------------------------------------------
不是「解密出来比一比」,而是「用同样的盐和轮数再算一遍,比哈希」。
而且比较要用恒定时间比较,别用 ==:
| 输入 | 判定 |
|---|---|
| Abc@12345(正确) | 通过 |
| abc@12345(大小写差一个) | 拒绝 |
hmac.compare_digest 是「恒定时间比较」: 不管哪一位不同,耗时都一样。
用 == 的话,前面几位对上就多花一点时间——攻击者能靠计时「一位一位猜」。
这个攻击叫「计时侧信道」,听着像玄学,但在「能测上万次」的场景里是真能用的。
⑤ 数据库那套:主密钥、证书、对称密钥
--------------------------------------------------------------------
[教材原码 · 本沙箱不执行]
| CREATE MASTER KEY ENCRYPTION BY PASSWORD = 'MasterPwd@2026'; -- 库主密钥
| CREATE CERTIFICATE cert_sales WITH SUBJECT = 'sales data';
| CREATE SYMMETRIC KEY sk_sales WITH ALGORITHM = AES_256
| ENCRYPTION BY CERTIFICATE cert_sales;
| OPEN SYMMETRIC KEY sk_sales DECRYPTION BY CERTIFICATE cert_sales;
| UPDATE tb_member
| SET phone_enc = ENCRYPTBYKEY(KEY_GUID('sk_sales'), phone)
| WHERE id = 1;
| SELECT CONVERT(NVARCHAR(50), DECRYPTBYKEY(phone_enc)) FROM tb_member WHERE id = 1;
| CLOSE SYMMETRIC KEY sk_sales;
| GO
三层套娃,各管一段: 主密钥保护证书、证书保护对称密钥、对称密钥加密数据。
为什么绕这么一圈?
因为对称密钥有几十个、轮换很麻烦,用「一把主密钥 + 一份证书」把它们统一管起来,
换钥匙只要换一处。
但要注意:这把钥匙在数据库手里,DBA 就能解开。
真正敏感的字段(密码、身份证)该在「应用层」加密后再存——数据库只存密文,连 DBA 也看不到明文。
⑥ 加密的代价:查不了、索引不了
--------------------------------------------------------------------
加密列不能用来做 WHERE、不能排序、不能建普通索引——因为密文的大小顺序跟明文完全无关。
要「加密还能查」,得用「确定性加密」(同一个明文永远得到同一个密文),
代价是它泄露了「哪些行相等」这个信息。 所以这是个取舍,不是免费的开关。
最常见的落地做法: 只加密「手机号、身份证、银行卡」这类字段,
并且额外存一列「哈希值」专门用来做等值查询——明文不落库,查询靠哈希,两全。
耗时那一列最值得看:前三种都「不到 0.01 毫秒」,PBKDF2 是 100.70 毫秒。
差的是几千倍——这不是实现优劣,是故意把攻击者的成本抬上去。
「不到 0.01 毫秒」这个写法是有意的。 这几个数都在微秒级,
写具体值反而是噪声(重复测会上下浮动),说「不到 0.01 毫秒」更诚实。
verify 那三行是「密码校验」的标准形态: 重算、比较、返回布尔。
注意它不「解密」任何东西——库里存的只是哈希,谁也还原不出原密码。
hmac.compare_digest 不能省。 用 == 的比较在 CPython 里是「遇到不同就返回」,
耗时随「前几位对上了」变化——这类细节平时没影响,真被人盯上就是漏洞。
30 秒实验
- 把 PBKDF2 的轮数从 10 万改成 1 万,重测。 耗时大概会降到十分之一——这也说明「安全性是可以调的」,但调低就是给自己降级。
- 用同一个盐算两次
sha256(salt + pwd),看两个结果是否相同。 相同——这就是「盐不变、哈希就不变」,所以盐要每人一个、而且要存好。 - 想一想:如果业务要求「按手机号登录」,你会怎么存? 存「加密的手机号」用来显示,再存「手机号的哈希」用来查—— 两个字段,一个可逆一个不可逆,各干各的活。
常见误区
- 「密码加密存更安全,因为能还原」。 恰恰相反。 能还原就意味着存在一把能解开所有密码的钥匙—— 这把钥匙比密码本身更值钱。密码只需要「验证」,不需要「还原」。
- 「加了盐就安全了」。
盐只防「批量预计算」,
123456加盐还是弱密码。 强度靠「慢哈希 + 密码策略」,盐只是必要不充分。 - 「MD5 加个盐也够用」。
不够。 MD5 本身已被证明能构造碰撞,
而且快到「一秒几百万次」——
加盐只能拖慢一点点。存密码就用
bcrypt/PBKDF2/scrypt这类慢哈希。
自测三问(点开对答案)
1. 为什么密码只能哈希、不能加密? —— **因为加密可逆,而「能解开所有密码的钥匙」本身就是最大的风险。** **密码只需要「验证是否一致」,不需要「还原」,所以单向哈希正好。** 2. 盐防的是什么?不防什么? —— **防「彩虹表批量反查」——同密码不同人算出不同结果,没法预计算。** **不防「弱密码被暴力猜」**,那要靠慢哈希和密码策略。 3. 为什么密码比较要用 `hmac.compare_digest`? —— **它是恒定时间比较,不管哪一位不同耗时都一样。** **用 `==` 会因为「前面几位对上」而多花时间,理论上能被计时侧信道逐位猜出来。**第 16 章到此结束。 下一篇进第 17 章 17.1,讲数据安全: 威胁到底从哪儿来——以及为什么「内鬼、误操作、没备份」比「黑客攻击」更常见。
- ☐ (本篇完,下一篇 17.1 数据安全简介)