diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6dd610c..4953693 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,8 @@ jobs: - name: Build Project run: cmake --build build --config ${{ matrix.build-configuration }} - - name: Run Catch2 Unit Tests + - name: Run Catch2 Unit Tests (Release) + if: matrix.build-configuration == 'Release' # Runs the unit tests you specified run: ctest --test-dir build -C ${{ matrix.build-configuration }} --output-on-failure @@ -107,4 +108,4 @@ jobs: gh release create "$TAG_NAME" ./release-artifacts/*.zip \ --title "Development Build $TAG_NAME" \ --prerelease \ - --generate-notes \ No newline at end of file + --generate-notes diff --git a/.gitmodules b/.gitmodules index 0f66571..1ab53f1 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "lib/Base256"] - path = lib/Base256 - url = https://github.com/ParallelEngineering/Base256.git +[submodule "lib/BigInt"] + path = lib/BigInt + url = https://github.com/ParallelEngineering/BigInt.git diff --git a/CMakeLists.txt b/CMakeLists.txt index 9d782b2..cb6ef34 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,6 +8,8 @@ set(CMAKE_CXX_EXTENSIONS OFF) add_subdirectory(lib) add_subdirectory(src) + include(CTest) + add_subdirectory(tests) -target_link_libraries(RSA PRIVATE Base256) -target_include_directories(RSA PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) \ No newline at end of file +target_link_libraries(RSA PUBLIC Base256) +target_include_directories(RSA PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) \ No newline at end of file diff --git a/lib/Base256 b/lib/Base256 deleted file mode 160000 index 3fa56dd..0000000 --- a/lib/Base256 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3fa56dd387614d8cf67438df2e28ec34636452eb diff --git a/lib/BigInt b/lib/BigInt new file mode 160000 index 0000000..d3f446e --- /dev/null +++ b/lib/BigInt @@ -0,0 +1 @@ +Subproject commit d3f446eb6237a0891f17eaebc939bf419ea42a7c diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt index 35063ce..d87f02c 100644 --- a/lib/CMakeLists.txt +++ b/lib/CMakeLists.txt @@ -1,4 +1,4 @@ -if(NOT EXISTS "${CMAKE_CURRENT_LIST_DIR}/Base256/CMakeLists.txt") - message(FATAL_ERROR "Base256 submodule not initialized. Run: git submodule update --init --recursive") +if(NOT EXISTS "${CMAKE_CURRENT_LIST_DIR}/BigInt/CMakeLists.txt") + message(FATAL_ERROR "BigInt submodule not initialized. Run: git submodule update --init --recursive") endif() -add_subdirectory(Base256) \ No newline at end of file +add_subdirectory(BigInt) \ No newline at end of file diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4cc56a1..fef04ae 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -9,3 +9,7 @@ target_include_directories(RSA PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} ) + +if (WIN32) + target_link_libraries(RSA PRIVATE bcrypt) +endif () diff --git a/src/decrypt.cpp b/src/decrypt.cpp index 3d43bad..be9503d 100644 --- a/src/decrypt.cpp +++ b/src/decrypt.cpp @@ -2,13 +2,18 @@ #include -namespace core { -Decryptor::Decryptor(PrivateKey privKey) : key(std::move(privKey)) {} +#include "helper.h" +#include "math_utils.h" -std::string Decryptor::decrypt(const std::vector& ciphertext) const { +using namespace operations::math; + +namespace core::decryptor { +std::string decrypt(keyPair& keyPair, const std::vector& ciphertext) { std::string plaintext; - const size_t blockSize = key.n.getBytes().size(); + // The block size in bytes is determined by the size of modulus n multiplied by 8 (64 bits per + // limb) + const size_t blockSize = keyPair.getPrivateKey().n.getBytes().size() * 8; if (blockSize == 0 || ciphertext.size() % blockSize != 0) { std::cerr << "Decryption error: Invalid ciphertext block size alignment." << std::endl; return plaintext; @@ -17,18 +22,18 @@ std::string Decryptor::decrypt(const std::vector& ciphertext) const { // Process the ciphertext block-by-block using the fixed block size for (size_t i = 0; i < ciphertext.size(); i += blockSize) { // 1. Extract a single block chunk - std::vector chunk(ciphertext.begin() + i, ciphertext.begin() + i + blockSize); + std::vector chunk(ciphertext.begin() + i, ciphertext.begin() + i + blockSize); - // 2. Construct a Base256 representation from the extracted block chunk - const operations::Base256 c_num(chunk); + // 2. Construct representation from the extracted block chunk (using 64-bit limbs) + const operations::Base256 c_num(bytesToByteArray(chunk)); // 3. Perform RSA mathematical operation: M = C^d mod n - operations::Base256 m_num = operations::Base256::modPow(c_num, key.d, key.n); + operations::Base256 m_num = + modPow(c_num, keyPair.getPrivateKey().d, keyPair.getPrivateKey().n); // 4. Retrieve the decrypted byte value and convert it back to a character - const auto& m_bytes = m_num.getBytes(); + std::vector m_bytes = byteArrayToBytes(m_num.getBytes()); if (!m_bytes.empty()) { - // Assuming little-endian layout where index 0 is the least significant byte plaintext.push_back(static_cast(m_bytes[0])); } else { plaintext.push_back('\0'); // Fallback for a zero-value block @@ -37,4 +42,4 @@ std::string Decryptor::decrypt(const std::vector& ciphertext) const { return plaintext; } -} // namespace core \ No newline at end of file +} // namespace core::decryptor \ No newline at end of file diff --git a/src/decrypt.h b/src/decrypt.h index 1831624..7884d13 100644 --- a/src/decrypt.h +++ b/src/decrypt.h @@ -7,17 +7,11 @@ #include "keyPair.h" namespace core { -class Decryptor { - private: - PrivateKey key; +namespace decryptor { - public: - // Constructor binds the decryption process to a specific Private Key - explicit Decryptor(PrivateKey privKey); - - // Performs RSA decryption on a ciphertext byte vector - [[nodiscard]] std::string decrypt(const std::vector& ciphertext) const; -}; +// Performs RSA decryption on a ciphertext byte vector +[[nodiscard]] std::string decrypt(keyPair& keyPair, const std::vector& ciphertext); +}; // namespace decryptor } // namespace core #endif \ No newline at end of file diff --git a/src/encrypt.cpp b/src/encrypt.cpp index 5e0644f..8813c29 100644 --- a/src/encrypt.cpp +++ b/src/encrypt.cpp @@ -1,36 +1,33 @@ #include "encrypt.h" -namespace core { -Encryptor::Encryptor(PublicKey pubKey) : key(std::move(pubKey)) {} +#include "helper.h" +#include "math_utils.h" -std::vector Encryptor::encrypt(const std::string& plaintext) const { +using namespace operations::math; + +namespace core::encryptor { +std::vector encrypt(keyPair& keyPair, const std::string& plaintext) { std::vector ciphertext; - // The ciphertext block size is determined by the byte-length of the modulus n - const size_t blockSize = key.n.getBytes().size(); + // The block size in bytes is determined by the size of modulus n multiplied by 8 (64 bits per + // limb) + const size_t blockSize = keyPair.getPublicKey().n.getBytes().size() * 8; if (blockSize == 0) return ciphertext; for (const char c : plaintext) { - // 1. Convert the character byte to an arbitrary-precision Base256 representation + // 1. Convert the character byte to the internal 64-bit limb representation const operations::Base256 m(static_cast(c)); // 2. Perform RSA mathematical operation: C = M^e mod n - operations::Base256 c_num = operations::Base256::modPow(m, key.e, key.n); - - // 3. Extract the raw bytes from the computed ciphertext number - std::vector c_bytes = c_num.getBytes(); + operations::Base256 c_num = modPow(m, keyPair.getPublicKey().e, keyPair.getPublicKey().n); - // 4. Padding: Pad the byte vector with trailing zeros up to the required block size. - // (Assuming little-endian layout, where the most significant bytes are placed at the - // end) - while (c_bytes.size() < blockSize) { - c_bytes.push_back(0); - } + // 3. Extract raw bytes from the computed ciphertext number (padded to target block size) + std::vector c_bytes = byteArrayToBytes(c_num.getBytes(), blockSize); - // 5. Append the padded block to the final ciphertext vector + // 4. Append the padded block to the final ciphertext vector ciphertext.insert(ciphertext.end(), c_bytes.begin(), c_bytes.end()); } return ciphertext; } -} // namespace core \ No newline at end of file +} // namespace core::encryptor \ No newline at end of file diff --git a/src/encrypt.h b/src/encrypt.h index a66606d..4ae913a 100644 --- a/src/encrypt.h +++ b/src/encrypt.h @@ -7,17 +7,10 @@ #include "keyPair.h" namespace core { -class Encryptor { - private: - PublicKey key; - - public: - // Constructor binds the encryption process to a specific Public Key - explicit Encryptor(PublicKey pubKey); - - // Performs RSA encryption on a plaintext string - [[nodiscard]] std::vector encrypt(const std::string& plaintext) const; -}; +namespace encryptor { +// Performs RSA encryption on a plaintext string +[[nodiscard]] std::vector encrypt(keyPair& keyPair, const std::string& plaintext); +}; // namespace encryptor } // namespace core #endif \ No newline at end of file diff --git a/src/helper.h b/src/helper.h new file mode 100644 index 0000000..27fabb8 --- /dev/null +++ b/src/helper.h @@ -0,0 +1,55 @@ +#pragma once +#include +#include +#include +#include + +#include "base256.h" +#include "key_fwd.h" + +// Converts a little-endian byte array to a 64-bit word array +[[nodiscard]] inline ByteArray bytesToByteArray(const std::vector& bytes) { + if (bytes.empty()) return {0}; + ByteArray data; + data.reserve((bytes.size() + 7) / 8); + for (size_t i = 0; i < bytes.size(); i += 8) { + uint64_t val = 0; + for (size_t j = 0; j < 8 && (i + j) < bytes.size(); ++j) { + val |= (static_cast(bytes[i + j]) << (j * 8)); + } + data.push_back(val); + } + // Remove trailing zeros to normalize representation + while (data.size() > 1 && data.back() == 0) { + data.pop_back(); + } + return data; +} + +// Converts a 64-bit word array to a little-endian byte array +[[nodiscard]] inline std::vector byteArrayToBytes(const ByteArray& data, + size_t targetSize = 0) { + std::vector bytes; + bytes.reserve(data.size() * 8); + for (uint64_t word : data) { + for (int i = 0; i < 8; ++i) { + bytes.push_back(static_cast((word >> (i * 8)) & 0xFF)); + } + } + if (targetSize == 0) { + // Trim trailing zeros but keep at least 1 byte if the number is 0 + while (bytes.size() > 1 && bytes.back() == 0) { + bytes.pop_back(); + } + } else { + // Adjust array size to match the exact block or key size constraint + if (bytes.size() < targetSize) { + bytes.resize(targetSize, 0); + } else if (bytes.size() > targetSize) { + while (bytes.size() > targetSize && bytes.back() == 0) { + bytes.pop_back(); + } + } + } + return bytes; +} \ No newline at end of file diff --git a/src/keyPair.cpp b/src/keyPair.cpp index e7ee7e0..5c67b36 100644 --- a/src/keyPair.cpp +++ b/src/keyPair.cpp @@ -2,14 +2,134 @@ #include #include +#include #include -// Serializes two 4 bytes Byte Arrays with Big endian +// clang-format off +#if defined(_WIN32) +#ifndef _WIN32_WINNT +#define _WIN32_WINNT 0x0600 +#endif +#include +#include +#else +#include +#endif +// clang-format on + +#include "helper.h" +#include "math_utils.h" + +namespace { +// 256 bytes = 2048 bits for prime p and q. +// Resulting in a 4096-bit RSA modulus n = p * q. +constexpr size_t PRIME_SIZE_BYTES = 256; + +// Reads cryptographically secure random bytes from the operating system +std::vector getSecureRandomBytes(size_t size) { + std::vector buffer(size); +#if defined(_WIN32) + // Windows BCrypt API + const NTSTATUS status = BCryptGenRandom(nullptr, buffer.data(), static_cast(size), + BCRYPT_USE_SYSTEM_PREFERRED_RNG); + if (status < 0) { + throw std::runtime_error("BCryptGenRandom failed"); + } +#else +// Linux/macOS: getentropy() with /dev/urandom as a fallback +#if defined(__GLIBC__) && ((__GLIBC__ > 2) || (__GLIBC__ == 2 && __GLIBC_MINOR__ >= 25)) + if (getentropy(buffer.data(), size) == 0) { + return buffer; + } +#endif + std::ifstream urandom("/dev/urandom", std::ios::binary); + if (urandom.is_open()) { + urandom.read(reinterpret_cast(buffer.data()), size); + } +#endif + return buffer; +} + +// Generates a random odd candidate with the most significant bit set +std::vector generateCandidateBytes() { + std::vector candidate = getSecureRandomBytes(PRIME_SIZE_BYTES); + + // Ensure the most significant bit (MSB) is set (little endian) + candidate[PRIME_SIZE_BYTES - 1] |= 0x80; + + // Ensure the number is odd (least significant bit = 1) + candidate[0] |= 0x01; + + return candidate; +} + +// Generates a cryptographically secure 2048-bit prime number +operations::Base256 generateSecurePrime() { + std::vector candidateBytes = generateCandidateBytes(); + // Convert 256 byte vector to Base256 representation using 64-bit limbs + operations::Base256 candidate(bytesToByteArray(candidateBytes)); + + // Search sequentially for the next prime using the math_utils library + while (!operations::math::isPrime(candidate)) { + candidate += operations::Base256(2); + } + return candidate; +} +} // namespace + +// Default Constructor: Generates a new secure 4096-bit RSA keypair +keyPair::keyPair() { + const operations::Base256 p = generateSecurePrime(); + operations::Base256 q = generateSecurePrime(); + + // Ensure p and q are not identical + while (p == q) { + q = generateSecurePrime(); + } + + operations::Base256 phi = (p - operations::Base256(1)) * (q - operations::Base256(1)); + const operations::Base256 e(65537); + + // Ensure e and phi are coprime + while (operations::math::gcd(e, phi) != operations::Base256(1)) { + q = generateSecurePrime(); + while (p == q) { + q = generateSecurePrime(); + } + phi = (p - operations::Base256(1)) * (q - operations::Base256(1)); + } + + const operations::Base256 n = p * q; + const operations::Base256 d = operations::math::modInverse(e, phi); + + public_key.n = n; + public_key.e = e; + + private_key.n = n; + private_key.d = d; +} + +// Import Constructor: Imports keys from Base64 encoded serialized strings +keyPair::keyPair(const std::string &publicKey, const std::string &privateKey) { + const std::vector pubBytes = base64Decode(publicKey); + s_deserialize(pubBytes, public_key.n, public_key.e); + + const std::vector privBytes = base64Decode(privateKey); + s_deserialize(privBytes, private_key.n, private_key.d); +} + +std::vector PublicKey::serialize() const { return keyPair::s_serialize(n, e); } + +std::vector PrivateKey::serialize() const { return keyPair::s_serialize(n, d); } + +// Serializes two Base256 fields with Big-endian size headers std::vector keyPair::s_serialize(const operations::Base256 &first, const operations::Base256 &second) { std::vector serialized; - const auto &firstBytes = first.getBytes(); - const auto &secondBytes = second.getBytes(); + + // Safely extract the raw byte stream from the 64-bit limb vectors + std::vector firstBytes = byteArrayToBytes(first.getBytes()); + std::vector secondBytes = byteArrayToBytes(second.getBytes()); uint32_t firstSize = firstBytes.size(); uint32_t secondSize = secondBytes.size(); @@ -31,7 +151,7 @@ std::vector keyPair::s_serialize(const operations::Base256 &first, return serialized; } -// Deserializes two 4 bytes Byte Arrays with Big endian +// Deserializes two Base256 fields with Big-endian size headers bool keyPair::s_deserialize(const std::vector &data, operations::Base256 &outFirst, operations::Base256 &outSecond) { if (data.size() < 8) return false; @@ -63,8 +183,9 @@ bool keyPair::s_deserialize(const std::vector &data, operations::Base25 secondBegin + static_cast::difference_type>(secondSize); std::vector secondBytes(secondBegin, secondEnd); - outFirst = operations::Base256(firstBytes); - outSecond = operations::Base256(secondBytes); + // Convert deserialized byte streams back into 64-bit limb vectors + outFirst = operations::Base256(bytesToByteArray(firstBytes)); + outSecond = operations::Base256(bytesToByteArray(secondBytes)); return true; } @@ -128,4 +249,4 @@ uint8_t keyPair::getBase64Index(char letter) { } } return 0; -} +} \ No newline at end of file diff --git a/src/keyPair.h b/src/keyPair.h index fe693d9..b1570d0 100644 --- a/src/keyPair.h +++ b/src/keyPair.h @@ -35,24 +35,19 @@ class keyPair { static constexpr char base64Chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - static std::vector s_serialize(const operations::Base256 &first, - const operations::Base256 &second); - static bool s_deserialize(const std::vector &data, operations::Base256 &outFirst, - operations::Base256 &outSecond); - public: - keyPair() { - // Currently using dummy values - public_key.n = operations::Base256(937131); - public_key.e = operations::Base256(65537); + keyPair(); - private_key.n = operations::Base256(937131); - private_key.d = operations::Base256(129381); - } + keyPair(const std::string &publicKey, const std::string &privateKey); PublicKey getPublicKey() { return public_key; } PrivateKey getPrivateKey() { return private_key; } + static std::vector s_serialize(const operations::Base256 &first, + const operations::Base256 &second); + static bool s_deserialize(const std::vector &data, operations::Base256 &outFirst, + operations::Base256 &outSecond); + // Base64 helper functions static std::string base64Encode(const std::vector &data); static std::vector base64Decode(std::string data); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..073ab84 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,30 @@ +# Prevent CMake from re-downloading Catch2 every time CLion reloads +set(FETCHCONTENT_UPDATES_DISCONNECTED ON CACHE BOOL "Don't update FetchContent on every config" FORCE) + +# Download Catch2 +include(FetchContent) +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.11.0 +) +FetchContent_MakeAvailable(Catch2) + +# Find Catch 2 CMakeList +list(APPEND CMAKE_MODULE_PATH ${Catch2_SOURCE_DIR}/extras) +include(Catch) + +add_executable(RSA-Tests + test_rsa.cpp +) +target_link_libraries(RSA-Tests + PRIVATE + RSA + Catch2::Catch2WithMain +) + +# Catch2 integration in CTest +catch_discover_tests(RSA-Tests + TEST_PREFIX rsa: + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} +) diff --git a/tests/test_rsa.cpp b/tests/test_rsa.cpp new file mode 100644 index 0000000..8f335fb --- /dev/null +++ b/tests/test_rsa.cpp @@ -0,0 +1,77 @@ +#include +#include +#include + +#include "decrypt.h" +#include "encrypt.h" +#include "keyPair.h" + +using core::decryptor::decrypt; +using core::encryptor::encrypt; + +TEST_CASE("RSA Core: Basic Encryption and Decryption Roundtrip") { + static keyPair pair; + + SECTION("Decrypting encrypted normal string recovers the original text") { + std::string plaintext = "Hello, C++ World!"; + + std::vector ciphertext = encrypt(pair, plaintext); + + REQUIRE_FALSE(ciphertext.empty()); + + std::string recovered = decrypt(pair, ciphertext); + + REQUIRE(recovered == plaintext); + } + + SECTION("Decrypting encrypted empty string recovers empty string") { + std::string plaintext = ""; + + std::vector ciphertext = encrypt(pair, plaintext); + std::string recovered = decrypt(pair, ciphertext); + + REQUIRE(recovered == plaintext); + } + + SECTION("Correct handling of spaces, numbers, and special symbols") { + std::string plaintext = "RSA_4096_Test! @#$%^&*()_+ 12345"; + + std::vector ciphertext = encrypt(pair, plaintext); + std::string recovered = decrypt(pair, ciphertext); + + REQUIRE(recovered == plaintext); + } +} + +TEST_CASE("RSA Core: Security and Key Isolation") { + static keyPair pairA; + static keyPair pairB; + + std::string plaintext = "Highly Confidential Cryptographic Data"; + + std::vector ciphertext = encrypt(pairA, plaintext); + + SECTION("Decrypting with the incorrect keypair must not recover the plaintext") { + std::string recovered = decrypt(pairB, ciphertext); + + REQUIRE(recovered != plaintext); + } +} + +TEST_CASE("RSA Core: Key Serialization and Base64 Import/Export") { + static keyPair originalPair; + + std::string pubBase64 = keyPair::base64Encode(originalPair.getPublicKey().serialize()); + std::string privBase64 = keyPair::base64Encode(originalPair.getPrivateKey().serialize()); + + REQUIRE_FALSE(pubBase64.empty()); + REQUIRE_FALSE(privBase64.empty()); + + keyPair importedPair(pubBase64, privBase64); + + std::string plaintext = "Verification of Imported Keys"; + std::vector ciphertext = encrypt(importedPair, plaintext); + std::string recovered = decrypt(importedPair, ciphertext); + + REQUIRE(recovered == plaintext); +} \ No newline at end of file