为什么数据库里的密码是一串乱码?

zijieLeo
约 37 分钟
13 次浏览
为什么数据库里的密码是一串乱码?

写在三部曲收官

写这个博客的时候,我在 seed 脚本里给管理员设了个初始密码 admin123。存进数据库之前,我顺手查了眼 users 表,看到 password 字段是这样的:

$2a$10$N9qo8uLOickgx2ZMRZoMye...

一串乱码。当时脑子里冒出三个问题:

  1. 这串乱码是怎么来的?
  2. 都说它「不可逆」,那登录的时候系统怎么知道我密码对不对?
  3. 为什么不干脆加密存,需要的时候解密比对?

这是登录三部曲的最后一篇。第一篇讲传输(HTTPS 为什么能防抓包),第二篇讲会话(JWT 鉴权到底做了什么),这一篇补上最后一环——密码落到数据库那一刻。前两篇防的是「传输途中被偷」,这一篇防的是「数据库被整个端走」。

1)先排除两个错误答案

错误答案一:明文存

最直观,也最危险。数据库一旦被拖走(SQL 注入、备份泄露、离职员工带走了导出文件),所有用户的密码当场全部暴露。

很多人对拖库没体感,因为它很少上新闻。但想一想:你在一个小网站注册过的密码,和你的邮箱、支付密码,是同一个吗?攻击者拿到 A 站的明文密码,第一件事就是去 B 站试——这叫撞库。你泄露的不只是一个账号,是所有复用这个密码的地方。

错误答案二:「那我加密存总行了吧」

用 AES 把密码加密后存进数据库,需要时解密出来比对——听起来更高级,其实是安全幻觉:

  • 加密是可逆的:只要有密钥,就能还原出明文
  • 而密钥通常就放在同一台服务器的环境变量或配置文件里
  • 能拖走数据库的人,大概率也能拿到配置——两把钥匙挂在同一扇门上

判断标准很简单:验证密码根本不需要「还原」它。

正确的思路转折

系统从来不需要知道你的密码是什么。它只需要知道:你这次输入的,和当初注册时输入的,是不是同一个。

那就够了——把密码「压」成一个指纹(哈希)存起来,登录时把你输入的重新压一遍,比对两个指纹是否一致。指纹本身不可逆,拖走了也算不出原密码。

从此,问题从「怎么藏」变成了另一个问题:怎么让这个指纹算得足够慢。慢到合法服务器算一次无所谓,攻击者暴力试十亿次试不起。

2)哈希演进史:每一代都是被攻击逼出来的

密码哈希不是某个天才的一次性设计,而是三十年攻防对抗的接力。看懂「上一代怎么死的」,每一代的设计就都顺理成章了。


第一代:快哈希(MD5 / SHA-256)

它们本来是为文件校验设计的——下载一个 2GB 的镜像,算个哈希对一下没没损坏。快,是它们的设计目标。

但用在密码上,快变成了灾难:攻击者拿到哈希后,可以高速试错。更狠的是彩虹表:有人提前把常见密码的哈希全部算好,做成一张几十亿条的对照表,你的哈希一查就出原码——连实时计算都不用。

补丁:加盐。 注册时给每个用户生成一段随机数(盐),拼在密码前面一起哈希。同一个密码 admin123,张三和李四算出的哈希完全不同——预计算的彩虹表瞬间作废。

但盐救不了速度。现代 GPU 每秒能算几十亿次 SHA-256,把字典里几百万个常见密码挨个加盐试一遍,成本依然低到离谱。

第二代:慢哈希(bcrypt / scrypt)

思路反过来:故意设计得慢。bcrypt 内部跑几千轮加密运算,单次哈希耗时约 100 毫秒——对登录场景毫无影响(用户等 100ms 没感觉),但对攻击者是灭顶之灾:

SHA-256:每秒几十亿次 → bcrypt:单服务器每秒几千次。攻击成本直接涨了六个数量级。

bcrypt 还有一个贴心的设计:盐是自动生成、自动内置在哈希字符串里的(下一节拆开看),想忘都忘不掉。

第三代:内存困难(Argon2)

慢还不够——GPU 算力便宜,堆显卡照样划算。2015 年密码哈希竞赛(PHC)的冠军 Argon2 加了第二道门槛:吃内存。每次哈希要占用几百 MB 内存,而 GPU 的显存又贵又小,大规模并行直接不经济。

方案速度内存盐现状
MD5极快极低无❌ 密码场景已判死刑
SHA-256极快极低需自己拼❌ 快就是原罪
bcrypt慢(可调)低内置✅ 存量最大,依然合格
scrypt慢高内置✅ 过渡角色
Argon2id慢(可调)高(可调)内置✅ 新系统首选

一句话总结:快哈希输在快,bcrypt 赢在慢,Argon2 赢在又慢又贵。

3)回到我的项目:解剖那串 $2a$10$...

这个博客真实的密码存储代码(lib/auth.ts,总共两行核心):

import bcrypt from "bcryptjs"

// 注册时:存指纹
export async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, 10)
}

// 登录时:重算比对
export async function verifyPassword(
  password: string,
  hashedPassword: string
): Promise<boolean> {
  return bcrypt.compare(password, hashedPassword)
}

现在把数据库里那串乱码拆开看,每一截都有意义:

一段 bcrypt 哈希的解剖 $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZd 算法 cost 盐(22 字符) 哈希值(31 字符) $2a$ = bcrypt 10 轮 每用户随机 比对的就是它 盐直接存在哈希字符串里 —— 所以数据库不需要单独的 salt 字段

四个「原来如此」:

① 盐就存在字符串里。 所以验证时不需要额外的盐字段——从哈希串里切出来就行。这也是为什么加盐这件事在 bcrypt 里不可能被忘记。

② compare 不是解密。 bcrypt.compare(输入, 哈希) 做的事是:从哈希串里取出盐和 cost → 用同样的参数把用户输入重算一遍 → 比对两段哈希。全程没有任何「还原」动作。

③ cost=10 意味着 2¹⁰ = 1024 轮内部运算。 登录时那 100ms 左右的耗时不是性能问题,是防线本身——服务器嫌慢的,攻击者更嫌慢。

④ cost 也在字符串里。 明天你把 cost 调到 12,老用户验证通过后 rehash 存回去,新串自动带新 cost。算法升级可以平滑滚动。

顺带一提:这个博客的「加密文章解锁」接口里,我用了恒定时间比较(先比长度再 Buffer.equals)来防计时攻击——和密码比对是同一个思想:不让攻击者通过响应快慢推测出任何信息。

4)工程决策:cost 怎么选、老系统怎么迁

原理之外的四个实战问题:

cost 的权衡

cost 每加 1,耗时翻倍。它同时压着三方:

cost服务器单次耗时(量级)登录体感攻击者单卡每秒
8~25ms无感数万次
10~100ms无感数千次
12~400ms可感知数百次
14~1.6s太慢几十次

经验值:个人项目 10,生产服务 11-12,先在本机实测别照抄。 新系统直接上 Argon2id,参数调到登录耗时落在 100-300ms 区间即可。

存量 MD5 怎么迁移

老系统库里躺着五百万条 MD5,不可能让全体用户改密码。标准做法是登录时透明升级:

用户登录 → 先按 MD5 算一遍比对
        → 通过?立刻用 bcrypt 重新哈希,覆盖旧值
        → 不通过?照常拒绝

用户无感知,存量密码在一次次登录中自然换代。长期不登录的账号,强制下次登录时走「忘记密码」重置即可。

密码策略:长度 > 复杂度

「必须大小写 + 数字 + 特殊字符」这套老规矩正在被抛弃(NIST 800-63B 早已转向)。它逼出一堆 Admin123! 这种伪强密码,而真正防爆破的是长度。更有效的做法是:

  • 最小长度设 8-10 位,不设上限(截断上限反而伤真强密码)
  • 维护一份常见弱密码黑名单,注册时拦截 admin123、123456789、password
  • 不再强制定期改密码(NIST:除非有泄露证据)

bcrypt 的 72 字节坑

bcrypt 只处理密码的前 72 字节,超出部分静默截断。英文场景几乎碰不到,但中文场景要留心:UTF-8 里一个汉字 3 字节,72 字节只装得下 24 个汉字——对登录密码来说够用,但如果你把它拿去哈希长文本(比如 API token、文件摘要),就是 bug。另外不要试图「先 SHA-256 再 bcrypt」自己造混合方案,Argon2 已经把这件事做对了。

5)三部曲合体:一次登录的完整防线

三篇拼起来,才是「登录安全」的完整答案:


每一层各防一种攻击,谁也替代不了谁:

攻击方式防线出自
公共 WiFi / 中间人抓包TLS 传输加密第一篇
数据库被拖走单向哈希 + 每用户独立盐本篇
字典 / 暴力破解慢哈希(cost)+ 登录限流本篇
撞库(他站密码复用)弱密码黑名单本篇
token 被偷短 access + refresh 可撤销第二篇
内部人员作案库里无明文可看本篇

删掉任何一层,剩下的层都补不上那个洞——这就是为什么它们值得写成三部曲。

结尾:密码存储自检清单

上线前过一遍:

  • 存的是单向哈希(bcrypt / Argon2id),不是明文、不是可逆加密
  • 每个用户的盐独立且随机(bcrypt 内置,别自己拼)
  • cost 实测过,登录耗时在 100ms 量级,且随硬件升级重评
  • 登录 / 注册接口有限流(我这个博客是每分钟 3 次)
  • 存量弱哈希(MD5/SHA)有登录时透明升级方案
  • 日志和错误上报里永不出现密码字段
  • 常见弱密码黑名单已启用(admin123 就不该能注册成功)

最后用三句话收束整个系列。我现在判断一个系统的登录链路合不合格:

传输加密吗?密码算得慢吗?会话能撤销吗?

三个都点头,才算从「能跑」走到了「能扛」。

评论

登录后即可发表评论

暂无评论,快来发表第一条评论吧!