183 lines
6.2 KiB
JavaScript
183 lines
6.2 KiB
JavaScript
/**
|
|
* Password Hasher - CVE-2 Remediation
|
|
*
|
|
* Fixes weak password hashing by replacing SHA-256 with hardcoded salt
|
|
* with bcrypt using 12 rounds (configurable).
|
|
*
|
|
* Security Properties:
|
|
* - bcrypt with adaptive cost factor (12 rounds)
|
|
* - Automatic salt generation per password
|
|
* - Timing-safe comparison
|
|
* - Minimum password length enforcement
|
|
*
|
|
* @module v3/security/password-hasher
|
|
*/
|
|
import * as bcrypt from 'bcrypt';
|
|
export class PasswordHashError extends Error {
|
|
code;
|
|
constructor(message, code) {
|
|
super(message);
|
|
this.code = code;
|
|
this.name = 'PasswordHashError';
|
|
}
|
|
}
|
|
/**
|
|
* Secure password hasher using bcrypt.
|
|
*
|
|
* This class replaces the vulnerable SHA-256 + hardcoded salt implementation
|
|
* with industry-standard bcrypt hashing.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const hasher = new PasswordHasher({ rounds: 12 });
|
|
* const hash = await hasher.hash('securePassword123');
|
|
* const isValid = await hasher.verify('securePassword123', hash);
|
|
* ```
|
|
*/
|
|
export class PasswordHasher {
|
|
config;
|
|
constructor(config = {}) {
|
|
this.config = {
|
|
rounds: config.rounds ?? 12,
|
|
minLength: config.minLength ?? 8,
|
|
maxLength: config.maxLength ?? 128,
|
|
requireUppercase: config.requireUppercase ?? true,
|
|
requireLowercase: config.requireLowercase ?? true,
|
|
requireDigit: config.requireDigit ?? true,
|
|
requireSpecial: config.requireSpecial ?? false,
|
|
};
|
|
// Validate configuration
|
|
if (this.config.rounds < 10 || this.config.rounds > 20) {
|
|
throw new PasswordHashError('Bcrypt rounds must be between 10 and 20 for security and performance balance', 'INVALID_ROUNDS');
|
|
}
|
|
if (this.config.minLength < 8) {
|
|
throw new PasswordHashError('Minimum password length must be at least 8 characters', 'INVALID_MIN_LENGTH');
|
|
}
|
|
}
|
|
/**
|
|
* Validates password against configured requirements.
|
|
*
|
|
* @param password - The password to validate
|
|
* @returns Validation result with errors if any
|
|
*/
|
|
validate(password) {
|
|
const errors = [];
|
|
if (!password) {
|
|
errors.push('Password is required');
|
|
return { isValid: false, errors };
|
|
}
|
|
if (password.length < this.config.minLength) {
|
|
errors.push(`Password must be at least ${this.config.minLength} characters`);
|
|
}
|
|
if (password.length > this.config.maxLength) {
|
|
errors.push(`Password must not exceed ${this.config.maxLength} characters`);
|
|
}
|
|
if (this.config.requireUppercase && !/[A-Z]/.test(password)) {
|
|
errors.push('Password must contain at least one uppercase letter');
|
|
}
|
|
if (this.config.requireLowercase && !/[a-z]/.test(password)) {
|
|
errors.push('Password must contain at least one lowercase letter');
|
|
}
|
|
if (this.config.requireDigit && !/\d/.test(password)) {
|
|
errors.push('Password must contain at least one digit');
|
|
}
|
|
if (this.config.requireSpecial && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
|
|
errors.push('Password must contain at least one special character');
|
|
}
|
|
return {
|
|
isValid: errors.length === 0,
|
|
errors,
|
|
};
|
|
}
|
|
/**
|
|
* Hashes a password using bcrypt.
|
|
*
|
|
* @param password - The plaintext password to hash
|
|
* @returns The bcrypt hash
|
|
* @throws PasswordHashError if password is invalid
|
|
*/
|
|
async hash(password) {
|
|
const validation = this.validate(password);
|
|
if (!validation.isValid) {
|
|
throw new PasswordHashError(validation.errors.join('; '), 'VALIDATION_FAILED');
|
|
}
|
|
try {
|
|
// bcrypt automatically generates a random salt per hash
|
|
return await bcrypt.hash(password, this.config.rounds);
|
|
}
|
|
catch (error) {
|
|
throw new PasswordHashError('Failed to hash password', 'HASH_FAILED');
|
|
}
|
|
}
|
|
/**
|
|
* Verifies a password against a bcrypt hash.
|
|
* Uses timing-safe comparison internally.
|
|
*
|
|
* @param password - The plaintext password to verify
|
|
* @param hash - The bcrypt hash to compare against
|
|
* @returns True if password matches, false otherwise
|
|
*/
|
|
async verify(password, hash) {
|
|
if (!password || !hash) {
|
|
return false;
|
|
}
|
|
// Validate hash format (bcrypt hashes start with $2a$, $2b$, or $2y$)
|
|
if (!this.isValidBcryptHash(hash)) {
|
|
return false;
|
|
}
|
|
try {
|
|
// bcrypt.compare uses timing-safe comparison
|
|
return await bcrypt.compare(password, hash);
|
|
}
|
|
catch (error) {
|
|
// Return false on any error to prevent timing attacks
|
|
return false;
|
|
}
|
|
}
|
|
/**
|
|
* Checks if a hash needs to be rehashed with updated parameters.
|
|
* Useful for upgrading hash strength over time.
|
|
*
|
|
* @param hash - The bcrypt hash to check
|
|
* @returns True if hash should be updated
|
|
*/
|
|
needsRehash(hash) {
|
|
if (!this.isValidBcryptHash(hash)) {
|
|
return true;
|
|
}
|
|
// Extract rounds from hash (format: $2b$XX$...)
|
|
const match = hash.match(/^\$2[aby]\$(\d{2})\$/);
|
|
if (!match) {
|
|
return true;
|
|
}
|
|
const hashRounds = parseInt(match[1], 10);
|
|
return hashRounds < this.config.rounds;
|
|
}
|
|
/**
|
|
* Validates bcrypt hash format.
|
|
*
|
|
* @param hash - The hash to validate
|
|
* @returns True if valid bcrypt hash format
|
|
*/
|
|
isValidBcryptHash(hash) {
|
|
// bcrypt hash format: $2a$XX$22charsSalt31charsHash
|
|
// Total length: 60 characters
|
|
return /^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/.test(hash);
|
|
}
|
|
/**
|
|
* Returns current configuration (without sensitive defaults).
|
|
*/
|
|
getConfig() {
|
|
return { ...this.config };
|
|
}
|
|
}
|
|
/**
|
|
* Factory function to create a production-ready password hasher.
|
|
*
|
|
* @param rounds - Bcrypt rounds (default: 12)
|
|
* @returns Configured PasswordHasher instance
|
|
*/
|
|
export function createPasswordHasher(rounds = 12) {
|
|
return new PasswordHasher({ rounds });
|
|
}
|
|
//# sourceMappingURL=password-hasher.js.map
|