#pragma once #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef _WIN32 # ifndef WIN32_LEAN_AND_MEAN # define WIN32_LEAN_AND_MEAN # endif # ifndef NOMINMAX # define NOMINMAX # endif # include # include # include # include # include # include # pragma comment(lib, "iphlpapi.lib") # pragma comment(lib, "advapi32.lib") # pragma comment(lib, "crypt32.lib") // MinGW-w64's defines SYSTEM_HANDLE_INFORMATION/SYSTEM_HANDLE_ENTRY // (undocumented NT internals used by the memory-read detector below) but the // official MSVC/Windows SDK intentionally omits them. Without // this, the header fails to compile under MSVC with "SYSTEM_HANDLE_INFORMATION: // undeclared identifier". Field layout matches MinGW's definition exactly, so // this compiles identically on both toolchains - not a guess, a real // well-documented (if unofficial) NT struct layout. # if !defined(__MINGW32__) && !defined(__MINGW64_VERSION_MAJOR) typedef struct _SYSTEM_HANDLE_ENTRY { ULONG OwnerPid; BYTE ObjectType; BYTE HandleFlags; USHORT HandleValue; PVOID ObjectPointer; ULONG AccessMask; } SYSTEM_HANDLE_ENTRY, *PSYSTEM_HANDLE_ENTRY; typedef struct _SYSTEM_HANDLE_INFORMATION { ULONG Count; SYSTEM_HANDLE_ENTRY Handle[1]; } SYSTEM_HANDLE_INFORMATION, *PSYSTEM_HANDLE_INFORMATION; # endif #endif // On Windows, HTTP::post() uses WinHTTP by default - winhttp.dll ships with // every Windows install (nothing to link statically or ship alongside your // .exe), and it validates TLS certificates against the OS trust store // automatically, so there is no CA-bundle-file problem to work around at // all. Statically linking libcurl together with its full dependency chain // (OpenSSL, ngtcp2, nghttp3, libssh2) has been observed to produce a PE // image that fails to load (0xc000007b) on some real Windows machines, even // when it builds and runs fine elsewhere - reproduced with zero AstraGuard // code involved, so it is not safe to assume static curl always works. // Define AG_USE_CURL to keep using libcurl instead (e.g. to share code with // a non-Windows build, or if your project already depends on curl anyway). // Non-Windows platforms always use curl - WinHTTP is Windows-only. #if !defined(_WIN32) || defined(AG_USE_CURL) #define AG_HTTP_USE_CURL 1 // Define AG_DYNAMIC_CURL before including this header if you link against // libcurl as a shared DLL instead of statically - CURL_STATICLIB must NOT be // set in that case (curl.h needs it to pick the right dllimport/dllexport // declarations, mismatching it against how you actually link produces // garbled responses at runtime, not a compile error). #ifndef AG_DYNAMIC_CURL #define CURL_STATICLIB #endif #include #else #include #pragma comment(lib, "winhttp.lib") #endif #include #include // Optional local CA bundle support - see _localCaBundlePath() below. #ifdef _WIN32 inline const std::string& _localCaBundlePath() { static const std::string path = [] { char exePath[MAX_PATH] = {}; if (GetModuleFileNameA(nullptr, exePath, MAX_PATH)) { std::string dir(exePath); auto pos = dir.find_last_of("\\/"); if (pos != std::string::npos) dir = dir.substr(0, pos + 1); std::string candidate = dir + "cacert.pem"; std::ifstream f(candidate); if (f.good()) return candidate; } return std::string(); }(); return path; } #endif // Embedded (in-memory) CA bundle support - lets a single-file .exe ship its // own trust anchors via CURLOPT_CAINFO_BLOB instead of requiring a companion // cacert.pem next to the binary. Call AstraGuard::setEmbeddedCaBundle() once // at startup, before any validate()/activate() call, with a byte array // containing a PEM-formatted CA bundle (e.g. generated from curl's official // bundle at https://curl.se/docs/caextract.html and embedded as a static // const array in your own source). Takes priority over the local file lookup // above if both are set. inline const unsigned char*& _embeddedCaBundleData() { static const unsigned char* p = nullptr; return p; } inline size_t& _embeddedCaBundleLen() { static size_t l = 0; return l; } // Define AG_XOR_KEY (any single byte, e.g. 0x3C) BEFORE #include "astraguard.hpp" // to use your own obfuscation key instead of this shared default. Every // integration that skips this uses the exact same, publicly-documented key - // meaning someone who has reverse-engineered ONE AstraGuard C++ app already // knows the key for every other app still using the default. Picking your own // value costs one line and means an attacker has to redo the key-recovery work // specifically against your binary instead of reusing a known constant. #ifndef AG_XOR_KEY #define AG_XOR_KEY 0x7F #endif inline std::string _ag_deobf_impl(const unsigned char* data, size_t len, unsigned char key = AG_XOR_KEY) { std::string s(len, '\0'); for (size_t i = 0; i < len; i++) s[i] = static_cast(data[i] ^ key); return s; } #define AG_DEOBF(arr) ::_ag_deobf_impl(arr, sizeof(arr), AG_XOR_KEY) template struct _AgObfStr { char data[N]{}; constexpr explicit _AgObfStr(const char(&s)[N]) { for (size_t i = 0; i < N; ++i) data[i] = static_cast( static_cast(s[i]) ^ static_cast(AG_XOR_KEY ^ (i & 0xFF))); } inline std::string decode() const { std::string r(N > 0 ? N - 1 : 0, '\0'); for (size_t i = 0; i + 1 < N; ++i) r[i] = static_cast( static_cast(data[i]) ^ static_cast(AG_XOR_KEY ^ (i & 0xFF))); return r; } }; // Legacy single-key variant kept above for backward compat. AG_OBFSTR now // uses the per-call-site keyed variant below instead. // // Why this is stronger than a single AG_XOR_KEY (^ i): with one global key, // recovering it from ONE string (e.g. a known-plaintext like "Enter your // license key: ") hands the attacker every other string in the binary for // free - one `xor` loop, one constant, done. And the flat `key ^ (i & 0xFF)` // stream is exactly the trivial pattern FLARE's FLOSS auto-recovers. // // Here each AG_OBFSTR call site gets its OWN 64-bit key, seeded from // __COUNTER__ (unique per expansion) mixed with the user's AG_XOR_KEY, and // the per-byte keystream is a splitmix64 finalizer of (seed + i*golden), not // a linear `^ i`. Cracking one string's key reveals nothing about the next, // and the non-linear keystream is not a shape FLOSS pattern-matches. The // decode loop is of course still visible in a decompiler - no inline // decryptor can hide from that - but "read one, get all" is closed, and the // automated string-recovery tools stop working. constexpr uint64_t _ag_mix64(uint64_t x) { x ^= x >> 30; x *= 0xbf58476d1ce4e5b9ULL; x ^= x >> 27; x *= 0x94d049bb133111ebULL; x ^= x >> 31; return x; } template struct _AgObfStrK { char data[N]{}; constexpr explicit _AgObfStrK(const char(&s)[N]) { for (size_t i = 0; i < N; ++i) { uint8_t kb = static_cast( _ag_mix64(_ag_mix64(SEED) + i * 0x9E3779B97F4A7C15ULL) & 0xFF); data[i] = static_cast(static_cast(s[i]) ^ kb); } } inline std::string decode() const { std::string r(N > 0 ? N - 1 : 0, '\0'); for (size_t i = 0; i + 1 < N; ++i) { uint8_t kb = static_cast( _ag_mix64(_ag_mix64(SEED) + i * 0x9E3779B97F4A7C15ULL) & 0xFF); r[i] = static_cast(static_cast(data[i]) ^ kb); } return r; } }; #define AG_OBFSTR(s) ([]() -> std::string { \ constexpr uint64_t _agseed = ((static_cast(__COUNTER__) + 1) << 32) \ ^ (static_cast(__LINE__) * 0x100000001B3ULL) \ ^ (static_cast(static_cast(AG_XOR_KEY)) * 0x9E3779B97F4A7C15ULL); \ constexpr _AgObfStrK _o(s); \ return _o.decode(); \ }()) namespace AstraGuard { // SDK version - bumped per docs/architecture/ASTRAGUARD_SDK_RESPONSE_ // INTEGRITY_PLAN.md §7 Phase 1 (SemVer MINOR: additive full-body response // verification, no removed API, no changed default behavior for any // customer not already opted into response-key verification). constexpr const char* SDK_VERSION = "2.1.1"; // Ship a fully self-contained single .exe: embed a PEM CA bundle as a byte // array in your own source and register it once at startup (before any // validate()/activate() call) instead of shipping a companion cacert.pem file. inline void setEmbeddedCaBundle(const unsigned char* data, size_t len) { _embeddedCaBundleData() = data; _embeddedCaBundleLen() = len; } enum class ErrorCode { NONE = 0, NETWORK_ERROR, INVALID_KEY_FORMAT, INVALID_KEY_CHECKSUM, KEY_NOT_FOUND, KEY_REVOKED, KEY_INACTIVE, KEY_ALREADY_ACTIVATED, HWID_MISMATCH, HWID_RESET_PENDING, INVALID_PRODUCT, INVALID_SIGNATURE, LICENSE_EXPIRED, LICENSE_NOT_FOUND, MACHINE_MISMATCH, MISSING_PARAMS, BLOCKED_IP, BLOCKED_HWID, BLOCKED_VPN, BLOCKED_PROXY, SERVER_ERROR, BLOCKED_BY_SECURITY, RESPONSE_TAMPERED, RESPONSE_KEY_NOT_SET, UNKNOWN }; struct SecurityFlags { bool blockVm = false; bool blockDebug = false; bool integrityCheck = false; std::string integrityHash; // Local-only flag, never populated from the server response - opt in via // setInitialSecurityFlags(). See Security::isMemoryBeingRead(). bool blockMemRead = false; }; struct License { std::string id; std::string licenseKey; std::string machineId; std::string expiresAt; std::string issuedAt; std::string signature; std::string userId; std::vector features; int deviceLimit = 1; bool isLifetime = false; int remainingDays() const { if (isLifetime || expiresAt.empty()) return 9999; std::tm tm = {}; std::string s = expiresAt; auto dot = s.find('.'); if (dot != std::string::npos) s = s.substr(0, dot); if (!s.empty() && s.back() == 'Z') s.pop_back(); std::istringstream ss(s); ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S"); if (ss.fail()) return -1; auto expiry = std::mktime(&tm); auto now = std::time(nullptr); return static_cast(std::difftime(expiry, now) / 86400.0); } }; struct ValidateResult { bool valid = false; std::string reason; std::map variables; std::vector features; std::string expiresAt; bool isLifetime = false; std::string latestVersion; SecurityFlags security; std::string rt; int64_t rts = 0; std::string rn; // Present only when the server has integrityCheck enabled with hashes // configured AND this client sent a binaryHash - HMAC over // nonce|rts|valid|productId|binaryHash, binding the signed response to // the SPECIFIC binary that asked for it. A forged/replayed response // captured from a genuine (different) binary can't be reused to vouch // for a tampered one, because the hash is baked into the signature // itself, not just checked as a separate field an attacker's fake server // could echo back unchecked. Purely additive: absent on older servers or // when integrity hashes aren't configured, in which case _verifyRt2() is // simply skipped - this can never break an existing integration. std::string rt2; }; struct Response { bool success = false; std::string message; std::string errorCode; ErrorCode code = ErrorCode::NONE; License license; ValidateResult validateData; }; namespace HWID { #ifdef _WIN32 inline std::string _sha256hex(const std::string& input) { HCRYPTPROV hProv = 0; HCRYPTHASH hHash = 0; BYTE hash[32]; DWORD len = 32; std::string result; if (!CryptAcquireContextW(&hProv, nullptr, nullptr, PROV_RSA_AES, CRYPT_VERIFYCONTEXT)) return ""; if (CryptCreateHash(hProv, CALG_SHA_256, 0, 0, &hHash)) { if (CryptHashData(hHash, reinterpret_cast(input.c_str()), static_cast(input.size()), 0)) { if (CryptGetHashParam(hHash, HP_HASHVAL, hash, &len, 0)) { char hex[3]; for (DWORD i = 0; i < len; i++) { sprintf_s(hex, "%02x", hash[i]); result += hex; } } } CryptDestroyHash(hHash); } CryptReleaseContext(hProv, 0); return result; } inline std::string _getMachineGuid() { HKEY hKey = nullptr; char guid[64] = {}; DWORD size = static_cast(sizeof(guid)); if (RegOpenKeyExA(HKEY_LOCAL_MACHINE, "SOFTWARE\\Microsoft\\Cryptography", 0, KEY_READ | KEY_WOW64_64KEY, &hKey) == ERROR_SUCCESS) { RegQueryValueExA(hKey, "MachineGuid", nullptr, nullptr, reinterpret_cast(guid), &size); RegCloseKey(hKey); } return std::string(guid); } inline std::string _getComputerName() { char comp[MAX_COMPUTERNAME_LENGTH + 1] = {}; DWORD len = static_cast(sizeof(comp)); GetComputerNameA(comp, &len); return std::string(comp); } inline std::string _getMAC() { IP_ADAPTER_INFO info[16]; DWORD sz = sizeof(info); if (GetAdaptersInfo(info, &sz) == ERROR_SUCCESS) { for (PIP_ADAPTER_INFO a = info; a; a = a->Next) { bool nonZero = false; for (int i = 0; i < 6; i++) if (a->Address[i]) { nonZero = true; break; } if (!nonZero) continue; char mac[18]; sprintf_s(mac, "%02x:%02x:%02x:%02x:%02x:%02x", a->Address[0], a->Address[1], a->Address[2], a->Address[3], a->Address[4], a->Address[5]); return std::string(mac); } } return "no-mac"; } inline std::string _format(const std::string& fingerprint) { std::string h = _sha256hex(fingerprint); if (h.size() < 16) return "0000-0000-0000-0000"; std::string hwid = h.substr(0,4)+"-"+h.substr(4,4)+"-"+h.substr(8,4)+"-"+h.substr(12,4); std::transform(hwid.begin(), hwid.end(), hwid.begin(), ::toupper); return hwid; } #endif inline std::string generate() { #ifdef _WIN32 std::string guid = _getMachineGuid(); if (!guid.empty()) return _format(guid + "|" + _getComputerName()); return _format("win32|" + _getMAC()); #elif defined(__linux__) for (const char* path : {"/etc/machine-id", "/var/lib/dbus/machine-id"}) { FILE* f = fopen(path, "r"); if (f) { char buf[64] = {}; fgets(buf, sizeof(buf), f); fclose(f); std::string s(buf); s.erase(std::remove(s.begin(), s.end(), '\n'), s.end()); if (!s.empty()) return s.substr(0, 4)+"-"+s.substr(4,4)+"-"+s.substr(8,4)+"-"+s.substr(12,4); } } return "0000-0000-0000-0000"; #else return "0000-0000-0000-0000"; #endif } inline std::string fromString(const std::string& input) { #ifdef _WIN32 return _format(input); #else return "0000-0000-0000-0000"; #endif } inline bool isValid(const std::string& hwid) { if (hwid.size() != 19) return false; for (size_t i = 0; i < hwid.size(); i++) { if (i == 4 || i == 9 || i == 14) { if (hwid[i] != '-') return false; } else if (!isxdigit(static_cast(hwid[i]))) return false; } return true; } } namespace Security { inline std::string b64Encode(const std::vector& in) { static const char* T = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; std::string out; out.reserve(((in.size() + 2) / 3) * 4); for (size_t i = 0; i < in.size(); i += 3) { uint32_t v = static_cast(in[i]) << 16; if (i+1 < in.size()) v |= static_cast(in[i+1]) << 8; if (i+2 < in.size()) v |= static_cast(in[i+2]); out += T[(v>>18)&63]; out += T[(v>>12)&63]; out += (i+1 < in.size()) ? T[(v>>6)&63] : '='; out += (i+2 < in.size()) ? T[v&63] : '='; } return out; } inline std::vector b64Decode(const std::string& s) { static const int8_t T[256] = { -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,62,-1,-1,-1,63, 52,53,54,55,56,57,58,59,60,61,-1,-1,-1,-1,-1,-1, -1,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14, 15,16,17,18,19,20,21,22,23,24,25,-1,-1,-1,-1,-1, -1,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40, 41,42,43,44,45,46,47,48,49,50,51,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 }; std::vector out; out.reserve((s.size() * 3) / 4); uint32_t acc = 0; int bits = 0; for (unsigned char c : s) { int v = T[c]; if (v < 0) continue; acc = (acc << 6) | static_cast(v); bits += 6; if (bits >= 8) { bits -= 8; out.push_back(static_cast(acc >> bits)); } } return out; } #ifdef _WIN32 inline std::vector _sha256raw(const std::vector& data) { std::vector result(32, 0); HCRYPTPROV hProv = 0; HCRYPTHASH hHash = 0; if (!CryptAcquireContextW(&hProv, nullptr, nullptr, PROV_RSA_AES, CRYPT_VERIFYCONTEXT)) return result; if (CryptCreateHash(hProv, CALG_SHA_256, 0, 0, &hHash)) { if (CryptHashData(hHash, data.data(), static_cast(data.size()), 0)) { DWORD len = 32; CryptGetHashParam(hHash, HP_HASHVAL, result.data(), &len, 0); } CryptDestroyHash(hHash); } CryptReleaseContext(hProv, 0); return result; } inline std::vector hmacSha256Raw(const std::vector& key, const std::string& message) { const size_t BLK = 64; std::vector k(BLK, 0); if (key.size() > BLK) { auto h = _sha256raw(key); for (size_t i = 0; i < h.size(); i++) k[i] = h[i]; } else { for (size_t i = 0; i < key.size(); i++) k[i] = key[i]; } std::vector inner; inner.reserve(BLK + message.size()); for (size_t i = 0; i < BLK; i++) inner.push_back(k[i] ^ 0x36); for (char c : message) inner.push_back(static_cast(c)); auto ih = _sha256raw(inner); std::vector outer; outer.reserve(BLK + 32); for (size_t i = 0; i < BLK; i++) outer.push_back(k[i] ^ 0x5C); for (uint8_t b : ih) outer.push_back(b); return _sha256raw(outer); } inline std::string hmacSha256B64(const std::vector& key, const std::string& message) { return b64Encode(hmacSha256Raw(key, message)); } // Response Integrity implementation phase (docs/architecture/ // ASTRAGUARD_SDK_RESPONSE_INTEGRITY_PLAN.md §3): X-AstraGuard-Signature is // "sha256=", not base64 - this is the hex-encoding twin of // hmacSha256B64() above, sharing the exact same raw HMAC computation so // there is only ever one HMAC implementation to audit, not two. inline std::string hmacSha256Hex(const std::vector& key, const std::string& message) { static const char* H = "0123456789abcdef"; auto raw = hmacSha256Raw(key, message); std::string out; out.reserve(raw.size() * 2); for (uint8_t b : raw) { out += H[(b >> 4) & 0xF]; out += H[b & 0xF]; } return out; } // Verifies a full response body against an X-AstraGuard-Signature header // value ("sha256="), per docs/architecture/ // ASTRAGUARD_SDK_RESPONSE_INTEGRITY_PLAN.md §3/§6. A free function (not a // Client method) so it is independently unit-testable with a plain, // non-obfuscated key - Client::_verifyFullBodySignature() is a thin wrapper // that decodes its obfuscated-at-rest key onto the stack and delegates // here, exactly the same split hmacSha256B64()/_verifyRt() already use. inline bool verifyFullBodySignature(const std::vector& key, const std::string& rawBody, const std::string& signatureHeader) { if (key.empty() || signatureHeader.empty()) return false; const std::string prefix = "sha256="; if (signatureHeader.size() <= prefix.size() || signatureHeader.compare(0, prefix.size(), prefix) != 0) { return false; // unrecognized header format - never silently accepted } std::string providedHex = signatureHeader.substr(prefix.size()); std::transform(providedHex.begin(), providedHex.end(), providedHex.begin(), ::tolower); std::string expectedHex = hmacSha256Hex(key, rawBody); if (expectedHex.size() != providedHex.size()) return false; uint8_t diff = 0; for (size_t i = 0; i < expectedHex.size(); i++) diff |= static_cast(expectedHex[i]) ^ static_cast(providedHex[i]); return diff == 0; } inline std::string hashFile(const char* path) { HANDLE hFile = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); if (hFile == INVALID_HANDLE_VALUE) return ""; HCRYPTPROV hProv = 0; HCRYPTHASH hHash = 0; std::string result; if (CryptAcquireContextW(&hProv, nullptr, nullptr, PROV_RSA_AES, CRYPT_VERIFYCONTEXT)) { if (CryptCreateHash(hProv, CALG_SHA_256, 0, 0, &hHash)) { std::vector buf(65536); DWORD n = 0; bool ok = true; while (ReadFile(hFile, buf.data(), static_cast(buf.size()), &n, nullptr) && n > 0) if (!CryptHashData(hHash, buf.data(), n, 0)) { ok = false; break; } if (ok) { BYTE hash[32]; DWORD len = 32; if (CryptGetHashParam(hHash, HP_HASHVAL, hash, &len, 0)) { char hex[3]; for (DWORD i = 0; i < len; i++) { sprintf_s(hex, "%02x", hash[i]); result += hex; } } } CryptDestroyHash(hHash); } CryptReleaseContext(hProv, 0); } CloseHandle(hFile); return result; } #else inline std::vector _sha256raw(const std::vector&) { return {}; } inline std::vector hmacSha256Raw(const std::vector&, const std::string&) { return {}; } inline std::string hmacSha256B64(const std::vector&, const std::string&) { return ""; } inline std::string hmacSha256Hex(const std::vector&, const std::string&) { return ""; } inline bool verifyFullBodySignature(const std::vector&, const std::string&, const std::string&) { return false; } inline std::string hashFile(const char*) { return ""; } #endif #ifdef _WIN32 inline bool _dbg_api() { if (IsDebuggerPresent()) return true; BOOL remote = FALSE; CheckRemoteDebuggerPresent(GetCurrentProcess(), &remote); return remote != FALSE; } inline bool _dbg_nt() { typedef NTSTATUS(NTAPI* pfn)(HANDLE, UINT, PVOID, ULONG, PULONG); HMODULE hNt = GetModuleHandleA("ntdll.dll"); if (!hNt) return false; auto fn = reinterpret_cast(GetProcAddress(hNt, "NtQueryInformationProcess")); if (!fn) return false; HANDLE port = nullptr; NTSTATUS st = fn(GetCurrentProcess(), 7, &port, sizeof(port), nullptr); return (st == 0 && port != nullptr); } inline bool _dbg_heap() { HANDLE heap = GetProcessHeap(); if (!heap) return false; ULONG flags = 0; #ifdef _WIN64 flags = *reinterpret_cast(reinterpret_cast(heap) + 0x70); #else flags = *reinterpret_cast(reinterpret_cast(heap) + 0x44); #endif return (flags & 0x00000040) != 0; } inline bool _dbg_timing() { LARGE_INTEGER freq, t0, t1; QueryPerformanceFrequency(&freq); QueryPerformanceCounter(&t0); volatile int x = 0; for (int i = 0; i < 5000; i++) x ^= i; QueryPerformanceCounter(&t1); (void)x; double ms = static_cast(t1.QuadPart - t0.QuadPart) / freq.QuadPart * 1000.0; return ms > 100.0; } inline void _dbg_hideThread() { typedef NTSTATUS(NTAPI* pfn)(HANDLE, UINT, PVOID, ULONG); HMODULE hNt = GetModuleHandleA("ntdll.dll"); if (!hNt) return; auto fn = reinterpret_cast(GetProcAddress(hNt, "NtSetInformationThread")); if (fn) fn(GetCurrentThread(), 17, nullptr, 0); } inline bool _dbg_hwbp() { CONTEXT ctx = {}; ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS; if (GetThreadContext(GetCurrentThread(), &ctx)) return ctx.Dr0 || ctx.Dr1 || ctx.Dr2 || ctx.Dr3; return false; } inline bool _dbg_frida() { static const char* markers[] = { "frida", "gadget", "frida-agent", "frida-gum", nullptr }; HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, GetCurrentProcessId()); if (hSnap != INVALID_HANDLE_VALUE) { MODULEENTRY32 me = {}; me.dwSize = sizeof(me); if (Module32First(hSnap, &me)) { do { std::string name = me.szModule; std::transform(name.begin(), name.end(), name.begin(), ::tolower); for (int i = 0; markers[i]; i++) if (name.find(markers[i]) != std::string::npos) { CloseHandle(hSnap); return true; } } while (Module32Next(hSnap, &me)); } CloseHandle(hSnap); } DWORD pid = GetCurrentProcessId(); char pipe[64]; snprintf(pipe, sizeof(pipe), "\\\\.\\pipe\\frida-%lu", pid); HANDLE h = CreateFileA(pipe, GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); if (h != INVALID_HANDLE_VALUE) { CloseHandle(h); return true; } return false; } inline bool _dbg_blacklist() { static const char* banned[] = { "x32dbg.exe", "x64dbg.exe", "ollydbg.exe", "windbg.exe", "ntsd.exe", "cdb.exe", "kd.exe", "dbgview.exe", "ida.exe", "ida64.exe", "idaw.exe", "idaw64.exe", "idag.exe", "idag64.exe", "radare2.exe", "r2.exe", "ghidra.exe", "dnspy.exe", "dotpeek.exe", "ilspy.exe", "de4dot.exe", "cheatengine.exe", "cheatengine-x86_64.exe", "cheatengine-i386.exe", "ceserver.exe", "artmoney.exe", "tsearch.exe", "processhacker.exe", "procmon.exe", "procmon64.exe", "procexp.exe", "procexp64.exe", "processhacker2.exe", "reclass.exe", "reclass64.exe", "reclass-net.exe", "wireshark.exe", "fiddler.exe", "fiddlercap.exe", "charles.exe", "proxifier.exe", "proxycap.exe", nullptr }; HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (hSnap == INVALID_HANDLE_VALUE) return false; PROCESSENTRY32 pe = {}; pe.dwSize = sizeof(pe); bool found = false; if (Process32First(hSnap, &pe)) { do { std::string name = pe.szExeFile; std::transform(name.begin(), name.end(), name.begin(), ::tolower); for (int i = 0; banned[i]; i++) if (name == banned[i]) { found = true; break; } } while (!found && Process32Next(hSnap, &pe)); } CloseHandle(hSnap); return found; } inline bool isDebugger() { _dbg_hideThread(); return _dbg_api() || _dbg_nt() || _dbg_heap() || _dbg_timing() || _dbg_hwbp() || _dbg_frida() || _dbg_blacklist(); } inline bool _vm_cpuidBit() { int info[4] = {}; __cpuid(info, 1); return (info[2] & (1 << 31)) != 0; } inline std::string _vm_hypervisorVendorString() { int info[4] = {}; __cpuid(info, 0x40000000); char v[13] = {}; memcpy(v, &info[1], 4); memcpy(v + 4, &info[2], 4); memcpy(v + 8, &info[3], 4); return std::string(v, 12); } // Guest-only hypervisor vendor IDs - a bare-metal host never reports these, // even with virtualization extensions (VT-x/AMD-V) enabled in firmware. inline bool _vm_cpuidVendor() { std::string vendor = _vm_hypervisorVendorString(); return vendor == "VMwareVMware" || vendor.find("VBoxVBox") != std::string::npos || vendor.find("KVMKVMKVM") != std::string::npos || vendor.find("XenVMMXenVMM") != std::string::npos || vendor.find("TCGTCGTCGTCG") != std::string::npos; } // "Microsoft Hv" is ambiguous on Windows: the HOST root partition also // reports this vendor ID whenever Hyper-V, WSL2, Docker Desktop, Windows // Sandbox, or Core Isolation / Memory Integrity (VBS) is enabled - none of // which mean the process is running inside an actual guest VM. Treated as a // weak signal only (see isVm()); real guest evidence must corroborate it. inline bool _vm_hyperVVendor() { return _vm_hypervisorVendorString().find("Microsoft Hv") != std::string::npos; } // vmbus is the Hyper-V Virtual Machine Bus driver service. It gets // registered on the HOST too whenever Hyper-V, WSL2, Docker Desktop, // Windows Sandbox, or Core Isolation / Memory Integrity (VBS) is enabled - // its presence alone does not mean this process is running inside a guest. // Ambiguous just like the "Microsoft Hv" CPUID vendor string; grouped with // it in isVm() as a weak signal, kept separate from the genuinely // guest-only registry keys below. inline bool _vm_vmbusPresent() { HKEY hk; if (RegOpenKeyExA(HKEY_LOCAL_MACHINE, "SYSTEM\\CurrentControlSet\\Services\\vmbus", 0, KEY_READ, &hk) == ERROR_SUCCESS) { RegCloseKey(hk); return true; } return false; } inline bool _vm_registry() { static const char* keys[] = { "SOFTWARE\\VMware, Inc.\\VMware Tools", "SOFTWARE\\Oracle\\VirtualBox Guest Additions", "SYSTEM\\CurrentControlSet\\Services\\vmhgfs", "SYSTEM\\CurrentControlSet\\Services\\vmmouse", "SYSTEM\\CurrentControlSet\\Services\\VBoxGuest", "SYSTEM\\CurrentControlSet\\Services\\VBoxVideo", "SOFTWARE\\Microsoft\\Virtual Machine\\Guest\\Parameters", nullptr }; for (int i = 0; keys[i]; i++) { HKEY hk; if (RegOpenKeyExA(HKEY_LOCAL_MACHINE, keys[i], 0, KEY_READ, &hk) == ERROR_SUCCESS) { RegCloseKey(hk); return true; } } return false; } inline bool _vm_mac() { IP_ADAPTER_INFO info[16]; DWORD sz = sizeof(info); if (GetAdaptersInfo(info, &sz) != ERROR_SUCCESS) return false; static const unsigned char pfx[][3] = { {0x00,0x0C,0x29},{0x00,0x50,0x56},{0x00,0x05,0x69},{0x08,0x00,0x27} }; for (PIP_ADAPTER_INFO a = info; a; a = a->Next) for (auto& p : pfx) if (a->AddressLength >= 3 && a->Address[0]==p[0] && a->Address[1]==p[1] && a->Address[2]==p[2]) return true; return false; } inline bool _vm_guestProcs() { static const char* procs[] = { "vmtoolsd.exe","vmwaretray.exe","vmwareuser.exe", "vboxservice.exe","vboxtray.exe", "vmsrvc.exe","vmusrvc.exe", "xenservice.exe","xenguestservice.exe", "prl_cc.exe","prl_tools.exe", "qemu-ga.exe", nullptr }; HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (hSnap == INVALID_HANDLE_VALUE) return false; PROCESSENTRY32 pe = {}; pe.dwSize = sizeof(pe); bool found = false; if (Process32First(hSnap, &pe)) { do { std::string name = pe.szExeFile; std::transform(name.begin(), name.end(), name.begin(), ::tolower); for (int i = 0; procs[i]; i++) if (name == procs[i]) { found = true; break; } } while (!found && Process32Next(hSnap, &pe)); } CloseHandle(hSnap); return found; } inline bool isVm() { // Ambiguous "some hypervisor is active" signals - each one individually // fires on an ordinary Windows HOST with Hyper-V, WSL2, Docker Desktop, // Windows Sandbox, or Core Isolation / Memory Integrity (VBS) enabled. // They all just re-confirm the same underlying fact and never indicate // an actual guest on their own, so - unlike the checks below - they are // capped at a single combined +1 rather than stacking with each other. bool hyperVAmbiguous = _vm_cpuidBit() || _vm_hyperVVendor() || _vm_vmbusPresent(); int score = 0; if (hyperVAmbiguous) score += 1; if (_vm_cpuidVendor()) score += 3; // guest-only hypervisor vendor IDs if (_vm_registry()) score += 2; // guest-only tool/driver registry keys if (_vm_mac()) score += 2; if (_vm_guestProcs()) score += 2; return score >= 3; } // Detects another process holding a PROCESS_VM_READ / PROCESS_VM_WRITE / // PROCESS_VM_OPERATION handle open on us - the exact access MiniDumpWriteDump, // Process Hacker, and Cheat Engine's memory scanner all need to dump or edit // our memory. Uses NtQuerySystemInformation(SystemHandleInformation) + // NtDuplicateObject to walk every system handle and check which ones resolve // to our own process. // // A naive "any external memory-access handle = alert" check false-positives // immediately: the process that launched us (a shell, IDE, or debugger) // legitimately holds a full-access handle to us from creation. So the first // call snapshots the holder set as a baseline and records our parent PID; // every later call only reports holders that are BOTH new (absent from the // baseline) AND not our parent. // // Fail-safe by design: if the undocumented NTAPI is unavailable or any step // fails, this returns false rather than blocking a legitimate user. inline std::vector _memWatchScan(DWORD targetPid) { using pNtQuerySystemInformation = NTSTATUS(NTAPI*)(ULONG, PVOID, ULONG, PULONG); using pNtDuplicateObject = NTSTATUS(NTAPI*)(HANDLE, HANDLE, HANDLE, PHANDLE, ACCESS_MASK, ULONG, ULONG); std::vector holders; HMODULE ntdll = GetModuleHandleA("ntdll.dll"); if (!ntdll) return holders; auto fnQuery = (pNtQuerySystemInformation)GetProcAddress(ntdll, "NtQuerySystemInformation"); auto fnDup = (pNtDuplicateObject)GetProcAddress(ntdll, "NtDuplicateObject"); if (!fnQuery || !fnDup) return holders; const ULONG kSystemHandleInformation = 16; ULONG bufSize = 1 << 20; std::vector buffer; NTSTATUS status = 0; for (int attempt = 0; attempt < 8; attempt++) { buffer.resize(bufSize); ULONG returnLen = 0; status = fnQuery((SYSTEM_INFORMATION_CLASS)kSystemHandleInformation, buffer.data(), bufSize, &returnLen); if (status == 0) break; if ((ULONG)status == 0xC0000004) { bufSize *= 2; continue; } // STATUS_INFO_LENGTH_MISMATCH return holders; } if (status != 0) return holders; auto* info = reinterpret_cast(buffer.data()); const ACCESS_MASK dangerous = PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION; for (ULONG i = 0; i < info->Count; i++) { auto& h = info->Handle[i]; if (h.OwnerPid == targetPid) continue; if ((h.AccessMask & dangerous) == 0) continue; HANDLE srcProc = OpenProcess(PROCESS_DUP_HANDLE, FALSE, h.OwnerPid); if (!srcProc) continue; HANDLE dup = nullptr; // Must request real access rights here (DesiredAccess=0 silently // strips them, which breaks GetProcessId() on the duplicate). NTSTATUS dupStatus = fnDup(srcProc, (HANDLE)(uintptr_t)h.HandleValue, GetCurrentProcess(), &dup, PROCESS_QUERY_LIMITED_INFORMATION, 0, 0); CloseHandle(srcProc); if (dupStatus != 0 || !dup) continue; DWORD ownerTarget = GetProcessId(dup); CloseHandle(dup); if (ownerTarget == targetPid) holders.push_back(h.OwnerPid); } std::sort(holders.begin(), holders.end()); holders.erase(std::unique(holders.begin(), holders.end()), holders.end()); return holders; } inline DWORD _memWatchParentPid(DWORD pid) { HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snap == INVALID_HANDLE_VALUE) return 0; PROCESSENTRY32 pe{}; pe.dwSize = sizeof(pe); DWORD parent = 0; if (Process32First(snap, &pe)) { do { if (pe.th32ProcessID == pid) { parent = pe.th32ParentProcessID; break; } } while (Process32Next(snap, &pe)); } CloseHandle(snap); return parent; } inline bool isMemoryBeingRead() { static const DWORD myPid = GetCurrentProcessId(); static const DWORD parentPid = _memWatchParentPid(myPid); static const std::vector baseline = _memWatchScan(myPid); std::vector current = _memWatchScan(myPid); for (DWORD pid : current) { if (pid == parentPid) continue; if (std::binary_search(baseline.begin(), baseline.end(), pid)) continue; return true; // new, unexplained holder of a memory-access handle } return false; } inline void erasePeHeaders() { HMODULE hBase = GetModuleHandleA(nullptr); if (!hBase) return; DWORD old = 0; if (VirtualProtect(hBase, 0x1000, PAGE_READWRITE, &old)) { SecureZeroMemory(hBase, 0x1000); VirtualProtect(hBase, 0x1000, old, &old); } } inline std::string hashBinary() { char path[MAX_PATH] = {}; if (!GetModuleFileNameA(nullptr, path, MAX_PATH)) return ""; return hashFile(path); } inline bool integrityOk(const std::string& expected) { if (expected.empty()) return true; std::string actual = hashBinary(); if (actual.empty()) return false; std::string a = actual, e = expected; std::transform(a.begin(), a.end(), a.begin(), ::tolower); std::transform(e.begin(), e.end(), e.begin(), ::tolower); return a == e; } // integrityOk() above re-reads the .exe from disk - it cannot see a patch // applied directly to the LOADED process's memory (e.g. via WriteProcessMemory // after the program is already running), since the file on disk never changed. // This hashes the actual, currently-loaded .text section instead, so any // runtime code patch - a single flipped conditional jump included - changes // the hash and gets caught, regardless of whether the on-disk file is intact. // // KNOWN LIMITATION - packers that merge sections (UPX among them) will // break the naive "bake the expected hash into a constant in this same // binary" pattern: UPX consolidates .text/.rdata/.data into one compressed // section, so a hash CONSTANT stored anywhere UPX also compresses changes // the very bytes being hashed, and there is no stable fixed point (verified // empirically - iterating the build never converges). If you pack with // UPX, do not embed the expected value locally; send hashLoadedText() to // your own server on validate() instead and compare there, the same way // integrityHash / integrityCheck already works for the on-disk hash. inline std::string hashLoadedText() { HMODULE hMod = GetModuleHandleA(nullptr); if (!hMod) return ""; auto base = reinterpret_cast(hMod); auto dos = reinterpret_cast(base); if (dos->e_magic != IMAGE_DOS_SIGNATURE) return ""; auto nt = reinterpret_cast(base + dos->e_lfanew); if (nt->Signature != IMAGE_NT_SIGNATURE) return ""; auto sections = IMAGE_FIRST_SECTION(nt); for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) { auto& sec = sections[i]; if (memcmp(sec.Name, ".text", 5) == 0) { std::vector data(base + sec.VirtualAddress, base + sec.VirtualAddress + sec.Misc.VirtualSize); auto hash = _sha256raw(data); std::string hex; char buf[3]; for (uint8_t b : hash) { sprintf_s(buf, "%02x", b); hex += buf; } return hex; } } return ""; } inline bool selfIntegrityOk(const std::string& expectedTextHashHex) { if (expectedTextHashHex.empty()) return true; std::string actual = hashLoadedText(); if (actual.empty()) return false; std::string a = actual, e = expectedTextHashHex; std::transform(a.begin(), a.end(), a.begin(), ::tolower); std::transform(e.begin(), e.end(), e.begin(), ::tolower); return a == e; } // Hashes `len` bytes of currently-loaded code starting at fn's own address - // found via the function pointer itself at runtime, never a hardcoded // offset, so it keeps working across rebuilds without re-measuring anything // by hand. Meant for a small, dedicated, __declspec(noinline) function // containing ONLY control-flow logic (comparisons/branches/calls) and no // string literals or other .rdata references: unlike hashLoadedText() // (whole .text section), a narrow, reference-free range like this stays // stable even when UPX merges .text and .rdata into one compressed blob, // because nothing in the hashed bytes points at the .rdata content that // changes size when you embed a new expected hash. inline std::string hashCodeAt(const void* fn, size_t len) { auto* p = reinterpret_cast(fn); auto hash = _sha256raw(std::vector(p, p + len)); std::string hex; char buf[3]; for (uint8_t b : hash) { sprintf_s(buf, "%02x", b); hex += buf; } return hex; } inline bool codeRangeOk(const void* fn, size_t len, const std::string& expectedHex) { if (expectedHex.empty()) return true; std::string actual = hashCodeAt(fn, len); if (actual.empty()) return false; std::string a = actual, e = expectedHex; std::transform(a.begin(), a.end(), a.begin(), ::tolower); std::transform(e.begin(), e.end(), e.begin(), ::tolower); return a == e; } #else inline bool isDebugger() { return false; } inline bool isVm() { return false; } inline bool isMemoryBeingRead() { return false; } inline void erasePeHeaders() {} inline std::string hashBinary() { return ""; } inline bool integrityOk(const std::string&) { return true; } inline std::string hashLoadedText() { return ""; } inline bool selfIntegrityOk(const std::string&) { return true; } inline std::string hashCodeAt(const void*, size_t) { return ""; } inline bool codeRangeOk(const void*, size_t, const std::string&) { return true; } #endif } namespace HTTP { // Response Integrity implementation phase (docs/architecture/ // ASTRAGUARD_SDK_RESPONSE_INTEGRITY_PLAN.md §5.1): every transport now // returns the response body ALONGSIDE the X-AstraGuard-Signature header // value (empty string if the server didn't send one), instead of just the // body. This is the only structural HTTP-layer change either C-family // transport (WinHTTP or libcurl) needs - everything else about response // verification lives in Client, not here. struct HttpResponse { std::string body; std::string signatureHeader; // e.g. "sha256=", or empty if absent }; // HTTP header names are case-insensitive per spec; the server always sends // exactly "X-AstraGuard-Signature", but callback-based header capture // (libcurl) must not assume any particular casing survives transport. inline bool _headerNameMatches(const std::string& line, const char* name) { size_t nameLen = std::strlen(name); if (line.size() < nameLen) return false; for (size_t i = 0; i < nameLen; i++) if (std::tolower(static_cast(line[i])) != std::tolower(static_cast(name[i]))) return false; return true; } #ifdef AG_HTTP_USE_CURL inline size_t _writeCallback(void* ptr, size_t size, size_t nmemb, std::string* out) { out->append(static_cast(ptr), size * nmemb); return size * nmemb; } // Scans one raw header line ("Name: value\r\n", possibly a status line or a // blank line between header blocks on a redirect) for X-AstraGuard- // Signature and, if found, stores the trimmed value. libcurl invokes this // once per header line, across every header block (including any // intermediate 1xx/redirect responses) - only the LAST matching line before // body data starts is what the final response actually carries, but since // curl resets nothing between blocks, capturing on every match and letting // the final one win is the correct, simplest behavior here. inline size_t _headerCallback(char* buffer, size_t size, size_t nitems, std::string* out) { size_t total = size * nitems; std::string line(buffer, total); const char* prefix = "X-AstraGuard-Signature:"; if (_headerNameMatches(line, prefix)) { std::string value = line.substr(std::strlen(prefix)); size_t start = value.find_first_not_of(" \t"); size_t end = value.find_last_not_of(" \t\r\n"); *out = (start == std::string::npos) ? "" : value.substr(start, end - start + 1); } return total; } // pinnedCertSha256Hex is accepted for signature parity with the WinHTTP // implementation below but not yet enforced on this (non-default, opt-in // via AG_HTTP_USE_CURL) transport - libcurl pinning needs a // CURLOPT_SSL_CTX_FUNCTION callback, not implemented here yet. inline HttpResponse post(const std::string& url, const std::string& body, const std::string& productId = "", int maxRetries = 2, const std::string& pinnedCertSha256Hex = "") { (void)pinnedCertSha256Hex; for (int attempt = 0; attempt <= maxRetries; attempt++) { if (attempt > 0) std::this_thread::sleep_for(std::chrono::seconds(1)); std::string response; std::string signatureHeader; CURL* curl = curl_easy_init(); if (!curl) continue; struct curl_slist* headers = nullptr; headers = curl_slist_append(headers, "Content-Type: application/json"); if (!productId.empty()) headers = curl_slist_append(headers, ("X-Product-ID: " + productId).c_str()); curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str()); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, _writeCallback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, _headerCallback); curl_easy_setopt(curl, CURLOPT_HEADERDATA, &signatureHeader); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); curl_easy_setopt(curl, CURLOPT_USERAGENT, "AstraGuard-CPP/2.0"); // Windows + OpenSSL-linked libcurl builds (e.g. MSYS2/MinGW) rely on a // hardcoded default CA bundle path that generally does not exist on an // end user's machine, causing every request to fail TLS verification // with a generic "connection failed" error. curl builds using the // native Windows certificate store (Schannel, the vcpkg default) are // unaffected and never hit this path. Fix this either by embedding a // bundle via setEmbeddedCaBundle() (single-file .exe, no companion // file), or by shipping a cacert.pem next to your .exe (picked up // automatically). https://curl.se/docs/caextract.html has the bundle. #ifdef _WIN32 struct curl_blob _caBlob; if (_embeddedCaBundleLen() > 0) { _caBlob.data = const_cast(_embeddedCaBundleData()); _caBlob.len = _embeddedCaBundleLen(); _caBlob.flags = CURL_BLOB_NOCOPY; curl_easy_setopt(curl, CURLOPT_CAINFO_BLOB, &_caBlob); } else if (!_localCaBundlePath().empty()) { curl_easy_setopt(curl, CURLOPT_CAINFO, _localCaBundlePath().c_str()); } #endif // NOTE: Public-key pinning was removed. curl's CURLOPT_PINNEDPUBLICKEY // pins the LEAF certificate, which rotates on every TLS renewal (~90 days) // and silently broke every client as a "network error". MITM protection // is retained via full CA chain verification (CURLOPT_SSL_VERIFYPEER=1, // CURLOPT_SSL_VERIFYHOST=2 above). CURLcode res = curl_easy_perform(curl); curl_slist_free_all(headers); curl_easy_cleanup(curl); if (res == CURLE_OK && !response.empty()) return HttpResponse{ response, signatureHeader }; } return HttpResponse{}; } #else // Windows, WinHTTP (default) - no external dependency at all. // setEmbeddedCaBundle() / a companion cacert.pem are both unnecessary here: // WinHTTP validates the server certificate against the OS's own trust store // (the same store Windows Update / Edge / every other system component // uses), which is already correct and kept up to date by Windows itself. // // The OS trust store check above answers "is this certificate signed by a // CA my system trusts" - it does NOT answer "is this the ONE specific // certificate my server actually uses". A locally-installed proxy CA (the // kind used to MITM traffic for inspection/tampering - mkcert, Fiddler, // Burp, a corporate root cert, or one the user was talked into installing) // is, by definition, trusted by the OS store, so it sails through the check // above undetected. Pinning closes that gap: if pinnedCertSha256Hex is // non-empty, the leaf certificate presented during THIS connection must // hash to exactly that value or the response is discarded unread - no // apparently-valid-but-wrong certificate is ever trusted, regardless of // which CA vouched for it. inline bool _certMatchesPin(HINTERNET hRequest, const std::string& pinnedSha256Hex) { if (pinnedSha256Hex.empty()) return true; PCCERT_CONTEXT certCtx = nullptr; DWORD certCtxSize = sizeof(certCtx); if (!WinHttpQueryOption(hRequest, WINHTTP_OPTION_SERVER_CERT_CONTEXT, &certCtx, &certCtxSize) || !certCtx) { return false; // no certificate to check == treat as a pin failure, fail closed } BYTE digest[32] = {}; DWORD digestLen = sizeof(digest); // "SHA256" is the CNG algorithm identifier CryptHashCertificate2() expects // (the same string bcrypt.h defines as BCRYPT_SHA256_ALGORITHM) - written // out directly so this doesn't need a include just for one // constant; wincrypt.h + crypt32.lib (already used elsewhere) is enough. BOOL ok = CryptHashCertificate2(L"SHA256", 0, nullptr, certCtx->pbCertEncoded, certCtx->cbCertEncoded, digest, &digestLen); CertFreeCertificateContext(certCtx); if (!ok) return false; char hex[65] = {}; for (DWORD i = 0; i < digestLen; i++) sprintf_s(hex + i * 2, 3, "%02x", digest[i]); std::string actual(hex); std::string expected = pinnedSha256Hex; std::transform(expected.begin(), expected.end(), expected.begin(), ::tolower); return actual == expected; } // Reads a single response header via WinHttpQueryHeaders(WINHTTP_QUERY_CUSTOM), // which already resolves the header name case-insensitively (WinHTTP's own // documented behavior, unlike the manual folding _headerCallback needs on // the libcurl side) - growing the buffer once on ERROR_INSUFFICIENT_BUFFER // covers a signature value of any length without a fixed-size guess. inline std::string _queryHeader(HINTERNET hRequest, const wchar_t* name) { DWORD size = 0; WinHttpQueryHeaders(hRequest, WINHTTP_QUERY_CUSTOM, name, WINHTTP_NO_OUTPUT_BUFFER, &size, WINHTTP_NO_HEADER_INDEX); if (GetLastError() != ERROR_INSUFFICIENT_BUFFER || size == 0) return ""; std::wstring buf(size / sizeof(wchar_t), L'\0'); if (!WinHttpQueryHeaders(hRequest, WINHTTP_QUERY_CUSTOM, name, &buf[0], &size, WINHTTP_NO_HEADER_INDEX)) { return ""; } while (!buf.empty() && buf.back() == L'\0') buf.pop_back(); return std::string(buf.begin(), buf.end()); // header value is always ASCII (hex digest + "sha256=") } inline HttpResponse post(const std::string& url, const std::string& body, const std::string& productId = "", int maxRetries = 2, const std::string& pinnedCertSha256Hex = "") { std::wstring wUrl(url.begin(), url.end()); wchar_t hostName[256] = {}; wchar_t urlPath[2048] = {}; URL_COMPONENTS urlComp{}; urlComp.dwStructSize = sizeof(urlComp); urlComp.lpszHostName = hostName; urlComp.dwHostNameLength = 256; urlComp.lpszUrlPath = urlPath; urlComp.dwUrlPathLength = 2048; if (!WinHttpCrackUrl(wUrl.c_str(), static_cast(wUrl.size()), 0, &urlComp)) return HttpResponse{}; bool isHttps = (urlComp.nScheme == INTERNET_SCHEME_HTTPS); std::wstring headers = L"Content-Type: application/json\r\n"; if (!productId.empty()) { std::wstring wProductId(productId.begin(), productId.end()); headers += L"X-Product-ID: " + wProductId + L"\r\n"; } for (int attempt = 0; attempt <= maxRetries; attempt++) { if (attempt > 0) std::this_thread::sleep_for(std::chrono::seconds(1)); HINTERNET hSession = WinHttpOpen(L"AstraGuard-CPP/2.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); if (!hSession) continue; WinHttpSetTimeouts(hSession, 10000, 10000, 15000, 15000); // resolve, connect, send, receive (ms) HINTERNET hConnect = WinHttpConnect(hSession, hostName, urlComp.nPort, 0); if (!hConnect) { WinHttpCloseHandle(hSession); continue; } DWORD flags = isHttps ? WINHTTP_FLAG_SECURE : 0; HINTERNET hRequest = WinHttpOpenRequest(hConnect, L"POST", urlPath, nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags); if (!hRequest) { WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); continue; } BOOL sent = WinHttpSendRequest(hRequest, headers.c_str(), static_cast(headers.size()), const_cast(body.data()), static_cast(body.size()), static_cast(body.size()), 0); // Checked right after the TLS handshake completes (WinHttpSendRequest) // and before a single byte of the response is read - a pin mismatch // means "wrong certificate," full stop, regardless of what a // MITM proxy sitting behind it might otherwise send back. if (sent && isHttps && !_certMatchesPin(hRequest, pinnedCertSha256Hex)) { sent = FALSE; } std::string response; std::string signatureHeader; if (sent && WinHttpReceiveResponse(hRequest, nullptr)) { // Read the header before the body - it's already fully available // the moment WinHttpReceiveResponse() returns, and reading it // first keeps this symmetric with the libcurl path (which // receives header lines strictly before body data too). signatureHeader = _queryHeader(hRequest, L"X-AstraGuard-Signature"); DWORD available = 0; do { available = 0; if (!WinHttpQueryDataAvailable(hRequest, &available) || available == 0) break; std::vector buf(available); DWORD read = 0; if (!WinHttpReadData(hRequest, buf.data(), available, &read)) break; response.append(buf.data(), read); } while (available > 0); } WinHttpCloseHandle(hRequest); WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); if (!response.empty()) return HttpResponse{ response, signatureHeader }; } return HttpResponse{}; } #endif } inline ErrorCode _parseErrorCode(const std::string& code) { if (code == "ERR_INVALID_KEY_FORMAT") return ErrorCode::INVALID_KEY_FORMAT; if (code == "ERR_INVALID_KEY_CHECKSUM") return ErrorCode::INVALID_KEY_CHECKSUM; if (code == "ERR_KEY_NOT_FOUND") return ErrorCode::KEY_NOT_FOUND; if (code == "ERR_KEY_REVOKED") return ErrorCode::KEY_REVOKED; if (code == "ERR_KEY_INACTIVE") return ErrorCode::KEY_INACTIVE; if (code == "ERR_KEY_ALREADY_ACTIVATED") return ErrorCode::KEY_ALREADY_ACTIVATED; if (code == "ERR_HWID_MISMATCH") return ErrorCode::HWID_MISMATCH; if (code == "ERR_HWID_RESET_PENDING") return ErrorCode::HWID_RESET_PENDING; if (code == "ERR_INVALID_PRODUCT") return ErrorCode::INVALID_PRODUCT; if (code == "ERR_INVALID_SIGNATURE") return ErrorCode::INVALID_SIGNATURE; if (code == "ERR_LICENSE_EXPIRED") return ErrorCode::LICENSE_EXPIRED; if (code == "ERR_LICENSE_NOT_FOUND") return ErrorCode::LICENSE_NOT_FOUND; if (code == "ERR_MACHINE_MISMATCH") return ErrorCode::MACHINE_MISMATCH; if (code == "ERR_MISSING_PARAMS") return ErrorCode::MISSING_PARAMS; if (code == "ERR_BLOCKED_IP") return ErrorCode::BLOCKED_IP; if (code == "ERR_BLOCKED_HWID") return ErrorCode::BLOCKED_HWID; if (code == "ERR_BLOCKED_VPN") return ErrorCode::BLOCKED_VPN; if (code == "ERR_BLOCKED_PROXY") return ErrorCode::BLOCKED_PROXY; if (code == "ERR_SERVER") return ErrorCode::SERVER_ERROR; if (code == "ERR_BLOCKED_BY_SECURITY") return ErrorCode::BLOCKED_BY_SECURITY; // The strict-HWID-mismatch path on /validate returns a lowercase, unprefixed // "reason" string instead of the ERR_ convention used everywhere else on that // endpoint. Recognized here (client-side only) so getErrorCode() stays accurate // without touching the server's response shape. if (code == "hwid_mismatch") return ErrorCode::HWID_MISMATCH; return ErrorCode::UNKNOWN; } inline License _parseLicense(const nlohmann::json& j) { License lic; if (j.contains("id") && j["id"].is_string()) lic.id = j["id"]; if (j.contains("key") && j["key"].is_string()) lic.licenseKey = j["key"]; if (j.contains("machineId") && j["machineId"].is_string()) lic.machineId = j["machineId"]; if (j.contains("signature") && j["signature"].is_string()) lic.signature = j["signature"]; if (j.contains("issuedAt") && j["issuedAt"].is_string()) lic.issuedAt = j["issuedAt"]; if (j.contains("userId") && j["userId"].is_string()) lic.userId = j["userId"]; if (j.contains("deviceLimit") && j["deviceLimit"].is_number()) lic.deviceLimit = j["deviceLimit"]; if (j.contains("expiresAt") && !j["expiresAt"].is_null()) { lic.expiresAt = j["expiresAt"].get(); lic.isLifetime = false; } else { lic.isLifetime = true; } if (j.contains("features") && j["features"].is_array()) for (const auto& f : j["features"]) if (f.is_string()) lic.features.push_back(f); return lic; } inline ValidateResult _parseValidate(const nlohmann::json& j) { ValidateResult vr; vr.valid = j.value("valid", false); vr.reason = j.value("reason", ""); if (j.contains("variables") && j["variables"].is_object()) for (auto& [k, v] : j["variables"].items()) if (v.is_string()) vr.variables[k] = v.get(); if (j.contains("features") && j["features"].is_array()) for (const auto& f : j["features"]) if (f.is_string()) vr.features.push_back(f); if (j.contains("expiresAt") && !j["expiresAt"].is_null()) vr.expiresAt = j["expiresAt"].get(); vr.isLifetime = j.value("isLifetime", false); if (j.contains("latestVersion") && !j["latestVersion"].is_null()) vr.latestVersion = j["latestVersion"].get(); if (j.contains("security") && j["security"].is_object()) { const auto& s = j["security"]; vr.security.blockVm = s.value("blockVm", false); vr.security.blockDebug = s.value("blockDebug", false); vr.security.integrityCheck = s.value("integrityCheck", false); if (s.contains("integrityHash") && s["integrityHash"].is_string()) vr.security.integrityHash = s["integrityHash"].get(); } vr.rt = j.value("rt", ""); vr.rts = j.value("rts", int64_t(0)); vr.rn = j.value("rn", ""); vr.rt2 = j.value("rt2", ""); return vr; } class Client { public: License license; ValidateResult validateData; Response lastResponse; bool authenticated = false; Client(const std::string& apiUrl, const std::string& productId) : _apiUrl(apiUrl), _productId(productId) { #ifdef AG_HTTP_USE_CURL curl_global_init(CURL_GLOBAL_ALL); #endif _hwid = HWID::generate(); } ~Client() { stopHeartbeat(); stopSecurityMonitor(); #ifdef AG_HTTP_USE_CURL curl_global_cleanup(); #endif _secureZero(_responseKeyObfuscated); _secureZero(_responseKeyMask); } // Stored XOR'd against a runtime-random mask generated fresh each run, // rather than as raw decoded bytes - a memory dump/scan taken at a random // moment (i.e. NOT mid-verification) no longer finds the plain key sitting // in one static location for the whole object lifetime. The plaintext key // now only exists transiently on the stack inside _verifyRt(), for the // duration of a single HMAC computation, and is zeroed immediately after. void setResponseKey(const std::string& b64Key) { std::lock_guard lock(_mutex); auto decoded = Security::b64Decode(b64Key); std::random_device rd; std::mt19937 gen(rd()); std::uniform_int_distribution dist(0, 255); _responseKeyMask.resize(decoded.size()); for (auto& b : _responseKeyMask) b = static_cast(dist(gen)); _responseKeyObfuscated.resize(decoded.size()); for (size_t i = 0; i < decoded.size(); i++) _responseKeyObfuscated[i] = decoded[i] ^ _responseKeyMask[i]; _secureZero(decoded); } // Pins the exact server certificate this client will accept, by its // SHA-256 fingerprint (hex, case-insensitive) - get it once via e.g. // `openssl s_client -connect host:443 | openssl x509 -noout -fingerprint // -sha256`. Closes the gap the OS trust store alone leaves open: a // locally-installed proxy CA (used to intercept/tamper with traffic) is // trusted by the OS by definition, so it passes ordinary TLS validation // - pinning rejects any certificate that isn't THIS exact one, no matter // which CA vouched for it. Windows-only (WinHTTP transport); a no-op // when built with AG_HTTP_USE_CURL. Re-pin and ship a new build whenever // the certificate is renewed - that operational cost is the trade-off // for closing this specific gap. void setPinnedCertHash(const std::string& sha256Hex) { std::lock_guard lock(_mutex); _pinnedCertHash = sha256Hex; } void setHWID(const std::string& customHwid) { std::lock_guard lock(_mutex); _hwid = customHwid; } void setAppVersion(const std::string& version) { _appVersion = version; } void setInitialSecurityFlags(bool blockDebug, bool blockVm, bool blockMemRead = false) { _preFlags.blockDebug = blockDebug; _preFlags.blockVm = blockVm; _preFlags.blockMemRead = blockMemRead; } // ── Early startup security check ───────────────────────────────────────── // Call as the VERY FIRST thing in main(), before creating any window or UI. // Returns false if a threat is detected (requires setInitialSecurityFlags first). bool startupCheck() { if (_preFlags.blockDebug && Security::isDebugger()) { _setSecurity(ErrorCode::BLOCKED_BY_SECURITY, "Security check failed."); return false; } if (_preFlags.blockVm && Security::isVm()) { _setSecurity(ErrorCode::BLOCKED_BY_SECURITY, "Security check failed."); return false; } if (_preFlags.blockMemRead && Security::isMemoryBeingRead()) { _setSecurity(ErrorCode::BLOCKED_BY_SECURITY, "Security check failed."); return false; } return true; } // Same as startupCheck() but calls failHard() immediately on threat detection. void startupCheckOrExit() { if (!startupCheck()) failHard(); } // Start a background security monitor that polls for debuggers/VMs while // the login screen is shown. If a threat is detected, onThreat is invoked // (default: failHard, after a randomized delay - see below). Call // stopSecurityMonitor() before validate(). // intervalMs: base polling interval in milliseconds (default 500ms); each // wait is jittered +/-30% so a cracker single-stepping the process can't // predict the next poll from a fixed period. // // The default failHard() path is deliberately NOT instant: it waits a // randomized 1-4s after detection before terminating. A patch that // silences one detection function still leaves the timing decorrelated // from whatever the cracker just did (attached the debugger, resumed // execution, stepped over a breakpoint), which defeats the common // bisection technique of "undo the last change, see if the crash goes // away". Legitimate users are never detected in the first place, so this // delay has no user-visible effect on real usage. void startSecurityMonitor(int intervalMs = 500, std::function onThreat = nullptr) { stopSecurityMonitor(); _secMonStop = false; _secMonThread = std::thread([this, intervalMs, onThreat]() { std::random_device rd; std::mt19937 gen(rd()); std::uniform_int_distribution jitter(-intervalMs * 3 / 10, intervalMs * 3 / 10); std::uniform_int_distribution failDelayMs(1000, 4000); std::unique_lock lk(_secMonMutex); while (!_secMonStop) { int waitMs = intervalMs + jitter(gen); if (waitMs < 50) waitMs = 50; _secMonCv.wait_for(lk, std::chrono::milliseconds(waitMs), [this] { return _secMonStop.load(); }); if (_secMonStop) break; bool threat = (_preFlags.blockDebug && Security::isDebugger()) || (_preFlags.blockVm && Security::isVm()) || (_preFlags.blockMemRead && Security::isMemoryBeingRead()); if (threat) { lk.unlock(); // Documented contract: onThreat replaces the default // failHard() behavior, it does not run alongside it - a // caller providing their own callback (e.g. to log the // event, or just flip a flag) must not have the process // killed out from under them immediately afterward. The // randomized delay below only applies to the default path. if (onThreat) { try { onThreat(); } catch (...) {} } else { // Re-check once, shortly before actually terminating, // through a second independent poll - a patch that // only silences the check at the exact instant of the // first detection (a common "nop the compare" patch // targeted at a debugger breakpoint on this call) will // very likely still be active moments later too, but // this costs a cracker a second, separately-timed // check to defeat instead of just one. std::this_thread::sleep_for(std::chrono::milliseconds(failDelayMs(gen))); bool stillThreat = (_preFlags.blockDebug && Security::isDebugger()) || (_preFlags.blockVm && Security::isVm()) || (_preFlags.blockMemRead && Security::isMemoryBeingRead()); if (stillThreat) { failHard("Security monitor: threat detected."); } } lk.lock(); } } }); } // Stop the background security monitor (call before validate() or on success). void stopSecurityMonitor() { { std::lock_guard lk(_secMonMutex); _secMonStop = true; } _secMonCv.notify_all(); if (_secMonThread.joinable()) _secMonThread.join(); } std::string GetHWID() const { return _hwid; } std::string getError() const { return lastResponse.message; } ErrorCode getErrorCode() const { return lastResponse.code; } bool hasFeature(const std::string& feat) const { const auto& f = validateData.features.empty() ? license.features : validateData.features; return std::find(f.begin(), f.end(), feat) != f.end(); } std::string getVariable(const std::string& key, const std::string& def = "") const { auto it = validateData.variables.find(key); return it != validateData.variables.end() ? it->second : def; } const std::map& getVariables() const { return validateData.variables; } int getRemainingDays() const { return license.remainingDays(); } std::string getLatestVersion() const { return validateData.latestVersion; } SecurityFlags getSecurityFlags() const { return validateData.security; } // Independently re-checks the last validate() response's HMAC signature // and its "valid" verdict, using the exact same masked-key logic as the // internal check inside validate(). Call this a second time from a // DIFFERENT place in your own code (e.g. right before unlocking a // feature) so an attacker has to find and patch two separate checks // instead of one. A single patched branch is a five-minute problem; // several independent ones scattered through your own code are not. bool reverifyResponse() const { std::lock_guard lock(_mutex); if (!validateData.valid) return false; // No key means nothing was ever actually verified - validate()/ // activate() already fail closed in that case, so reaching this // with an empty key should not happen, but stay consistent rather // than reporting success for a check that never ran. if (_responseKeyObfuscated.empty()) return false; return _verifyRt(validateData.rt, validateData.rts, validateData.rn, validateData.valid); } Response activate(const std::string& key) { std::lock_guard lock(_mutex); std::string expectedNonce = _generateNonce(); nlohmann::json body; body["key"] = key; body["hwid"] = _hwid; body["productId"] = _productId; body["nonce"] = expectedNonce; if (!_appVersion.empty()) body["version"] = _appVersion; #ifdef _WIN32 { // Same flags validate() sends - without these, Block VM / Block Debug / // Integrity Check on the product only take effect starting with the // NEXT call, letting an attacker complete the one-time HWID bind first. std::string bHash = Security::hashBinary(); if (!bHash.empty()) body["binaryHash"] = bHash; body["debugDetected"] = Security::isDebugger(); } #endif auto httpResp = HTTP::post(_apiUrl + "/activate", body.dump(), _productId, 2, _pinnedCertHash); const std::string& raw = httpResp.body; Response resp = _makeResponse(raw); if (raw.empty()) { lastResponse = resp; return resp; } // Response Integrity implementation phase: same fail-before-parse // full-body check as _validateInternal() - see the comment there for // the full rationale. Skipped only when no key is configured, in // which case the existing RESPONSE_KEY_NOT_SET check further below // (unchanged) still applies exactly as before. if (!_responseKeyObfuscated.empty() && !_verifyFullBodySignature(raw, httpResp.signatureHeader)) { resp.success = false; resp.code = ErrorCode::RESPONSE_TAMPERED; resp.message = "Response body signature invalid - possible MITM tampering with license data."; lastResponse = resp; return resp; } try { auto j = nlohmann::json::parse(raw); resp.success = j.value("success", false); resp.message = j.value("message", ""); // Same fail-closed signature check validate() applies. Without // this, activate() trusted whatever JSON it received with zero // verification - a network attacker (hosts-file redirect + a // fake server that always answers "success": true) could bind // a license with no reverse engineering at all. if (resp.success) { if (_responseKeyObfuscated.empty()) { resp.success = false; resp.code = ErrorCode::RESPONSE_KEY_NOT_SET; resp.message = "setResponseKey() was not called - refusing to trust an unsigned response. See docs.astraguard.io for the Response Key."; } else { std::string rt = j.value("rt", ""); int64_t rts = j.value("rts", static_cast(0)); std::string rn = j.value("rn", ""); int64_t nowMs = static_cast(std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count()); int64_t skewMs = nowMs - rts; bool fresh = (skewMs < 0 ? -skewMs : skewMs) <= 120000; // 2 minutes bool nonceMatches = (rn == expectedNonce); if (!fresh || !nonceMatches || !_verifyRt(rt, rts, rn, true)) { resp.success = false; resp.code = ErrorCode::RESPONSE_TAMPERED; resp.message = "Response signature invalid."; } } } if (resp.success && j.contains("license") && j["license"].is_object()) { license = _parseLicense(j["license"]); license.licenseKey = key; resp.license = license; } if (!resp.success && resp.code == ErrorCode::NONE) _extractError(j, resp); authenticated = resp.success; } catch (const std::exception& e) { resp.success = false; resp.message = std::string("Parse error: ") + e.what(); resp.code = ErrorCode::UNKNOWN; } lastResponse = resp; return resp; } bool validate(const std::string& key) { std::lock_guard lock(_mutex); return _validateInternal(key); } bool verify() { std::lock_guard lock(_mutex); if (license.id.empty()) return false; nlohmann::json body; body["licenseId"] = license.id; body["machineId"] = _hwid; // NOTE: /verify's response is not signed by the server today // (signResponseBody is only wired onto /activate and /validate) - no // full-body check applies here. Flagged as a known, separate gap in // docs/architecture/ASTRAGUARD_SDK_RESPONSE_INTEGRITY_PLAN.md §2.2/§10, // out of scope for this change. auto httpResp = HTTP::post(_apiUrl + "/verify", body.dump(), "", 2, _pinnedCertHash); const std::string& raw = httpResp.body; Response resp = _makeResponse(raw); if (raw.empty()) { lastResponse = resp; return false; } try { auto j = nlohmann::json::parse(raw); resp.success = j.value("valid", false); if (!resp.success) _extractError(j, resp); authenticated = resp.success; } catch (...) { resp.success = false; resp.code = ErrorCode::UNKNOWN; } lastResponse = resp; return authenticated; } void startHeartbeat(const std::string& key, int intervalSeconds = 300, std::function onRevoked = nullptr) { stopHeartbeat(); _heartbeatStop = false; _heartbeatKey = key; _heartbeatThread = std::thread([this, intervalSeconds, onRevoked]() { std::unique_lock lk(_heartbeatMutex); while (!_heartbeatStop) { _heartbeatCv.wait_for(lk, std::chrono::seconds(intervalSeconds), [this] { return _heartbeatStop.load(); }); if (_heartbeatStop) break; bool ok = validate(_heartbeatKey); if (!ok) { if (onRevoked) { try { onRevoked(); } catch (...) {} } failHard("License revoked or expired."); break; } } }); } void stopHeartbeat() { { std::lock_guard lk(_heartbeatMutex); _heartbeatStop = true; } _heartbeatCv.notify_all(); if (_heartbeatThread.joinable()) _heartbeatThread.join(); } void validateOrExit(const std::string& key, const std::string& title = "AstraGuard") { if (!validate(key)) { #ifdef _WIN32 MessageBoxA(nullptr, lastResponse.message.c_str(), title.c_str(), MB_ICONERROR | MB_OK); #else std::cerr << "[" << title << "] " << lastResponse.message << "\n"; #endif std::exit(1); } } [[noreturn]] void failHard(const std::string& reason = "") { #ifdef _WIN32 if (!reason.empty()) OutputDebugStringA(("[AstraGuard] FAIL: " + reason).c_str()); TerminateProcess(GetCurrentProcess(), 0xDEAD); #endif std::abort(); } private: std::string _apiUrl; std::string _productId; std::string _appVersion; std::string _hwid; std::string _pinnedCertHash; std::vector _responseKeyObfuscated; std::vector _responseKeyMask; SecurityFlags _preFlags; SecurityFlags _cachedSecurity; mutable std::mutex _mutex; std::thread _heartbeatThread; std::atomic _heartbeatStop{false}; std::mutex _heartbeatMutex; std::condition_variable _heartbeatCv; std::string _heartbeatKey; std::thread _secMonThread; std::atomic _secMonStop{false}; std::mutex _secMonMutex; std::condition_variable _secMonCv; // 32 random bytes as 64 hex chars. Sent as the request's nonce and then // checked against the response's echoed-back rn (see _validateInternal) // - without this, _verifyRt only proves a response was signed by // whoever holds the response key AT SOME POINT, never that it is the // response to THIS request. A single captured, genuinely-valid {rt, // rts, rn} tuple (e.g. sniffed once via a MITM proxy) could otherwise // be replayed forever afterward for any other request, since the // signature alone never expires. A fresh, unpredictable nonce per // request that the client insists on seeing echoed back exactly turns // that into a one-shot token instead. static std::string _generateNonce() { std::random_device rd; std::mt19937 gen(rd()); std::uniform_int_distribution dist(0, 255); static const char* hexChars = "0123456789abcdef"; std::string hex; hex.reserve(64); for (int i = 0; i < 32; i++) { uint8_t b = static_cast(dist(gen)); hex += hexChars[b >> 4]; hex += hexChars[b & 0xF]; } return hex; } bool _validateInternal(const std::string& key) { const SecurityFlags& pre = authenticated ? _cachedSecurity : _preFlags; if (!_enforceFlags(pre)) { _setSecurity(ErrorCode::BLOCKED_BY_SECURITY, "Security check failed."); return false; } std::string expectedNonce = _generateNonce(); std::string sentBinaryHash; nlohmann::json body; body["key"] = key; body["hwid"] = _hwid; body["productId"] = _productId; body["nonce"] = expectedNonce; if (!_appVersion.empty()) body["version"] = _appVersion; #ifdef _WIN32 { sentBinaryHash = Security::hashBinary(); if (!sentBinaryHash.empty()) body["binaryHash"] = sentBinaryHash; body["debugDetected"] = Security::isDebugger(); } #endif auto httpResp = HTTP::post(_apiUrl + "/validate", body.dump(), _productId, 2, _pinnedCertHash); const std::string& raw = httpResp.body; Response resp = _makeResponse(raw); if (raw.empty()) { lastResponse = resp; return false; } // Response Integrity implementation phase: verify the FULL response // body against X-AstraGuard-Signature BEFORE parsing it into // validateData - nothing from an unsigned or tampered body may ever // populate features/variables/security/latestVersion // (docs/architecture/ASTRAGUARD_SDK_RESPONSE_INTEGRITY_PLAN.md §4.3). // Skipped only when no key is configured at all - that case is still // handled, unchanged, by the existing RESPONSE_KEY_NOT_SET check // below (after parsing), so a customer who never called // setResponseKey() sees exactly the same behavior as before this // change - this check only ever ADDS a rejection, never removes one. if (!_responseKeyObfuscated.empty() && !_verifyFullBodySignature(raw, httpResp.signatureHeader)) { resp.success = false; resp.code = ErrorCode::RESPONSE_TAMPERED; resp.message = "Response body signature invalid - possible MITM tampering with license data."; authenticated = false; lastResponse = resp; return false; } try { auto j = nlohmann::json::parse(raw); validateData = _parseValidate(j); resp.validateData = validateData; resp.success = validateData.valid; resp.message = j.value("reason", validateData.valid ? "License valid" : "Validation failed"); if (_responseKeyObfuscated.empty()) { // Fail closed, not open. Without a response key there is no // way to tell a genuine server reply from one served by a // network-level attacker (hosts-file redirect + a fake // server that always answers "valid": a local emulator // needs zero reverse engineering to defeat this - it just // has to run). Call setResponseKey() with the key from // Dashboard -> Products -> Response Key before validating. resp.success = false; resp.code = ErrorCode::RESPONSE_KEY_NOT_SET; resp.message = "setResponseKey() was not called - refusing to trust an unsigned response. See docs.astraguard.io for the Response Key."; authenticated = false; lastResponse = resp; return false; } { // rts freshness (defense in depth - generous window for // clock skew/latency, the nonce check below is the real // anti-replay guarantee) and an exact nonce match (a // replayed response carries a stale, different nonce from // whatever request it was originally issued for) both have // to hold, in addition to the existing signature check. int64_t nowMs = static_cast(std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count()); int64_t skewMs = nowMs - validateData.rts; bool fresh = (skewMs < 0 ? -skewMs : skewMs) <= 120000; // 2 minutes bool nonceMatches = (validateData.rn == expectedNonce); // rt2 is optional (see ValidateResult::rt2) - only enforced // when the server actually sent one, which only happens when // this client sent a binaryHash AND the product has // integrity hashes configured. Older servers or products // without integrity hashes never send rt2, so this can never // reject a response that would otherwise have passed. bool rt2Ok = validateData.rt2.empty() || _verifyRt2(validateData.rt2, validateData.rts, validateData.rn, validateData.valid, sentBinaryHash); if (!fresh || !nonceMatches || !rt2Ok || !_verifyRt(validateData.rt, validateData.rts, validateData.rn, validateData.valid)) { resp.success = false; resp.code = ErrorCode::RESPONSE_TAMPERED; resp.message = "Response signature invalid."; authenticated = false; lastResponse = resp; return false; } } if (validateData.valid) { license.licenseKey = key; license.features = validateData.features; license.expiresAt = validateData.expiresAt; license.isLifetime = validateData.isLifetime; _cachedSecurity = validateData.security; // Mirrors activate()'s own `resp.license = license;` (see // above) - without this, lastResponse.license.features stays // empty after every validate() call even on full success, // even though this->license.features and // lastResponse.validateData.features are both correctly // populated. Callers reading response.license.features (the // natural choice, since that's what activate() populates) // silently see zero entitled features after a validate() // call, despite a fully valid, correctly-entitled license. resp.license = license; if (!_enforceFlags(validateData.security)) { resp.success = false; resp.code = ErrorCode::BLOCKED_BY_SECURITY; resp.message = "Security check failed."; authenticated = false; lastResponse = resp; return false; } } if (!resp.success) _extractError(j, resp); authenticated = resp.success; } catch (const std::exception& e) { resp.success = false; resp.message = std::string("Parse error: ") + e.what(); resp.code = ErrorCode::UNKNOWN; } lastResponse = resp; return authenticated; } static void _secureZero(std::vector& v) { if (v.empty()) return; #ifdef _WIN32 SecureZeroMemory(v.data(), v.size()); #else volatile uint8_t* p = v.data(); for (size_t i = 0; i < v.size(); i++) p[i] = 0; #endif v.clear(); } bool _verifyRt(const std::string& rt, int64_t rts, const std::string& nonce, bool valid) const { // Defense in depth: the caller already fails closed when no key is // configured, but this function must never itself fall back to // "trust it" - an empty rt (server didn't sign) is a hard failure. if (_responseKeyObfuscated.empty() || rt.empty()) return false; // Decode the key transiently on the stack for this one HMAC call only. std::vector key(_responseKeyObfuscated.size()); for (size_t i = 0; i < key.size(); i++) key[i] = _responseKeyObfuscated[i] ^ _responseKeyMask[i]; std::string payload = nonce + "|" + std::to_string(rts) + "|" + (valid ? "1" : "0") + "|" + _productId; std::string expected = Security::hmacSha256B64(key, payload); _secureZero(key); if (expected.size() != rt.size()) return false; uint8_t diff = 0; for (size_t i = 0; i < expected.size(); i++) diff |= static_cast(expected[i]) ^ static_cast(rt[i]); return diff == 0; } // Verifies the optional hash-bound signature (see ValidateResult::rt2). // ONLY called when the server actually sent rt2 - a missing rt2 is not a // failure, it just means the server didn't have integrity hashes // configured for this product, and _validateInternal() skips this check // entirely in that case. binaryHash must be the exact same string this // client sent in the request (not re-hashed here) - the server signed // over the value IT received, so verification must use that same value. bool _verifyRt2(const std::string& rt2, int64_t rts, const std::string& nonce, bool valid, const std::string& binaryHash) const { if (_responseKeyObfuscated.empty() || rt2.empty() || binaryHash.empty()) return false; std::vector key(_responseKeyObfuscated.size()); for (size_t i = 0; i < key.size(); i++) key[i] = _responseKeyObfuscated[i] ^ _responseKeyMask[i]; std::string payload = nonce + "|" + std::to_string(rts) + "|" + (valid ? "1" : "0") + "|" + _productId + "|" + binaryHash; std::string expected = Security::hmacSha256B64(key, payload); _secureZero(key); if (expected.size() != rt2.size()) return false; uint8_t diff = 0; for (size_t i = 0; i < expected.size(); i++) diff |= static_cast(expected[i]) ^ static_cast(rt2[i]); return diff == 0; } // Response Integrity implementation phase (docs/architecture/ // ASTRAGUARD_SDK_RESPONSE_INTEGRITY_PLAN.md §4-§6): verifies the FULL // response body against X-AstraGuard-Signature. Unlike _verifyRt()/ // _verifyRt2() (which re-derive a pipe-delimited string from individual // fields), this hashes the exact raw bytes received - no field list to // maintain, no risk of a future response field silently going // unprotected. Complementary to, not a replacement for, _verifyRt(): a // full-body signature proves these exact bytes were vouched for by the // key holder, but says nothing about whether this response is fresh or // a replay of an earlier, genuinely-signed one for a DIFFERENT request - // that freshness guarantee is still _verifyRt()'s job, which callers // continue to run afterward, unchanged. bool _verifyFullBodySignature(const std::string& rawBody, const std::string& signatureHeader) const { // Caller already gates on _responseKeyObfuscated being non-empty // before calling this, but this function must never itself fall // back to "trust it" - defense in depth, matching _verifyRt()'s own // discipline. Decode transiently on the stack for this one call, // same pattern as _verifyRt()/_verifyRt2(). if (_responseKeyObfuscated.empty()) return false; std::vector key(_responseKeyObfuscated.size()); for (size_t i = 0; i < key.size(); i++) key[i] = _responseKeyObfuscated[i] ^ _responseKeyMask[i]; bool ok = Security::verifyFullBodySignature(key, rawBody, signatureHeader); _secureZero(key); return ok; } bool _enforceFlags(const SecurityFlags& f) const { if (f.blockDebug && Security::isDebugger()) { #ifdef AG_DEBUG std::cout << "[AstraGuard] BLOCKED: debugger detected\n"; #endif return false; } if (f.blockVm && Security::isVm()) { #ifdef AG_DEBUG std::cout << "[AstraGuard] BLOCKED: virtual machine detected\n"; #endif return false; } if (f.blockMemRead && Security::isMemoryBeingRead()) { #ifdef AG_DEBUG std::cout << "[AstraGuard] BLOCKED: external process reading our memory\n"; #endif return false; } if (f.integrityCheck && !f.integrityHash.empty()) { if (!Security::integrityOk(f.integrityHash)) { #ifdef AG_DEBUG std::cout << "[AstraGuard] BLOCKED: binary integrity check failed\n"; #endif return false; } } return true; } void _setSecurity(ErrorCode code, const std::string& msg) { lastResponse.success = false; lastResponse.code = code; lastResponse.message = msg; authenticated = false; } Response _makeResponse(const std::string& raw) const { Response r; r.success = false; if (raw.empty()) { r.message = "Network error"; r.code = ErrorCode::NETWORK_ERROR; } return r; } void _extractError(const nlohmann::json& j, Response& r) const { r.errorCode = j.value("code", j.value("error", j.value("reason", "UNKNOWN"))); r.code = _parseErrorCode(r.errorCode); if (r.message.empty()) r.message = j.value("message", r.errorCode); } }; }