Files
freno-dev/src/server/security/password.test.ts
2025-12-28 20:04:29 -05:00

530 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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]);
}
});
});
});