Cybersecurity-Projects/PROJECTS/beginner/hash-cracker/learn/02-ARCHITECTURE.md

12 KiB

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):

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):

template <Hasher H, AttackStrategy A>
auto crack(const CrackConfig& cfg) -> std::expected<CrackResult, CrackError> {
    H hasher;
    // ... hasher.hash(candidate) is a direct call, gets inlined
}

When you write Engine::crack<SHA256Hasher, DictionaryAttack>(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:

template <typename T>
concept Hasher = requires(T h, std::string_view input) {
    { h.hash(input) } -> std::same_as<std::string>;
    { T::name() } -> std::convertible_to<std::string_view>;
    { T::digest_length() } -> std::same_as<std::size_t>;
};

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<SHA256Hasher, DictionaryAttack>(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:

struct SharedState {
    alignas(64) std::atomic<bool> found{false};
    alignas(64) std::atomic<std::size_t> tested_count{0};
    std::mutex result_mutex;
    std::optional<std::string> 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:

// 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:

++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<T, CrackError>. There are no exceptions in the codebase.

enum class CrackError {
    FileNotFound,
    InvalidHash,
    UnsupportedAlgorithm,
    OpenSSLError,
    InvalidConfig,
    Exhausted
};

The error type is in the function signature. Callers are forced to handle it:

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:

auto* mapped = static_cast<const char*>(
    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:

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:

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<string> 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:
    using SHA3_256Hasher = EVPHasher<EVP_sha3_256, "SHA3-256", 64>;
    
  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