From d3122ad70144fda5d53dafd8e5a559e3ead91a4f Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 20 May 2026 03:57:20 +0000 Subject: [PATCH] fix(seed): use better-auth/crypto hashPassword to match verifyPassword params The seed.ts password hashing used N=32768, r=8, p=1 with base64 encoding, which does not match @better-auth/utils@0.4.0's actual implementation (N=16384, r=16, p=1, dkLen=64, hex encoding). This caused every seeded UAT credential to fail verifyPassword at sign-in. Fix: import hashPassword from "better-auth/crypto" in seed.ts and in the test helper. This delegates to Better-Auth's own implementation, guaranteeing parameter and encoding match. Also updates test assertions to expect hex format (saltHex:keyHex) and verifies the hash using the correct scrypt params (N=16384, r=16, p=1). Co-Authored-By: Paperclip --- .../__tests__/seed-uat-credentials.test.ts | 45 ++++++++++--------- apps/api/src/db/seed.ts | 12 +++-- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/apps/api/src/__tests__/seed-uat-credentials.test.ts b/apps/api/src/__tests__/seed-uat-credentials.test.ts index e04b7a9..aa1fdc0 100644 --- a/apps/api/src/__tests__/seed-uat-credentials.test.ts +++ b/apps/api/src/__tests__/seed-uat-credentials.test.ts @@ -32,12 +32,11 @@ const UAT_ACCOUNTS = [ const TEST_PASSWORD = "test-password-123"; -// ─── Password hashing (must match the implementation in seed.ts) ─────────────── +// ─── Password hashing — must match better-auth/crypto (N=16384, r=16, p=1, dkLen=64, hex) ─── -function hashPassword(password: string): string { - const salt = randomBytes(16); - const hashed = scryptSync(password, salt, 64, { N: 4096, r: 8, p: 1 }).toString("base64"); - return `${salt.toString("base64")}:${hashed}`; +async function hashPassword(password: string): Promise { + const { hashPassword } = await import("better-auth/crypto"); + return hashPassword(password); } // ─── Mock DB state ───────────────────────────────────────────────────────────── @@ -177,9 +176,9 @@ async function seedUatCredentials( if (existingAccount) { // skip — already has credential account } else { - const salt = randomBytes(16); - const hashed = scryptSync(password, salt, 64, { N: 4096, r: 8, p: 1 }).toString("base64"); - const passwordHash = `${salt.toString("base64")}:${hashed}`; + // Use Better-Auth's hashPassword so test helper matches production seed.ts + const { hashPassword } = await import("better-auth/crypto"); + const passwordHash = await hashPassword(password); const newAccount: AccountRow = { id: mockUuid(), @@ -236,11 +235,12 @@ describe("seedUatCredentials — credential provisioning logic", () => { expect(insertedAccounts).toHaveLength(4); for (const acct of insertedAccounts) { expect(acct.providerId).toBe("credential"); - expect(acct.password).toMatch(/^[A-Za-z0-9+/=]+:[A-Za-z0-9+/=]+$/); // salt:hash - // Verify the hash is scrypt with correct params - const [saltB64, hashB64] = acct.password!.split(":"); - const salt = Buffer.from(saltB64, "base64"); - const storedHash = Buffer.from(hashB64, "base64"); + // Better-Auth uses hex encoding: saltHex:keyHex (both lowercase hex) + expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/); + // Verify the hash is scrypt with correct params (N=16384, r=16, p=1, dkLen=64) + const [saltHex, keyHex] = acct.password!.split(":"); + const salt = Buffer.from(saltHex, "hex"); + const storedHash = Buffer.from(keyHex, "hex"); expect(salt).toHaveLength(16); expect(storedHash).toHaveLength(64); } @@ -271,13 +271,18 @@ describe("seedUatCredentials — credential provisioning logic", () => { const acct = insertedAccounts[0]!; expect(acct.providerId).toBe("credential"); - const [saltB64, hashB64] = acct.password!.split(":"); - expect(() => Buffer.from(saltB64, "base64")).not.toThrow(); - expect(() => Buffer.from(hashB64, "base64")).not.toThrow(); - // Verify the hash can be verified with the original password - const salt = Buffer.from(saltB64, "base64"); - const storedHash = Buffer.from(hashB64, "base64"); - const computed = scryptSync(TEST_PASSWORD, salt, 64, { N: 4096, r: 8, p: 1 }); + // Better-Auth uses hex: saltHex (32 chars) : keyHex (128 chars) + expect(acct.password).toMatch(/^[a-f0-9]+:[a-f0-9]+$/); + const [saltHex, keyHex] = acct.password!.split(":"); + expect(() => Buffer.from(saltHex, "hex")).not.toThrow(); + expect(() => Buffer.from(keyHex, "hex")).not.toThrow(); + const salt = Buffer.from(saltHex, "hex"); + const storedHash = Buffer.from(keyHex, "hex"); + expect(salt).toHaveLength(16); + expect(storedHash).toHaveLength(64); + // Verify the hash can be verified with the original password using Better-Auth params + const { scryptSync } = await import("node:crypto"); + const computed = scryptSync(TEST_PASSWORD.normalize("NFKC"), saltHex, 64, { N: 16384, r: 16, p: 1 }); expect(computed).toEqual(storedHash); }); diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index a12699a..566da17 100644 --- a/apps/api/src/db/seed.ts +++ b/apps/api/src/db/seed.ts @@ -563,13 +563,11 @@ async function seedKnownUsers() { if (existingAccount) { console.log(`✓ Credential account for '${acct.email}' already exists — skipping`); } else { - // Hash password using the same scrypt derivation as crypto.ts (AES-256-GCM key derivation). - // Better-Auth defaults to scrypt for password hashing; match those parameters here. - const { scryptSync, randomBytes } = await import("node:crypto"); - const salt = randomBytes(16); - // scryptSync(password, salt, keylen=64, N=32768, r=8, p=1) — matches common better-auth scrypt params - const hashed = scryptSync(password, salt, 64, { N: 32768, r: 8, p: 1 }).toString("base64"); - const passwordHash = `${salt.toString("base64")}:${hashed}`; + // Use Better-Auth's own hashPassword to guarantee parameter/encoding match. + // better-auth/crypto uses: N=16384, r=16, p=1, dkLen=64, salt as 16-byte random + // hex string, key hex-encoded, format saltHex:keyHex. + const { hashPassword } = await import("better-auth/crypto"); + const passwordHash = await hashPassword(password); await db.insert(schema.account).values({ id: uuid(),