From e2bda71baaf3ed63cbb4c9cbd6c0b454ce700aa6 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Mon, 9 Mar 2026 11:36:24 -0700 Subject: [PATCH] feat: add password module with native node:crypto scrypt support Adds a new `./password` export to @better-auth/utils with two implementations: - Default: uses @noble/hashes/scrypt (pure JS, web/CF Workers compatible) - Node condition: uses node:crypto scrypt (native, faster for Node.js/CF Workers) Both implementations share the same hash format (`salt:hex`) ensuring full compatibility with existing stored password hashes regardless of which implementation generated them. Closes: https://github.com/better-auth/better-auth/issues/8456 --- build.config.ts | 18 ++++++++ package.json | 15 ++++++- pnpm-lock.yaml | 24 ++++++++++ src/password.node.test.ts | 93 +++++++++++++++++++++++++++++++++++++++ src/password.node.ts | 46 +++++++++++++++++++ src/password.test.ts | 74 +++++++++++++++++++++++++++++++ src/password.ts | 37 ++++++++++++++++ 7 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 src/password.node.test.ts create mode 100644 src/password.node.ts create mode 100644 src/password.test.ts create mode 100644 src/password.ts diff --git a/build.config.ts b/build.config.ts index 1ce452f..933f944 100644 --- a/build.config.ts +++ b/build.config.ts @@ -3,4 +3,22 @@ import { defineBuildConfig } from "unbuild"; export default defineBuildConfig({ outDir: "dist", declaration: true, + rollup: { + emitCJS: true, + }, + entries: [ + "src/index", + "src/base32", + "src/base64", + "src/binary", + "src/hash", + "src/ecdsa", + "src/hex", + "src/hmac", + "src/otp", + "src/random", + "src/rsa", + "src/password", + { input: "src/password.node.ts", name: "password.node" }, + ], }); diff --git a/package.json b/package.json index 955b4ea..a4de4eb 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,20 @@ "./rsa": { "import": "./dist/rsa.mjs", "require": "./dist/rsa.cjs" + }, + "./password": { + "node": { + "import": "./dist/password.node.mjs", + "require": "./dist/password.node.cjs" + }, + "import": "./dist/password.mjs", + "require": "./dist/password.cjs" } }, - "files": ["dist"] + "files": [ + "dist" + ], + "dependencies": { + "@noble/hashes": "^2.0.1" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf525d2..52ad054 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + '@noble/hashes': + specifier: ^2.0.1 + version: 2.0.1 devDependencies: '@biomejs/biome': specifier: ^1.9.4 @@ -125,24 +129,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@1.9.4': resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@1.9.4': resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@1.9.4': resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@1.9.4': resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} @@ -594,6 +602,10 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -694,51 +706,61 @@ packages: resolution: {integrity: sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.28.1': resolution: {integrity: sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.28.1': resolution: {integrity: sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.28.1': resolution: {integrity: sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.28.1': resolution: {integrity: sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.28.1': resolution: {integrity: sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.28.1': resolution: {integrity: sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.28.1': resolution: {integrity: sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.28.1': resolution: {integrity: sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.28.1': resolution: {integrity: sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.28.1': resolution: {integrity: sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A==} @@ -2162,6 +2184,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 diff --git a/src/password.node.test.ts b/src/password.node.test.ts new file mode 100644 index 0000000..0798e66 --- /dev/null +++ b/src/password.node.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import { hashPassword, verifyPassword } from "./password.node"; + +describe("password (node:crypto)", () => { + it("hashPassword produces salt:hex format", async () => { + const hash = await hashPassword("mypassword"); + const parts = hash.split(":"); + expect(parts).toHaveLength(2); + expect(parts[0]).toMatch(/^[a-f0-9]{32}$/); // 16 bytes hex salt + expect(parts[1]).toMatch(/^[a-f0-9]{128}$/); // 64 bytes hex key + }); + + it("verifyPassword returns true for correct password", async () => { + const hash = await hashPassword("correcthorsebatterystaple"); + expect(await verifyPassword(hash, "correcthorsebatterystaple")).toBe(true); + }); + + it("verifyPassword returns false for wrong password", async () => { + const hash = await hashPassword("correcthorsebatterystaple"); + expect(await verifyPassword(hash, "wrongpassword")).toBe(false); + }); + + it("throws on invalid hash format", async () => { + await expect(verifyPassword("invalidhash", "password")).rejects.toThrow( + "Invalid password hash", + ); + }); + + it("each call produces a unique hash", async () => { + const hash1 = await hashPassword("samepassword"); + const hash2 = await hashPassword("samepassword"); + expect(hash1).not.toBe(hash2); + }); + + it("handles empty password", async () => { + const hash = await hashPassword(""); + expect(await verifyPassword(hash, "")).toBe(true); + expect(await verifyPassword(hash, "notempty")).toBe(false); + }); + + it("handles very long password", async () => { + const long = "a".repeat(1000); + const hash = await hashPassword(long); + expect(await verifyPassword(hash, long)).toBe(true); + expect(await verifyPassword(hash, "a".repeat(999))).toBe(false); + }); + + it("normalizes unicode passwords (NFKC)", async () => { + // fi (U+FB01, LATIN SMALL LIGATURE FI) normalizes to "fi" under NFKC + const hash = await hashPassword("\uFB01"); + expect(await verifyPassword(hash, "fi")).toBe(true); + }); + + it("returns false for tampered key in hash", async () => { + const hash = await hashPassword("password"); + const [salt, key] = hash.split(":"); + const tampered = `${salt}:${"0".repeat(key!.length)}`; + expect(await verifyPassword(tampered, "password")).toBe(false); + }); + + it("returns false for tampered salt in hash", async () => { + const hash = await hashPassword("password"); + const [, key] = hash.split(":"); + const tampered = `${"0".repeat(32)}:${key}`; + expect(await verifyPassword(tampered, "password")).toBe(false); + }); + + it("handles special characters in password", async () => { + const special = "p@$$w0rd!#%^&*()"; + const hash = await hashPassword(special); + expect(await verifyPassword(hash, special)).toBe(true); + expect(await verifyPassword(hash, "p@$$w0rd")).toBe(false); + }); +}); + +describe("cross-compatibility: noble and node:crypto produce identical keys", () => { + it("verifies noble hash with node:crypto", async () => { + // Import noble implementation directly for cross-compat test + const { hashPassword: nobleHash, verifyPassword: nobleVerify } = + await import("./password"); + const hash = await nobleHash("crossplatformtest"); + // node:crypto verifyPassword should accept a noble-generated hash + expect(await verifyPassword(hash, "crossplatformtest")).toBe(true); + }); + + it("verifies node:crypto hash with noble", async () => { + const { verifyPassword: nobleVerify } = await import("./password"); + const hash = await hashPassword("crossplatformtest"); + // noble verifyPassword should accept a node:crypto-generated hash + expect(await nobleVerify(hash, "crossplatformtest")).toBe(true); + }); +}); diff --git a/src/password.node.ts b/src/password.node.ts new file mode 100644 index 0000000..86419b8 --- /dev/null +++ b/src/password.node.ts @@ -0,0 +1,46 @@ +import { scrypt, randomBytes } from "node:crypto"; + +const config = { + N: 16384, + r: 16, + p: 1, + dkLen: 64, +}; + +function generateKey(password: string, salt: string): Promise { + return new Promise((resolve, reject) => { + scrypt( + password.normalize("NFKC"), + salt, + config.dkLen, + { + N: config.N, + r: config.r, + p: config.p, + maxmem: 128 * config.N * config.r * 2, + }, + (err, key) => { + if (err) reject(err); + else resolve(key); + }, + ); + }); +} + +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16).toString("hex"); + const key = await generateKey(password, salt); + return `${salt}:${key.toString("hex")}`; +} + +export async function verifyPassword( + hash: string, + password: string, +): Promise { + const [salt, key] = hash.split(":"); + if (!salt || !key) { + throw new Error("Invalid password hash"); + } + const targetKey = await generateKey(password, salt); + return targetKey.toString("hex") === key; +} diff --git a/src/password.test.ts b/src/password.test.ts new file mode 100644 index 0000000..fa8e54f --- /dev/null +++ b/src/password.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { hashPassword, verifyPassword } from "./password"; + +describe("password (noble/scrypt)", () => { + it("hashPassword produces salt:hex format", async () => { + const hash = await hashPassword("mypassword"); + const parts = hash.split(":"); + expect(parts).toHaveLength(2); + expect(parts[0]).toMatch(/^[a-f0-9]{32}$/); // 16 bytes hex salt + expect(parts[1]).toMatch(/^[a-f0-9]{128}$/); // 64 bytes hex key + }); + + it("verifyPassword returns true for correct password", async () => { + const hash = await hashPassword("correcthorsebatterystaple"); + expect(await verifyPassword(hash, "correcthorsebatterystaple")).toBe(true); + }); + + it("verifyPassword returns false for wrong password", async () => { + const hash = await hashPassword("correcthorsebatterystaple"); + expect(await verifyPassword(hash, "wrongpassword")).toBe(false); + }); + + it("throws on invalid hash format", async () => { + await expect(verifyPassword("invalidhash", "password")).rejects.toThrow( + "Invalid password hash", + ); + }); + + it("each call produces a unique hash", async () => { + const hash1 = await hashPassword("samepassword"); + const hash2 = await hashPassword("samepassword"); + expect(hash1).not.toBe(hash2); + }); + + it("handles empty password", async () => { + const hash = await hashPassword(""); + expect(await verifyPassword(hash, "")).toBe(true); + expect(await verifyPassword(hash, "notempty")).toBe(false); + }); + + it("handles very long password", async () => { + const long = "a".repeat(1000); + const hash = await hashPassword(long); + expect(await verifyPassword(hash, long)).toBe(true); + expect(await verifyPassword(hash, "a".repeat(999))).toBe(false); + }); + + it("normalizes unicode passwords (NFKC)", async () => { + // fi (U+FB01, LATIN SMALL LIGATURE FI) normalizes to "fi" under NFKC + const hash = await hashPassword("\uFB01"); + expect(await verifyPassword(hash, "fi")).toBe(true); + }); + + it("returns false for tampered key in hash", async () => { + const hash = await hashPassword("password"); + const [salt, key] = hash.split(":"); + const tampered = `${salt}:${"0".repeat(key!.length)}`; + expect(await verifyPassword(tampered, "password")).toBe(false); + }); + + it("returns false for tampered salt in hash", async () => { + const hash = await hashPassword("password"); + const [, key] = hash.split(":"); + const tampered = `${"0".repeat(32)}:${key}`; + expect(await verifyPassword(tampered, "password")).toBe(false); + }); + + it("handles special characters in password", async () => { + const special = "p@$$w0rd!#%^&*()"; + const hash = await hashPassword(special); + expect(await verifyPassword(hash, special)).toBe(true); + expect(await verifyPassword(hash, "p@$$w0rd")).toBe(false); + }); +}); diff --git a/src/password.ts b/src/password.ts new file mode 100644 index 0000000..67ecad7 --- /dev/null +++ b/src/password.ts @@ -0,0 +1,37 @@ +import { scryptAsync } from "@noble/hashes/scrypt.js"; +import { hex } from "./hex"; + +const config = { + N: 16384, + r: 16, + p: 1, + dkLen: 64, +}; + +async function generateKey(password: string, salt: string): Promise { + return scryptAsync(password.normalize("NFKC"), salt, { + N: config.N, + r: config.r, + p: config.p, + dkLen: config.dkLen, + maxmem: 128 * config.N * config.r * 2, + }); +} + +export async function hashPassword(password: string): Promise { + const salt = hex.encode(crypto.getRandomValues(new Uint8Array(16))); + const key = await generateKey(password, salt); + return `${salt}:${hex.encode(key)}`; +} + +export async function verifyPassword( + hash: string, + password: string, +): Promise { + const [salt, key] = hash.split(":"); + if (!salt || !key) { + throw new Error("Invalid password hash"); + } + const targetKey = await generateKey(password, salt); + return hex.encode(targetKey) === key; +}