密码学入门:哈希、盐与 HMAC 的工程实践
约 1 分钟· 653 字 74 次阅读
给博客做了"加密文章"功能,密码怎么存成了第一个安全问题。这篇从工程角度讲清楚哈希、盐、HMAC 三者的区别与正确用法。
哈希:单向的指纹
哈希函数是单向的:sha256("123456") 很容易算,反过来几乎不可能。
const crypto = require("node:crypto");
const hash = crypto.createHash("sha256").update("123456").digest("hex");
但直接存哈希有个致命问题:彩虹表。攻击者预先算好常见密码的哈希表,撞库即可。"123456" 的 sha256 在彩虹表里一查就有。
盐:让每个密码都独一无二
盐(salt)就是给密码加一段随机字符串再哈希:
const salt = crypto.randomBytes(16).toString("hex");
const hash = crypto
.createHash("sha256")
.update(`${salt}:${password}`)
.digest("hex");
// 存储格式:{ salt, hash }
因为盐是随机的,同一个密码在不同用户那里哈希完全不同——彩虹表直接失效。验证时用存的盐重新拼一次再比对:
function verify(password, record) {
const h = crypto
.createHash("sha256")
.update(`${record.salt}:${password}`)
.digest("hex");
return h === record.hash;
}
HMAC:带密钥的哈希
HMAC(Hash-based Message Authentication Code)是"哈希 + 密钥"的组合:HMAC-SHA256(secret, message)。
它和"盐+哈希"的区别在于语义:
| 用途 | 工具 | 关键点 |
|---|---|---|
| 存密码 | 盐 + 哈希 | 防彩虹表 |
| 防篡改(token/签名) | HMAC + 密钥 | 无密钥无法伪造 |
HMAC 的典型工程场景是签名 token。比如给"已解锁原文"的用户发短期凭证:
// 生成:slug + 过期时间 + 签名
const payload = `${slug}:${Date.now() + 30 * 60 * 1000}`;
const sig = crypto.createHmac("sha256", SECRET).update(payload).digest("hex");
const token = `${payload}:${sig}`;
// 验证:重新签名比对 + 检查过期
function verify(slug, token) {
const [slugPart, exp, sig] = token.split(":");
if (slugPart !== slug || Date.now() > Number(exp)) return false;
const expect = crypto.createHmac("sha256", SECRET).update(`${slugPart}:${exp}`).digest("hex");
return sig === expect;
}
HMAC 的核心价值:只有持有密钥的人才能签发合法 token,篡改任何一个字段都会导致签名不匹配。
工程里的常见错误
| 错误 | 后果 | 正解 |
|---|---|---|
| 明文存密码 | 脱裤即泄露 | 盐 + 哈希 |
| 不加盐直接哈希 | 彩虹表秒破 | 每用户随机盐 |
| 用 MD5/SHA1 | 已被攻破 | SHA-256 起 |
| 用普通哈希做 token 签名 | 无密钥,可伪造 | 用 HMAC |
| 密码强度限制太少 | 弱密码被撞 | 结合盐 + 合理策略 |
小结
一句话总结三者的分工:
- 哈希——把密码变成不可逆的指纹;
- 盐——让每个指纹独一无二,干掉彩虹表;
- HMAC——用密钥给数据签名,防止篡改与伪造。
存密码用「盐+哈希」,发凭证用「HMAC」,这两条规则够覆盖绝大多数中小项目的安全需求。