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 参数分别表示什么

示例参数便于学习和运行,不应被当作永远不变的生产标准。生产配置应结合当前安全建议和自己的服务器压测,确保正常登录可接受,同时不会因过高成本轻易遭受拒绝服务。

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("账号或密码错误");
}

真实接口还应做到:

3.8 scrypt 与 Argon2id 怎样选择

Node.js 内置 scrypt() 的优点是不需要新增依赖,适合学习密码哈希的基本原理,也可以用于经过正确设计的实际系统。

现代密码保存也常优先选择 Argon2id。当前项目已经安装 argon2,并有更完整的教程:

二者共同的核心模型是:随机盐 + 可调成本 + 不可逆派生 + 专用验证接口/安全比较。不要为了“只用内置模块”而把 SHA-256 当成密码哈希。