mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: enhance password security with server-side hashing and constant-time comparisons
This commit is contained in:
+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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user