diff --git a/my-sonicjs-app/scripts/seed-admin.ts b/my-sonicjs-app/scripts/seed-admin.ts index 9fdb857ac..77ad79186 100644 --- a/my-sonicjs-app/scripts/seed-admin.ts +++ b/my-sonicjs-app/scripts/seed-admin.ts @@ -3,14 +3,37 @@ import { eq } from 'drizzle-orm' import { getPlatformProxy } from 'wrangler' /** - * Hash password using Web Crypto API (same as SonicJS AuthManager) + * Hash password using PBKDF2 via Web Crypto API (same as SonicJS AuthManager) */ async function hashPassword(password: string): Promise { + const iterations = 100000 + const salt = new Uint8Array(16) + crypto.getRandomValues(salt) + const encoder = new TextEncoder() - const data = encoder.encode(password + 'salt-change-in-production') - const hashBuffer = await crypto.subtle.digest('SHA-256', data) - const hashArray = Array.from(new Uint8Array(hashBuffer)) - return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(password), + 'PBKDF2', + false, + ['deriveBits'] + ) + + const hashBuffer = await crypto.subtle.deriveBits( + { + name: 'PBKDF2', + salt, + iterations, + hash: 'SHA-256' + }, + keyMaterial, + 256 + ) + + const saltHex = Array.from(salt).map(b => b.toString(16).padStart(2, '0')).join('') + const hashHex = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('') + + return `pbkdf2:${iterations}:${saltHex}:${hashHex}` } /** diff --git a/packages/core/src/__tests__/middleware/auth.test.ts b/packages/core/src/__tests__/middleware/auth.test.ts index 608b216b1..9d25a5d76 100644 --- a/packages/core/src/__tests__/middleware/auth.test.ts +++ b/packages/core/src/__tests__/middleware/auth.test.ts @@ -62,23 +62,25 @@ describe('AuthManager', () => { }) }) - describe('hashPassword', () => { - it('should hash a password', async () => { + describe('hashPassword (PBKDF2)', () => { + it('should hash a password in PBKDF2 format', async () => { const password = 'test-password-123' const hash = await AuthManager.hashPassword(password) expect(hash).toBeTruthy() expect(typeof hash).toBe('string') expect(hash).not.toBe(password) - expect(hash.length).toBe(64) // SHA-256 produces 64 hex characters + expect(hash.startsWith('pbkdf2:100000:')).toBe(true) + const parts = hash.split(':') + expect(parts).toHaveLength(4) }) - it('should generate same hash for same password', async () => { + it('should generate different hashes for same password (random salt)', async () => { const password = 'test-password-123' const hash1 = await AuthManager.hashPassword(password) const hash2 = await AuthManager.hashPassword(password) - expect(hash1).toBe(hash2) + expect(hash1).not.toBe(hash2) // Different salts }) it('should generate different hashes for different passwords', async () => { @@ -90,7 +92,7 @@ describe('AuthManager', () => { }) describe('verifyPassword', () => { - it('should verify correct password', async () => { + it('should verify correct password against PBKDF2 hash', async () => { const password = 'test-password-123' const hash = await AuthManager.hashPassword(password) @@ -98,7 +100,7 @@ describe('AuthManager', () => { expect(isValid).toBe(true) }) - it('should reject incorrect password', async () => { + it('should reject incorrect password against PBKDF2 hash', async () => { const password = 'test-password-123' const hash = await AuthManager.hashPassword(password) @@ -106,13 +108,41 @@ describe('AuthManager', () => { expect(isValid).toBe(false) }) - it('should reject empty password', async () => { + it('should reject empty password against PBKDF2 hash', async () => { const password = 'test-password-123' const hash = await AuthManager.hashPassword(password) const isValid = await AuthManager.verifyPassword('', hash) expect(isValid).toBe(false) }) + + it('should verify correct password against legacy SHA-256 hash', async () => { + const password = 'test-password-123' + const legacyHash = await AuthManager.hashPasswordLegacy(password) + + const isValid = await AuthManager.verifyPassword(password, legacyHash) + expect(isValid).toBe(true) + }) + + it('should reject incorrect password against legacy SHA-256 hash', async () => { + const password = 'test-password-123' + const legacyHash = await AuthManager.hashPasswordLegacy(password) + + const isValid = await AuthManager.verifyPassword('wrong-password', legacyHash) + expect(isValid).toBe(false) + }) + }) + + describe('isLegacyHash', () => { + it('should detect PBKDF2 hash as non-legacy', async () => { + const hash = await AuthManager.hashPassword('test') + expect(AuthManager.isLegacyHash(hash)).toBe(false) + }) + + it('should detect SHA-256 hash as legacy', async () => { + const hash = await AuthManager.hashPasswordLegacy('test') + expect(AuthManager.isLegacyHash(hash)).toBe(true) + }) }) }) diff --git a/packages/core/src/middleware/auth.ts b/packages/core/src/middleware/auth.ts index 29c501ed4..75e2e1514 100644 --- a/packages/core/src/middleware/auth.ts +++ b/packages/core/src/middleware/auth.ts @@ -43,7 +43,37 @@ export class AuthManager { } static async hashPassword(password: string): Promise { - // In Cloudflare Workers, we'll use Web Crypto API + const iterations = 100000 + const salt = new Uint8Array(16) + crypto.getRandomValues(salt) + + const encoder = new TextEncoder() + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(password), + 'PBKDF2', + false, + ['deriveBits'] + ) + + const hashBuffer = await crypto.subtle.deriveBits( + { + name: 'PBKDF2', + salt, + iterations, + hash: 'SHA-256' + }, + keyMaterial, + 256 + ) + + const saltHex = Array.from(salt).map(b => b.toString(16).padStart(2, '0')).join('') + const hashHex = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('') + + return `pbkdf2:${iterations}:${saltHex}:${hashHex}` + } + + static async hashPasswordLegacy(password: string): Promise { const encoder = new TextEncoder() const data = encoder.encode(password + 'salt-change-in-production') const hashBuffer = await crypto.subtle.digest('SHA-256', data) @@ -51,9 +81,65 @@ export class AuthManager { return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') } - static async verifyPassword(password: string, hash: string): Promise { - const passwordHash = await this.hashPassword(password) - return passwordHash === hash + static async verifyPassword(password: string, storedHash: string): Promise { + if (storedHash.startsWith('pbkdf2:')) { + // PBKDF2 format: pbkdf2::: + const parts = storedHash.split(':') + if (parts.length !== 4) return false + + const iterationsStr = parts[1]! + const saltHex = parts[2]! + const expectedHashHex = parts[3]! + const iterations = parseInt(iterationsStr, 10) + + const saltBytes = saltHex.match(/.{2}/g) + if (!saltBytes) return false + const salt = new Uint8Array(saltBytes.map(byte => parseInt(byte, 16))) + + const encoder = new TextEncoder() + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(password), + 'PBKDF2', + false, + ['deriveBits'] + ) + + const hashBuffer = await crypto.subtle.deriveBits( + { + name: 'PBKDF2', + salt, + iterations, + hash: 'SHA-256' + }, + keyMaterial, + 256 + ) + + const actualHashHex = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('') + + // Constant-time comparison + if (actualHashHex.length !== expectedHashHex.length) return false + let result = 0 + for (let i = 0; i < actualHashHex.length; i++) { + result |= actualHashHex.charCodeAt(i) ^ expectedHashHex.charCodeAt(i) + } + return result === 0 + } + + // Legacy SHA-256 format (no colons in hash) + const legacyHash = await this.hashPasswordLegacy(password) + // Constant-time comparison for legacy too + if (legacyHash.length !== storedHash.length) return false + let result = 0 + for (let i = 0; i < legacyHash.length; i++) { + result |= legacyHash.charCodeAt(i) ^ storedHash.charCodeAt(i) + } + return result === 0 + } + + static isLegacyHash(storedHash: string): boolean { + return !storedHash.startsWith('pbkdf2:') } /** diff --git a/packages/core/src/routes/auth.ts b/packages/core/src/routes/auth.ts index c36513ff2..adfe8ce92 100644 --- a/packages/core/src/routes/auth.ts +++ b/packages/core/src/routes/auth.ts @@ -224,10 +224,22 @@ authRoutes.post('/login', async (c) => { if (!isValidPassword) { return c.json({ error: 'Invalid email or password' }, 401) } - + + // Transparent password hash migration: re-hash legacy SHA-256 to PBKDF2 + if (AuthManager.isLegacyHash(user.password_hash)) { + try { + const newHash = await AuthManager.hashPassword(password) + await db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?') + .bind(newHash, Date.now(), user.id) + .run() + } catch (rehashError) { + console.error('Password rehash failed (non-fatal):', rehashError) + } + } + // Generate JWT token const token = await AuthManager.generateToken(user.id, user.email, user.role) - + // Set HTTP-only cookie setCookie(c, 'auth_token', token, { httpOnly: true, @@ -235,7 +247,7 @@ authRoutes.post('/login', async (c) => { sameSite: 'Strict', maxAge: 60 * 60 * 24 // 24 hours }) - + // Update last login await db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?') .bind(new Date().getTime(), user.id) @@ -514,10 +526,22 @@ authRoutes.post('/login/form', async (c) => { `) } - + + // Transparent password hash migration: re-hash legacy SHA-256 to PBKDF2 + if (AuthManager.isLegacyHash(user.password_hash)) { + try { + const newHash = await AuthManager.hashPassword(password) + await db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?') + .bind(newHash, Date.now(), user.id) + .run() + } catch (rehashError) { + console.error('Password rehash failed (non-fatal):', rehashError) + } + } + // Generate JWT token const token = await AuthManager.generateToken(user.id, user.email, user.role) - + // Set HTTP-only cookie setCookie(c, 'auth_token', token, { httpOnly: true, @@ -525,12 +549,12 @@ authRoutes.post('/login/form', async (c) => { sameSite: 'Strict', maxAge: 60 * 60 * 24 // 24 hours }) - + // Update last login await db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?') .bind(new Date().getTime(), user.id) .run() - + return c.html(html`