// ============================================================================ // 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 #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; } // STRICT canonical base64 decoder. Ignores ASCII whitespace, but otherwise // rejects anything a canonical encoder would never emit: length not a multiple // of 4 (incl. padding), padding in the wrong place / too much, data after // padding, invalid characters, and non-zero padding bits. This guarantees a // value decodes to the SAME bytes across the C#, C++, and browser SDKs (a lenient // decoder let distinct strings map to one blob, and differed from Convert. // FromBase64String / atob). Returns false on any non-canonical input. bool base64Decode(const std::string& in, std::vector& out) { out.clear(); unsigned char group[4]; int gi = 0; // symbols filled in the current 4-char group int pad = 0; // '=' count in the current group bool done = false; // a padded (final) group was seen; no more data allowed for (char c : in) { if (c == '\n' || c == '\r' || c == ' ' || c == '\t') continue; if (done) return false; // characters after a padded final group if (c == '=') { if (gi < 2) return false; // '=' can only be the 3rd/4th symbol pad++; if (pad > 2) return false; group[gi++] = 0; } else { if (pad > 0) return false; // data character after '=' in a group int v = b64val(c); if (v < 0) return false; group[gi++] = static_cast(v); } if (gi == 4) { if (pad == 0) { out.push_back((group[0] << 2) | (group[1] >> 4)); out.push_back(((group[1] & 0xF) << 4) | (group[2] >> 2)); out.push_back(((group[2] & 0x3) << 6) | group[3]); } else if (pad == 1) { if ((group[2] & 0x3) != 0) return false; // non-canonical trailing bits out.push_back((group[0] << 2) | (group[1] >> 4)); out.push_back(((group[1] & 0xF) << 4) | (group[2] >> 2)); done = true; } else { // pad == 2 if ((group[1] & 0xF) != 0) return false; // non-canonical trailing bits out.push_back((group[0] << 2) | (group[1] >> 4)); done = true; } gi = 0; pad = 0; } } if (gi != 0) return false; // input not a whole number of 4-char groups 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; // H12: pin the curve. Reject anything that is not an EC key on // NID_X9_62_prime256r1 (P-256) so a wrong/hostile key type (RSA, Ed25519, // a different EC curve, etc.) cannot be smuggled in and silently trusted. { if (EVP_PKEY_base_id(pkey) != EVP_PKEY_EC) { EVP_PKEY_free(pkey); return false; } EC_KEY* ec = EVP_PKEY_get1_EC_KEY(pkey); if (!ec) { EVP_PKEY_free(pkey); return false; } const EC_GROUP* grp = EC_KEY_get0_group(ec); int curveNid = grp ? EC_GROUP_get_curve_name(grp) : NID_undef; EC_KEY_free(ec); if (curveNid != NID_X9_62_prime256r1) { EVP_PKEY_free(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. `pinnedKeys` (when // non-empty) is applied as CURLOPT_PINNEDPUBLICKEY (SPKI SHA-256 pinning). bool httpPostJson(const std::string& url, const std::string& body, std::string& response, long& httpCode, std::string& err, const std::string& pinnedKeys = std::string()) { 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); if (!pinnedKeys.empty()) curl_easy_setopt(curl, CURLOPT_PINNEDPUBLICKEY, pinnedKeys.c_str()); // 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, const std::string& pinnedKeys = std::string()) { 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 (!pinnedKeys.empty()) curl_easy_setopt(curl, CURLOPT_PINNEDPUBLICKEY, pinnedKeys.c_str()); #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::setPinnedPublicKeys(std::string sha256Pins) { std::lock_guard lk(mtx_); pinnedKeys_ = std::move(sha256Pins); } void Client::setError(const std::string& e) { lastError_ = e; } void Client::setErrorLocked(const std::string& e) { std::lock_guard lk(mtx_); lastError_ = e; } // Stale window = max(hb*3, hb+20) seconds. Caller must hold mtx_. int Client::staleWindowSecondsLocked() const { const int hb = heartbeatSeconds_ > 0 ? heartbeatSeconds_ : 10; const int a = hb * 3; const int b = hb + 20; return a > b ? a : b; } // ---------------------------------------------------------------------------- // 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, const std::string& expectedAud, const std::string& expectedSid, 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; } // 5b) IDENTITY BINDING (v2). Bind the response to THIS app, THIS endpoint, and // (for session calls) THIS session, so a validly-signed response minted for // the attacker's own session/endpoint cannot be relayed to another client // (defeats the "one paid session as a universal signing oracle" attack). auto itV = outFields.find("v"); if (itV == outFields.end() || itV->second != "2") { setErrorLocked("unsupported envelope version (expected 2) - response rejected"); return false; } auto itApp = outFields.find("app_id"); if (itApp == outFields.end() || itApp->second != app_id_) { setErrorLocked("app_id mismatch - response is not for this app"); return false; } auto itAud = outFields.find("aud"); if (itAud == outFields.end() || itAud->second != expectedAud) { setErrorLocked("audience mismatch - response is for a different endpoint"); return false; } if (!expectedSid.empty()) { auto itSid = outFields.find("sid"); if (itSid == outFields.end() || itSid->second != expectedSid) { setErrorLocked("session mismatch - response is not for this session"); return false; } } // 6) Clock-skew check - HARD failure. A verified payload whose timestamp is // more than API_MAX_SKEW_SECONDS from the local clock is rejected and NOT // trusted: this bounds the freshness of a signed response and blocks a // replayed/stale (but validly-signed) response from being accepted. A // missing or non-numeric `t` is also a hard failure - we refuse to trust // a signed response we cannot freshness-check. auto itT = outFields.find("t"); if (itT == outFields.end()) { setErrorLocked("verified payload missing timestamp 't' - rejected"); return false; } long long t = 0; try { size_t consumed = 0; t = std::stoll(itT->second, &consumed); if (consumed != itT->second.size()) throw std::invalid_argument("trailing"); } catch (...) { setErrorLocked("verified payload has non-numeric timestamp 't' - rejected"); return false; } 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 - response rejected"); return false; } 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& expectedSid) { const std::string url = std::string(API_BASE_URL) + endpoint; std::string response; long httpCode = 0; std::string httpErr; std::string pins; { std::lock_guard lk(mtx_); pins = pinnedKeys_; } if (!httpPostJson(url, bodyJson, response, httpCode, httpErr, pins)) { 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, bound to this endpoint (aud) // and (when provided) this session (expectedSid). return verifyEnvelope(response, sentNonce, endpoint, expectedSid, 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, session)) 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; serverAuthed_ = true; // a verified login is server-confirmed auth; sessionAlive() is live now lastGoodCheck_ = std::chrono::steady_clock::now(); 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, session)) 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; serverAuthed_ = true; // a verified login is server-confirmed auth; sessionAlive() is live now lastGoodCheck_ = std::chrono::steady_clock::now(); 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, session)) 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; serverAuthed_ = true; // a verified login is server-confirmed auth; sessionAlive() is live now lastGoodCheck_ = std::chrono::steady_clock::now(); 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(); // Bind /var to this device: send the SAME HWID used on login/check, or the // server refuses to hand over the (secret) value. const std::string hwid = computeHwid(); JsonBuilder b; b.str("app_id", app_id_).str("nonce", nonce).str("session", session) .str("name", name).str("hwid", hwid); std::map f; if (!postSigned("/var", b.done(), nonce, f, session)) 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; std::string pins; { std::lock_guard lk(mtx_); pins = pinnedKeys_; } if (!httpGetRaw(url, response, httpCode, httpErr, pins)) { 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, session); } // ---------------------------------------------------------------------------- // 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(); // Bind the heartbeat to this device: send the SAME HWID the SDK computes for // login/register/license so the server can verify the session is still on // the enrolled machine. const std::string hwid = computeHwid(); JsonBuilder b; b.str("app_id", app_id_).str("nonce", nonce).str("session", session) .str("hwid", hwid); std::map f; if (!postSigned("/check", b.done(), nonce, f, session)) { // Verification/transport failure => treat as invalid. Fail closed: // clear the server-authoritative authed flag so sessionAlive() drops. { std::lock_guard lk(mtx_); serverAuthed_ = false; } 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"); // SERVER-AUTHORITATIVE login state from the VERIFIED payload. This is the // only source of truth sessionAlive() trusts for "authed"; the client-side // loggedIn_ flag is not sufficient on its own. const bool authed = fieldBoolTrue(f, "authed"); 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; serverAuthed_ = false; // any invalidation drops server-authed liveness lastError_ = "session invalidated: " + reason; return false; } // Server reports authed==false. This does NOT kick on its own: a pre-login // heartbeat is legitimately not-authed (it monitors app status / thread pauses // before the user logs in). It only kicks if we BELIEVED we were logged in and // the server revoked auth. Either way serverAuthed_ drops so sessionAlive() is // false until a real, server-confirmed login. if (!authed) { std::lock_guard lk(mtx_); serverAuthed_ = false; if (loggedIn_) { reason = "not_authed"; loggedIn_ = false; lastError_ = "session invalidated: " + reason; return false; } // Pre-login: keep the heartbeat alive so it keeps monitoring. if (hbStarted_) lastGoodCheck_ = std::chrono::steady_clock::now(); return true; } // Successful, verified beat with server authed==true: record the monotonic // timestamp and the server-authoritative authed flag so sessionAlive()/ // secondsSinceHeartbeat() reflect real liveness. Only mark liveness if the // heartbeat has been started (lastGoodCheck_ is valid); manual check() // callers who never call startHeartbeat() do not gain a liveness window // they are not maintaining. { std::lock_guard lk(mtx_); serverAuthed_ = true; if (hbStarted_) lastGoodCheck_ = std::chrono::steady_clock::now(); } 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, session); // best-effort } std::lock_guard lk(mtx_); loggedIn_ = false; serverAuthed_ = false; // logged out => not server-authed 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); // Seed the liveness clock to "now" and mark the heartbeat started. The // immediately-preceding login/register/license already proved authed==true via // a signature-verified response, so we CARRY THAT FORWARD (serverAuthed_=true) // so sessionAlive() is live right after login with no dead window - matching the // C# SDK. The first verified /check re-confirms it (or any failure drops it). // Because liveness is anchored to the MONOTONIC steady_clock, a suspended // heartbeat thread still decays sessionAlive() to false after the stale window. { std::lock_guard lk(mtx_); lastGoodCheck_ = std::chrono::steady_clock::now(); hbStarted_ = true; serverAuthed_ = true; } hbThread_ = std::thread([this, onKicked]() { // Monotonic timestamp of the previous beat's boundary. steady_clock // keeps advancing while this thread is suspended (debugger, SIGSTOP, // VM pause, cheat "freeze"), so comparing it to now() after we wake // reveals a freeze that a naive interval sleep would hide. auto prevBeat = std::chrono::steady_clock::now(); // Count of CONSECUTIVE transport/verify failures (connection lost). A // successful verified beat resets it to 0. After 2 consecutive we give // up and kick with "connection lost". int consecutiveFailures = 0; for (;;) { int interval; int staleWindow; { std::lock_guard lk(mtx_); interval = heartbeatSeconds_ > 0 ? heartbeatSeconds_ : 10; staleWindow = staleWindowSecondsLocked(); } // 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; // ANTI-TAMPER: measure REAL elapsed wall-clock time since the last // beat boundary on the monotonic clock. If it exceeds the stale // window, this thread was frozen then resumed: kick and stop. // `prevBeat` marks the end of the previous beat (after its /check // returned), so a slow-but-alive network round-trip is NOT counted // as a freeze - only the sleep/wake cycle is measured, which is // exactly what a thread-suspend attack stalls. const auto now = std::chrono::steady_clock::now(); const long long elapsed = std::chrono::duration_cast(now - prevBeat) .count(); if (elapsed > staleWindow) { { std::lock_guard lk(mtx_); loggedIn_ = false; // any kick => sessionAlive() false serverAuthed_ = false; // drop server-authed liveness lastError_ = "heartbeat stalled"; } hbRunning_.store(false); if (onKicked) onKicked("heartbeat stalled"); return; } std::string reason; if (!check(reason)) { // FAIL CLOSED. A failed beat is one of two kinds: // (a) transport/verify failure -> reason == "unverified": // the connection is down or a response could not be // verified. check() already cleared serverAuthed_ (so // sessionAlive() is already false) but did NOT clear // loggedIn_ and did NOT refresh the last-good anchor. // We tolerate up to 2 CONSECUTIVE such failures, then // give up with "connection lost". // (b) definitive server invalidation (killed/banned/expired/ // not_authed/app not active): check() already cleared // loggedIn_ and serverAuthed_. This is authoritative - // kick immediately with the server's reason. // In BOTH cases the anchor (lastGoodCheck_) is left stale, so // sessionAlive() cannot report alive off an old timestamp. if (reason == "unverified") { ++consecutiveFailures; if (consecutiveFailures >= 2) { { std::lock_guard lk(mtx_); loggedIn_ = false; serverAuthed_ = false; lastError_ = "connection lost"; } hbRunning_.store(false); if (onKicked) onKicked("connection lost"); return; } // First transient failure: stay in the loop and retry on the // next interval. We do NOT refresh the liveness anchor // (lastGoodCheck_) - check() left it stale, so sessionAlive() // stays false until a verified beat lands. We DO advance // prevBeat (the freeze-detector's sleep/wake baseline, a // different variable) to now so the next iteration measures // only the upcoming sleep for freeze detection and does not // mis-attribute this failed beat's network time as a freeze. prevBeat = std::chrono::steady_clock::now(); continue; } // Definitive invalidation: kick now. hbRunning_.store(false); if (onKicked) onKicked(reason.empty() ? "invalid" : reason); return; } // Beat completed successfully: reset the failure counter and mark // the end-of-beat boundary on the monotonic clock. check() already // refreshed lastGoodCheck_ and recorded serverAuthed_. consecutiveFailures = 0; prevBeat = std::chrono::steady_clock::now(); } hbRunning_.store(false); }); } void Client::stopHeartbeat() { hbStop_.store(true); if (hbThread_.joinable()) { hbThread_.join(); } hbRunning_.store(false); // Stopping the heartbeat ends the verified-liveness window: drop the // server-authoritative authed flag so sessionAlive() returns false until a // fresh heartbeat produces a new verified authed==true beat. std::lock_guard lk(mtx_); serverAuthed_ = 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_; } // ---------------------------------------------------------------------------- // Anti-tamper liveness accessors. // // DEVELOPERS: gate sensitive work on sessionAlive(). It is true ONLY while the // session is logged in AND a successful signed /check landed within the stale // window, measured on the MONOTONIC steady_clock. A heartbeat thread that is // suspended/frozen cannot refresh the timestamp, so once real time passes the // stale window this returns false and stays false; any kick clears loggedIn_ // so it is also false after a kick. // ---------------------------------------------------------------------------- bool Client::sessionAlive() const { std::lock_guard lk(mtx_); // Require, in order: a locally-authenticated session, a started heartbeat, // and the SERVER-authoritative authed flag from the most recent VERIFIED // /check. serverAuthed_ is set ONLY from a cryptographically-verified // payload and reset on any kick/failure/stop, so a patched client-side // loggedIn_ alone can NOT drive this true. if (!loggedIn_ || !hbStarted_ || !serverAuthed_) return false; // AND real (monotonic) seconds since the last good verified /check must be // within the stale window - a frozen heartbeat cannot keep this true. const auto elapsed = std::chrono::duration_cast( std::chrono::steady_clock::now() - lastGoodCheck_).count(); return elapsed <= staleWindowSecondsLocked(); } int Client::secondsSinceHeartbeat() const { std::lock_guard lk(mtx_); if (!hbStarted_) return std::numeric_limits::max(); const auto elapsed = std::chrono::duration_cast( std::chrono::steady_clock::now() - lastGoodCheck_).count(); if (elapsed < 0) return 0; if (elapsed > static_cast(std::numeric_limits::max())) return std::numeric_limits::max(); return static_cast(elapsed); } // ---------------------------------------------------------------------------- // Secret channel: authenticated decryption v2 (see atlasauth.h for the model). // Format (byte-identical to the C# AtlasCrypto helper + dashboard tool): // blob = base64( 0x02 || iv[16] || AES-256-CBC(plaintext, PKCS7) || HMAC-SHA256[32] ) // HKDF-SHA256 (RFC 5869) over the master, bound to app id + variable name: // salt = SHA256("atlasauth-hkdf-salt-v2") // PRK = HMAC-SHA256(salt, master) // context = LP("atlasauth") || 0x02 || LP(app_id) || LP(var_name) (LP = uint32-BE len prefix) // OKM = HKDF-Expand(PRK, LP("enc+mac") || context, 64) -> encKey || macKey // mac = HMAC-SHA256(macKey, 0x02 || context || iv || ct) [encrypt-then-MAC] // FAIL-CLOSED: any bad key / malformed blob / MAC failure -> std::nullopt. // ---------------------------------------------------------------------------- // Length-prefix: uint32 big-endian length || bytes (anti-canonicalization). static void scAppendLp(std::vector& out, const unsigned char* p, size_t n) { out.push_back(static_cast((n >> 24) & 0xFF)); out.push_back(static_cast((n >> 16) & 0xFF)); out.push_back(static_cast((n >> 8) & 0xFF)); out.push_back(static_cast(n & 0xFF)); out.insert(out.end(), p, p + n); } static void scAppendLp(std::vector& out, const std::string& s) { scAppendLp(out, reinterpret_cast(s.data()), s.size()); } // context = LP("atlasauth") || 0x02 || LP(app_id) || LP(var_name) static std::vector scContext(const std::string& appId, const std::string& varName) { std::vector c; scAppendLp(c, std::string("atlasauth")); c.push_back(0x02); scAppendLp(c, appId); scAppendLp(c, varName); return c; } static std::array scHmac(const unsigned char* key, size_t keyLen, const unsigned char* msg, size_t msgLen) { std::array out{}; unsigned int outLen = 0; HMAC(EVP_sha256(), key, static_cast(keyLen), msg, msgLen, out.data(), &outLen); return out; } // HKDF-Expand (RFC 5869) to exactly 64 bytes (two SHA-256 blocks). static std::array scHkdfExpand64(const std::array& prk, const std::vector& info) { std::vector in1(info.begin(), info.end()); in1.push_back(0x01); std::array t1 = scHmac(prk.data(), prk.size(), in1.data(), in1.size()); std::vector in2(t1.begin(), t1.end()); in2.insert(in2.end(), info.begin(), info.end()); in2.push_back(0x02); std::array t2 = scHmac(prk.data(), prk.size(), in2.data(), in2.size()); std::array okm{}; std::copy(t1.begin(), t1.end(), okm.begin()); std::copy(t2.begin(), t2.end(), okm.begin() + 32); // Scrub the temporaries that hold derived key bytes (in2 contains T1 == encKey). OPENSSL_cleanse(in1.data(), in1.size()); OPENSSL_cleanse(in2.data(), in2.size()); OPENSSL_cleanse(t1.data(), t1.size()); OPENSSL_cleanse(t2.data(), t2.size()); return okm; } std::optional> decryptBytes(const std::string& blobBase64, const std::string& masterKeyBase64, const std::string& appId, const std::string& varName) { std::vector master; if (!base64Decode(masterKeyBase64, master)) return std::nullopt; if (master.size() != 32) { OPENSSL_cleanse(master.data(), master.size()); return std::nullopt; } std::vector blob; if (!base64Decode(blobBase64, blob)) { OPENSSL_cleanse(master.data(), master.size()); return std::nullopt; } const size_t ivLen = 16, macLen = 32; // version(1) + iv(16) + at least one AES block(16) + mac(32); version must be 0x02. if (blob.size() < 1 + ivLen + 16 + macLen || blob[0] != 0x02) { OPENSSL_cleanse(master.data(), master.size()); return std::nullopt; } // HKDF-SHA256 key derivation, bound to (app_id, var_name). std::array salt = sha256(std::string("atlasauth-hkdf-salt-v2")); std::array prk = scHmac(salt.data(), salt.size(), master.data(), master.size()); OPENSSL_cleanse(master.data(), master.size()); std::vector ctx = scContext(appId, varName); std::vector info; scAppendLp(info, std::string("enc+mac")); info.insert(info.end(), ctx.begin(), ctx.end()); std::array okm = scHkdfExpand64(prk, info); OPENSSL_cleanse(prk.data(), prk.size()); std::array encKey{}, macKey{}; std::copy(okm.begin(), okm.begin() + 32, encKey.begin()); std::copy(okm.begin() + 32, okm.end(), macKey.begin()); OPENSSL_cleanse(okm.data(), okm.size()); const unsigned char* iv = blob.data() + 1; const unsigned char* ct = blob.data() + 1 + ivLen; const size_t ctLen = blob.size() - 1 - ivLen - macLen; const unsigned char* mac = blob.data() + blob.size() - macLen; if (ctLen == 0 || (ctLen % 16) != 0) { OPENSSL_cleanse(encKey.data(), encKey.size()); OPENSSL_cleanse(macKey.data(), macKey.size()); return std::nullopt; } // Recompute HMAC-SHA256(macKey, 0x02 || context || iv || ct). std::vector macIn; macIn.reserve(1 + ctx.size() + ivLen + ctLen); macIn.push_back(0x02); macIn.insert(macIn.end(), ctx.begin(), ctx.end()); macIn.insert(macIn.end(), iv, iv + ivLen); macIn.insert(macIn.end(), ct, ct + ctLen); std::array expected = scHmac(macKey.data(), macKey.size(), macIn.data(), macIn.size()); OPENSSL_cleanse(macKey.data(), macKey.size()); // CONSTANT-TIME compare BEFORE any decryption (encrypt-then-MAC). if (CRYPTO_memcmp(expected.data(), mac, macLen) != 0) { OPENSSL_cleanse(encKey.data(), encKey.size()); return std::nullopt; } // AES-256-CBC decrypt (PKCS7 padding, enabled by default). EVP_CIPHER_CTX* c = EVP_CIPHER_CTX_new(); if (!c) { OPENSSL_cleanse(encKey.data(), encKey.size()); return std::nullopt; } std::vector out(ctLen + EVP_MAX_BLOCK_LENGTH); int outl1 = 0, outl2 = 0; bool ok = EVP_DecryptInit_ex(c, EVP_aes_256_cbc(), nullptr, encKey.data(), iv) == 1 && EVP_DecryptUpdate(c, out.data(), &outl1, ct, static_cast(ctLen)) == 1 && EVP_DecryptFinal_ex(c, out.data() + outl1, &outl2) == 1; EVP_CIPHER_CTX_free(c); OPENSSL_cleanse(encKey.data(), encKey.size()); if (!ok) return std::nullopt; // wrong key / bad padding => fail closed out.resize(static_cast(outl1) + static_cast(outl2)); return out; } std::optional decryptString(const std::string& blobBase64, const std::string& masterKeyBase64, const std::string& appId, const std::string& varName) { auto bytes = decryptBytes(blobBase64, masterKeyBase64, appId, varName); if (!bytes) return std::nullopt; std::string s(bytes->begin(), bytes->end()); // Scrub the intermediate plaintext copy (the returned string itself cannot be // scrubbed - prefer decryptSecretBytes for the most sensitive values). OPENSSL_cleanse(bytes->data(), bytes->size()); return s; } // ---------------------------------------------------------------------------- // Secret channel: Level 2 (poison-on-invalid) + tamper hooks. // ---------------------------------------------------------------------------- // Deterministic, nonzero garbage derived from the name (stable per name, but // unrelated to the real value the attacker never received). Internal linkage. static std::vector atlasPoison(const std::string& name, size_t len) { std::vector out; out.reserve(len); int ctr = 0; while (out.size() < len) { auto h = sha256("atlasauth-poison|" + name + "|" + std::to_string(ctr++)); for (size_t i = 0; i < h.size() && out.size() < len; ++i) out.push_back(h[i]); } return out; } void Client::addIntegrityCheck(std::function isSuspicious) { std::lock_guard lk(mtx_); if (isSuspicious) integrityChecks_.push_back(std::move(isSuspicious)); } bool Client::compromised_() const { // Copy out under the lock, then call predicates WITHOUT holding it (a // predicate may call back into the client). A throwing predicate is ignored. std::vector> checks; { std::lock_guard lk(mtx_); checks = integrityChecks_; } for (auto& c : checks) { try { if (c && c()) return true; } catch (...) { /* ignore */ } } return false; } std::vector Client::secretBytes(const std::string& name) { std::optional v = var(name); if (v && !compromised_()) { // base64Decode is now STRICT/canonical (matches C# Convert.FromBase64String), // so a non-canonical value simply fails and falls through to poison. std::vector b; if (base64Decode(*v, b)) return b; } return atlasPoison(name, 32); } std::string Client::secretString(const std::string& name) { std::optional v = var(name); if (v && !compromised_()) return *v; auto p = atlasPoison(name, 24); return base64Encode(p.data(), p.size()); } long long Client::secretLong(const std::string& name) { std::optional v = var(name); if (v && !compromised_()) { try { size_t consumed = 0; long long n = std::stoll(*v, &consumed); if (consumed == v->size()) return n; } catch (...) { /* fall through to poison */ } } auto p = atlasPoison(name, 8); // Accumulate in UNSIGNED to avoid signed left-shift UB ([expr.shift]), then // reinterpret - equivalent to a fixed-endianness fold of the poison bytes. unsigned long long u = 0; for (int i = 0; i < 8; ++i) u = (u << 8) | static_cast(p[i]); long long g = static_cast(u); if (g == 0) g = static_cast(0x9E3779B97F4A7C15LL); // never 0 return g; } std::optional Client::decryptSecret(const std::string& keyVarName, const std::string& blobBase64) { std::optional master = var(keyVarName); if (!master) return std::nullopt; return decryptString(blobBase64, *master, app_id_, keyVarName); } std::optional> Client::decryptSecretBytes(const std::string& keyVarName, const std::string& blobBase64) { std::optional master = var(keyVarName); if (!master) return std::nullopt; return decryptBytes(blobBase64, *master, app_id_, keyVarName); } // ---------------------------------------------------------------------------- // Process control / tamper helpers. // ---------------------------------------------------------------------------- void hardExit(int exitCode) { #ifdef _WIN32 TerminateProcess(GetCurrentProcess(), static_cast(exitCode)); # if defined(_MSC_VER) __fastfail(FAST_FAIL_FATAL_APP_EXIT); # endif ExitProcess(static_cast(exitCode)); // fallback if the above returned #else std::_Exit(exitCode); #endif } bool debuggerPresent() { #ifdef _WIN32 if (IsDebuggerPresent()) return true; BOOL present = FALSE; if (CheckRemoteDebuggerPresent(GetCurrentProcess(), &present) && present) return true; #endif return false; } } // namespace atlas