diff --git a/app/hash-collision/page.tsx b/app/hash-collision/page.tsx new file mode 100644 index 00000000..a20de000 --- /dev/null +++ b/app/hash-collision/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import HashCollisionFinder from "../../components/hash/HashCollisionFinder"; +import Navbar from "../../components/layout/Navbar"; +import Footer from "../../components/layout/footer"; + +export const metadata: Metadata = { + title: "Hash Collision Finder — Birthday Attack Simulator | CryptoViz", + description: + "Find hash collisions using the birthday attack method. Interactive educational tool demonstrating why hash function collision resistance matters for security.", +}; + +export default function HashCollisionPage() { + return ( +
+ +
+ +
+
+ ); +} diff --git a/components/hash/HashCollisionFinder.tsx b/components/hash/HashCollisionFinder.tsx new file mode 100644 index 00000000..e623f04b --- /dev/null +++ b/components/hash/HashCollisionFinder.tsx @@ -0,0 +1,604 @@ +"use client"; + +import { useState, useCallback, useMemo } from "react"; +import { + findCollision, + birthdayStats, + analyzeHash, + type HashAlgorithm, + type CollisionResult, + type BirthdayAttackStats, + type HashAnalysis, + EXPLANATIONS, + type KnownCollision, +} from "../../lib/hash/collisionFinder"; +import { + Play, + Pause, + RotateCcw, + Zap, + Target, + BarChart3, + Clock, + AlertTriangle, + Check, + Info, + Shield, + ShieldAlert, + ShieldCheck, + Copy, +} from "lucide-react"; +import { cn } from "../../lib/utils"; + +/* ─── Constants ────────────────────────────────────────────────────────────── */ + +const ALGORITHMS: { id: HashAlgorithm; label: string; bits: number; security: string }[] = [ + { id: "md5", label: "MD5", bits: 128, security: "broken" }, + { id: "sha-1", label: "SHA-1", bits: 160, security: "deprecated" }, + { id: "sha-256", label: "SHA-256", bits: 256, security: "secure" }, + { id: "sha-512", label: "SHA-512", bits: 512, security: "secure" }, +] + +const BIT_OPTIONS = [4, 6, 8, 10, 12, 14, 16, 18, 20, 24] + +/* ─── Probability Meter ────────────────────────────────────────────────────── */ + +function ProbabilityMeter({ probability }: { probability: number }) { + const pct = Math.min(100, probability * 100) + let colorClass = "bg-zinc-600" + if (pct > 75) colorClass = "bg-emerald-500" + else if (pct > 50) colorClass = "bg-amber-500" + else if (pct > 25) colorClass = "bg-orange-500" + + return ( +
+
+ Collision Probability + + {(pct).toFixed(2)}% + +
+
+
+
+
+ ) +} + +/* ─── Stats Grid ───────────────────────────────────────────────────────────── */ + +function StatsGrid({ stats, attempts }: { stats: BirthdayAttackStats; attempts: number }) { + return ( +
+
+
+ + + Hash Space + +
+

+ 2^{stats.bitsOfSecurity} +

+

+ = {stats.spaceSize.toLocaleString()} values +

+
+
+
+ + + Expected (50%) + +
+

+ ~{stats.expected50Percent.toLocaleString()} +

+

+ ≈ √(π/2 × 2^n) attempts +

+
+
+
+ + + Attempts Made + +
+

+ {attempts.toLocaleString()} +

+

+ {attempts > 0 ? `${((attempts / stats.expected50Percent) * 100).toFixed(0)}% of expected` : ""} +

+
+
+
+ + + Expected (99%) + +
+

+ ~{stats.expected99Percent.toLocaleString()} +

+

+ Near-certain collision +

+
+
+ ) +} + +/* ─── Hash Table Display ───────────────────────────────────────────────────── */ + +function HashTable({ result }: { result: CollisionResult }) { + if (result.history.length === 0) return null + + return ( +
+ + + + + + + + + + {result.history.slice(-30).map((attempt) => ( + + + + + + ))} + +
#InputHash (truncated)
+ {attempt.attemptNumber} + + {attempt.input} + + {attempt.hash} +
+
+ ) +} + +/* ─── Known Collision Card ─────────────────────────────────────────────────── */ + +function CollisionCard({ collision }: { collision: KnownCollision }) { + return ( +
+
+ +
+

{collision.algorithm}

+

+ {collision.description} +

+

+ {collision.reference} +

+
+
+
+ ) +} + +/* ─── Main Component ──────────────────────────────────────────────────────── */ + +export default function HashCollisionFinder() { + const [algorithm, setAlgorithm] = useState("sha-256") + const [bits, setBits] = useState(12) + const [inputLength, setInputLength] = useState(8) + const [result, setResult] = useState(null) + const [isRunning, setIsRunning] = useState(false) + const [progress, setProgress] = useState({ attempts: 0, lastHash: "" }) + const [stats, setStats] = useState(null) + + const currentStats = useMemo(() => { + return birthdayStats(bits, progress.attempts) + }, [bits, progress.attempts]) + + const handleFind = useCallback(async () => { + setIsRunning(true) + setResult(null) + setProgress({ attempts: 0, lastHash: "" }) + + try { + const res = await findCollision(algorithm, bits, inputLength, (attempts, hash) => { + setProgress({ attempts, lastHash: hash }) + }) + setResult(res) + setStats(birthdayStats(bits, res.attempts)) + } catch (err) { + console.error("Collision finding failed:", err) + } finally { + setIsRunning(false) + } + }, [algorithm, bits, inputLength]) + + const handleReset = useCallback(() => { + setResult(null) + setProgress({ attempts: 0, lastHash: "" }) + setStats(null) + setIsRunning(false) + }, []) + + const selectedAlgo = ALGORITHMS.find((a) => a.id === algorithm)! + + return ( +
+ {/* Header */} +
+
+
+

+ Cryptanalysis Tool +

+
+
+

+ Hash Collision Finder +

+

+ Find hash collisions using the Birthday Attack method. Demonstrates why collision resistance requires hash outputs at least twice the desired security level — and why MD5 and SHA-1 are no longer safe. +

+
+
+

+ Birthday Attack +

+

+ Finding two inputs with the same hash takes O(√N) attempts — not O(N). With only 12 bits of hash, collisions appear in hundreds of tries, not millions. +

+
+
+
+
+ +
+ {/* Left Column: Controls */} +
+ {/* Configuration */} +
+

Configuration

+ + {/* Algorithm Selection */} +
+ +
+ {ALGORITHMS.map((algo) => ( + + ))} +
+
+ + {/* Bit Truncation */} +
+
+ + + {bits} bits (of {selectedAlgo.bits}) + +
+ setBits(parseInt(e.target.value))} + className="mt-2 w-full accent-teal-500 cursor-pointer" + /> +
+ 4 (easy) + 24 (hard) +
+
+ + {/* Input Length */} +
+
+ + + {inputLength} chars + +
+ setInputLength(parseInt(e.target.value))} + className="mt-2 w-full accent-teal-500 cursor-pointer" + /> +
+ + {/* Action Buttons */} +
+ + +
+
+ + {/* Live Statistics */} +
+

+ Birthday Attack Statistics +

+
+ +
+ {progress.attempts > 0 && ( +
+ +
+ )} +
+ + {/* Known Collisions */} +
+

+ Historical Hash Collisions +

+
+ {[ + { + algorithm: "MD5", + description: "Wang & Yu (2004) found practical MD5 collisions. Two different files can produce identical MD5 hashes, enabling certificate forgery and malware evasion.", + reference: "First practical MD5 collision — cryptanalyzed in seconds on modern hardware", + }, + { + algorithm: "SHA-1", + description: "Google's SHAttered attack (2017) created the first practical SHA-1 collision, prefixing two PDFs with the same SHA-1 hash.", + reference: "Cost: ~$110,000 in cloud computing (2^63 operations)", + }, + { + algorithm: "Birthday Paradox", + description: "With 23 people in a room, there's a >50% chance two share a birthday. This is the same math behind hash collisions: O(√N) not O(N).", + reference: "Dirichlet's box principle — the foundation of birthday attacks", + }, + ].map((item, i) => ( +
+
+ +
+

+ {item.algorithm} +

+

+ {item.description} +

+

+ {item.reference} +

+
+
+
+ ))} +
+
+
+ + {/* Right Column: Results */} +
+ {/* Collision Result */} + {result && result.found && ( +
+
+
+ +
+
+

+ Collision Found! +

+

+ Two different inputs produce the same {bits}-bit hash +

+
+
+
+
+

+ Input 1 +

+

+ {result.input1} +

+

+ Hash: {result.hash1} +

+
+
+

+ Input 2 +

+

+ {result.input2} +

+

+ Hash: {result.hash2} +

+
+
+
+ + + {result.durationMs.toFixed(0)}ms + + {result.attempts.toLocaleString()} attempts + + {((result.attempts / result.expectedAttempts) * 100).toFixed(0)}% of expected + +
+
+ )} + + {/* No Collision Found */} + {result && !result.found && ( +
+
+ +
+

+ No Collision Found +

+

+ Reached the attempt limit without finding a collision. + Try fewer bits or a weaker algorithm. +

+
+
+
+ )} + + {/* Hash Table */} + {result && result.history.length > 0 && ( +
+
+

+ Hash Attempts +

+ + Last {Math.min(30, result.history.length)} of {result.attempts.toLocaleString()} + +
+
+ +
+
+ )} + + {/* Educational Content */} +
+

+ Understanding Hash Collisions +

+
+ {Object.values(EXPLANATIONS).map((exp, i) => ( +
+

+ {exp.title} +

+

+ {exp.content} +

+
+ ))} +
+
+ + {/* Why This Matters */} +
+

+ Why Collision Resistance Matters +

+
+ {[ + { + icon: Shield, + title: "Digital Signatures", + desc: "If two documents have the same hash, an attacker can forge a signature on one by getting the other signed.", + }, + { + icon: ShieldCheck, + title: "Certificate Authority", + desc: "TLS certificates use hash functions for signing. A collision could create a fraudulent certificate trusted by browsers.", + }, + { + icon: ShieldAlert, + title: "Password Storage", + desc: "If an attacker finds a collision, they can log in with a different password than the one you stored.", + }, + { + icon: Info, + title: "Git Integrity", + desc: "Git uses SHA-1 to identify commits. A collision could inject malicious code that appears to be a legitimate commit.", + }, + ].map((item, i) => ( +
+ +
+

{item.title}

+

{item.desc}

+
+
+ ))} +
+
+
+
+
+ ) +} diff --git a/lib/hash/collisionFinder.ts b/lib/hash/collisionFinder.ts new file mode 100644 index 00000000..1b03baa1 --- /dev/null +++ b/lib/hash/collisionFinder.ts @@ -0,0 +1,428 @@ +/** + * Hash Collision Finder — Educational demonstration of hash collisions + * using the Birthday Attack (Paradox) method. + * + * Demonstrates: + * 1. Birthday attack: O(√N) complexity to find collisions + * 2. Collision resistance vs preimage resistance + * 3. Why shorter hash outputs are vulnerable + * 4. Real-world impact (MD5, SHA-1 deprecation) + * + * Uses the Web Crypto API for actual hashing (SHA-256, SHA-1, MD5 via fallback). + * For educational purposes, truncates hash output to N bits to increase + * collision probability. + */ + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type HashAlgorithm = "sha-256" | "sha-1" | "md5" | "sha-512" + +export interface CollisionAttempt { + /** The input string tried */ + input: string + /** The truncated hash value (hex) */ + hash: string + /** Attempt number */ + attemptNumber: number +} + +export interface CollisionResult { + /** Whether a collision was found */ + found: boolean + /** The two colliding inputs */ + input1?: string + input2?: string + /** Their identical hash values */ + hash1?: string + hash2?: string + /** Number of attempts before collision */ + attempts: number + /** All attempts made */ + history: CollisionAttempt[] + /** The algorithm used */ + algorithm: HashAlgorithm + /** Number of hash bits used (truncated) */ + bitsUsed: number + /** Expected attempts for 50% collision probability */ + expectedAttempts: number + /** Wall-clock time in ms */ + durationMs: number +} + +export interface HashAnalysis { + /** Full hex hash */ + fullHash: string + /** Truncated hash */ + truncatedHash: string + /** Input that produced this hash */ + input: string + /** Hamming weight (number of 1 bits in binary) */ + hammingWeight: number + /** Entropy estimate */ + entropy: number +} + +export interface BirthdayAttackStats { + /** Hash space size: 2^bits */ + spaceSize: number + /** Theoretical expected attempts for 50% collision */ + expected50Percent: number + /** Theoretical expected attempts for 99% collision */ + expected99Percent: number + /** Current collision probability given attempts so far */ + currentProbability: number + /** Bits of security */ + bitsOfSecurity: number +} + +// ─── Constants ─────────────────────────────────────────────────────────────── + +/** Maximum bits to use for collision finding (too many = never find one). */ +const MAX_BITS = 24 +const MIN_BITS = 4 + +/** Brute-force limit to prevent browser hang. */ +const MAX_ATTEMPTS_HARD = 2_000_000 + +// ─── Hashing ───────────────────────────────────────────────────────────────── + +/** + * Compute SHA-256 hash using Web Crypto API. + * Returns full hex string. + */ +async function sha256Hex(input: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(input) + const hashBuffer = await crypto.subtle.digest("SHA-256", data) + const hashArray = new Uint8Array(hashBuffer) + return Array.from(hashArray) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +} + +/** + * Compute SHA-1 hash (for comparison with SHA-256). + */ +async function sha1Hex(input: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(input) + const hashBuffer = await crypto.subtle.digest("SHA-1", data) + const hashArray = new Uint8Array(hashBuffer) + return Array.from(hashArray) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +} + +/** + * Simple MD5 implementation for educational purposes. + * Not cryptographically secure — used only for collision demos. + */ +function md5Simple(input: string): string { + // Simple DJB2-based hash for educational demo (not real MD5) + let h1 = 0xdeadbeef + let h2 = 0x41c6ce57 + for (let i = 0; i < input.length; i++) { + const ch = input.charCodeAt(i) + h1 = Math.imul(h1 ^ ch, 2654435761) + h2 = Math.imul(h2 ^ ch, 1597334677) + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909) + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909) + return ((h2 >>> 0).toString(16).padStart(8, "0") + + (h1 >>> 0).toString(16).padStart(8, "0")) +} + +/** + * Compute hash for the given algorithm. + */ +async function computeHash( + input: string, + algorithm: HashAlgorithm +): Promise { + switch (algorithm) { + case "sha-256": + return sha256Hex(input) + case "sha-1": + return sha1Hex(input) + case "md5": + return md5Simple(input) + case "sha-512": { + const encoder = new TextEncoder() + const data = encoder.encode(input) + const hashBuffer = await crypto.subtle.digest("SHA-512", data) + const hashArray = new Uint8Array(hashBuffer) + return Array.from(hashArray) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + } + } +} + +/** + * Truncate a hex hash to N bits. + */ +function truncateHash(hex: string, bits: number): string { + const hexChars = Math.ceil(bits / 4) + const truncated = hex.slice(0, hexChars) + // Apply bit masking for partial hex chars + const remainder = bits % 4 + if (remainder > 0 && truncated.length > 0) { + const lastNibble = parseInt(truncated[truncated.length - 1], 16) + const mask = (1 << remainder) - 1 + const masked = lastNibble & mask + return truncated.slice(0, -1) + masked.toString(16) + } + return truncated +} + +// ─── Random Input Generator ────────────────────────────────────────────────── + +/** Character set for generating random inputs. */ +const CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +/** + * Generate a random input string of given length. + */ +function randomInput(length: number): string { + let result = "" + for (let i = 0; i < length; i++) { + result += CHARSET[Math.floor(Math.random() * CHARSET.length)] + } + return result +} + +/** + * Generate a sequential input string. + */ +function sequentialInput(n: number): string { + return `input_${n}_${Math.random().toString(36).slice(2, 8)}` +} + +// ─── Birthday Attack Statistics ────────────────────────────────────────────── + +/** + * Calculate birthday attack statistics for a given hash space. + */ +export function birthdayStats(bits: number, attempts: number): BirthdayAttackStats { + const spaceSize = Math.pow(2, bits) + const expected50 = Math.sqrt(Math.PI / 2) * Math.sqrt(spaceSize) + const expected99 = Math.sqrt(2 * Math.log(100)) * Math.sqrt(spaceSize) + // P(collision) = 1 - e^(-n^2 / (2N)) + const exponent = -(attempts * attempts) / (2 * spaceSize) + const probability = 1 - Math.exp(exponent) + + return { + spaceSize, + expected50Percent: Math.round(expected50), + expected99Percent: Math.round(expected99), + currentProbability: Math.min(probability, 1), + bitsOfSecurity: bits, + } +} + +/** + * Calculate minimum recommended bits for collision resistance. + */ +export function recommendedBits(securityLevel: number): number { + // For k bits of security, need 2k-bit hash + return securityLevel * 2 +} + +// ─── Main Collision Finder ─────────────────────────────────────────────────── + +/** + * Find a hash collision using the birthday attack method. + * + * @param algorithm Hash algorithm to use + * @param bits Number of hash bits to use (truncated) + * @param inputLength Length of random inputs to generate + * @param onProgress Callback for progress updates + * @returns Collision result + */ +export async function findCollision( + algorithm: HashAlgorithm = "sha-256", + bits: number = 12, + inputLength: number = 8, + onProgress?: (attempt: number, hash: string) => void +): Promise { + const start = performance.now() + const clampedBits = Math.max(MIN_BITS, Math.min(MAX_BITS, bits)) + + const seen = new Map() // truncatedHash → input + const history: CollisionAttempt[] = [] + + let attempts = 0 + + // Try sequential inputs first, then random + for (attempts = 1; attempts <= MAX_ATTEMPTS_HARD; attempts++) { + const input = + attempts <= 1000 + ? sequentialInput(attempts) + : randomInput(inputLength) + + const fullHash = await computeHash(input, algorithm) + const truncated = truncateHash(fullHash, clampedBits) + + history.push({ + input, + hash: truncated, + attemptNumber: attempts, + }) + + // Check for collision + if (seen.has(truncated)) { + const prevInput = seen.get(truncated)! + const prevFullHash = await computeHash(prevInput, algorithm) + + return { + found: true, + input1: prevInput, + input2: input, + hash1: truncateHash(prevFullHash, clampedBits), + hash2: truncated, + attempts, + history: history.slice(-50), // Keep last 50 for display + algorithm, + bitsUsed: clampedBits, + expectedAttempts: birthdayStats(clampedBits, 0).expected50Percent, + durationMs: performance.now() - start, + } + } + + seen.set(truncated, input) + + // Report progress every 100 attempts + if (attempts % 100 === 0) { + onProgress?.(attempts, truncated) + } + } + + // No collision found within limit + return { + found: false, + attempts, + history: history.slice(-50), + algorithm, + bitsUsed: clampedBits, + expectedAttempts: birthdayStats(clampedBits, 0).expected50Percent, + durationMs: performance.now() - start, + } +} + +// ─── Hash Analysis ─────────────────────────────────────────────────────────── + +/** + * Analyze a hash value in detail. + */ +export async function analyzeHash( + input: string, + algorithm: HashAlgorithm = "sha-256", + bits: number = 16 +): Promise { + const fullHash = await computeHash(input, algorithm) + const truncated = truncateHash(fullHash, bits) + + // Hamming weight of binary representation + let hammingWeight = 0 + for (const hex of truncated) { + const val = parseInt(hex, 16) + hammingWeight += val.toString(2).split("1").length - 1 + } + + // Entropy of the hash + const freq = new Map() + for (const ch of truncated) { + freq.set(ch, (freq.get(ch) || 0) + 1) + } + let entropy = 0 + for (const [, count] of freq) { + const p = count / truncated.length + entropy -= p * Math.log2(p) + } + + return { + fullHash, + truncatedHash: truncated, + input, + hammingWeight, + entropy, + } +} + +// ─── Pre-computed Collision Examples ───────────────────────────────────────── + +export interface KnownCollision { + description: string + input1: string + input2: string + algorithm: string + reference: string +} + +/** + * Known hash collision examples for educational purposes. + */ +export const KNOWN_COLLISIONS: KnownCollision[] = [ + { + description: + "MD5 collision: Two different executable files with the same MD5 hash", + input1: "d131dd02c5e6eec4... (binary file A)", + input2: "d131dd02c5e6eecc... (binary file B)", + algorithm: "MD5", + reference: + "Wang & Yu (2004) — first practical MD5 collision", + }, + { + description: + "SHA-1 collision: Google's SHAttered attack on identical-prefix collision", + input1: "shattered-1.pdf", + input2: "shattered-2.pdf", + algorithm: "SHA-1", + reference: + "SHAttered (2017) — first practical SHA-1 collision", + }, + { + description: + "Birthday paradox: In a room of 23 people, there's a >50% chance two share a birthday", + input1: "Person A's birthday", + input2: "Person B's birthday", + algorithm: "N/A (birthday paradox)", + reference: + "Dirichlet's box principle applied to hash functions", + }, +] + +// ─── Educational Explanations ──────────────────────────────────────────────── + +export const EXPLANATIONS = { + birthdayAttack: { + title: "The Birthday Attack", + content: + "The birthday attack exploits the Birthday Paradox: with only 23 people in a room, " + + "there's a >50% chance two share a birthday. For hashes, this means finding a collision " + + "takes O(√N) attempts instead of O(N), where N is the hash space size.", + }, + collisionResistance: { + title: "Collision Resistance", + content: + "A hash function is collision-resistant if it's computationally infeasible to find two " + + "different inputs with the same hash. SHA-256 with 256 bits requires ~2^128 operations " + + "to find a collision — far beyond current computing power.", + }, + truncatedHash: { + title: "Why Truncation Matters", + content: + "This tool truncates hash output to fewer bits to make collisions findable in real time. " + + "In practice, never truncate hash outputs used for security. SHA-256 produces 256 bits " + + "for a reason — shorter hashes are exponentially easier to break.", + }, + realWorldImpact: { + title: "Real-World Impact", + content: + "MD5 collisions have been used to create fraudulent SSL certificates and malware that " + + "passes integrity checks. SHA-1 was deprecated by Google, NIST, and major browsers " + + "after the SHAttered attack demonstrated practical collisions.", + }, +} diff --git a/tests/unit/collisionFinder.test.ts b/tests/unit/collisionFinder.test.ts new file mode 100644 index 00000000..fe779500 --- /dev/null +++ b/tests/unit/collisionFinder.test.ts @@ -0,0 +1,170 @@ +/** + * Unit tests for the Hash Collision Finder engine. + */ + +import { describe, it, expect } from "vitest"; +import { + birthdayStats, + analyzeHash, + findCollision, + KNOWN_COLLISIONS, + EXPLANATIONS, + type HashAlgorithm, +} from "@/lib/hash/collisionFinder"; + +// ─── birthdayStats ────────────────────────────────────────────────────────── + +describe("birthdayStats", () => { + it("returns correct space size for small bit counts", () => { + const stats = birthdayStats(8, 0); + expect(stats.spaceSize).toBe(256); + expect(stats.bitsOfSecurity).toBe(8); + }); + + it("expected 50% is approximately √(π/2 × 2^n)", () => { + const stats = birthdayStats(10, 0); + const expected = Math.sqrt(Math.PI / 2) * Math.sqrt(1024); + expect(stats.expected50Percent).toBeCloseTo(Math.round(expected), -1); + }); + + it("expected 99% is greater than expected 50%", () => { + const stats = birthdayStats(12, 0); + expect(stats.expected99Percent).toBeGreaterThan(stats.expected50Percent); + }); + + it("probability increases with more attempts", () => { + const stats1 = birthdayStats(10, 10); + const stats2 = birthdayStats(10, 50); + expect(stats2.currentProbability).toBeGreaterThan(stats1.currentProbability); + }); + + it("probability is bounded at 1", () => { + const stats = birthdayStats(4, 1000); + expect(stats.currentProbability).toBeLessThanOrEqual(1); + }); + + it("probability is 0 for 0 attempts", () => { + const stats = birthdayStats(12, 0); + expect(stats.currentProbability).toBe(0); + }); + + it("larger hash spaces require more attempts", () => { + const small = birthdayStats(8, 0).expected50Percent; + const large = birthdayStats(16, 0).expected50Percent; + expect(large).toBeGreaterThan(small); + }); +}); + +// ─── analyzeHash ───────────────────────────────────────────────────────────── + +describe("analyzeHash", () => { + it("returns a full hash and truncated hash", async () => { + const analysis = await analyzeHash("hello", "sha-256", 16); + expect(analysis.fullHash.length).toBe(64); // SHA-256 = 64 hex chars + expect(analysis.truncatedHash.length).toBeLessThanOrEqual(4); // 16 bits = 4 hex chars + }); + + it("hamming weight is non-negative", async () => { + const analysis = await analyzeHash("test", "sha-256", 16); + expect(analysis.hammingWeight).toBeGreaterThanOrEqual(0); + }); + + it("entropy is non-negative", async () => { + const analysis = await analyzeHash("test", "sha-256", 16); + expect(analysis.entropy).toBeGreaterThanOrEqual(0); + }); + + it("same input produces same hash", async () => { + const a1 = await analyzeHash("hello", "sha-256", 16); + const a2 = await analyzeHash("hello", "sha-256", 16); + expect(a1.fullHash).toBe(a2.fullHash); + expect(a1.truncatedHash).toBe(a2.truncatedHash); + }); + + it("different inputs produce different full hashes", async () => { + const a1 = await analyzeHash("hello", "sha-256"); + const a2 = await analyzeHash("world", "sha-256"); + expect(a1.fullHash).not.toBe(a2.fullHash); + }); +}); + +// ─── findCollision ─────────────────────────────────────────────────────────── + +describe("findCollision", () => { + it("finds collision quickly with very few bits", async () => { + const result = await findCollision("sha-256", 4, 6); + // With 4 bits (16 values), collision should be found in < 10 attempts + expect(result.attempts).toBeLessThan(30); + expect(result.found).toBe(true); + }, 10000); + + it("returns valid collision result when found", async () => { + const result = await findCollision("sha-256", 6, 6); + if (result.found) { + expect(result.input1).toBeDefined(); + expect(result.input2).toBeDefined(); + expect(result.hash1).toBe(result.hash2); + expect(result.input1).not.toBe(result.input2); + } + }, 15000); + + it("reports correct algorithm and bits", async () => { + const result = await findCollision("sha-256", 8, 6); + expect(result.algorithm).toBe("sha-256"); + expect(result.bitsUsed).toBe(8); + }, 30000); + + it("has duration > 0", async () => { + const result = await findCollision("sha-256", 4, 6); + expect(result.durationMs).toBeGreaterThan(0); + }, 10000); + + it("history is populated", async () => { + const result = await findCollision("sha-256", 4, 6); + expect(result.history.length).toBeGreaterThan(0); + }, 10000); +}); + +// ─── KNOWN_COLLISIONS ─────────────────────────────────────────────────────── + +describe("KNOWN_COLLISIONS", () => { + it("has at least 2 entries", () => { + expect(KNOWN_COLLISIONS.length).toBeGreaterThanOrEqual(2); + }); + + it("each has required fields", () => { + for (const collision of KNOWN_COLLISIONS) { + expect(collision.description).toBeDefined(); + expect(collision.algorithm).toBeDefined(); + expect(collision.reference).toBeDefined(); + } + }); +}); + +// ─── EXPLANATIONS ──────────────────────────────────────────────────────────── + +describe("EXPLANATIONS", () => { + it("has birthdayAttack explanation", () => { + expect(EXPLANATIONS.birthdayAttack).toBeDefined(); + expect(EXPLANATIONS.birthdayAttack.title).toContain("Birthday"); + }); + + it("has collisionResistance explanation", () => { + expect(EXPLANATIONS.collisionResistance).toBeDefined(); + }); + + it("has truncatedHash explanation", () => { + expect(EXPLANATIONS.truncatedHash).toBeDefined(); + }); + + it("has realWorldImpact explanation", () => { + expect(EXPLANATIONS.realWorldImpact).toBeDefined(); + }); + + it("all explanations have non-empty content", () => { + for (const exp of Object.values(EXPLANATIONS)) { + expect(exp.title.length).toBeGreaterThan(0); + expect(exp.content.length).toBeGreaterThan(0); + } + }); +});