Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions my-sonicjs-app/scripts/seed-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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}`
}

/**
Expand Down
46 changes: 38 additions & 8 deletions packages/core/src/__tests__/middleware/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -90,29 +92,57 @@ 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)

const isValid = await AuthManager.verifyPassword(password, hash)
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)

const isValid = await AuthManager.verifyPassword('wrong-password', hash)
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)
})
})
})

Expand Down
94 changes: 90 additions & 4 deletions packages/core/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,103 @@ export class AuthManager {
}

static async hashPassword(password: string): Promise<string> {
// 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<string> {
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('')
}

static async verifyPassword(password: string, hash: string): Promise<boolean> {
const passwordHash = await this.hashPassword(password)
return passwordHash === hash
static async verifyPassword(password: string, storedHash: string): Promise<boolean> {
if (storedHash.startsWith('pbkdf2:')) {
// PBKDF2 format: pbkdf2:<iterations>:<salt_hex>:<hash_hex>
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:')
}

/**
Expand Down
38 changes: 31 additions & 7 deletions packages/core/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,18 +224,30 @@ 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,
secure: true,
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)
Expand Down Expand Up @@ -514,23 +526,35 @@ authRoutes.post('/login/form', async (c) => {
</div>
`)
}


// 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,
secure: false, // Set to true in production with HTTPS
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`
<div id="form-response">
<div class="rounded-lg bg-green-100 dark:bg-lime-500/10 p-4 ring-1 ring-green-400 dark:ring-lime-500/20">
Expand Down