From 1a94f8dd443944dce9412bb40bec2d541314d019 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 1 Mar 2026 20:22:48 +0800 Subject: [PATCH] feat: enhance password security with server-side hashing and constant-time comparisons --- .gitignore | 3 +-- src/handlers/accounts.ts | 28 +++++++++++++++----- src/handlers/identity.ts | 4 +-- src/services/auth.ts | 52 +++++++++++++++++++++++++++++++++----- src/utils/recovery-code.ts | 9 ++++++- src/utils/totp.ts | 12 +++++++-- webapp/vite.config.ts | 2 +- 7 files changed, 88 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index 47e5f8b..f46de0d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,13 +7,12 @@ node_modules/ wrangler.my.toml RELEASE_NOTES.md tests/selfcheck.ts +problem.md # Build output dist/ build/ - - # IDE .vscode/ .idea/ diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index 8d7439d..993a1ca 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -122,11 +122,14 @@ export async function handleRegister(request: Request, env: Env): Promise { const storage = new StorageService(env.DB); + const auth = new AuthService(env); const user = await storage.getUserById(userId); if (!user) { @@ -251,6 +255,7 @@ export async function handleSetKeys(request: Request, env: Env, userId: string): } let body: { + masterPasswordHash?: string; key?: string; encryptedPrivateKey?: string; publicKey?: string; @@ -262,6 +267,15 @@ export async function handleSetKeys(request: Request, env: Env, userId: string): return errorResponse('Invalid JSON', 400); } + // Require password verification before allowing key replacement. + if (!body.masterPasswordHash) { + return errorResponse('masterPasswordHash is required', 400); + } + const passwordValid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email); + if (!passwordValid) { + return errorResponse('Invalid password', 400); + } + if (body.key) user.key = body.key; if (body.encryptedPrivateKey) user.privateKey = body.encryptedPrivateKey; if (body.publicKey) user.publicKey = body.publicKey; @@ -308,7 +322,7 @@ export async function handleChangePassword(request: Request, env: Env, userId: s const currentHash = body.currentPasswordHash || body.masterPasswordHash; if (!currentHash) return errorResponse('Current password hash is required', 400); - const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash); + const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email); if (!valid) return errorResponse('Invalid password', 400); if (!body.newMasterPasswordHash) { @@ -324,7 +338,7 @@ export async function handleChangePassword(request: Request, env: Env, userId: s return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400); } - user.masterPasswordHash = body.newMasterPasswordHash; + user.masterPasswordHash = await auth.hashPasswordServer(body.newMasterPasswordHash, user.email); if (nextKey) user.key = nextKey; if (nextPrivateKey) user.privateKey = nextPrivateKey; if (nextPublicKey) user.publicKey = nextPublicKey; @@ -395,7 +409,7 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st if (!body.masterPasswordHash) { return errorResponse('masterPasswordHash is required to disable TOTP', 400); } - const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash); + const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email); if (!valid) return errorResponse('Invalid password', 400); user.totpSecret = null; @@ -430,7 +444,7 @@ export async function handleGetTotpRecoveryCode(request: Request, env: Env, user const currentHash = String(body.masterPasswordHash || body.master_password_hash || body.password || '').trim(); if (!currentHash) return errorResponse('masterPasswordHash is required', 400); - const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash); + const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email); if (!valid) return errorResponse('Invalid password', 400); if (!user.totpRecoveryCode) { @@ -488,7 +502,7 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis return errorResponse('Invalid credentials or recovery code', 400); } - const validPassword = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash); + const validPassword = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email); if (!validPassword) { await rateLimit.recordFailedLogin(recoverLimitKey); return errorResponse('Invalid credentials or recovery code', 400); @@ -547,7 +561,7 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s return errorResponse('masterPasswordHash is required', 400); } - const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash); + const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email); if (!valid) { return errorResponse('Invalid password', 400); } diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 13d6ce9..3c30ca5 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -114,7 +114,7 @@ export async function handleToken(request: Request, env: Env): Promise const twoFactorToken = body.twoFactorToken; const twoFactorProvider = body.twoFactorProvider; const twoFactorRemember = body.twoFactorRemember; - const loginIdentifier = clientIdentifier; + const loginIdentifier = `${clientIdentifier}:${email}`; const deviceInfo = readAuthRequestDeviceInfo(body, request); if (!email || !passwordHash) { @@ -142,7 +142,7 @@ export async function handleToken(request: Request, env: Env): Promise return identityErrorResponse('Account is disabled', 'invalid_grant', 400); } - const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash); + const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email); if (!valid) { return recordFailedLoginAndBuildResponse( rateLimit, diff --git a/src/services/auth.ts b/src/services/auth.ts index 12c1424..cf73386 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -2,6 +2,11 @@ import { Env, JWTPayload, User } from '../types'; import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt'; import { StorageService } from './storage'; +// Server-side iterations for second-layer hashing. +// The client already does heavy PBKDF2 (600k iterations). +// This second layer only needs to be non-trivial, not expensive. +const SERVER_HASH_ITERATIONS = 100_000; + export class AuthService { private storage: StorageService; @@ -9,15 +14,48 @@ export class AuthService { this.storage = new StorageService(env.DB); } - // Verify password hash (compare with stored hash) - async verifyPassword(inputHash: string, storedHash: string): Promise { - const input = new TextEncoder().encode(inputHash); - const stored = new TextEncoder().encode(storedHash); - if (input.length !== stored.length) return false; + // Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations). + // Ensures database contents alone cannot be used to authenticate (pass-the-hash defense). + // Result is prefixed with "$s$" to distinguish from legacy raw client hashes. + async hashPasswordServer(clientHash: string, email: string): Promise { + const keyMaterial = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(clientHash), + 'PBKDF2', + false, + ['deriveBits'] + ); + const salt = new TextEncoder().encode(email.toLowerCase().trim()); + const bits = await crypto.subtle.deriveBits( + { name: 'PBKDF2', hash: 'SHA-256', salt, iterations: SERVER_HASH_ITERATIONS }, + keyMaterial, + 256 + ); + const bytes = new Uint8Array(bits); + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return '$s$' + btoa(binary); + } + // Verify password: hash the input the same way, then constant-time compare. + async verifyPassword(inputHash: string, storedHash: string, email?: string): Promise { + // New server-hashed passwords are prefixed with "$s$". + // Legacy accounts (created before the upgrade) store raw client hashes without prefix. + if (email && storedHash.startsWith('$s$')) { + const serverHash = await this.hashPasswordServer(inputHash, email); + return this.constantTimeEquals(serverHash, storedHash); + } + // Legacy path: direct constant-time comparison of raw client hashes. + return this.constantTimeEquals(inputHash, storedHash); + } + + private constantTimeEquals(a: string, b: string): boolean { + const encA = new TextEncoder().encode(a); + const encB = new TextEncoder().encode(b); + if (encA.length !== encB.length) return false; let diff = 0; - for (let i = 0; i < input.length; i++) { - diff |= input[i] ^ stored[i]; + for (let i = 0; i < encA.length; i++) { + diff |= encA[i] ^ encB[i]; } return diff === 0; } diff --git a/src/utils/recovery-code.ts b/src/utils/recovery-code.ts index 017f083..730a5b5 100644 --- a/src/utils/recovery-code.ts +++ b/src/utils/recovery-code.ts @@ -24,5 +24,12 @@ export function createRecoveryCode(): string { export function recoveryCodeEquals(input: string, storedCode: string | null | undefined): boolean { if (!storedCode) return false; - return normalizeRecoveryCode(input) === normalizeRecoveryCode(storedCode); + const a = new TextEncoder().encode(normalizeRecoveryCode(input)); + const b = new TextEncoder().encode(normalizeRecoveryCode(storedCode)); + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a[i] ^ b[i]; + } + return diff === 0; } diff --git a/src/utils/totp.ts b/src/utils/totp.ts index 3d41117..f013630 100644 --- a/src/utils/totp.ts +++ b/src/utils/totp.ts @@ -69,11 +69,19 @@ export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs if (!secret) return false; const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS); + let matched = false; for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) { const expected = await hotp(secret, currentCounter + delta); - if (expected === token) return true; + // Constant-time comparison: always check all windows, never short-circuit. + const a = new TextEncoder().encode(expected); + const b = new TextEncoder().encode(token); + let diff = a.length ^ b.length; + for (let i = 0; i < a.length && i < b.length; i++) { + diff |= a[i] ^ b[i]; + } + if (diff === 0) matched = true; } - return false; + return matched; } export function isTotpEnabled(secretRaw: string | undefined | null): boolean { diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts index 20da10f..68f5b3d 100644 --- a/webapp/vite.config.ts +++ b/webapp/vite.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ }, build: { outDir: path.resolve(rootDir, '../dist'), - emptyOutDir: false, + emptyOutDir: true, sourcemap: true, }, server: {