从"千万别存明文"到"故意让哈希慢一万倍"——数据库被拖走之后,你还剩多少防线。
假设你的服务器数据库被拖走了。这不是"如果",迟早会发生。此刻,用户密码的存储方式,决定了这场事故是"改个密码就好"还是"几亿账号在黑市流通"。这一章讲的就是:如何存密码,才能让攻击者即使拿到整个数据库也难以还原密码。
直接存 password="123456"。拖库即完全沦陷,还会连累用户在其它网站的同款密码。今天任何这么做的系统都是事故待发。永远不要存明文。
存 SHA256(password) 而非密码本身。哈希不可逆(第 10 章),看起来拖库了攻击者也还原不出密码。但有两个致命弱点:
盐(salt)是给每个用户随机生成的一段数据,和密码拼在一起再哈希:SHA256(salt ‖ password),盐明文存在数据库里(它不是秘密)。
盐解决了第 1 层的两个问题:每个用户盐不同,所以相同密码也得到不同哈希;更重要的是,攻击者无法预计算彩虹表了——他不可能为每一个可能的盐都造一张几十亿行的表。盐把攻击从"一次预计算、处处查表"逼回"针对每个用户单独爆破"。
盐挡住了预计算,却挡不住在线爆破。攻击者可以针对某个用户,拿他的盐,把常见密码一个个哈希、逐个比对。而 SHA-256 在 GPU 上每秒能算百亿次——一个 8 位纯数字密码(1 亿种)不到一秒就试完。问题的根源是:SHA-256 太快了。快对于文件校验是优点,对于密码存储却是致命伤。
最后的洞察反直觉却极其有效:故意让密码哈希慢下来。专门的密码哈希函数把一次哈希变成成千上万次迭代,或强制消耗大量内存:
| 算法 | 慢的方式 | 说明 |
|---|---|---|
| PBKDF2 | 迭代哈希 N 万次 | 最老、最广泛支持(Android 内置)。抗 GPU 一般,但胜在到处都有。 |
| bcrypt | 基于 Blowfish,可调成本因子 | 久经考验,自带盐。有 72 字节输入上限。 |
| scrypt | 迭代 + 大量内存 | "内存硬",让 GPU/ASIC 的并行优势失效。 |
| Argon2 | 内存硬 + 可调并行 | 2015 密码哈希竞赛冠军,今天的首选。 |
把单次哈希调到耗时约 0.25 秒,对用户登录几乎无感;但对要试几十亿次的攻击者,就是把破解成本乘以几百万倍。内存硬更狠:GPU 有几千个核心却共享有限显存,Argon2 每次哈希吃掉几十 MB 内存,GPU 的并行优势瞬间瓦解。
下面的 Demo 破解一个短密码。左边用快哈希(单次 SHA-256,模拟裸哈希存储),右边用慢哈希(多次迭代,模拟 PBKDF2)。同样的密码、同样的爆破,看两边的耗时差距——仅仅把迭代次数从 1 提到几千,破解时间就从"瞬间"变成"喝杯咖啡"。真实的 PBKDF2 是几十万次迭代,差距还要再放大百倍。
① 密码校验发生在服务端,慢哈希也跑在服务端。首选 Argon2id,退而求其次 bcrypt 或 PBKDF2(≥ 60 万次迭代 HMAC-SHA256,按 OWASP 建议)。
② 每个用户独立随机盐(≥16 字节),和哈希一起存。
③ App 端几乎不该存用户密码——用 token(OAuth / JWT)。真要在设备上保护一个本地凭证,用第 20 章的 Keystore + 生物识别,而不是自己哈希。
④ 想额外加固:再叠一层服务端密钥(pepper / HMAC),让"光拖库、没拿到密钥"也无法离线爆破。
密码哈希和"从密码派生密钥"是同一件事的两面。当你用密码加密一个文件、或 WPA2 Wi-Fi 用密码生成会话密钥时,底层跑的正是 PBKDF2 这类KDF(密钥派生函数)——把低熵的人类密码,通过大量迭代"拉伸"成一把高熵密钥。慢,在这里同样是特性而非缺陷。第 24 章的钱包助记词派生,用的也是同一族思想。
哈希这一 Phase,我们从"数据指纹"出发,理解了三大安全性质与雪崩,见识了生日攻击如何腰斩抗碰撞强度,拆穿了长度扩展的陷阱并用 HMAC 修好,最后学会了用"慢"守护密码。哈希看似朴素,却是完整性、认证、密码存储的共同地基——更是接下来数字签名和区块链不可或缺的零件。
但我们始终没能回答那个最古老的问题:Alice 和 Bob 从未见过面,怎么在满是窃听者的网络上凭空商定一把共享密钥?对称密码和哈希都无能为力。解开它需要一场真正的思想革命——把一把钥匙拆成"公开"和"私有"两半。下一个 Phase,公钥密码学登场。