From 201602b733125b57ed31eaf8b2b1adb7be3abafd Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 8 May 2026 16:36:58 +0000 Subject: [PATCH] fix: use compiled-safe password hashing --- changelog.md | 7 ++ deno.json | 2 +- package.json | 2 +- test/auth_test.ts | 19 ++---- ts/opsserver/handlers/admin.handler.ts | 7 +- ts/plugins.ts | 4 -- ts/utils/auth.ts | 90 ++++++++++++++++++++++---- 7 files changed, 95 insertions(+), 36 deletions(-) diff --git a/changelog.md b/changelog.md index 18e824f..cd38fe0 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-05-08 - 1.24.6 - fix(auth) + +avoid bcrypt worker crashes in compiled binaries during login and password creation + +- replace bcrypt password hashing with a Web Crypto PBKDF2 hash format +- remove legacy password-hash fallbacks; existing deployments need their admin user hash updated + ## 2026-05-08 - 1.24.5 - fix(opsserver) start the OpsServer with typedserver custom routes registered through the UtilityWebsiteServer hook diff --git a/deno.json b/deno.json index 34d3a57..cbe4ab5 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@serve.zone/onebox", - "version": "1.24.5", + "version": "1.24.6", "exports": "./mod.ts", "tasks": { "test": "deno test --allow-all test/", diff --git a/package.json b/package.json index 41d2764..7368dd3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@serve.zone/onebox", - "version": "1.24.5", + "version": "1.24.6", "description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers", "main": "mod.ts", "type": "module", diff --git a/test/auth_test.ts b/test/auth_test.ts index 0fb8626..27dca98 100644 --- a/test/auth_test.ts +++ b/test/auth_test.ts @@ -5,8 +5,7 @@ import type { IUser as IDatabaseUser } from '../ts/types.ts'; import { AdminHandler } from '../ts/opsserver/handlers/admin.handler.ts'; import { hashPassword, - isBcryptHash, - needsPasswordUpgrade, + isPbkdf2Hash, verifyPassword, } from '../ts/utils/auth.ts'; @@ -45,18 +44,14 @@ async function createAdminHandler(users: IDatabaseUser[]): Promise return adminHandler; } -Deno.test('password helpers support bcrypt and legacy password hashes', async () => { +Deno.test('password helpers support PBKDF2 password hashes', async () => { const password = 'correct horse battery staple'; - const bcryptHash = await hashPassword(password); + const passwordHash = await hashPassword(password); - assert(isBcryptHash(bcryptHash)); - assert(await verifyPassword(password, bcryptHash)); - assert(!(await verifyPassword('wrong password', bcryptHash))); - assert(!needsPasswordUpgrade(bcryptHash)); - - const legacyHash = btoa(password); - assert(await verifyPassword(password, legacyHash)); - assert(needsPasswordUpgrade(legacyHash)); + assert(isPbkdf2Hash(passwordHash)); + assert(await verifyPassword(password, passwordHash)); + assert(!(await verifyPassword('wrong password', passwordHash))); + assert(!(await verifyPassword(password, btoa(password)))); }); Deno.test('verified identity is derived from the signed JWT and database, not client fields', async () => { diff --git a/ts/opsserver/handlers/admin.handler.ts b/ts/opsserver/handlers/admin.handler.ts index 7fd0093..d940e78 100644 --- a/ts/opsserver/handlers/admin.handler.ts +++ b/ts/opsserver/handlers/admin.handler.ts @@ -2,7 +2,7 @@ import * as plugins from '../../plugins.ts'; import { logger } from '../../logging.ts'; import type { OpsServer } from '../classes.opsserver.ts'; import * as interfaces from '../../../ts_interfaces/index.ts'; -import { hashPassword, needsPasswordUpgrade, verifyPassword } from '../../utils/auth.ts'; +import { hashPassword, verifyPassword } from '../../utils/auth.ts'; export interface IJwtData { userId: string; @@ -112,11 +112,6 @@ export class AdminHandler { throw new plugins.typedrequest.TypedResponseError('Invalid credentials'); } - if (needsPasswordUpgrade(user.passwordHash)) { - const upgradedHash = await hashPassword(dataArg.password); - this.opsServerRef.oneboxRef.database.updateUserPassword(user.username, upgradedHash); - } - const expiresAt = Date.now() + 24 * 3600 * 1000; const freshUser = this.opsServerRef.oneboxRef.database.getUserByUsername(user.username) || user; const identity = await this.createIdentityForUser(freshUser, expiresAt); diff --git a/ts/plugins.ts b/ts/plugins.ts index 1ddcac5..e297517 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -55,10 +55,6 @@ export const awsS3 = { import * as taskbuffer from '@push.rocks/taskbuffer'; export { taskbuffer }; -// Crypto utilities (for password hashing, encryption) -import * as bcrypt from 'https://deno.land/x/bcrypt@v0.4.1/mod.ts'; -export { bcrypt }; - // JWT for authentication import * as jwt from 'https://deno.land/x/djwt@v3.0.2/mod.ts'; export { jwt}; diff --git a/ts/utils/auth.ts b/ts/utils/auth.ts index ac812d1..017b31c 100644 --- a/ts/utils/auth.ts +++ b/ts/utils/auth.ts @@ -1,17 +1,79 @@ -import * as plugins from '../plugins.ts'; +const pbkdf2HashPattern = /^pbkdf2-sha256\$(\d+)\$([A-Za-z0-9+/=]+)\$([A-Za-z0-9+/=]+)$/; +const pbkdf2Iterations = 210_000; +const pbkdf2KeyLengthBits = 256; -const bcryptHashPattern = /^\$2[abxy]\$\d\d\$/; +const bytesToBase64 = (bytesArg: Uint8Array): string => { + let binary = ''; + for (const byte of bytesArg) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +}; -export function isBcryptHash(passwordHash: string): boolean { - return bcryptHashPattern.test(passwordHash); -} +const base64ToBytes = (base64Arg: string): Uint8Array => { + const binary = atob(base64Arg); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +}; -export function needsPasswordUpgrade(passwordHash: string): boolean { - return !isBcryptHash(passwordHash); +const timingSafeEqual = (aArg: Uint8Array, bArg: Uint8Array): boolean => { + if (aArg.length !== bArg.length) { + return false; + } + + let diff = 0; + for (let i = 0; i < aArg.length; i++) { + diff |= aArg[i] ^ bArg[i]; + } + return diff === 0; +}; + +const toArrayBuffer = (bytesArg: Uint8Array): ArrayBuffer => { + return bytesArg.buffer.slice( + bytesArg.byteOffset, + bytesArg.byteOffset + bytesArg.byteLength, + ) as ArrayBuffer; +}; + +const derivePasswordHash = async ( + passwordArg: string, + saltArg: Uint8Array, + iterationsArg: number, +): Promise => { + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(passwordArg), + 'PBKDF2', + false, + ['deriveBits'], + ); + + const bits = await crypto.subtle.deriveBits( + { + name: 'PBKDF2', + hash: 'SHA-256', + salt: toArrayBuffer(saltArg), + iterations: iterationsArg, + }, + key, + pbkdf2KeyLengthBits, + ); + + return new Uint8Array(bits); +}; + +export function isPbkdf2Hash(passwordHash: string): boolean { + return pbkdf2HashPattern.test(passwordHash); } export async function hashPassword(password: string): Promise { - return await plugins.bcrypt.hash(password); + // Use Web Crypto only so compiled binaries do not depend on external worker files. + const salt = crypto.getRandomValues(new Uint8Array(16)); + const hash = await derivePasswordHash(password, salt, pbkdf2Iterations); + return `pbkdf2-sha256$${pbkdf2Iterations}$${bytesToBase64(salt)}$${bytesToBase64(hash)}`; } export async function verifyPassword(password: string, passwordHash: string): Promise { @@ -19,10 +81,14 @@ export async function verifyPassword(password: string, passwordHash: string): Pr return false; } - if (isBcryptHash(passwordHash)) { - return await plugins.bcrypt.compare(password, passwordHash); + const pbkdf2Match = passwordHash.match(pbkdf2HashPattern); + if (pbkdf2Match) { + const iterations = Number(pbkdf2Match[1]); + const salt = base64ToBytes(pbkdf2Match[2]); + const expectedHash = base64ToBytes(pbkdf2Match[3]); + const actualHash = await derivePasswordHash(password, salt, iterations); + return timingSafeEqual(actualHash, expectedHash); } - // Legacy compatibility for older databases that stored base64-encoded passwords. - return passwordHash === btoa(password); + return false; }