diff --git a/packages/auth/server/routes/email-password.ts b/packages/auth/server/routes/email-password.ts index 1c2bd5d297..ddfceb4961 100644 --- a/packages/auth/server/routes/email-password.ts +++ b/packages/auth/server/routes/email-password.ts @@ -4,6 +4,7 @@ import { isEmailDomainAllowedForSignup, isSigninEnabledForProvider, isSignupEnabledForProvider, + TIMING_SAFE_DUMMY_PASSWORD_HASH, } from '@documenso/lib/constants/auth'; import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email'; import { AppError } from '@documenso/lib/errors/app-error'; @@ -102,6 +103,10 @@ export const emailPasswordRoute = new Hono() // same INVALID_CREDENTIALS error as a wrong password, so probing the // endpoint cannot reveal which emails are on the allowlist. if (!isSigninEnabledForProvider('email') && !isBreakGlassEmail(email)) { + // Equalise timing with the real compare below so measuring response + // time cannot reveal allowlist membership either. + await compare(password, TIMING_SAFE_DUMMY_PASSWORD_HASH); + throw new AppError(AuthenticationErrorCode.InvalidCredentials, { message: 'Invalid email or password', }); @@ -118,6 +123,10 @@ export const emailPasswordRoute = new Hono() }); if (!user || !user.password) { + // Equalise timing with the real compare below: unknown users and + // passwordless accounts must not be distinguishable by response time. + await compare(password, TIMING_SAFE_DUMMY_PASSWORD_HASH); + throw new AppError(AuthenticationErrorCode.InvalidCredentials, { message: 'Invalid email or password', }); diff --git a/packages/lib/constants/auth.test.ts b/packages/lib/constants/auth.test.ts index 06403ab1e8..5dffb9d47e 100644 --- a/packages/lib/constants/auth.test.ts +++ b/packages/lib/constants/auth.test.ts @@ -1,6 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getBreakGlassEmails, isBreakGlassEmail, isBreakGlassSigninEnabled } from './auth'; +import { + getBreakGlassEmails, + isBreakGlassEmail, + isBreakGlassSigninEnabled, + SALT_ROUNDS, + TIMING_SAFE_DUMMY_PASSWORD_HASH, +} from './auth'; describe('break-glass password signin allowlist', () => { afterEach(() => { @@ -33,3 +39,14 @@ describe('break-glass password signin allowlist', () => { expect(isBreakGlassEmail('')).toBe(false); }); }); + +describe('timing-safe dummy password hash', () => { + it('is a bcrypt hash at the same cost factor as real password hashing', () => { + // bcrypt format: $$$<22-char salt><31-char hash> + const parts = TIMING_SAFE_DUMMY_PASSWORD_HASH.split('$'); + + expect(parts[1]).toMatch(/^2[aby]$/); + expect(Number(parts[2])).toBe(SALT_ROUNDS); + expect(TIMING_SAFE_DUMMY_PASSWORD_HASH).toHaveLength(60); + }); +}); diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts index 20230a0e5f..f995c339fe 100644 --- a/packages/lib/constants/auth.ts +++ b/packages/lib/constants/auth.ts @@ -5,6 +5,16 @@ import { NEXT_PUBLIC_WEBAPP_URL } from './app'; export const SALT_ROUNDS = 12; +/** + * Precomputed bcrypt hash (cost = SALT_ROUNDS) of an unguessable string. + * Compared against the submitted password on early-rejected signin attempts + * (signin disabled suite-wide, unknown user, user without a password) so + * every failing path pays the same bcrypt cost and the endpoint cannot be + * probed by measuring verification time. Never validates anything: any + * compare against it returns false. + */ +export const TIMING_SAFE_DUMMY_PASSWORD_HASH = '$2y$12$eAJ6CR54acEljh1J/AN.peIPUd19yidDQRKhynGQriewBgzF1bQdm'; + export const IDENTITY_PROVIDER_NAME: Record = { DOCUMENSO: 'Documenso', GOOGLE: 'Google',