feat: all attack strategies (dictionary, brute-force, rules)

DictionaryAttack: mmap-based wordlist reader with thread partitioning.
BruteForceAttack: keyspace generator with mathematical partitioning.
RuleSet: 7 mutation transforms via std::generator (capitalize, leet,
append/prepend digits, reverse, toggle case).
RuleAttack: combines dictionary words with rule mutations.
22 new tests, all passing.
This commit is contained in:
CarterPerez-dev 2026-03-22 09:25:09 -04:00
parent dd27eb2620
commit 6542a097f8
12 changed files with 536 additions and 35 deletions

View File

@ -75,4 +75,6 @@ target_link_libraries(hashcracker_tests
PRIVATE hashcracker_lib GTest::gtest_main
)
include(GoogleTest)
gtest_discover_tests(hashcracker_tests)
gtest_discover_tests(hashcracker_tests
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
)

View File

@ -2,13 +2,64 @@
// BruteForceAttack.cpp
#include "src/attack/BruteForceAttack.hpp"
#include <algorithm>
BruteForceAttack::BruteForceAttack(std::string_view, std::size_t,
unsigned, unsigned) {}
std::expected<std::string, AttackComplete> BruteForceAttack::next() {
return std::unexpected(AttackComplete{});
std::size_t BruteForceAttack::compute_keyspace(std::size_t charset_size,
std::size_t max_length) {
std::size_t total = 0;
std::size_t power = 1;
for (std::size_t len = 1; len <= max_length; ++len) {
power *= charset_size;
total += power;
}
return total;
}
std::size_t BruteForceAttack::total() const { return total_; }
std::size_t BruteForceAttack::progress() const { return current_; }
BruteForceAttack::BruteForceAttack(std::string_view charset,
std::size_t max_length,
unsigned thread_index,
unsigned total_threads)
: charset_(charset), max_length_(max_length),
total_keyspace_(compute_keyspace(charset.size(), max_length)) {
std::size_t per_thread = total_keyspace_ / total_threads;
std::size_t remainder = total_keyspace_ % total_threads;
start_index_ = thread_index * per_thread
+ std::min(static_cast<std::size_t>(thread_index), remainder);
std::size_t my_count = per_thread + (thread_index < remainder ? 1 : 0);
end_index_ = start_index_ + my_count;
current_index_ = start_index_;
}
std::string BruteForceAttack::index_to_candidate(std::size_t index) const {
std::size_t base = charset_.size();
std::size_t cumulative = 0;
std::size_t power = base;
std::size_t length = 1;
while (cumulative + power <= index && length < max_length_) {
cumulative += power;
++length;
power *= base;
}
std::size_t offset = index - cumulative;
std::string result(length, charset_[0]);
for (std::size_t i = length; i > 0; --i) {
result[i - 1] = charset_[offset % base];
offset /= base;
}
return result;
}
std::expected<std::string, AttackComplete> BruteForceAttack::next() {
if (current_index_ >= end_index_) {
return std::unexpected(AttackComplete{});
}
return index_to_candidate(current_index_++);
}
std::size_t BruteForceAttack::total() const { return total_keyspace_; }
std::size_t BruteForceAttack::progress() const { return current_index_ - start_index_; }

View File

@ -19,6 +19,14 @@ public:
std::size_t progress() const;
private:
std::size_t total_ = 0;
std::size_t current_ = 0;
std::string charset_;
std::size_t max_length_;
std::size_t total_keyspace_;
std::size_t start_index_;
std::size_t end_index_;
std::size_t current_index_;
std::string index_to_candidate(std::size_t index) const;
static std::size_t compute_keyspace(std::size_t charset_size,
std::size_t max_length);
};

View File

@ -2,15 +2,167 @@
// DictionaryAttack.cpp
#include "src/attack/DictionaryAttack.hpp"
#include <algorithm>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
static std::size_t count_lines_in_range(const char* data,
std::size_t start,
std::size_t end) {
std::size_t count = 0;
for (std::size_t i = start; i < end; ++i) {
if (data[i] == '\n') {
++count;
}
}
return count;
}
static std::size_t find_next_newline(const char* data,
std::size_t pos,
std::size_t size) {
while (pos < size && data[pos] != '\n') {
++pos;
}
return pos < size ? pos + 1 : size;
}
std::expected<DictionaryAttack, CrackError> DictionaryAttack::create(
std::string_view, unsigned, unsigned) {
return std::unexpected(CrackError::FileNotFound);
std::string_view path, unsigned thread_index, unsigned total_threads) {
std::string path_str(path);
int fd = open(path_str.c_str(), O_RDONLY);
if (fd < 0) {
return std::unexpected(CrackError::FileNotFound);
}
struct stat sb{};
if (fstat(fd, &sb) < 0) {
close(fd);
return std::unexpected(CrackError::FileNotFound);
}
auto file_size = static_cast<std::size_t>(sb.st_size);
if (file_size == 0) {
close(fd);
return std::unexpected(CrackError::InvalidConfig);
}
auto* mapped = static_cast<const char*>(
mmap(nullptr, file_size, PROT_READ, MAP_PRIVATE, fd, 0));
if (mapped == MAP_FAILED) {
close(fd);
return std::unexpected(CrackError::FileNotFound);
}
madvise(const_cast<char*>(mapped), file_size, MADV_SEQUENTIAL);
std::size_t total_lines = count_lines_in_range(mapped, 0, file_size);
if (file_size > 0 && mapped[file_size - 1] != '\n') {
++total_lines;
}
std::size_t lines_per_thread = total_lines / total_threads;
std::size_t remainder = total_lines % total_threads;
std::size_t my_start_line = thread_index * lines_per_thread
+ std::min(static_cast<std::size_t>(thread_index), remainder);
std::size_t my_line_count = lines_per_thread
+ (thread_index < remainder ? 1 : 0);
std::size_t start_offset = 0;
for (std::size_t i = 0; i < my_start_line; ++i) {
start_offset = find_next_newline(mapped, start_offset, file_size);
}
std::size_t end_offset = start_offset;
for (std::size_t i = 0; i < my_line_count; ++i) {
end_offset = find_next_newline(mapped, end_offset, file_size);
}
DictionaryAttack attack;
attack.mapped_data_ = mapped;
attack.file_size_ = file_size;
attack.fd_ = fd;
attack.start_offset_ = start_offset;
attack.end_offset_ = end_offset;
attack.current_offset_ = start_offset;
attack.total_words_ = my_line_count;
attack.words_read_ = 0;
return attack;
}
DictionaryAttack::~DictionaryAttack() {
if (mapped_data_ && mapped_data_ != MAP_FAILED) {
munmap(const_cast<char*>(mapped_data_), file_size_);
}
if (fd_ >= 0) {
close(fd_);
}
}
DictionaryAttack::DictionaryAttack(DictionaryAttack&& other) noexcept
: mapped_data_(other.mapped_data_), file_size_(other.file_size_),
fd_(other.fd_), start_offset_(other.start_offset_),
end_offset_(other.end_offset_), current_offset_(other.current_offset_),
total_words_(other.total_words_), words_read_(other.words_read_) {
other.mapped_data_ = nullptr;
other.fd_ = -1;
}
DictionaryAttack& DictionaryAttack::operator=(DictionaryAttack&& other) noexcept {
if (this != &other) {
if (mapped_data_ && mapped_data_ != MAP_FAILED) {
munmap(const_cast<char*>(mapped_data_), file_size_);
}
if (fd_ >= 0) {
close(fd_);
}
mapped_data_ = other.mapped_data_;
file_size_ = other.file_size_;
fd_ = other.fd_;
start_offset_ = other.start_offset_;
end_offset_ = other.end_offset_;
current_offset_ = other.current_offset_;
total_words_ = other.total_words_;
words_read_ = other.words_read_;
other.mapped_data_ = nullptr;
other.fd_ = -1;
}
return *this;
}
std::expected<std::string, AttackComplete> DictionaryAttack::next() {
return std::unexpected(AttackComplete{});
if (current_offset_ >= end_offset_) {
return std::unexpected(AttackComplete{});
}
std::size_t line_start = current_offset_;
std::size_t line_end = line_start;
while (line_end < end_offset_ && mapped_data_[line_end] != '\n') {
++line_end;
}
std::size_t word_end = line_end;
if (word_end > line_start && mapped_data_[word_end - 1] == '\r') {
--word_end;
}
current_offset_ = (line_end < end_offset_) ? line_end + 1 : end_offset_;
++words_read_;
if (word_end <= line_start) {
return next();
}
return std::string(mapped_data_ + line_start, word_end - line_start);
}
std::size_t DictionaryAttack::total() const { return 0; }
std::size_t DictionaryAttack::progress() const { return 0; }
std::size_t DictionaryAttack::total() const { return total_words_; }
std::size_t DictionaryAttack::progress() const { return words_read_; }

View File

@ -7,6 +7,7 @@
#include <expected>
#include <string>
#include <string_view>
#include <vector>
#include "src/core/Concepts.hpp"
class DictionaryAttack {
@ -14,10 +15,25 @@ public:
static std::expected<DictionaryAttack, CrackError> create(
std::string_view path, unsigned thread_index, unsigned total_threads);
~DictionaryAttack();
DictionaryAttack(DictionaryAttack&& other) noexcept;
DictionaryAttack& operator=(DictionaryAttack&& other) noexcept;
std::expected<std::string, AttackComplete> next();
std::size_t total() const;
std::size_t progress() const;
private:
DictionaryAttack() = default;
const char* mapped_data_ = nullptr;
std::size_t file_size_ = 0;
int fd_ = -1;
std::size_t start_offset_ = 0;
std::size_t end_offset_ = 0;
std::size_t current_offset_ = 0;
std::size_t total_words_ = 0;
std::size_t words_read_ = 0;
};

View File

@ -2,15 +2,48 @@
// RuleAttack.cpp
#include "src/attack/RuleAttack.hpp"
#include "src/rules/RuleSet.hpp"
RuleAttack::RuleAttack(DictionaryAttack dict, bool chain_rules)
: dict_(std::move(dict)), chain_rules_(chain_rules) {}
std::expected<RuleAttack, CrackError> RuleAttack::create(
std::string_view, bool, unsigned, unsigned) {
return std::unexpected(CrackError::FileNotFound);
std::string_view path, bool chain_rules,
unsigned thread_index, unsigned total_threads) {
auto dict = DictionaryAttack::create(path, thread_index, total_threads);
if (!dict.has_value()) {
return std::unexpected(dict.error());
}
return RuleAttack(std::move(*dict), chain_rules);
}
bool RuleAttack::load_next_word() {
auto word = dict_.next();
if (!word.has_value()) {
return false;
}
mutations_.clear();
mutations_.push_back(*word);
for (auto&& m : RuleSet::apply_all(*word)) {
mutations_.push_back(std::move(m));
}
mutation_index_ = 0;
return true;
}
std::expected<std::string, AttackComplete> RuleAttack::next() {
return std::unexpected(AttackComplete{});
while (mutation_index_ >= mutations_.size()) {
if (!load_next_word()) {
return std::unexpected(AttackComplete{});
}
}
++candidates_yielded_;
return std::move(mutations_[mutation_index_++]);
}
std::size_t RuleAttack::total() const { return 0; }
std::size_t RuleAttack::progress() const { return 0; }
std::size_t RuleAttack::total() const { return dict_.total(); }
std::size_t RuleAttack::progress() const { return candidates_yielded_; }

View File

@ -7,6 +7,8 @@
#include <expected>
#include <string>
#include <string_view>
#include <vector>
#include "src/attack/DictionaryAttack.hpp"
#include "src/core/Concepts.hpp"
class RuleAttack {
@ -20,5 +22,13 @@ public:
std::size_t progress() const;
private:
RuleAttack() = default;
RuleAttack(DictionaryAttack dict, bool chain_rules);
DictionaryAttack dict_;
bool chain_rules_;
std::vector<std::string> mutations_;
std::size_t mutation_index_ = 0;
std::size_t candidates_yielded_ = 0;
bool load_next_word();
};

View File

@ -2,12 +2,77 @@
// RuleSet.cpp
#include "src/rules/RuleSet.hpp"
#include "src/config/Config.hpp"
#include <algorithm>
#include <cctype>
#include <unordered_map>
std::generator<std::string> RuleSet::capitalize_first(std::string_view) { co_return; }
std::generator<std::string> RuleSet::uppercase_all(std::string_view) { co_return; }
std::generator<std::string> RuleSet::leet_speak(std::string_view) { co_return; }
std::generator<std::string> RuleSet::append_digits(std::string_view) { co_return; }
std::generator<std::string> RuleSet::prepend_digits(std::string_view) { co_return; }
std::generator<std::string> RuleSet::reverse(std::string_view) { co_return; }
std::generator<std::string> RuleSet::toggle_case(std::string_view) { co_return; }
std::generator<std::string> RuleSet::apply_all(std::string_view) { co_return; }
static const std::unordered_map<char, char> LEET_MAP = {
{'a', '@'}, {'e', '3'}, {'i', '1'},
{'o', '0'}, {'s', '$'}, {'t', '7'}
};
std::generator<std::string> RuleSet::capitalize_first(std::string_view word) {
if (word.empty()) { co_return; }
std::string result(word);
result[0] = static_cast<char>(std::toupper(static_cast<unsigned char>(result[0])));
co_yield std::move(result);
}
std::generator<std::string> RuleSet::uppercase_all(std::string_view word) {
std::string result(word);
std::ranges::transform(result, result.begin(), [](unsigned char c) {
return static_cast<char>(std::toupper(c));
});
co_yield std::move(result);
}
std::generator<std::string> RuleSet::leet_speak(std::string_view word) {
std::string result(word);
for (auto& c : result) {
auto it = LEET_MAP.find(static_cast<char>(
std::tolower(static_cast<unsigned char>(c))));
if (it != LEET_MAP.end()) {
c = it->second;
}
}
co_yield std::move(result);
}
std::generator<std::string> RuleSet::append_digits(std::string_view word) {
std::string base(word);
for (std::size_t i = 0; i <= config::MAX_APPEND_DIGIT; ++i) {
co_yield base + std::to_string(i);
}
}
std::generator<std::string> RuleSet::prepend_digits(std::string_view word) {
std::string base(word);
for (std::size_t i = 0; i <= config::MAX_PREPEND_DIGIT; ++i) {
co_yield std::to_string(i) + base;
}
}
std::generator<std::string> RuleSet::reverse(std::string_view word) {
std::string result(word.rbegin(), word.rend());
co_yield std::move(result);
}
std::generator<std::string> RuleSet::toggle_case(std::string_view word) {
std::string result(word);
std::ranges::transform(result, result.begin(), [](unsigned char c) {
if (std::islower(c)) { return static_cast<char>(std::toupper(c)); }
return static_cast<char>(std::tolower(c));
});
co_yield std::move(result);
}
std::generator<std::string> RuleSet::apply_all(std::string_view word) {
for (auto&& s : capitalize_first(word)) { co_yield std::move(s); }
for (auto&& s : uppercase_all(word)) { co_yield std::move(s); }
for (auto&& s : leet_speak(word)) { co_yield std::move(s); }
for (auto&& s : append_digits(word)) { co_yield std::move(s); }
for (auto&& s : prepend_digits(word)) { co_yield std::move(s); }
for (auto&& s : reverse(word)) { co_yield std::move(s); }
for (auto&& s : toggle_case(word)) { co_yield std::move(s); }
}

View File

@ -0,0 +1,10 @@
password
123456
qwerty
letmein
dragon
master
monkey
shadow
sunshine
trustno1

View File

@ -2,7 +2,54 @@
// test_bruteforce_attack.cpp
#include <gtest/gtest.h>
#include "src/attack/BruteForceAttack.hpp"
#include <set>
#include <vector>
TEST(BruteForceAttackTest, Stub) {
EXPECT_TRUE(true);
TEST(BruteForceAttackTest, GeneratesAllSingleCharCombinations) {
BruteForceAttack attack("ab", 1, 0, 1);
std::vector<std::string> results;
while (auto candidate = attack.next()) {
results.push_back(std::move(*candidate));
}
EXPECT_EQ(results.size(), 2);
EXPECT_EQ(results[0], "a");
EXPECT_EQ(results[1], "b");
}
TEST(BruteForceAttackTest, GeneratesUpToMaxLength) {
BruteForceAttack attack("ab", 2, 0, 1);
std::vector<std::string> results;
while (auto candidate = attack.next()) {
results.push_back(std::move(*candidate));
}
EXPECT_EQ(results.size(), 6);
}
TEST(BruteForceAttackTest, PartitionsKeyspace) {
BruteForceAttack p0("ab", 2, 0, 2);
BruteForceAttack p1("ab", 2, 1, 2);
std::set<std::string> all;
while (auto c = p0.next()) { all.insert(*c); }
while (auto c = p1.next()) { all.insert(*c); }
EXPECT_EQ(all.size(), 6);
}
TEST(BruteForceAttackTest, ReportsCorrectTotal) {
BruteForceAttack attack("abc", 3, 0, 1);
EXPECT_EQ(attack.total(), 3 + 9 + 27);
}
TEST(BruteForceAttackTest, GeneratesCorrectTwoCharCombinations) {
BruteForceAttack attack("ab", 2, 0, 1);
std::set<std::string> results;
while (auto candidate = attack.next()) {
results.insert(*candidate);
}
EXPECT_TRUE(results.contains("a"));
EXPECT_TRUE(results.contains("b"));
EXPECT_TRUE(results.contains("aa"));
EXPECT_TRUE(results.contains("ab"));
EXPECT_TRUE(results.contains("ba"));
EXPECT_TRUE(results.contains("bb"));
}

View File

@ -2,7 +2,41 @@
// test_dictionary_attack.cpp
#include <gtest/gtest.h>
#include "src/attack/DictionaryAttack.hpp"
#include <vector>
TEST(DictionaryAttackTest, Stub) {
EXPECT_TRUE(true);
TEST(DictionaryAttackTest, ReadsAllWords) {
auto attack = DictionaryAttack::create("tests/data/small_wordlist.txt", 0, 1);
ASSERT_TRUE(attack.has_value());
std::vector<std::string> words;
while (auto word = attack->next()) {
words.push_back(std::move(*word));
}
EXPECT_EQ(words.size(), 10);
EXPECT_EQ(words.front(), "password");
EXPECT_EQ(words.back(), "trustno1");
}
TEST(DictionaryAttackTest, PartitionsAcrossThreads) {
auto p0 = DictionaryAttack::create("tests/data/small_wordlist.txt", 0, 2);
auto p1 = DictionaryAttack::create("tests/data/small_wordlist.txt", 1, 2);
ASSERT_TRUE(p0.has_value());
ASSERT_TRUE(p1.has_value());
std::size_t count0 = 0, count1 = 0;
while (p0->next()) { ++count0; }
while (p1->next()) { ++count1; }
EXPECT_EQ(count0 + count1, 10);
}
TEST(DictionaryAttackTest, ReportsTotal) {
auto attack = DictionaryAttack::create("tests/data/small_wordlist.txt", 0, 1);
ASSERT_TRUE(attack.has_value());
EXPECT_EQ(attack->total(), 10);
}
TEST(DictionaryAttackTest, MissingFileReturnsError) {
auto attack = DictionaryAttack::create("nonexistent.txt", 0, 1);
EXPECT_FALSE(attack.has_value());
}

View File

@ -2,7 +2,80 @@
// test_rules.cpp
#include <gtest/gtest.h>
#include "src/attack/RuleAttack.hpp"
#include "src/rules/RuleSet.hpp"
#include <algorithm>
#include <vector>
TEST(RuleSetTest, Stub) {
EXPECT_TRUE(true);
static std::vector<std::string> collect(std::generator<std::string> gen) {
std::vector<std::string> out;
for (auto&& s : gen) {
out.push_back(std::move(s));
}
return out;
}
TEST(RuleSetTest, CapitalizeFirst) {
auto results = collect(RuleSet::capitalize_first("password"));
ASSERT_EQ(results.size(), 1);
EXPECT_EQ(results[0], "Password");
}
TEST(RuleSetTest, UppercaseAll) {
auto results = collect(RuleSet::uppercase_all("password"));
ASSERT_EQ(results.size(), 1);
EXPECT_EQ(results[0], "PASSWORD");
}
TEST(RuleSetTest, LeetSpeak) {
auto results = collect(RuleSet::leet_speak("password"));
ASSERT_EQ(results.size(), 1);
EXPECT_EQ(results[0], "p@$$w0rd");
}
TEST(RuleSetTest, AppendDigits) {
auto results = collect(RuleSet::append_digits("pass"));
EXPECT_EQ(results.size(), 1000);
EXPECT_EQ(results[0], "pass0");
EXPECT_EQ(results[999], "pass999");
}
TEST(RuleSetTest, PrependDigits) {
auto results = collect(RuleSet::prepend_digits("pass"));
EXPECT_EQ(results.size(), 1000);
EXPECT_EQ(results[0], "0pass");
}
TEST(RuleSetTest, Reverse) {
auto results = collect(RuleSet::reverse("password"));
ASSERT_EQ(results.size(), 1);
EXPECT_EQ(results[0], "drowssap");
}
TEST(RuleSetTest, ToggleCase) {
auto results = collect(RuleSet::toggle_case("password"));
ASSERT_EQ(results.size(), 1);
EXPECT_EQ(results[0], "PASSWORD");
}
TEST(RuleSetTest, AllRulesProduceMutations) {
auto all = collect(RuleSet::apply_all("password"));
EXPECT_GT(all.size(), 2000);
EXPECT_TRUE(std::ranges::find(all, "Password") != all.end());
EXPECT_TRUE(std::ranges::find(all, "p@$$w0rd") != all.end());
EXPECT_TRUE(std::ranges::find(all, "password0") != all.end());
}
TEST(RuleAttackTest, AppliesRulesToDictionaryWords) {
auto attack = RuleAttack::create("tests/data/small_wordlist.txt", false, 0, 1);
ASSERT_TRUE(attack.has_value());
std::vector<std::string> candidates;
while (auto c = attack->next()) {
candidates.push_back(std::move(*c));
}
EXPECT_GT(candidates.size(), 10);
EXPECT_TRUE(std::ranges::find(candidates, "Password") != candidates.end());
EXPECT_TRUE(std::ranges::find(candidates, "p@$$w0rd") != candidates.end());
EXPECT_TRUE(std::ranges::find(candidates, "password") != candidates.end());
}