mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: enhance password security with server-side hashing and constant-time comparisons
This commit is contained in:
+1
-2
@@ -7,13 +7,12 @@ node_modules/
|
||||
wrangler.my.toml
|
||||
RELEASE_NOTES.md
|
||||
tests/selfcheck.ts
|
||||
problem.md
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
|
||||
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
@@ -122,11 +122,14 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const auth = new AuthService(env);
|
||||
const serverHash = await auth.hashPasswordServer(masterPasswordHash, email);
|
||||
|
||||
const user: User = {
|
||||
id: generateUUID(),
|
||||
email,
|
||||
name: name || email,
|
||||
masterPasswordHash,
|
||||
masterPasswordHash: serverHash,
|
||||
key,
|
||||
privateKey,
|
||||
publicKey,
|
||||
@@ -244,6 +247,7 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
|
||||
// POST /api/accounts/keys
|
||||
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
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<Response>
|
||||
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,
|
||||
|
||||
+45
-7
@@ -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<boolean> {
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+10
-2
@@ -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 {
|
||||
|
||||
@@ -15,7 +15,7 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
outDir: path.resolve(rootDir, '../dist'),
|
||||
emptyOutDir: false,
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
},
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user