功能说明
hashPassword 使用 Node 内置 scrypt 与十六字节随机盐生成可存储的密码哈希,输出中自带算法名、成本参数、盐与密钥,便于后续升级参数。verifyPassword 从存储字符串解析参数,重新派生密钥并用 timingSafeEqual 比较。适合自建账号系统、CLI 初始化管理员和需要离线校验的桌面应用。
关键代码
派生函数把归一化后的密码、盐、密钥长度与成本参数交给 scrypt,异步回调包装为 Promise。存储格式用美元符号分隔六段:算法标识、N、r、p、Base64 盐与 Base64 密钥。校验时先检查段数与算法名,再以存储的密钥长度派生,避免长度不一致导致比较异常。任何参数解析或 scrypt 错误都返回 false,不向调用方泄露细节。
使用注意
scrypt 成本参数需要按硬件基准调整,N 翻倍会显著增加内存与时间,过低无法抵抗离线爆破,过高会拖慢登录。密码应先做长度与类型校验,再归一化,确保同一密码在不同平台得到相同结果。盐必须随机且与哈希一起存储,绝不能全局复用。哈希字符串包含参数,迁移时应支持旧参数校验并在成功登录后重哈希。
验证方式
测试哈希后校验成功、错误密码失败、空密码与短密码抛错。确认同一密码两次哈希的盐与结果不同,但都能校验通过。篡改存储字符串的算法名、盐、密钥或段数,断言返回 false 而不抛异常。检查哈希格式为六段且参数可解析,并用较长密码与 Unicode 密码验证归一化行为。
代码示例
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 } as const;
function deriveKey(password: string, salt: Buffer, length: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password.normalize("NFKC"), salt, length, { ...SCRYPT_PARAMS }, (error, key) => {
if (error) reject(error);
else resolve(key);
});
});
}
export async function hashPassword(password: string): Promise<string> {
if (typeof password !== "string" || password.length < 8) {
throw new RangeError("password must be at least 8 characters");
}
const salt = randomBytes(16);
const key = await deriveKey(password, salt, 64);
return [
"scrypt",
SCRYPT_PARAMS.N,
SCRYPT_PARAMS.r,
SCRYPT_PARAMS.p,
salt.toString("base64"),
key.toString("base64"),
].join("$");
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
if (typeof stored !== "string") return false;
const parts = stored.split("$");
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
const [, rawN, rawR, rawP, saltText, keyText] = parts;
const expected = Buffer.from(keyText!, "base64");
const salt = Buffer.from(saltText!, "base64");
if (expected.length === 0 || salt.length === 0) return false;
let actual: Buffer;
try {
actual = await new Promise<Buffer>((resolve, reject) => {
scrypt(
password.normalize("NFKC"),
salt,
expected.length,
{ N: Number(rawN), r: Number(rawR), p: Number(rawP), maxmem: 64 * 1024 * 1024 },
(error, key) => error ? reject(error) : resolve(key),
);
});
} catch {
return false;
}
return actual.length === expected.length && timingSafeEqual(actual, expected);
}