// ============================================================================ // AtlasAuth - Official C++ SDK (C++17) // Implementation: atlasauth.cpp // ---------------------------------------------------------------------------- // See atlasauth.h for the security model. The verification ordering enforced // here is the heart of the SDK: // recover signed bytes (JSON-unescape the "payload" string) // -> verify ECDSA over those exact bytes // -> THEN parse those bytes for field access // -> THEN check nonce + advisory skew. // ============================================================================ #include "atlasauth.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef _WIN32 # define WIN32_LEAN_AND_MEAN # include # include // __cpuid #else # include #endif namespace atlas { namespace { // ============================================================================ // base64 (standard alphabet, with padding) - encode/decode. // ============================================================================ const char kB64Alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; std::string base64Encode(const unsigned char* data, size_t len) { std::string out; out.reserve(((len + 2) / 3) * 4); size_t i = 0; for (; i + 2 < len; i += 3) { unsigned n = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2]; out.push_back(kB64Alphabet[(n >> 18) & 0x3F]); out.push_back(kB64Alphabet[(n >> 12) & 0x3F]); out.push_back(kB64Alphabet[(n >> 6) & 0x3F]); out.push_back(kB64Alphabet[n & 0x3F]); } if (i < len) { unsigned n = data[i] << 16; bool two = (i + 1 < len); if (two) n |= data[i + 1] << 8; out.push_back(kB64Alphabet[(n >> 18) & 0x3F]); out.push_back(kB64Alphabet[(n >> 12) & 0x3F]); out.push_back(two ? kB64Alphabet[(n >> 6) & 0x3F] : '='); out.push_back('='); } return out; } std::string base64Encode(const std::string& s) { return base64Encode(reinterpret_cast(s.data()), s.size()); } int b64val(char c) { if (c >= 'A' && c <= 'Z') return c - 'A'; if (c >= 'a' && c <= 'z') return c - 'a' + 26; if (c >= '0' && c <= '9') return c - '0' + 52; if (c == '+') return 62; if (c == '/') return 63; return -1; } // Decodes standard base64 (ignores ASCII whitespace; tolerant of padding). // Returns false on a genuinely malformed character. bool base64Decode(const std::string& in, std::vector& out) { out.clear(); int buf = 0, bits = 0; for (char c : in) { if (c == '=' ) break; if (c == '\n' || c == '\r' || c == ' ' || c == '\t') continue; int v = b64val(c); if (v < 0) return false; buf = (buf << 6) | v; bits += 6; if (bits >= 8) { bits -= 8; out.push_back(static_cast((buf >> bits) & 0xFF)); } } return true; } // ============================================================================ // Minimal JSON parser / serializer. // Scope (sufficient for this contract): FLAT objects whose values are // string / number / bool / null. Nested objects/arrays are not needed by any // endpoint payload and are intentionally unsupported. All values are surfaced // to the SDK as strings (numbers/bools kept verbatim) in a std::map. // // IMPORTANT: jsonUnescape() below is the function that recovers the original // signed bytes from the envelope's escaped "payload" string. It is exercised // separately from full parsing so we can verify the signature over the exact // recovered bytes BEFORE we ever parse them. // ============================================================================ // Append `s` to `out` as a JSON string literal (with surrounding quotes). void jsonEscapeTo(std::string& out, const std::string& s) { out.push_back('"'); for (unsigned char c : s) { switch (c) { case '"': out += "\\\""; break; case '\\': out += "\\\\"; break; case '\b': out += "\\b"; break; case '\f': out += "\\f"; break; case '\n': out += "\\n"; break; case '\r': out += "\\r"; break; case '\t': out += "\\t"; break; default: if (c < 0x20) { static const char* hex = "0123456789abcdef"; out += "\\u00"; out.push_back(hex[(c >> 4) & 0xF]); out.push_back(hex[c & 0xF]); } else { out.push_back(static_cast(c)); } } } out.push_back('"'); } // Encode a code point as UTF-8 into `out`. void appendUtf8(std::string& out, unsigned cp) { if (cp <= 0x7F) { out.push_back(static_cast(cp)); } else if (cp <= 0x7FF) { out.push_back(static_cast(0xC0 | (cp >> 6))); out.push_back(static_cast(0x80 | (cp & 0x3F))); } else if (cp <= 0xFFFF) { out.push_back(static_cast(0xE0 | (cp >> 12))); out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); out.push_back(static_cast(0x80 | (cp & 0x3F))); } else { out.push_back(static_cast(0xF0 | (cp >> 18))); out.push_back(static_cast(0x80 | ((cp >> 12) & 0x3F))); out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); out.push_back(static_cast(0x80 | (cp & 0x3F))); } } int hexNibble(char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return c - 'a' + 10; if (c >= 'A' && c <= 'F') return c - 'A' + 10; return -1; } // Given a JSON-escaped string literal value WITHOUT surrounding quotes (the raw // characters between the quotes), produce the original (unescaped) UTF-8 bytes. // This is exactly the inverse of jsonEscapeTo for the cases the server emits. // Returns false if the escape sequence is malformed. bool jsonUnescape(const std::string& in, std::string& out) { out.clear(); out.reserve(in.size()); for (size_t i = 0; i < in.size(); ++i) { char c = in[i]; if (c != '\\') { out.push_back(c); continue; } if (++i >= in.size()) return false; char e = in[i]; switch (e) { case '"': out.push_back('"'); break; case '\\': out.push_back('\\'); break; case '/': out.push_back('/'); break; case 'b': out.push_back('\b'); break; case 'f': out.push_back('\f'); break; case 'n': out.push_back('\n'); break; case 'r': out.push_back('\r'); break; case 't': out.push_back('\t'); break; case 'u': { if (i + 4 >= in.size()) return false; int h0 = hexNibble(in[i + 1]), h1 = hexNibble(in[i + 2]); int h2 = hexNibble(in[i + 3]), h3 = hexNibble(in[i + 4]); if (h0 < 0 || h1 < 0 || h2 < 0 || h3 < 0) return false; unsigned cp = (h0 << 12) | (h1 << 8) | (h2 << 4) | h3; i += 4; // Handle UTF-16 surrogate pairs (\uD800-\uDBFF + \uDC00-\uDFFF). if (cp >= 0xD800 && cp <= 0xDBFF) { if (i + 6 < in.size() && in[i + 1] == '\\' && in[i + 2] == 'u') { int g0 = hexNibble(in[i + 3]), g1 = hexNibble(in[i + 4]); int g2 = hexNibble(in[i + 5]), g3 = hexNibble(in[i + 6]); if (g0 >= 0 && g1 >= 0 && g2 >= 0 && g3 >= 0) { unsigned lo = (g0 << 12) | (g1 << 8) | (g2 << 4) | g3; if (lo >= 0xDC00 && lo <= 0xDFFF) { cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00); i += 6; } } } } appendUtf8(out, cp); break; } default: return false; } } return true; } // Extract the RAW (still-escaped) value bytes of a top-level string member // `key` from a flat JSON object text. We deliberately do NOT unescape here: // the caller decides whether to unescape (to recover signed bytes) or to use a // parsed view. Returns false if not found / not a string. // // This is a focused scanner, not a general parser: it finds "key" then the // following ':' then a '"', and copies characters until the matching closing // unescaped '"', preserving backslash escapes verbatim. bool extractRawStringMember(const std::string& obj, const std::string& key, std::string& rawValue) { const std::string needle = "\"" + key + "\""; size_t pos = 0; while ((pos = obj.find(needle, pos)) != std::string::npos) { size_t i = pos + needle.size(); // skip whitespace while (i < obj.size() && (obj[i] == ' ' || obj[i] == '\t' || obj[i] == '\n' || obj[i] == '\r')) ++i; if (i >= obj.size() || obj[i] != ':') { pos += needle.size(); continue; } ++i; while (i < obj.size() && (obj[i] == ' ' || obj[i] == '\t' || obj[i] == '\n' || obj[i] == '\r')) ++i; if (i >= obj.size() || obj[i] != '"') { pos += needle.size(); continue; } ++i; // past opening quote size_t start = i; // Copy the still-escaped value verbatim up to the matching closing // quote, skipping over backslash escapes so an escaped \" is not // mistaken for the terminator. while (i < obj.size()) { char c = obj[i]; if (c == '\\') { if (i + 1 >= obj.size()) return false; i += 2; // skip the escape pair continue; } if (c == '"') { // closing quote rawValue.assign(obj, start, i - start); return true; } ++i; } return false; // unterminated } return false; } // Parse a FLAT JSON object into key -> stringified value. Strings are // unescaped; numbers/bools/null are kept verbatim (null -> "null"). // Returns false on a structural error. bool parseFlatObject(const std::string& text, std::map& out) { out.clear(); size_t i = 0, n = text.size(); auto skipWs = [&]() { while (i < n && (text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r')) ++i; }; auto parseString = [&](std::string& dst) -> bool { if (i >= n || text[i] != '"') return false; ++i; std::string raw; while (i < n) { char c = text[i]; if (c == '\\') { if (i + 1 >= n) return false; raw.push_back(c); raw.push_back(text[i + 1]); i += 2; continue; } if (c == '"') { ++i; return jsonUnescape(raw, dst); } raw.push_back(c); ++i; } return false; }; skipWs(); if (i >= n || text[i] != '{') return false; ++i; skipWs(); if (i < n && text[i] == '}') { ++i; return true; } while (i < n) { skipWs(); std::string key; if (!parseString(key)) return false; skipWs(); if (i >= n || text[i] != ':') return false; ++i; skipWs(); if (i >= n) return false; std::string val; if (text[i] == '"') { if (!parseString(val)) return false; } else { // number / bool / null - copy raw token until , or } size_t s = i; while (i < n && text[i] != ',' && text[i] != '}') ++i; size_t e = i; while (e > s && (text[e - 1] == ' ' || text[e - 1] == '\t' || text[e - 1] == '\n' || text[e - 1] == '\r')) --e; val.assign(text, s, e - s); } out[key] = val; skipWs(); if (i < n && text[i] == ',') { ++i; continue; } if (i < n && text[i] == '}') { ++i; return true; } return false; } return false; } // Extract the RAW text of a top-level ARRAY member `key` from a JSON object, // returning the bytes BETWEEN the surrounding [ ] (exclusive). Skips over // strings (honouring escapes) so brackets inside string values do not confuse // the bracket-depth counter. Returns false if the member is absent / not an // array. Scoped helper for the unsigned /news endpoint, whose top level the // flat parser cannot represent. Nesting inside the array is tracked so the // matching close bracket is found correctly. bool extractRawArrayMember(const std::string& obj, const std::string& key, std::string& inner) { const std::string needle = "\"" + key + "\""; size_t pos = 0; while ((pos = obj.find(needle, pos)) != std::string::npos) { size_t i = pos + needle.size(); while (i < obj.size() && (obj[i] == ' ' || obj[i] == '\t' || obj[i] == '\n' || obj[i] == '\r')) ++i; if (i >= obj.size() || obj[i] != ':') { pos += needle.size(); continue; } ++i; while (i < obj.size() && (obj[i] == ' ' || obj[i] == '\t' || obj[i] == '\n' || obj[i] == '\r')) ++i; if (i >= obj.size() || obj[i] != '[') { pos += needle.size(); continue; } size_t start = i + 1; // first char inside the array int depth = 0; bool inStr = false; for (size_t j = i; j < obj.size(); ++j) { char c = obj[j]; if (inStr) { if (c == '\\') { ++j; continue; } // skip escaped char if (c == '"') inStr = false; continue; } if (c == '"') { inStr = true; continue; } if (c == '[') ++depth; else if (c == ']') { if (--depth == 0) { inner.assign(obj, start, j - start); return true; } } } return false; // unterminated array } return false; } // Split the inner bytes of a JSON array (the text between [ and ]) into the // raw text of each top-level OBJECT element ("{...}"), preserving each // element's bytes verbatim so parseFlatObject can consume it. Non-object // elements are skipped. String-aware and depth-aware so nested braces / // bracketed string contents do not split an element early. void splitTopLevelObjects(const std::string& inner, std::vector& objects) { objects.clear(); int depth = 0; bool inStr = false; size_t start = std::string::npos; for (size_t j = 0; j < inner.size(); ++j) { char c = inner[j]; if (inStr) { if (c == '\\') { ++j; continue; } if (c == '"') inStr = false; continue; } if (c == '"') { inStr = true; continue; } if (c == '{') { if (depth == 0) start = j; ++depth; } else if (c == '}') { if (depth > 0 && --depth == 0 && start != std::string::npos) { objects.emplace_back(inner, start, j - start + 1); start = std::string::npos; } } } } // ---------------------------------------------------------------------------- // JSON object builder for request bodies (flat, string/number/bool values). // ---------------------------------------------------------------------------- class JsonBuilder { public: JsonBuilder& str(const std::string& k, const std::string& v) { comma(); jsonEscapeTo(buf_, k); buf_.push_back(':'); jsonEscapeTo(buf_, v); return *this; } JsonBuilder& raw(const std::string& k, const std::string& rawVal) { comma(); jsonEscapeTo(buf_, k); buf_.push_back(':'); buf_ += rawVal; // already a valid JSON token (number/bool) return *this; } std::string done() { return "{" + buf_ + "}"; } private: void comma() { if (!buf_.empty()) buf_.push_back(','); } std::string buf_; }; // ============================================================================ // Crypto helpers (OpenSSL). // ============================================================================ // Convert a 64-byte IEEE P1363 r||s signature into DER-encoded ECDSA_SIG. // Returns the DER bytes; empty on failure. Frees all OpenSSL objects. std::vector p1363_to_der(const unsigned char* sig64, size_t len) { std::vector der; if (len != 64) return der; BIGNUM* r = BN_bin2bn(sig64, 32, nullptr); BIGNUM* s = BN_bin2bn(sig64 + 32, 32, nullptr); if (!r || !s) { if (r) BN_free(r); if (s) BN_free(s); return der; } ECDSA_SIG* sig = ECDSA_SIG_new(); if (!sig) { BN_free(r); BN_free(s); return der; } // ECDSA_SIG_set0 takes ownership of r and s on success. if (ECDSA_SIG_set0(sig, r, s) != 1) { BN_free(r); BN_free(s); ECDSA_SIG_free(sig); return der; } int derLen = i2d_ECDSA_SIG(sig, nullptr); if (derLen <= 0) { ECDSA_SIG_free(sig); return der; } der.resize(static_cast(derLen)); unsigned char* p = der.data(); if (i2d_ECDSA_SIG(sig, &p) != derLen) der.clear(); ECDSA_SIG_free(sig); // also frees r, s (owned) return der; } // Verify ECDSA P-256 / SHA-256 over `msg` using SPKI-DER public key bytes. // `sig64` is the raw 64-byte P1363 signature. Returns true iff valid. bool ecdsaVerifyP256(const std::vector& spkiDer, const unsigned char* msg, size_t msgLen, const unsigned char* sig64, size_t sigLen) { if (spkiDer.empty() || sigLen != 64) return false; const unsigned char* pp = spkiDer.data(); EVP_PKEY* pkey = d2i_PUBKEY(nullptr, &pp, static_cast(spkiDer.size())); if (!pkey) return false; std::vector der = p1363_to_der(sig64, sigLen); if (der.empty()) { EVP_PKEY_free(pkey); return false; } EVP_MD_CTX* ctx = EVP_MD_CTX_new(); if (!ctx) { EVP_PKEY_free(pkey); return false; } bool ok = false; if (EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, pkey) == 1 && EVP_DigestVerifyUpdate(ctx, msg, msgLen) == 1) { int rc = EVP_DigestVerifyFinal(ctx, der.data(), der.size()); ok = (rc == 1); } EVP_MD_CTX_free(ctx); EVP_PKEY_free(pkey); return ok; } // SHA-256 -> 32 raw bytes. std::array sha256(const unsigned char* data, size_t len) { std::array out{}; SHA256(data, len, out.data()); return out; } std::array sha256(const std::string& s) { return sha256(reinterpret_cast(s.data()), s.size()); } // 16 cryptographically-random bytes -> lowercase hex (32 chars). std::string randomNonceHex() { unsigned char b[16]; if (RAND_bytes(b, sizeof(b)) != 1) { // RAND_bytes failure is fatal for the security model. throw std::runtime_error("RAND_bytes failed"); } static const char* hex = "0123456789abcdef"; std::string out; out.reserve(32); for (unsigned char c : b) { out.push_back(hex[(c >> 4) & 0xF]); out.push_back(hex[c & 0xF]); } return out; } int64_t nowUnix() { return static_cast( std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count()); } // ============================================================================ // HTTP (libcurl). TLS strictly enforced. // ============================================================================ size_t curlWriteCb(char* ptr, size_t size, size_t nmemb, void* userdata) { auto* s = static_cast(userdata); s->append(ptr, size * nmemb); return size * nmemb; } // One-time global curl init (thread-safe via static). struct CurlGlobal { CurlGlobal() { curl_global_init(CURL_GLOBAL_DEFAULT); } ~CurlGlobal() { curl_global_cleanup(); } }; void ensureCurlGlobal() { static CurlGlobal g; } // POST `body` (JSON) to `url`. On success returns true and fills `response` // and `httpCode`. TLS peer+host verification is mandatory. bool httpPostJson(const std::string& url, const std::string& body, std::string& response, long& httpCode, std::string& err) { ensureCurlGlobal(); CURL* curl = curl_easy_init(); if (!curl) { err = "curl_easy_init failed"; return false; } response.clear(); httpCode = 0; struct curl_slist* headers = nullptr; headers = curl_slist_append(headers, "Content-Type: application/json"); headers = curl_slist_append(headers, "Accept: application/json"); curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); curl_easy_setopt(curl, CURLOPT_POST, 1L); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str()); curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast(body.size())); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCb); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); // --- TLS enforcement (do not relax) --- curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); // Restrict to HTTPS only. The *_STR variants were added in libcurl 7.85.0; // fall back to the integer bitmask form on older libcurl (e.g. apt LTS). #if defined(CURLOPT_PROTOCOLS_STR) && (LIBCURL_VERSION_NUM >= 0x075500) curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https"); #else curl_easy_setopt(curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS); curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTPS); #endif curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); // --- timeouts --- curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); curl_easy_setopt(curl, CURLOPT_USERAGENT, "AtlasAuth-CPP-SDK/1.0"); CURLcode rc = curl_easy_perform(curl); if (rc == CURLE_OK) { curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode); } else { err = curl_easy_strerror(rc); } curl_slist_free_all(headers); curl_easy_cleanup(curl); return rc == CURLE_OK; } // GET `url`. On success returns true and fills `response` and `httpCode`. Used // only by the UNSIGNED informational endpoints (status/news). TLS peer+host // verification is mandatory, identical to httpPostJson; the sole difference is // the HTTP method and the absence of a request body. bool httpGetRaw(const std::string& url, std::string& response, long& httpCode, std::string& err) { ensureCurlGlobal(); CURL* curl = curl_easy_init(); if (!curl) { err = "curl_easy_init failed"; return false; } response.clear(); httpCode = 0; struct curl_slist* headers = nullptr; headers = curl_slist_append(headers, "Accept: application/json"); curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCb); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); // --- TLS enforcement (do not relax) --- curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); #if defined(CURLOPT_PROTOCOLS_STR) && (LIBCURL_VERSION_NUM >= 0x075500) curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https"); #else curl_easy_setopt(curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS); curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTPS); #endif curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); // --- timeouts --- curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); curl_easy_setopt(curl, CURLOPT_USERAGENT, "AtlasAuth-CPP-SDK/1.0"); CURLcode rc = curl_easy_perform(curl); if (rc == CURLE_OK) { curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode); } else { err = curl_easy_strerror(rc); } curl_slist_free_all(headers); curl_easy_cleanup(curl); return rc == CURLE_OK; } // Best-effort helper to read a "code"/"error" field for messaging. std::string fieldOr(const std::map& m, const std::string& k, const std::string& dflt = "") { auto it = m.find(k); return it == m.end() ? dflt : it->second; } bool fieldBoolTrue(const std::map& m, const std::string& k) { auto it = m.find(k); return it != m.end() && it->second == "true"; } } // anonymous namespace // ============================================================================ // Client // ============================================================================ Client::Client(std::string app_id, std::string base64SpkiPubKey) : app_id_(std::move(app_id)), pubKeyB64_(std::move(base64SpkiPubKey)) {} Client::~Client() { stopHeartbeat(); } void Client::setHwidSeed(std::string seed) { std::lock_guard lk(mtx_); hwidSeed_ = std::move(seed); } void Client::setVersion(std::string version) { std::lock_guard lk(mtx_); version_ = std::move(version); } void Client::setError(const std::string& e) { lastError_ = e; } void Client::setErrorLocked(const std::string& e) { std::lock_guard lk(mtx_); lastError_ = e; } // ---------------------------------------------------------------------------- // HWID // _WIN32 : SHA256( MachineGuid | C: volume serial | cpuid ) -> base64 // else : /etc/machine-id (fallback to /var/lib/dbus/machine-id) hashed. // seed : base64( SHA256("atlasauth-hwid-seed|" + seed) ) // ---------------------------------------------------------------------------- std::string Client::computeHwid() const { std::string seed; { std::lock_guard lk(mtx_); seed = hwidSeed_; } if (!seed.empty()) { auto h = sha256("atlasauth-hwid-seed|" + seed); return base64Encode(h.data(), h.size()); } std::string material; #ifdef _WIN32 // 1) Cryptography\MachineGuid { HKEY hKey; if (RegOpenKeyExA(HKEY_LOCAL_MACHINE, "SOFTWARE\\Microsoft\\Cryptography", 0, KEY_READ | KEY_WOW64_64KEY, &hKey) == ERROR_SUCCESS) { char val[256]; DWORD sz = sizeof(val); DWORD type = 0; if (RegQueryValueExA(hKey, "MachineGuid", nullptr, &type, reinterpret_cast(val), &sz) == ERROR_SUCCESS && type == REG_SZ) { material.append(val, (sz > 0 ? sz - 1 : 0)); // drop NUL } RegCloseKey(hKey); } } material.push_back('|'); // 2) C: volume serial number { DWORD serial = 0; if (GetVolumeInformationA("C:\\", nullptr, 0, &serial, nullptr, nullptr, nullptr, 0)) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "%08lX", static_cast(serial)); material += tmp; } } material.push_back('|'); // 3) CPUID (vendor + feature leaves) for additional stability { int regs[4] = {0, 0, 0, 0}; __cpuid(regs, 0); char tmp[64]; std::snprintf(tmp, sizeof(tmp), "%08X%08X%08X%08X", regs[0], regs[1], regs[2], regs[3]); material += tmp; __cpuid(regs, 1); std::snprintf(tmp, sizeof(tmp), "%08X%08X%08X%08X", regs[0], regs[1], regs[2], regs[3]); material += tmp; } #else // Linux: stable machine id. auto readFile = [](const char* path, std::string& out) -> bool { std::ifstream f(path); if (!f) return false; std::getline(f, out); // trim trailing whitespace/newline while (!out.empty() && (out.back() == '\n' || out.back() == '\r' || out.back() == ' ')) out.pop_back(); return !out.empty(); }; std::string mid; if (!readFile("/etc/machine-id", mid)) { readFile("/var/lib/dbus/machine-id", mid); } material = mid; #endif if (material.empty()) material = "atlasauth-unknown-host"; auto h = sha256(material); return base64Encode(h.data(), h.size()); } // ---------------------------------------------------------------------------- // Envelope verification (the security-critical core). // ---------------------------------------------------------------------------- bool Client::verifyEnvelope(const std::string& envelopeJson, const std::string& sentNonce, std::map& outFields) { // 1) Recover the EXACT signed bytes: pull the still-escaped "payload" // string value out of the envelope, then JSON-unescape it ONLY. // We never re-serialize a parsed object - that would change the bytes. std::string rawPayloadEscaped; if (!extractRawStringMember(envelopeJson, "payload", rawPayloadEscaped)) { setErrorLocked("malformed response: missing signed payload"); return false; } std::string payloadBytes; if (!jsonUnescape(rawPayloadEscaped, payloadBytes)) { setErrorLocked("malformed response: bad payload escaping"); return false; } // 2) Pull and base64-decode the signature -> 64 raw bytes. std::string sigB64; if (!extractRawStringMember(envelopeJson, "sig", sigB64)) { setErrorLocked("malformed response: missing signature"); return false; } std::string sigB64Unescaped; if (!jsonUnescape(sigB64, sigB64Unescaped)) { setErrorLocked("malformed response: bad signature escaping"); return false; } std::vector sig; if (!base64Decode(sigB64Unescaped, sig) || sig.size() != 64) { setErrorLocked("invalid signature length (expected 64 bytes P1363)"); return false; } // 3) Decode the embedded SPKI public key and verify over payloadBytes. std::vector spki; if (!base64Decode(pubKeyB64_, spki) || spki.empty()) { setErrorLocked("embedded public key is not valid base64 SPKI"); return false; } bool valid = ecdsaVerifyP256( spki, reinterpret_cast(payloadBytes.data()), payloadBytes.size(), sig.data(), sig.size()); if (!valid) { setErrorLocked("signature verification FAILED (hostile/MITM response)"); return false; } // 4) Signature is valid - NOW it is safe to parse the payload bytes. if (!parseFlatObject(payloadBytes, outFields)) { setErrorLocked("verified payload is not parseable JSON"); return false; } // 5) Nonce binding: payload.nonce MUST equal the nonce we sent. auto itNonce = outFields.find("nonce"); if (itNonce == outFields.end() || itNonce->second != sentNonce) { setErrorLocked("nonce mismatch (replay/tamper) - response rejected"); return false; } // 6) Advisory clock-skew check. auto itT = outFields.find("t"); if (itT != outFields.end()) { try { long long t = std::stoll(itT->second); long long skew = std::llabs(static_cast(nowUnix()) - t); if (skew > API_MAX_SKEW_SECONDS) { setErrorLocked("clock skew exceeds " + std::to_string(API_MAX_SKEW_SECONDS) + "s (advisory)"); // Advisory only: do not hard-fail. Field set for visibility. // (Per contract step 6 this is advisory.) } } catch (...) { // Non-numeric t: ignore (advisory). } } return true; } // ---------------------------------------------------------------------------- // Signed POST: build request, send, verify, hand back parsed verified fields. // ---------------------------------------------------------------------------- bool Client::postSigned(const std::string& endpoint, const std::string& bodyJson, const std::string& sentNonce, std::map& outFields) { const std::string url = std::string(API_BASE_URL) + endpoint; std::string response; long httpCode = 0; std::string httpErr; if (!httpPostJson(url, bodyJson, response, httpCode, httpErr)) { setErrorLocked("transport error: " + httpErr); return false; } // Any non-200 is an unsigned transport-level error per the contract; we // never trust its contents. Surface its machine code if present. if (httpCode != 200) { std::map errFields; std::string code, human; if (parseFlatObject(response, errFields)) { code = fieldOr(errFields, "code"); human = fieldOr(errFields, "error"); } std::string msg = "server returned HTTP " + std::to_string(httpCode); if (!code.empty()) msg += " (" + code + ")"; else if (!human.empty()) msg += " (" + human + ")"; setErrorLocked(msg); return false; } // 200 => must be a verifiable signed envelope. return verifyEnvelope(response, sentNonce, outFields); } // ---------------------------------------------------------------------------- // init // ---------------------------------------------------------------------------- bool Client::init() { const std::string nonce = randomNonceHex(); std::string version; { std::lock_guard lk(mtx_); version = version_; } JsonBuilder b; b.str("app_id", app_id_).str("nonce", nonce); if (!version.empty()) b.str("version", version); std::map f; if (!postSigned("/init", b.done(), nonce, f)) return false; const std::string status = fieldOr(f, "app_status", "unknown"); const std::string statusMsg = fieldOr(f, "status_message"); int hb = 10; try { hb = std::stoi(fieldOr(f, "heartbeat", "10")); } catch (...) {} { std::lock_guard lk(mtx_); session_ = fieldOr(f, "session"); appStatus_ = status; statusMessage_ = statusMsg; heartbeatSeconds_ = (hb > 0 ? hb : 10); inited_ = true; } if (!fieldBoolTrue(f, "ok")) { setErrorLocked("init failed: " + fieldOr(f, "code", "not ok")); return false; } if (status != "active") { setErrorLocked(statusMsg.empty() ? ("app not active (" + status + ")") : statusMsg); return false; } // Forced-version mismatch (version_ok==false) => caller should update. auto vo = f.find("version_ok"); if (vo != f.end() && vo->second == "false") { setErrorLocked("client version outdated; latest=" + fieldOr(f, "latest_version", "?")); return false; } if (session_.empty()) { setErrorLocked("init returned no session token"); return false; } return true; } // ---------------------------------------------------------------------------- // login // ---------------------------------------------------------------------------- bool Client::login(const std::string& user, const std::string& pass) { std::string session; { std::lock_guard lk(mtx_); if (!inited_ || session_.empty()) { setError("call init() before login()"); return false; } session = session_; } const std::string nonce = randomNonceHex(); const std::string hwid = computeHwid(); JsonBuilder b; b.str("app_id", app_id_).str("nonce", nonce).str("session", session) .str("username", user).str("password", pass).str("hwid", hwid); std::map f; if (!postSigned("/login", b.done(), nonce, f)) return false; if (!fieldBoolTrue(f, "ok")) { setErrorLocked("login failed: " + fieldOr(f, "code", fieldOr(f, "error", "denied"))); return false; } std::lock_guard lk(mtx_); username_ = fieldOr(f, "username", user); auto exp = f.find("expiry"); if (exp != f.end() && exp->second != "null" && !exp->second.empty()) { try { expiry_ = std::stoll(exp->second); } catch (...) { expiry_ = std::nullopt; } } else { expiry_ = std::nullopt; // lifetime } loggedIn_ = true; lastError_.clear(); return true; } // ---------------------------------------------------------------------------- // register_ // ---------------------------------------------------------------------------- bool Client::register_(const std::string& user, const std::string& pass, const std::string& license, const std::string& email) { std::string session; { std::lock_guard lk(mtx_); if (!inited_ || session_.empty()) { setError("call init() before register_()"); return false; } session = session_; } const std::string nonce = randomNonceHex(); const std::string hwid = computeHwid(); JsonBuilder b; b.str("app_id", app_id_).str("nonce", nonce).str("session", session) .str("username", user).str("password", pass) .str("license", license).str("hwid", hwid); if (!email.empty()) b.str("email", email); std::map f; if (!postSigned("/register", b.done(), nonce, f)) return false; if (!fieldBoolTrue(f, "ok")) { setErrorLocked("register failed: " + fieldOr(f, "code", fieldOr(f, "error", "denied"))); return false; } std::lock_guard lk(mtx_); username_ = fieldOr(f, "username", user); auto exp = f.find("expiry"); if (exp != f.end() && exp->second != "null" && !exp->second.empty()) { try { expiry_ = std::stoll(exp->second); } catch (...) { expiry_ = std::nullopt; } } else { expiry_ = std::nullopt; } loggedIn_ = true; lastError_.clear(); return true; } // ---------------------------------------------------------------------------- // licenseLogin // ---------------------------------------------------------------------------- bool Client::licenseLogin(const std::string& key) { std::string session; { std::lock_guard lk(mtx_); if (!inited_ || session_.empty()) { setError("call init() before licenseLogin()"); return false; } session = session_; } const std::string nonce = randomNonceHex(); const std::string hwid = computeHwid(); JsonBuilder b; b.str("app_id", app_id_).str("nonce", nonce).str("session", session) .str("license", key).str("hwid", hwid); std::map f; if (!postSigned("/license", b.done(), nonce, f)) return false; if (!fieldBoolTrue(f, "ok")) { setErrorLocked("license login failed: " + fieldOr(f, "code", fieldOr(f, "error", "denied"))); return false; } std::lock_guard lk(mtx_); auto exp = f.find("expiry"); if (exp != f.end() && exp->second != "null" && !exp->second.empty()) { try { expiry_ = std::stoll(exp->second); } catch (...) { expiry_ = std::nullopt; } } else { expiry_ = std::nullopt; } loggedIn_ = true; lastError_.clear(); return true; } // ---------------------------------------------------------------------------- // var // ---------------------------------------------------------------------------- std::optional Client::var(const std::string& name) { std::string session; { std::lock_guard lk(mtx_); if (!inited_ || session_.empty()) { setError("call init() before var()"); return std::nullopt; } session = session_; } const std::string nonce = randomNonceHex(); JsonBuilder b; b.str("app_id", app_id_).str("nonce", nonce).str("session", session) .str("name", name); std::map f; if (!postSigned("/var", b.done(), nonce, f)) return std::nullopt; if (!fieldBoolTrue(f, "ok") || !fieldBoolTrue(f, "found")) { setErrorLocked("var unavailable: " + fieldOr(f, "code", "not found")); return std::nullopt; } auto it = f.find("value"); if (it == f.end()) return std::nullopt; return it->second; } // ---------------------------------------------------------------------------- // httpGet (member wrapper around httpGetRaw; UNSIGNED endpoints only) // ---------------------------------------------------------------------------- bool Client::httpGet(const std::string& path, std::string& response, long& httpCode) { const std::string url = std::string(API_BASE_URL) + path; std::string httpErr; if (!httpGetRaw(url, response, httpCode, httpErr)) { setErrorLocked("transport error: " + httpErr); return false; } return true; } // ---------------------------------------------------------------------------- // status (GET /status/{app_id}) - UNSIGNED, informational. No nonce, no // signature; we parse the flat JSON object directly. 404 => unknown app. // ---------------------------------------------------------------------------- std::optional Client::status() { std::string response; long httpCode = 0; if (!httpGet("/status/" + app_id_, response, httpCode)) return std::nullopt; std::map f; if (!parseFlatObject(response, f)) { setErrorLocked("status: unparseable response"); return std::nullopt; } if (httpCode != 200 || !fieldBoolTrue(f, "ok")) { setErrorLocked("status unavailable: " + fieldOr(f, "error", "http " + std::to_string(httpCode))); return std::nullopt; } StatusInfo info; info.appId = fieldOr(f, "app_id", app_id_); info.name = fieldOr(f, "name"); info.status = fieldOr(f, "status", "unknown"); info.statusMessage = fieldOr(f, "status_message"); info.online = 0; try { info.online = std::stoi(fieldOr(f, "online", "0")); } catch (...) {} setErrorLocked(""); return info; } // ---------------------------------------------------------------------------- // news (GET /news/{app_id}) - UNSIGNED, informational. The top level is an // object with an ARRAY of objects under "news"; the flat parser cannot model // that, so we carve out the array text and parse each element object with the // existing flat parser. Returns the items in server order (pinned-first then // newest). Empty vector on unknown app / transport / parse failure. // ---------------------------------------------------------------------------- std::vector Client::news() { std::vector out; std::string response; long httpCode = 0; if (!httpGet("/news/" + app_id_, response, httpCode)) return out; // Top-level ok/error: reuse the flat parser on the whole body. It stops at // the first structural element it cannot model (the array), but the scalar // members emitted before it ("ok", "app_id") are still captured, which is // all we need to detect the 404 unknown_app case. std::map top; parseFlatObject(response, top); if (httpCode != 200 || top.find("error") != top.end()) { setErrorLocked("news unavailable: " + fieldOr(top, "error", "http " + std::to_string(httpCode))); return out; } std::string arrayInner; if (!extractRawArrayMember(response, "news", arrayInner)) { // 200 with no "news" array is treated as "no news" rather than error. setErrorLocked(""); return out; } std::vector elems; splitTopLevelObjects(arrayInner, elems); out.reserve(elems.size()); for (const std::string& obj : elems) { std::map nf; if (!parseFlatObject(obj, nf)) continue; // skip a malformed element NewsItem item; item.id = fieldOr(nf, "id"); item.title = fieldOr(nf, "title"); item.body = fieldOr(nf, "body"); item.pinned = fieldBoolTrue(nf, "pinned"); item.createdAt = 0; try { item.createdAt = std::stoll(fieldOr(nf, "created_at", "0")); } catch (...) {} out.push_back(std::move(item)); } setErrorLocked(""); return out; } // ---------------------------------------------------------------------------- // log (best-effort) // ---------------------------------------------------------------------------- void Client::log(const std::string& level, const std::string& message) { std::string session; { std::lock_guard lk(mtx_); if (!inited_) return; session = session_; } const std::string nonce = randomNonceHex(); JsonBuilder b; b.str("app_id", app_id_).str("nonce", nonce); if (!session.empty()) b.str("session", session); b.str("level", level).str("message", message); std::map f; // Best-effort: ignore result. Verification still runs but we never block. postSigned("/log", b.done(), nonce, f); } // ---------------------------------------------------------------------------- // check (one heartbeat round-trip) // ---------------------------------------------------------------------------- bool Client::check(std::string& reason) { reason.clear(); std::string session; { std::lock_guard lk(mtx_); if (!inited_ || session_.empty()) { setError("call init() before check()"); reason = "not_initialized"; return false; } session = session_; } const std::string nonce = randomNonceHex(); JsonBuilder b; b.str("app_id", app_id_).str("nonce", nonce).str("session", session); std::map f; if (!postSigned("/check", b.done(), nonce, f)) { // Verification/transport failure => treat as invalid. reason = "unverified"; return false; } const bool ok = fieldBoolTrue(f, "ok"); const bool valid = fieldBoolTrue(f, "valid"); const bool keyValid = fieldBoolTrue(f, "key_valid"); const bool banned = fieldBoolTrue(f, "banned"); const std::string st = fieldOr(f, "app_status", "active"); // Update cached status + expiry from the signed beat. { std::lock_guard lk(mtx_); appStatus_ = st; statusMessage_ = fieldOr(f, "status_message"); auto exp = f.find("expiry"); if (exp != f.end()) { if (exp->second == "null" || exp->second.empty()) expiry_ = std::nullopt; else { try { expiry_ = std::stoll(exp->second); } catch (...) {} } } } const bool good = ok && valid && keyValid && !banned && (st == "active"); if (!good) { reason = fieldOr(f, "reason"); if (reason.empty()) { if (!ok || !valid) reason = "killed"; else if (!keyValid) reason = "expired"; else if (banned) reason = "banned"; else if (st != "active") reason = (st == "maintenance") ? "app_maintenance" : "app_disabled"; } std::lock_guard lk(mtx_); loggedIn_ = false; lastError_ = "session invalidated: " + reason; return false; } return true; } // ---------------------------------------------------------------------------- // logout (best-effort; always clears local state) // ---------------------------------------------------------------------------- void Client::logout() { std::string session; { std::lock_guard lk(mtx_); session = session_; } if (!session.empty()) { const std::string nonce = randomNonceHex(); JsonBuilder b; b.str("app_id", app_id_).str("nonce", nonce).str("session", session); std::map f; postSigned("/logout", b.done(), nonce, f); // best-effort } std::lock_guard lk(mtx_); loggedIn_ = false; session_.clear(); username_.clear(); expiry_ = std::nullopt; } // ---------------------------------------------------------------------------- // Heartbeat thread // ---------------------------------------------------------------------------- void Client::startHeartbeat(std::function onKicked) { { std::lock_guard lk(mtx_); if (hbRunning_.load()) return; // already running if (!inited_ || !loggedIn_) { setError("startHeartbeat: not authenticated"); return; } } // A previous heartbeat thread may have self-terminated (e.g. on a kick) // without being joined; hbThread_ would then still be joinable. Assigning // a new std::thread to a joinable hbThread_ calls std::terminate(). Join // the finished thread first so re-arming after a kick is safe. if (hbThread_.joinable()) { hbStop_.store(true); hbThread_.join(); } hbStop_.store(false); hbRunning_.store(true); hbThread_ = std::thread([this, onKicked]() { for (;;) { int interval; { std::lock_guard lk(mtx_); interval = heartbeatSeconds_ > 0 ? heartbeatSeconds_ : 10; } // Sleep in 200ms slices so stopHeartbeat() is responsive. for (int slept = 0; slept < interval * 1000 && !hbStop_.load(); slept += 200) { std::this_thread::sleep_for(std::chrono::milliseconds(200)); } if (hbStop_.load()) break; std::string reason; if (!check(reason)) { hbRunning_.store(false); if (onKicked) onKicked(reason.empty() ? "invalid" : reason); return; } } hbRunning_.store(false); }); } void Client::stopHeartbeat() { hbStop_.store(true); if (hbThread_.joinable()) { hbThread_.join(); } hbRunning_.store(false); } // ---------------------------------------------------------------------------- // Getters // ---------------------------------------------------------------------------- std::string Client::username() const { std::lock_guard lk(mtx_); return username_; } std::optional Client::expiryUnix() const { std::lock_guard lk(mtx_); return expiry_; } std::string Client::appStatus() const { std::lock_guard lk(mtx_); return appStatus_; } std::string Client::lastError() const { std::lock_guard lk(mtx_); return lastError_; } bool Client::loggedIn() const { std::lock_guard lk(mtx_); return loggedIn_; } } // namespace atlas