forked from Mike/freno-dev
security hardening
This commit is contained in:
529
src/server/security/password.test.ts
Normal file
529
src/server/security/password.test.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
/**
|
||||
* Password Security Tests
|
||||
* Tests for password hashing, validation, strength requirements, and timing attacks
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
hashPassword,
|
||||
checkPassword,
|
||||
checkPasswordSafe
|
||||
} from "~/server/password";
|
||||
import { validatePassword, passwordsMatch } from "~/lib/validation";
|
||||
import { measureTime } from "./test-utils";
|
||||
|
||||
describe("Password Security", () => {
|
||||
describe("Password Hashing", () => {
|
||||
it("should hash passwords using bcrypt", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
expect(hash).toBeDefined();
|
||||
expect(typeof hash).toBe("string");
|
||||
// Bcrypt hashes start with $2b$ or $2a$
|
||||
expect(hash).toMatch(/^\$2[ab]\$/);
|
||||
});
|
||||
|
||||
it("should generate unique hashes for same password", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash1 = await hashPassword(password);
|
||||
const hash2 = await hashPassword(password);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it("should generate hashes with sufficient length", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
// Bcrypt hashes are 60 characters long
|
||||
expect(hash.length).toBe(60);
|
||||
});
|
||||
|
||||
it("should handle very long passwords", async () => {
|
||||
const longPassword = "a".repeat(1000);
|
||||
const hash = await hashPassword(longPassword);
|
||||
|
||||
expect(hash).toBeDefined();
|
||||
expect(hash.length).toBe(60);
|
||||
});
|
||||
|
||||
it("should handle passwords with special characters", async () => {
|
||||
const specialPassword = "P@ssw0rd!#$%^&*()_+-=[]{}|;:',.<>?/~`";
|
||||
const hash = await hashPassword(specialPassword);
|
||||
|
||||
expect(hash).toBeDefined();
|
||||
const match = await checkPassword(specialPassword, hash);
|
||||
expect(match).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle passwords with unicode characters", async () => {
|
||||
const unicodePassword = "Pässwörd123🔐🛡️";
|
||||
const hash = await hashPassword(unicodePassword);
|
||||
|
||||
expect(hash).toBeDefined();
|
||||
const match = await checkPassword(unicodePassword, hash);
|
||||
expect(match).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle empty passwords", async () => {
|
||||
const emptyPassword = "";
|
||||
const hash = await hashPassword(emptyPassword);
|
||||
|
||||
expect(hash).toBeDefined();
|
||||
expect(hash.length).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Password Verification", () => {
|
||||
it("should verify correct password", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
const match = await checkPassword(password, hash);
|
||||
|
||||
expect(match).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject incorrect password", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const wrongPassword = "WrongPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
const match = await checkPassword(wrongPassword, hash);
|
||||
|
||||
expect(match).toBe(false);
|
||||
});
|
||||
|
||||
it("should be case-sensitive", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
const match = await checkPassword("testpassword123!", hash);
|
||||
|
||||
expect(match).toBe(false);
|
||||
});
|
||||
|
||||
it("should detect single character differences", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
const almostMatch = "TestPassword124!";
|
||||
const match = await checkPassword(almostMatch, hash);
|
||||
|
||||
expect(match).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject password with extra characters", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
const match = await checkPassword(password + "x", hash);
|
||||
|
||||
expect(match).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject password missing characters", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
const match = await checkPassword(password.slice(0, -1), hash);
|
||||
|
||||
expect(match).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Timing Attack Prevention", () => {
|
||||
it("should use constant time comparison in checkPasswordSafe", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
// Measure time for correct password
|
||||
const { duration: correctDuration } = await measureTime(() =>
|
||||
checkPasswordSafe(password, hash)
|
||||
);
|
||||
|
||||
// Measure time for incorrect password
|
||||
const { duration: incorrectDuration } = await measureTime(() =>
|
||||
checkPasswordSafe("WrongPassword123!", hash)
|
||||
);
|
||||
|
||||
// Bcrypt comparison should take similar time regardless
|
||||
const timingDifference = Math.abs(correctDuration - incorrectDuration);
|
||||
|
||||
// Allow reasonable variance (bcrypt is inherently slow)
|
||||
expect(timingDifference).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it("should handle null hash without timing leak", async () => {
|
||||
const password = "TestPassword123!";
|
||||
|
||||
// Measure time for null hash
|
||||
const { result: result1, duration: duration1 } = await measureTime(() =>
|
||||
checkPasswordSafe(password, null)
|
||||
);
|
||||
|
||||
// Measure time for undefined hash
|
||||
const { result: result2, duration: duration2 } = await measureTime(() =>
|
||||
checkPasswordSafe(password, undefined)
|
||||
);
|
||||
|
||||
expect(result1).toBe(false);
|
||||
expect(result2).toBe(false);
|
||||
|
||||
// Should take similar time
|
||||
const timingDifference = Math.abs(duration1 - duration2);
|
||||
expect(timingDifference).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it("should run bcrypt even when user doesn't exist", async () => {
|
||||
const password = "TestPassword123!";
|
||||
|
||||
// checkPasswordSafe should always run bcrypt to prevent timing attacks
|
||||
const { duration } = await measureTime(() =>
|
||||
checkPasswordSafe(password, null)
|
||||
);
|
||||
|
||||
// Should take at least a few milliseconds (bcrypt is slow)
|
||||
expect(duration).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("should have consistent timing for user exists vs not exists", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
// User exists
|
||||
const { duration: existsDuration } = await measureTime(() =>
|
||||
checkPasswordSafe("WrongPassword", hash)
|
||||
);
|
||||
|
||||
// User doesn't exist (null hash)
|
||||
const { duration: notExistsDuration } = await measureTime(() =>
|
||||
checkPasswordSafe("WrongPassword", null)
|
||||
);
|
||||
|
||||
// Timing should be similar to prevent user enumeration
|
||||
const timingDifference = Math.abs(existsDuration - notExistsDuration);
|
||||
expect(timingDifference).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Password Validation", () => {
|
||||
it("should accept strong passwords", () => {
|
||||
const strongPassword = "MyStr0ng!P@ssw0rd";
|
||||
const result = validatePassword(strongPassword);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.strength).toBe("good");
|
||||
});
|
||||
|
||||
it("should reject passwords shorter than 12 characters", () => {
|
||||
const shortPassword = "Short1!";
|
||||
const result = validatePassword(shortPassword);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
"Password must be at least 12 characters long"
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject passwords without uppercase letters", () => {
|
||||
const noUppercase = "lowercase123!@#";
|
||||
const result = validatePassword(noUppercase);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
"Password must contain at least one uppercase letter"
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject passwords without lowercase letters", () => {
|
||||
const noLowercase = "UPPERCASE123!@#";
|
||||
const result = validatePassword(noLowercase);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
"Password must contain at least one lowercase letter"
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject passwords without numbers", () => {
|
||||
const noNumbers = "NoNumbersHere!@#";
|
||||
const result = validatePassword(noNumbers);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
"Password must contain at least one number"
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject passwords without special characters", () => {
|
||||
const noSpecial = "NoSpecialChars123";
|
||||
const result = validatePassword(noSpecial);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
"Password must contain at least one special character"
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject common weak passwords", () => {
|
||||
const commonPatterns = [
|
||||
"Password123!",
|
||||
"Qwerty123456!",
|
||||
"Letmein12345!",
|
||||
"Welcome123!@"
|
||||
];
|
||||
|
||||
for (const password of commonPatterns) {
|
||||
const result = validatePassword(password);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("common patterns"))).toBe(
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should calculate password strength correctly", () => {
|
||||
const fairPassword = "MyP@ssw0rd12"; // 12 chars
|
||||
const goodPassword = "MyStr0ng!P@ssw0rd"; // 17 chars
|
||||
const strongPassword = "MyV3ry!Str0ng@P@ssw0rd123"; // 25 chars
|
||||
|
||||
expect(validatePassword(fairPassword).strength).toBe("fair");
|
||||
expect(validatePassword(goodPassword).strength).toBe("good");
|
||||
expect(validatePassword(strongPassword).strength).toBe("strong");
|
||||
});
|
||||
|
||||
it("should mark weak passwords appropriately", () => {
|
||||
const weakPassword = "weak";
|
||||
const result = validatePassword(weakPassword);
|
||||
|
||||
expect(result.strength).toBe("weak");
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Password Matching", () => {
|
||||
it("should confirm matching passwords", () => {
|
||||
const password = "TestPassword123!";
|
||||
const confirmation = "TestPassword123!";
|
||||
|
||||
expect(passwordsMatch(password, confirmation)).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject non-matching passwords", () => {
|
||||
const password = "TestPassword123!";
|
||||
const confirmation = "DifferentPassword123!";
|
||||
|
||||
expect(passwordsMatch(password, confirmation)).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject empty passwords", () => {
|
||||
expect(passwordsMatch("", "")).toBe(false);
|
||||
});
|
||||
|
||||
it("should be case-sensitive", () => {
|
||||
const password = "TestPassword123!";
|
||||
const confirmation = "testpassword123!";
|
||||
|
||||
expect(passwordsMatch(password, confirmation)).toBe(false);
|
||||
});
|
||||
|
||||
it("should detect single character differences", () => {
|
||||
const password = "TestPassword123!";
|
||||
const confirmation = "TestPassword124!";
|
||||
|
||||
expect(passwordsMatch(password, confirmation)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Password Attack Scenarios", () => {
|
||||
it("should resist brute force attacks with bcrypt slowness", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
// Measure time for multiple checks (simulating brute force)
|
||||
const start = performance.now();
|
||||
const attempts = 10;
|
||||
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
await checkPassword(`attempt${i}`, hash);
|
||||
}
|
||||
|
||||
const duration = performance.now() - start;
|
||||
const avgPerAttempt = duration / attempts;
|
||||
|
||||
// Each attempt should take significant time (bcrypt is slow)
|
||||
// This makes brute force impractical
|
||||
expect(avgPerAttempt).toBeGreaterThan(5); // At least 5ms per attempt
|
||||
});
|
||||
|
||||
it("should prevent rainbow table attacks with unique salts", async () => {
|
||||
const password = "CommonPassword123!";
|
||||
|
||||
// Generate multiple hashes for same password
|
||||
const hashes = await Promise.all(
|
||||
Array.from({ length: 10 }, () => hashPassword(password))
|
||||
);
|
||||
|
||||
// All hashes should be unique (different salts)
|
||||
const uniqueHashes = new Set(hashes);
|
||||
expect(uniqueHashes.size).toBe(10);
|
||||
});
|
||||
|
||||
it("should prevent password spraying with validation", () => {
|
||||
// Common passwords that should be rejected
|
||||
const commonPasswords = [
|
||||
"Password123!",
|
||||
"Welcome123!",
|
||||
"Admin123!@#",
|
||||
"Letmein123!"
|
||||
];
|
||||
|
||||
for (const password of commonPasswords) {
|
||||
const result = validatePassword(password);
|
||||
expect(result.isValid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should resist dictionary attacks", () => {
|
||||
// Dictionary words that should be caught
|
||||
const dictionaryBased = ["Sunshine123!", "Princess456!", "Dragon789!@"];
|
||||
|
||||
for (const password of dictionaryBased) {
|
||||
const result = validatePassword(password);
|
||||
expect(result.isValid).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle very long passwords", async () => {
|
||||
const longPassword = "A1!a" + "x".repeat(1000); // Very long but valid
|
||||
const hash = await hashPassword(longPassword);
|
||||
const match = await checkPassword(longPassword, hash);
|
||||
|
||||
expect(match).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle passwords with only whitespace", async () => {
|
||||
const whitespacePassword = " ";
|
||||
const result = validatePassword(whitespacePassword);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle null bytes in passwords", async () => {
|
||||
const nullBytePassword = "Test\0Password123!";
|
||||
const hash = await hashPassword(nullBytePassword);
|
||||
const match = await checkPassword(nullBytePassword, hash);
|
||||
|
||||
// Behavior may vary - just ensure no crash
|
||||
expect(typeof match).toBe("boolean");
|
||||
});
|
||||
|
||||
it("should handle passwords with emoji", () => {
|
||||
const emojiPassword = "MyP@ssw0rd🔐🛡️123";
|
||||
const result = validatePassword(emojiPassword);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle passwords with newlines", async () => {
|
||||
const newlinePassword = "Test\nPassword123!";
|
||||
const hash = await hashPassword(newlinePassword);
|
||||
const match = await checkPassword(newlinePassword, hash);
|
||||
|
||||
expect(match).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle passwords with tabs", async () => {
|
||||
const tabPassword = "Test\tPassword123!";
|
||||
const hash = await hashPassword(tabPassword);
|
||||
const match = await checkPassword(tabPassword, hash);
|
||||
|
||||
expect(match).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance", () => {
|
||||
it("should hash passwords with appropriate slowness", async () => {
|
||||
const password = "TestPassword123!";
|
||||
|
||||
const start = performance.now();
|
||||
await hashPassword(password);
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Bcrypt should be slow enough to deter brute force
|
||||
// With 10 rounds, should take at least a few milliseconds
|
||||
expect(duration).toBeGreaterThan(5);
|
||||
// But not too slow for normal operation
|
||||
expect(duration).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it("should verify passwords with consistent timing", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
const durations: number[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const start = performance.now();
|
||||
await checkPassword(password, hash);
|
||||
durations.push(performance.now() - start);
|
||||
}
|
||||
|
||||
// Timing should be relatively consistent
|
||||
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
|
||||
const maxDeviation = Math.max(...durations.map((d) => Math.abs(d - avg)));
|
||||
|
||||
// Allow reasonable variance
|
||||
expect(maxDeviation).toBeLessThan(avg * 0.5);
|
||||
});
|
||||
|
||||
it("should validate passwords quickly", () => {
|
||||
const password = "TestPassword123!";
|
||||
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
validatePassword(password);
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Validation is CPU-bound but should be fast
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bcrypt Salt Rounds", () => {
|
||||
it("should use appropriate salt rounds for security", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
// Check that hash uses correct salt rounds
|
||||
// Bcrypt format: $2b$rounds$salthash
|
||||
const parts = hash.split("$");
|
||||
const rounds = parseInt(parts[2]);
|
||||
|
||||
// Should use 10 rounds (from password.ts)
|
||||
expect(rounds).toBe(10);
|
||||
});
|
||||
|
||||
it("should generate cryptographically random salts", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hashes = await Promise.all(
|
||||
Array.from({ length: 100 }, () => hashPassword(password))
|
||||
);
|
||||
|
||||
// Extract salts from hashes
|
||||
const salts = hashes.map((hash) => {
|
||||
const parts = hash.split("$");
|
||||
return parts[3].substring(0, 22); // Salt is 22 characters
|
||||
});
|
||||
|
||||
// All salts should be unique
|
||||
const uniqueSalts = new Set(salts);
|
||||
expect(uniqueSalts.size).toBe(100);
|
||||
|
||||
// Check for patterns in salts (should be random)
|
||||
for (let i = 1; i < salts.length; i++) {
|
||||
// Salts should not be sequential or predictable
|
||||
expect(salts[i]).not.toBe(salts[i - 1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user