feat(crypto): use native node:crypto scrypt with fallback to @noble/hashes

Add native scrypt support via node:crypto for environments like
Cloudflare Workers that provide it, while preserving the @noble/hashes
fallback for environments without native crypto. The hash format is
unchanged so existing stored password hashes continue to work.

Closes #8456

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
David Bauch
2026-03-06 12:19:06 -07:00
co-authored by Claude Opus 4.6
parent 98cd201a17
commit dbf2c2e87a
2 changed files with 71 additions and 2 deletions
@@ -1,3 +1,5 @@
import { hex } from "@better-auth/utils/hex";
import { scryptAsync } from "@noble/hashes/scrypt.js";
import { describe, expect, it } from "vitest";
import { hashPassword, verifyPassword } from "./password";
@@ -60,4 +62,28 @@ describe("Password hashing and verification", () => {
const isValid = await verifyPassword({ hash, password });
expect(isValid).toBe(true);
});
it("should verify a password hashed with @noble/hashes (cross-compatibility)", async () => {
const password = "crossCompatTest!";
const salt = hex.encode(crypto.getRandomValues(new Uint8Array(16)));
const key = await scryptAsync(password.normalize("NFKC"), salt, {
N: 16384,
p: 1,
r: 16,
dkLen: 64,
maxmem: 128 * 16384 * 16 * 2,
});
const nobleHash = `${salt}:${hex.encode(key)}`;
const isValid = await verifyPassword({ hash: nobleHash, password });
expect(isValid).toBe(true);
});
it("should verify a known test vector", async () => {
const password = "betterAuthTestVector";
const hash = await hashPassword(password);
const isValid = await verifyPassword({ hash, password });
expect(isValid).toBe(true);
const isInvalid = await verifyPassword({ hash, password: "wrong" });
expect(isInvalid).toBe(false);
});
});
+45 -2
View File
@@ -11,8 +11,51 @@ const config = {
dkLen: 64,
};
async function generateKey(password: string, salt: string) {
return await scryptAsync(password.normalize("NFKC"), salt, {
const nativeScryptFn: Promise<typeof import("node:crypto").scrypt | null> =
(async () => {
try {
const { scrypt } = await import("node:crypto");
if (typeof scrypt === "function") return scrypt;
return null;
} catch {
return null;
}
})();
function nativeGenerateKey(
scryptFn: typeof import("node:crypto").scrypt,
password: string,
salt: string,
): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
scryptFn(
password,
salt,
config.dkLen,
{
N: config.N,
r: config.r,
p: config.p,
maxmem: 128 * config.N * config.r * 2,
},
(err, derivedKey) => {
if (err) reject(err);
else resolve(new Uint8Array(derivedKey));
},
);
});
}
async function generateKey(
password: string,
salt: string,
): Promise<Uint8Array> {
const normalizedPassword = password.normalize("NFKC");
const scryptFn = await nativeScryptFn;
if (scryptFn) {
return nativeGenerateKey(scryptFn, normalizedPassword, salt);
}
return scryptAsync(normalizedPassword, salt, {
N: config.N,
p: config.p,
r: config.r,