# System Architecture This document breaks down how the system is designed and why certain architectural decisions were made. ## High Level Architecture ``` ┌────────────────────┐ │ CLI Layer │ (main.cpp) │ - parse args │ Boost.program_options │ - detect hash │ Auto-type from length │ - dispatch │ Template instantiation └─────────┬──────────┘ │ ▼ ┌────────────────────┐ │ Engine Layer │ (Engine.hpp) │ - create threads │ std::jthread pool │ - partition work │ Per-thread attack slices │ - coordinate │ Shared atomics │ - display │ Progress thread └─────────┬──────────┘ │ ┌─────┴─────┐ ▼ ▼ ┌────────┐ ┌──────────┐ │ Hasher │ │ Attack │ │ Policy │ │ Strategy │ └────────┘ └──────────┘ │ │ ▼ ▼ ┌────────┐ ┌──────────────────────────────────┐ │ EVP │ │ DictionaryAttack (mmap) │ │ Hasher │ │ BruteForceAttack (keyspace math) │ │ │ │ RuleAttack (dict + mutations) │ └────────┘ └──────────────────────────────────┘ ``` ### Component Breakdown **CLI Layer (main.cpp)** - Purpose: Parse command line arguments and dispatch to the correct Engine instantiation - Responsibilities: Argument validation, hash type detection, charset construction, JSON output - Key design choice: Uses a template lambda to avoid runtime polymorphism. The `switch` on hash type creates a compile-time-resolved call path **Engine Layer (Engine.hpp)** - Purpose: Coordinate hashing, attack strategy, threading, and progress display - Responsibilities: Thread pool management, salt application, result collection, timing - Key design choice: Header-only template function. The hasher and attack strategy types are template parameters, so the compiler inlines the hash function directly into the cracking loop **Hasher Policy (EVPHasher.hpp)** - Purpose: Compute cryptographic hashes via OpenSSL - Responsibilities: EVP context management, digest computation, hex encoding - Key design choice: Single template parameterized by algorithm function pointer. All four hash types are type aliases of the same implementation **Attack Strategies (attack/)** - Purpose: Generate candidate passwords - Responsibilities: Wordlist reading, keyspace generation, mutation application, thread partitioning - Key design choice: Each strategy satisfies the `AttackStrategy` concept and handles its own partitioning via `create(path, thread_index, total_threads)` **Progress Display (display/)** - Purpose: Show real-time cracking progress - Responsibilities: Progress bar rendering, speed/ETA calculation, result formatting - Key design choice: Runs on its own thread, reads shared atomics with relaxed ordering, no-ops when stdout isn't a terminal ## Core Design: Policy-Based Templates The central architectural decision is resolving the hasher and attack strategy at compile time rather than runtime. Compare the two approaches: **Runtime polymorphism (what we didn't do):** ```cpp class IHasher { public: virtual std::string hash(std::string_view input) = 0; virtual ~IHasher() = default; }; void crack(IHasher* hasher, ...) { for (each candidate) { hasher->hash(candidate); // virtual call every iteration } } ``` Every `hash()` call goes through the vtable, which means an indirect branch. The CPU can't inline the function body, can't optimize across the call boundary, and pays a branch prediction penalty. In a loop that runs millions of times per second, this adds up. **Compile-time polymorphism (what we did):** ```cpp template auto crack(const CrackConfig& cfg) -> std::expected { H hasher; // ... hasher.hash(candidate) is a direct call, gets inlined } ``` When you write `Engine::crack(cfg)`, the compiler generates a version of `crack` with SHA256Hasher hardcoded. The `hash()` call becomes a direct function call that gets inlined into the loop. No vtable, no indirection, no overhead. The tradeoff: you get a separate copy of the function for each hasher/attack combination (12 total: 4 hashers x 3 attacks). This increases binary size slightly. For a CLI tool, that's irrelevant. ## Concepts as Contracts C++20 concepts define what a Hasher or AttackStrategy must provide: ```cpp template concept Hasher = requires(T h, std::string_view input) { { h.hash(input) } -> std::same_as; { T::name() } -> std::convertible_to; { T::digest_length() } -> std::same_as; }; ``` This is a compile-time contract. If you write a new hasher that doesn't satisfy `Hasher`, you get a clear error message at compile time instead of a mysterious linker error or runtime crash. It documents the interface without requiring inheritance. ## Data Flow ### Dictionary Attack Flow ``` 1. CLI parses --hash and --wordlist main.cpp dispatches Engine::crack(cfg) 2. Engine resolves thread count (hardware_concurrency if 0) Creates ThreadPool, spawns N jthreads 3. Each thread calls DictionaryAttack::create(path, thread_id, N) create() opens the file with mmap, counts lines, computes this thread's byte range [start_offset, end_offset) 4. Thread loop: a. attack.next() reads the next word from the mmap region b. If salt is set, prepend/append it to the candidate c. hasher.hash(candidate) computes the digest d. Compare to target hash e. If match: set atomic found flag, store result, break f. Increment local counter, flush to shared atomic every 1024 iterations 5. Display thread wakes every 100ms, reads shared atomics, renders progress bar to terminal 6. All threads join (jthread RAII) Engine returns CrackResult or CrackError::Exhausted ``` ### Brute Force Partitioning The total keyspace for a charset of size C and max length L is: ``` total = C^1 + C^2 + C^3 + ... + C^L ``` Each thread gets a contiguous slice of the flat index space. Thread 0 gets indices `[0, total/N)`, thread 1 gets `[total/N, 2*total/N)`, and so on. Converting a flat index to a candidate string uses mixed-radix decomposition, similar to converting a decimal number to an arbitrary base but with variable-length output. This means threads never communicate during the cracking loop. No shared queue, no work stealing, no locks. Each thread is completely independent. ## Threading Model ### Shared State Only two values are shared between threads: ```cpp struct SharedState { alignas(64) std::atomic found{false}; alignas(64) std::atomic tested_count{0}; std::mutex result_mutex; std::optional result; }; ``` The `alignas(64)` places each atomic on its own cache line. Without this, writes to `tested_count` from one thread would invalidate reads of `found` on other threads because they share a 64-byte cache line. This is called false sharing and can cause a 5x slowdown. ### Counter Batching Instead of doing an atomic increment on every candidate: ```cpp // Bad: atomic write every iteration (cross-core cache traffic) state.tested_count.fetch_add(1, std::memory_order_relaxed); ``` Each thread maintains a local counter and flushes every 1024 iterations: ```cpp ++local_count; if ((local_count & 0x3FF) == 0) { state.tested_count.fetch_add(local_count, std::memory_order_relaxed); local_count = 0; } ``` This reduces cross-core atomic traffic by 1024x. The progress display reads an approximate count, which is fine for a UI update every 100ms. ## Error Handling Strategy All fallible operations return `std::expected`. There are no exceptions in the codebase. ```cpp enum class CrackError { FileNotFound, InvalidHash, UnsupportedAlgorithm, OpenSSLError, InvalidConfig, Exhausted }; ``` The error type is in the function signature. Callers are forced to handle it: ```cpp auto attack = DictionaryAttack::create(path, tid, total); if (!attack.has_value()) { return; } ``` This makes error paths explicit and visible. You never have to guess whether a function might throw. ## Memory-Mapped I/O DictionaryAttack uses `mmap` instead of `std::ifstream` for reading wordlists: ```cpp auto* mapped = static_cast( mmap(nullptr, file_size, PROT_READ, MAP_PRIVATE, fd, 0)); madvise(mapped, file_size, MADV_SEQUENTIAL); ``` The file's contents are mapped directly into the process address space. Reading a word is pointer arithmetic (advance to the next newline). No `read()` syscalls, no kernel-to-user buffer copies. For a 140MB wordlist like rockyou.txt, this matters. The `MappedFile` RAII wrapper handles cleanup: ```cpp class MappedFile { ~MappedFile() { munmap(data_, size_); close(fd_); } }; ``` DictionaryAttack holds a `MappedFile` member. The compiler generates correct move operations automatically (Rule of Zero). ## Configuration All magic numbers live in `Config.hpp`: ```cpp namespace config { constexpr unsigned DEFAULT_THREAD_COUNT = 0; constexpr std::size_t DEFAULT_MAX_BRUTE_LENGTH = 6; constexpr int PROGRESS_UPDATE_MS = 100; constexpr std::string_view CHARSET_LOWER = "abcdefghijklmnopqrstuvwxyz"; // ... } ``` Runtime configuration flows through `CrackConfig`, populated by the CLI parser and passed to the Engine. No globals are mutated after startup. ## Performance Considerations **Hot path**: The inner loop in `Engine.hpp` is hash-and-compare. Every microsecond saved here multiplies by millions of iterations. The EVPHasher uses a lookup-table hex encoder instead of `std::ostringstream`, avoiding heap allocation per hash. **Bottleneck**: On CPU, the bottleneck is the hash computation itself (OpenSSL's EVP internals). The surrounding code (candidate generation, comparison, counter update) is negligible in comparison. GPU acceleration (CUDA/OpenCL) would be the next step for real performance gains. **Memory**: mmap means the wordlist is paged in on demand by the kernel. The tool's actual memory footprint is small regardless of wordlist size. ## Design Decisions | Decision | Alternative | Why This Way | |----------|------------|-------------| | Template policies | Virtual interfaces | Zero overhead in hot loop | | `std::expected` | Exceptions | Explicit error paths, no hidden control flow | | `std::generator` | Return `vector` | Lazy evaluation, early termination | | mmap | `std::ifstream` | Zero-copy, no syscall per line | | Work partitioning | Work stealing queue | Zero contention between threads | | Relaxed atomics | seq_cst | Approximate progress is fine, saves fence cost | | Header-only Engine | Compiled .cpp | Template must be in header anyway | ## Extensibility **Adding a new hash algorithm:** 1. Add a type alias in a new header: ```cpp using SHA3_256Hasher = EVPHasher; ``` 2. Add a case in `HashDetector::detect()` (if the digest length is unique) 3. Add a case in `main.cpp`'s dispatch switch 4. Write tests against known vectors **Adding a new attack strategy:** 1. Create a class satisfying the `AttackStrategy` concept (needs `next()`, `total()`, `progress()`) 2. Add a `create(path, thread_index, total_threads)` factory 3. Add a dispatch path in `main.cpp` and `Engine.hpp` Both extension points require zero changes to the Engine itself. ## Limitations - CPU only. No GPU acceleration. A dedicated tool like hashcat on a modern GPU is ~1000x faster - No bcrypt/scrypt/argon2 support. These require different libraries and fundamentally different cracking strategies (the intentional slowness changes the economics) - No distributed cracking. Each run uses one machine - The `--chain-rules` flag generates a large number of candidates per word, which can make rule attacks slow on large wordlists - Linux/macOS only (mmap is POSIX). Windows would need `CreateFileMapping` instead