🪟 第16章 SQL Server数据库安全
📄 一句话概括

密码不能加密,只能哈希:加密可逆,而「能解开所有密码的钥匙」本身就是最大的风险。 实测 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 秒实验

  1. 把 PBKDF2 的轮数从 10 万改成 1 万,重测。 耗时大概会降到十分之一——这也说明「安全性是可以调的」,但调低就是给自己降级。
  2. 用同一个盐算两次 sha256(salt + pwd),看两个结果是否相同。 相同——这就是「盐不变、哈希就不变」,所以盐要每人一个、而且要存好。
  3. 想一想:如果业务要求「按手机号登录」,你会怎么存? 存「加密的手机号」用来显示,再存「手机号的哈希」用来查—— 两个字段,一个可逆一个不可逆,各干各的活。

常见误区

  1. 「密码加密存更安全,因为能还原」。 恰恰相反。 能还原就意味着存在一把能解开所有密码的钥匙—— 这把钥匙比密码本身更值钱。密码只需要「验证」,不需要「还原」。
  2. 「加了盐就安全了」。 盐只防「批量预计算」,123456 加盐还是弱密码。 强度靠「慢哈希 + 密码策略」,盐只是必要不充分。
  3. 「MD5 加个盐也够用」。 不够。 MD5 本身已被证明能构造碰撞, 而且快到「一秒几百万次」—— 加盐只能拖慢一点点。存密码就用 bcrypt/PBKDF2/scrypt 这类慢哈希。
自测三问(点开对答案) 1. 为什么密码只能哈希、不能加密? —— **因为加密可逆,而「能解开所有密码的钥匙」本身就是最大的风险。** **密码只需要「验证是否一致」,不需要「还原」,所以单向哈希正好。** 2. 盐防的是什么?不防什么? —— **防「彩虹表批量反查」——同密码不同人算出不同结果,没法预计算。** **不防「弱密码被暴力猜」**,那要靠慢哈希和密码策略。 3. 为什么密码比较要用 `hmac.compare_digest`? —— **它是恒定时间比较,不管哪一位不同耗时都一样。** **用 `==` 会因为「前面几位对上」而多花时间,理论上能被计时侧信道逐位猜出来。**

第 16 章到此结束。 下一篇进第 17 章 17.1,讲数据安全: 威胁到底从哪儿来——以及为什么「内鬼、误操作、没备份」比「黑客攻击」更常见。

相关笔记