3. 使用 scrypt 安全保存密码
3.1 保存密码的真实需求
服务端不需要知道用户原来的密码,只需要在登录时回答:
这次输入的密码,是否和注册时的密码相同?
因此数据库应该保存不可逆的密码哈希,而不是明文或可解密密文。
注册:密码 + 随机盐 + 成本参数 -> scrypt -> 保存结果
登录:输入密码 + 保存的盐和参数 -> scrypt -> 比较结果
如果数据库泄露,攻击者仍然可以不断猜测候选密码,但无法直接解密。scrypt 故意消耗 CPU 和内存,使每次猜测都有成本。
3.2 为什么不能使用 SHA-256
下面的代码不适合保存密码:
// 错误示例
const passwordHash = createHash("sha256")
.update(password)
.digest("hex");
SHA-256 的目标是快速处理大量数据,攻击者也可以非常快地尝试常见密码。密码哈希恰好需要“适度地慢”,并且最好消耗较多内存,以抬高批量破解成本。
3.3 Salt 解决什么问题
如果没有盐,相同密码永远得到相同结果:
张三的 123456 -> 相同哈希
李四的 123456 -> 相同哈希
加入每个密码独立的随机盐:
123456 + salt-A -> 哈希 A
123456 + salt-B -> 哈希 B
Salt 不需要保密,可以和哈希一起存储。它的作用是让攻击者不能轻易复用预计算结果,也不能一眼看出哪些用户密码相同。Salt 不能把弱密码变强,登录限流和密码策略仍然需要存在。
3.4 一份完整的 TypeScript 实现
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
const KEY_LENGTH = 64;
const SCRYPT_OPTIONS = {
N: 16_384,
r: 8,
p: 1,
maxmem: 64 * 1024 * 1024,
} as const;
function deriveKey(password: string, salt: Buffer): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(
password,
salt,
KEY_LENGTH,
SCRYPT_OPTIONS,
(error, derivedKey) => {
if (error) {
reject(error);
return;
}
resolve(derivedKey);
}
);
});
}
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
const derivedKey = await deriveKey(password, salt);
return [
"scrypt",
`N=${SCRYPT_OPTIONS.N},r=${SCRYPT_OPTIONS.r},p=${SCRYPT_OPTIONS.p}`,
salt.toString("base64url"),
derivedKey.toString("base64url"),
].join("$");
}
export async function verifyPassword(
password: string,
storedValue: string
): Promise<boolean> {
const parts = storedValue.split("$");
if (parts.length !== 4) {
return false;
}
const [algorithm, parameters, saltText, keyText] = parts;
if (
algorithm !== "scrypt" ||
parameters !== "N=16384,r=8,p=1" ||
saltText === undefined ||
keyText === undefined
) {
return false;
}
const salt = Buffer.from(saltText, "base64url");
const storedKey = Buffer.from(keyText, "base64url");
if (salt.length !== 16 || storedKey.length !== KEY_LENGTH) {
return false;
}
const inputKey = await deriveKey(password, salt);
return timingSafeEqual(inputKey, storedKey);
}
使用示例:
async function main(): Promise<void> {
const passwordHash = await hashPassword("correct horse battery staple");
console.log(passwordHash);
console.log(await verifyPassword("wrong password", passwordHash)); // false
console.log(
await verifyPassword("correct horse battery staple", passwordHash)
); // true
}
main().catch(console.error);
数据库只保存完整的 passwordHash 字符串。示例把算法名、成本参数、盐和派生结果放到同一字符串中:
scrypt$N=16384,r=8,p=1$盐$派生结果
这样以后升级参数时,验证代码知道旧记录使用了什么方案。示例为了突出原理只接受一组参数;真实系统升级时,可以安全解析白名单内的旧参数,登录验证成功后再用新参数重新哈希。
3.5 参数分别表示什么
N:CPU 和内存成本,必须是符合scrypt要求的 2 的幂。r:块大小参数,会影响内存和计算成本。p:并行化参数。maxmem:Node.js 允许本次调用使用的内存上限,不是安全强度本身。KEY_LENGTH:派生结果的字节数。
示例参数便于学习和运行,不应被当作永远不变的生产标准。生产配置应结合当前安全建议和自己的服务器压测,确保正常登录可接受,同时不会因过高成本轻易遭受拒绝服务。
3.6 为什么用异步 scrypt()
scryptSync() 会在 Node.js 主线程中完成前一直阻塞 JavaScript:
请求 A 执行 scryptSync
↓ 主线程被占用
请求 B、C、D 暂时无法执行 JavaScript
异步 scrypt() 会把耗时工作交给 Node.js 的 libuv 线程池,完成后再把回调放回事件循环。它并不会让计算消失,但能避免单次密码计算直接卡住主线程。
线程池容量仍然有限,所以登录接口依然要限流。不要因为用了异步 API 就同时接受无限数量的密码哈希任务。
3.7 注册与登录应该怎样接入
注册:
const passwordHash = await hashPassword(req.body.password);
await userRepository.create({
username: req.body.username,
passwordHash,
});
登录:
const user = await userRepository.findByUsername(req.body.username);
if (!user) {
throw new Error("账号或密码错误");
}
const matched = await verifyPassword(
req.body.password,
user.passwordHash
);
if (!matched) {
throw new Error("账号或密码错误");
}
真实接口还应做到:
- 请求进入后先检查输入类型和合理长度。
- 不把密码、派生结果或完整请求体写入日志。
- 账号不存在和密码错误对外使用相同提示。
- 对账号、IP 等维度限制登录频率。
- 哈希参数变化时设计渐进升级。
3.8 scrypt 与 Argon2id 怎样选择
Node.js 内置 scrypt() 的优点是不需要新增依赖,适合学习密码哈希的基本原理,也可以用于经过正确设计的实际系统。
现代密码保存也常优先选择 Argon2id。当前项目已经安装 argon2,并有更完整的教程:
二者共同的核心模型是:随机盐 + 可调成本 + 不可逆派生 + 专用验证接口/安全比较。不要为了“只用内置模块”而把 SHA-256 当成密码哈希。