// ============================================================================ // AtlasAuth - Official C++ SDK (C++17) // Header: atlasauth.h // ---------------------------------------------------------------------------- // Implements the frozen AtlasAuth wire contract (v1). See CONTRACT.md. // // Dependencies: libcurl (HTTPS transport) and OpenSSL (ECDSA verify, SHA-256, // base64, RAND). No other third-party libraries are required: the JSON // parser/serializer used by the SDK is self-contained (see atlasauth.cpp). // // =========================================================================== // SECURITY MODEL (read before modifying anything in this SDK) // =========================================================================== // // Every security-relevant server response is SIGNED with the app's ECDSA // P-256 / SHA-256 PRIVATE key. The SDK embeds only the app's PUBLIC key // (SPKI DER, base64) and the app_id (a UUID). There is NO shared secret in // the client; a stolen SDK cannot forge server responses. // // Signed envelope (HTTP 200 body): // { "payload": "", "sig": "" } // // Verification procedure (MUST happen before trusting any field): // 1. Read the envelope's "payload" member as an OPAQUE string. Because it // lives inside JSON, its value is JSON-ESCAPED on the wire. We recover // the ORIGINAL signed bytes by JSON-UNESCAPING that string value ONLY. // We do NOT re-serialize a parsed object - re-serialization would change // byte order / spacing / escaping and break the signature. // 2. base64-decode "sig" -> exactly 64 bytes (IEEE P1363 r||s). // 3. Convert P1363 r||s -> DER ECDSA_SIG, then EVP_DigestVerify with // EVP_sha256 over the EXACT UTF-8 payload bytes from step 1. // 4. Only if the signature is valid do we JSON-parse the payload bytes for // field access. // 5. Require payload.nonce == the fresh random nonce this request sent. // Mismatch => replay/tamper => abort. // 6. Require abs(localUnix - payload.t) <= 60 seconds of skew. This is a // HARD failure: a response outside the window is rejected and not // trusted (protects against replayed/stale signed responses). // // A fresh cryptographically-random nonce (16 bytes, hex) is generated for // every request and held until that request's response is verified. // // Any non-verifiable response (bad signature, missing envelope, transport // error, nonce mismatch) is treated as a FAILED / HOSTILE call: the relevant // method sets lastError() and returns false/empty. // // TLS is enforced on every request (peer + host verification, HTTPS only). // =========================================================================== // ============================================================================ #ifndef ATLASAUTH_H #define ATLASAUTH_H #include #include #include #include #include #include #include #include #include #include #include namespace atlas { // ---------------------------------------------------------------------------- // Public, UNSIGNED, informational types (CONTRACT.md section 7). // These come from plain GET endpoints that carry NO signature/nonce - they are // display/monitoring data only. NEVER gate execution on them; the signed // /check heartbeat remains the sole enforcement path. // ---------------------------------------------------------------------------- // GET /status/{app_id} → snapshot of an app's public status. struct StatusInfo { std::string appId; // echoed app_id std::string name; // app display name std::string status; // "active" | "maintenance" | "disabled" std::string statusMessage; // text shown to users when not active int online; // active sessions in the last 5 minutes }; // One published news/changelog entry from GET /news/{app_id}. struct NewsItem { std::string id; std::string title; std::string body; bool pinned; long long createdAt; // unix seconds }; // Maximum tolerated clock skew (seconds) between the local clock and the // server-signed timestamp `payload.t`. Enforced as a HARD failure: a verified // payload whose `t` is further than this from the local clock is rejected. constexpr long API_MAX_SKEW_SECONDS = 60; // Base URL for all v1 endpoints (POST JSON). constexpr const char* API_BASE_URL = "https://atlasauth.cc/api/v1"; // ---------------------------------------------------------------------------- // Client - one instance per running application session. // // Lifecycle (per CONTRACT.md section 2): // Client c(APP_ID, PUBLIC_KEY); // c.init(); // required first // c.login(user, pass) | c.register_(...) | c.licenseLogin(key); // c.startHeartbeat(onKicked); // background check() loop // ... app runs ... // c.stopHeartbeat(); // c.logout(); // // Thread-safety: all shared/mutable state is guarded by an internal mutex so // the heartbeat thread and the calling thread can coexist safely. The public // getters take the lock; you may call them from any thread. // ---------------------------------------------------------------------------- class Client { public: // app_id: the app's UUID (paste into the example constant). // base64SpkiPubKey: the app's PUBLIC key, SPKI DER, base64 (standard). Client(std::string app_id, std::string base64SpkiPubKey); ~Client(); Client(const Client&) = delete; Client& operator=(const Client&) = delete; // Optional: set a reproducible HWID seed. When set, the SDK reports // base64( SHA256("atlasauth-hwid-seed|" + seed) ) // instead of the hashed hardware fingerprint. Set BEFORE init/login. void setHwidSeed(std::string seed); // Optional: override the client app version reported to /init (for the // server's forced-version check). Defaults to empty (no version sent). void setVersion(std::string version); // Optional (advanced): pin the server's TLS public key(s). When set, libcurl // rejects any certificate whose SPKI SHA-256 is not in this list, defeating // a local proxy (Fiddler/Charles/Burp/mitmproxy) that installs its own // trusted CA to read/rewrite traffic. Format is libcurl's: // "sha256//BASE64PIN1=;sha256//BASE64PIN2=" // Ship TWO pins (a primary and a backup) so a key rotation does not brick // clients. Empty (the default) disables pinning. NOTE: response signing // already blocks a forged/emulated server without the private key; pinning // additionally stops a rogue-CA proxy from READING the traffic. Only use // this if you control the cert key lifecycle - a mismatched pin locks users // out until they update. Set BEFORE init(). Thread-safe. void setPinnedPublicKeys(std::string sha256Pins); // ---- API --------------------------------------------------------------- // POST /init. Required before anything else. Returns false (and sets // lastError) if the app is not "active", a forced version mismatches, or // the response cannot be verified. bool init(); // POST /login (username + password). Returns true only on signed ok:true. bool login(const std::string& user, const std::string& pass); // POST /register (self-signup with a license). `email` is optional. // NOTE: trailing underscore avoids the C++ keyword `register`. bool register_(const std::string& user, const std::string& pass, const std::string& license, const std::string& email = ""); // POST /license (license-key-only login, no username/password). bool licenseLogin(const std::string& key); // POST /var. Returns the variable value on signed ok:true && found:true, // otherwise std::nullopt (auth-required / not found / verify failure). std::optional var(const std::string& name); // ---- Public unsigned informational endpoints (CONTRACT.md §7) ---------- // These are plain HTTPS GETs with NO signature and NO nonce. They are // informational ONLY (status pages, launchers, uptime monitors); never // gate execution on them - enforcement rides on the signed /check beat. // TLS verification is still enforced. May be called without init(). // GET /status/{app_id}. Returns the app's public status snapshot, or // std::nullopt on unknown app / transport / parse failure (sets lastError). std::optional status(); // GET /news/{app_id}. Returns published news (pinned-first, newest-first), // or an empty vector on unknown app / transport / parse failure (sets // lastError). An app with no news also yields an empty vector. std::vector news(); // POST /log. Best-effort; never throws, never blocks meaningfully. // level must be "info" | "warn" | "error". void log(const std::string& level, const std::string& message); // POST /logout. Best-effort; clears local auth state regardless. void logout(); // ---- Heartbeat --------------------------------------------------------- // Spawn a background thread that calls /check every `heartbeat` seconds // (value learned from /init; defaults to 10). If a beat indicates the // session is no longer valid (ok==false || valid==false || // app_status!="active" || key_valid==false || banned==true), onKicked is // invoked once with the reason string and the loop stops. // No-op if already running or not authenticated. // // ANTI-TAMPER: this is also a security heartbeat. Each beat measures the // REAL wall-clock time elapsed since the previous beat using a MONOTONIC // clock (std::chrono::steady_clock) that keeps advancing even while the // process/thread is suspended by a debugger, SIGSTOP, VM pause, or a // "freeze the heartbeat thread" cheat. If that elapsed time exceeds the // STALE WINDOW = max(heartbeatSeconds*3, heartbeatSeconds+20) seconds, the // thread was frozen then resumed: onKicked is invoked with reason // "heartbeat stalled", the session is marked not-alive, and the loop stops. void startHeartbeat(std::function onKicked); // Stop and join the heartbeat thread. Safe to call multiple times. void stopHeartbeat(); // ---- Anti-tamper liveness (thread-safe) -------------------------------- // DEVELOPERS: gate every sensitive operation (feature unlock, decrypt, // network action, etc.) on sessionAlive(). It returns true ONLY while the // session is logged in AND the heartbeat has produced a successful, signed // /check within the stale window (measured on a MONOTONIC clock, so a // suspended/frozen heartbeat thread cannot keep it true). A heartbeat that // is frozen forever, or any kick, drives this to false and keeps it false, // so a tampered app stops working instead of running unchecked. // true iff loggedIn() AND the SERVER-authoritative authed flag from the // MOST RECENT VERIFIED /check is true AND real seconds since that last good // verified /check are within the stale window. The server `authed` bool is // read only from a cryptographically-verified payload, so a patched // client-side logged-in flag alone can NOT force this true. Any kick, // verify/transport failure, or stop drives it false and keeps it false // until a fresh verified authed==true beat lands. bool sessionAlive() const; // Real (monotonic) seconds elapsed since the last successful verified // /check (or since the heartbeat started, before the first beat). Returns a // large sentinel if the heartbeat has never been started. int secondsSinceHeartbeat() const; // ---- Getters (thread-safe) -------------------------------------------- std::string username() const; // authenticated username (if any) // Account expiry as unix seconds. nullopt = lifetime / not set. std::optional expiryUnix() const; std::string appStatus() const; // "active" | "maintenance" | "disabled" std::string lastError() const; // human-readable last failure bool loggedIn() const; // session authenticated & not kicked // Single /check round-trip. Public so callers can poll manually if they // prefer not to use startHeartbeat. Returns true if the session is still // fully valid; sets `reason` (out) and lastError on failure. bool check(std::string& reason); // ---- Secret channel: Level 2 (poison-on-invalid) + tamper hooks -------- // Register a predicate that returns true when the environment looks hostile // (debugger, failed integrity check). When ANY registered predicate is true, // the secret* helpers below return POISON instead of the real value, so a // tripped check makes your data wrong (breaks later, far from the check) // rather than calling a findable exit. HONEST SCOPE: the STRONG guarantee is // session validity (without a live valid session the server never sends the // value, so no local patch conjures it); these checks are a SECONDARY layer - // the branch consulting them can itself be patched by someone who owns the // binary, so they buy attacker-hours, not immunity. Opt-in; off by default. // Thread-safe. See atlas::debuggerPresent(). void addIntegrityCheck(std::function isSuspicious); // Fetch a secret variable over the secret channel, or return deterministic // POISON if the session is invalid (no live authed HWID-bound session) or an // integrity check trips. These NEVER signal failure via a bool/throw - you // just USE the result, and a cracked/tampered client silently gets garbage // that breaks downstream, far from any check. // secretBytes : stored value must be base64; returns raw bytes (or poison) // secretString: returns the value (or a poison string) // secretLong : parses a 64-bit int (or a nonzero poison number) std::vector secretBytes(const std::string& name); std::string secretString(const std::string& name); long long secretLong(const std::string& name); // Decrypt a v2 blob you shipped inside your app. Fetches the 32-byte master // key stored under the secret variable `keyVarName` over the signed HWID-bound // channel, and binds this app id + variable name into the decryption, so a blob // opens only for a live valid session in the right app/slot. std::nullopt // otherwise. Encrypt the matching blob with the dashboard "Encrypt a value" // tool (same variable name). std::optional decryptSecret(const std::string& keyVarName, const std::string& blobBase64); std::optional> decryptSecretBytes(const std::string& keyVarName, const std::string& blobBase64); private: // --- networking --- // Performs one POST of `bodyJson` to `endpoint` (e.g. "/login"). // On success, fills `outVerifiedPayloadFields` with the parsed fields of // the VERIFIED signed payload and returns true. On any verification or // transport failure returns false and sets lastError (under lock). // `sentNonce` is the nonce that was placed in the request body. bool postSigned(const std::string& endpoint, const std::string& bodyJson, const std::string& sentNonce, std::map& outVerifiedPayloadFields, const std::string& expectedSid = std::string()); // Verify a signed envelope string. On success unescapes + parses the // payload into `outFields` and returns true. Checks nonce == sentNonce, // advisory skew, AND v2 identity binding: v==2, app_id == this app, aud == // expectedAud (the endpoint), and (when expectedSid is non-empty) sid == // expectedSid (this session). Sets lastError on failure. bool verifyEnvelope(const std::string& envelopeJson, const std::string& sentNonce, const std::string& expectedAud, const std::string& expectedSid, std::map& outFields); // Plain HTTPS GET of `path` (e.g. "/status/") for the UNSIGNED // informational endpoints. Returns the body and HTTP status; no signature // is involved. TLS peer+host verification is enforced exactly as for POST. // Returns false (and sets lastError) on a transport error. bool httpGet(const std::string& path, std::string& response, long& httpCode); // Compute the HWID string per the contract (seed override or hw hash). std::string computeHwid() const; void setError(const std::string& e); // lock-free; caller holds lock void setErrorLocked(const std::string& e); // takes the lock // Stale window (seconds) after which a gap between beats means the thread // was frozen, and after which sessionAlive() flips false. Computed from the // learned heartbeat interval: max(hb*3, hb+20). Caller must hold mtx_. int staleWindowSecondsLocked() const; // True if ANY registered integrity predicate reports hostile (drives secret* // poison). A predicate that throws is treated as NOT hostile (avoids FPs). bool compromised_() const; // --- immutable after construction --- const std::string app_id_; const std::string pubKeyB64_; // SPKI DER, base64 // --- guarded state --- mutable std::mutex mtx_; std::string hwidSeed_; // empty => hardware fingerprint std::string version_; // empty => not sent std::string pinnedKeys_; // empty => TLS pinning off (see setter) std::vector> integrityChecks_; // secret* poison triggers std::string session_; // opaque server session token std::string username_; std::string appStatus_ = "unknown"; std::string statusMessage_; std::string lastError_; std::optional expiry_; // nullopt = lifetime / unknown int heartbeatSeconds_ = 10; bool loggedIn_ = false; bool inited_ = false; // --- anti-tamper liveness (guarded by mtx_) --- // Monotonic timestamp of the last SUCCESSFUL, verified /check. Seeded when // the heartbeat STARTS and refreshed on every good beat. Only meaningful // while `hbStarted_` is true. NOTE: seeding this alone no longer makes the // session alive - sessionAlive() also requires serverAuthed_ (set only from // a verified authed==true /check payload), so liveness begins at the first // good verified beat, not at login. std::chrono::steady_clock::time_point lastGoodCheck_{}; // Whether the heartbeat has ever been started (i.e. lastGoodCheck_ is valid). bool hbStarted_ = false; // SERVER-AUTHORITATIVE login state from the most recent VERIFIED /check // payload ("authed" bool). Set ONLY from a verified payload; reset to false // on any kick / verify failure / transport failure / stop. sessionAlive() // requires this to be true, so a patched client-side loggedIn_ cannot make // the session appear alive without the server signing authed==true. bool serverAuthed_ = false; // --- heartbeat thread --- std::thread hbThread_; std::atomic hbRunning_{false}; std::atomic hbStop_{false}; }; // ---------------------------------------------------------------------------- // Secret channel: authenticated decryption of developer resources. // ---------------------------------------------------------------------------- // // THE MODEL. Ship the strings/config/URLs/asset-bytes your app genuinely needs // ENCRYPTED inside your binary, and keep the 32-byte master key server-side as a // SECRET app variable. At runtime decrypt with Client::decryptSecret() (which // fetches the key over the signed, HWID-bound channel and binds the app + variable // name for you). A patched / emulated / bypassed client never receives a genuine // key, so the decrypt FAILS (returns std::nullopt) - there is no "isValid" boolean // to flip, because the bytes that make your app work do not exist until a real // signed session delivers the key. Gate by CONSUMING the plaintext. // // FORMAT v2 (byte-identical to the C# AtlasCrypto helper and the dashboard // "Encrypt a value" tool): // blob = base64( 0x02 || iv[16] || AES-256-CBC(plaintext, PKCS7) || HMAC-SHA256[32] ) // Keys come from HKDF-SHA256 (RFC 5869) over the master, bound to the app id and // variable name (LP = 4-byte big-endian length prefix): // salt = SHA256("atlasauth-hkdf-salt-v2") // PRK = HMAC-SHA256(salt, master) // context = LP("atlasauth") || 0x02 || LP(app_id) || LP(var_name) // OKM = HKDF-Expand(PRK, LP("enc+mac") || context, 64) -> encKey || macKey // The MAC covers 0x02 || context || iv || ct and is verified in CONSTANT TIME // BEFORE any decryption runs, so a tampered ciphertext never reaches the cipher. // // FAIL-CLOSED: any bad key, malformed blob, or authentication failure yields // std::nullopt - never partial or garbage plaintext. // Decrypt a v2 blob to raw bytes; std::nullopt on any failure. `appId`/`varName` // must match what the blob was encrypted for (they are bound into the keys + MAC). // Prefer Client::decryptSecretBytes, which supplies the key + this app id. std::optional> decryptBytes(const std::string& blobBase64, const std::string& masterKeyBase64, const std::string& appId, const std::string& varName); // Decrypt a v2 blob to a UTF-8 string; std::nullopt on any failure. std::optional decryptString(const std::string& blobBase64, const std::string& masterKeyBase64, const std::string& appId, const std::string& varName); // ---------------------------------------------------------------------------- // Process control / tamper helpers (optional). // ---------------------------------------------------------------------------- // Hard-kill the current process abruptly - a LOUD LAST RESORT, not protection. // Windows: TerminateProcess + __fastfail (no DLL detach, no atexit, uncatchable); // POSIX: std::_Exit. HONEST SCOPE: the CALL is still one patchable instruction, so // do NOT gate protection on `if(!valid) hardExit()`. Use secret*/decrypt poison as // the real defense and fire this only as a redundant, non-obvious consequence of // already-corrupted state. Does not return. void hardExit(int exitCode); // Optional low-false-positive debugger check to register via addIntegrityCheck(). // A determined attacker who owns the machine can defeat it; feed it into the // poison path, never a bool. Returns false off-Windows so it never false-trips. bool debuggerPresent(); } // namespace atlas #endif // ATLASAUTH_H