// ============================================================================= // AtlasAuth - Official C# SDK (single file, dependency-free) // Wire contract: AtlasAuth API & Signing Contract v1 (CONTRACT.md) // // Compatible with .NET Framework 4.7 / 4.8 / 4.8.1 and .NET 5/6/8+. // No external dependencies (BCL only; no NuGet, no System.Text.Json). // ============================================================================= // // SECURITY MODEL (read this before touching anything) // --------------------------------------------------------------------------- // Every security-relevant server response is SIGNED by the app's ECDSA P-256 // private key (which never leaves the server). This SDK embeds ONLY the app's // PUBLIC key and verifies every signed response before trusting a single byte // of it. A fake or man-in-the-middle server therefore cannot forge a valid // answer (e.g. "your license is valid" / "app_status active") without the // private key. // // The signed envelope returned by the server (HTTP 200) is exactly: // // { "payload": "", "sig": "" } // // Verification procedure (implemented in VerifyAndParse below): // 1. Read `payload` as an OPAQUE UTF-8 string and JSON-UNESCAPE it to recover // the EXACT bytes the server signed. NEVER re-serialize it - the signature // is computed over those exact bytes, so any round-trip through a JSON // serializer (key reordering, whitespace, number formatting) would break // verification. // 2. base64-decode `sig` -> exactly 64 bytes (IEEE P1363 r||s, NOT DER). // 3. ECDSA-P256-SHA256 verify(sig, utf8(payload), embeddedPublicKey). // If invalid -> treat the whole call as hostile and ABORT (throw). Do not // parse the payload. // 4. Only after a good signature: JSON-parse the payload string. // 5. Require payload.nonce == the fresh random nonce THIS request sent // (constant-time compare). Mismatch -> replay/tamper -> abort. // 6. HARD-FAIL: reject if abs(localUnix - payload.t) > 60s clock skew. // // A signed `ok:false` (wrong password, expired key, ...) is still a SIGNED // TRUTH: it is surfaced via LastError/LastCode, not treated as an attack. // Only UNVERIFIABLE responses (bad signature, nonce mismatch, transport-level // unsigned {error,code}) are treated as failures. // // CRYPTO NOTES / PORTABILITY (SINGLE PATH - no #if, compiles everywhere) // --------------------------------------------------------------------------- // * Public key import: we do NOT use ImportSubjectPublicKeyInfo (modern-only, // absent on Framework). Instead we parse the raw uncompressed EC point out of // the SPKI DER (the last 65 bytes: 0x04 || X[32] || Y[32] for P-256), build // an ECParameters { Curve = nistP256, Q = { X, Y } }, and call // ECDsa.Create() + ImportParameters(...). ImportParameters + ECCurve / // ECParameters / ECPoint exist on .NET Framework 4.7+ and modern .NET. // * Signature verification: we use the 3-arg overload // ecdsa.VerifyData(payloadBytes, sig64, HashAlgorithmName.SHA256). On BOTH // Framework and modern .NET this overload consumes a raw IEEE P1363 r||s // signature (exactly the server's 64-byte format). We deliberately do NOT // use the DSASignatureFormat overload (modern-only; will not compile on // Framework). // * Nonce: RandomNumberGenerator.Create() + rng.GetBytes(byte[]) (the static // RandomNumberGenerator.GetBytes(int) is net6+ only and is avoided). // * JSON: a tiny hand-rolled reader/writer for FLAT objects (values are // string/number/bool/null) plus a JSON string unescaper - System.Text.Json // is not present on Framework. // // DEPENDENCIES: only the BCL - // System.Security.Cryptography, System.Net (HttpWebRequest), System.IO, // System.Text, Microsoft.Win32 (registry, Windows-only HWID). No // System.Net.Http, no System.Management / WMI. No third-party packages. // // USAGE: fill in APP_ID and PUBLIC_KEY below (or pass them to the constructor), // then: Init() -> Login()/Register()/License() -> StartHeartbeat(...) -> Logout(). // ============================================================================= using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Net; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Win32; namespace AtlasAuth { // ========================================================================= // Exceptions // ========================================================================= /// /// Thrown for any AtlasAuth failure the caller should treat as fatal/hostile: /// signature verification failure, nonce mismatch, an unverifiable/transport /// error, or a malformed envelope. A signed ok:false response is NOT /// an exception - it is surfaced via / /// and the method's boolean result. /// public sealed class AtlasAuthException : Exception { /// Machine-readable code when one is available (else null). public string Code { get; private set; } /// Create an exception with a human message. public AtlasAuthException(string message) : base(message) { } /// Create an exception with a human message and machine code. public AtlasAuthException(string message, string code) : base(message) { Code = code; } /// Create an exception wrapping an inner cause. public AtlasAuthException(string message, Exception inner) : base(message, inner) { } } // ========================================================================= // Minimal JSON support (flat objects; values string/number/bool/null) // ========================================================================= /// /// A tiny JSON parser producing a generic object tree. Supports the full /// grammar for reading (objects, arrays, strings, numbers, bool, null) which /// is all we need to read envelopes, payloads and the unsigned informational /// endpoints. Values are represented as: /// object -> Dictionary<string, object> /// array -> List<object> /// string -> string /// number -> double (with the original literal preserved via JsonNumber) /// true/false -> bool /// null -> null /// This is intentionally small and dependency-free (no System.Text.Json). /// internal static class Json { // -- Writer: serialize a flat object (string/number/bool/null values) ---- public static string SerializeObject(IDictionary obj) { var sb = new StringBuilder(); sb.Append('{'); bool first = true; foreach (var kv in obj) { if (kv.Value == null) continue; // omit nulls (matches prior behavior) if (!first) sb.Append(','); first = false; WriteString(sb, kv.Key); sb.Append(':'); WriteValue(sb, kv.Value); } sb.Append('}'); return sb.ToString(); } private static void WriteValue(StringBuilder sb, object value) { if (value == null) { sb.Append("null"); return; } if (value is string s) { WriteString(sb, s); return; } if (value is bool b) { sb.Append(b ? "true" : "false"); return; } if (value is int || value is long || value is short || value is byte) { sb.Append(Convert.ToInt64(value).ToString(CultureInfo.InvariantCulture)); return; } if (value is double || value is float || value is decimal) { sb.Append(Convert.ToDouble(value, CultureInfo.InvariantCulture) .ToString("R", CultureInfo.InvariantCulture)); return; } // Fallback: stringify anything else. WriteString(sb, Convert.ToString(value, CultureInfo.InvariantCulture)); } public static void WriteString(StringBuilder sb, string s) { sb.Append('"'); for (int i = 0; i < s.Length; i++) { char c = s[i]; switch (c) { case '"': sb.Append("\\\""); break; case '\\': sb.Append("\\\\"); break; case '\b': sb.Append("\\b"); break; case '\f': sb.Append("\\f"); break; case '\n': sb.Append("\\n"); break; case '\r': sb.Append("\\r"); break; case '\t': sb.Append("\\t"); break; default: if (c < 0x20) sb.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); else sb.Append(c); break; } } sb.Append('"'); } // -- Reader -------------------------------------------------------------- /// Parse a JSON document, returning the root value. Throws JsonParseException on error. public static object Parse(string text) { var p = new Parser(text); p.SkipWhitespace(); object value = p.ParseValue(); p.SkipWhitespace(); if (!p.AtEnd) throw new JsonParseException("Trailing content after JSON value."); return value; } /// /// Extract the RAW (already JSON-unescaped) value of a top-level string /// property from a JSON object text, WITHOUT re-serializing. Returns true /// and sets to the unescaped UTF-16 string if the /// named property exists and is a string. This is used to recover the exact /// bytes the server signed (Encoding.UTF8.GetBytes(value)). /// public static bool TryGetTopLevelString(string objectText, string name, out string value) { value = null; try { object root = Parse(objectText); var dict = root as Dictionary; if (dict == null) return false; object v; if (!dict.TryGetValue(name, out v)) return false; var sv = v as string; if (sv == null) return false; value = sv; return true; } catch (JsonParseException) { return false; } } private sealed class Parser { private readonly string _s; private int _i; public Parser(string s) { _s = s ?? ""; _i = 0; } public bool AtEnd { get { return _i >= _s.Length; } } public void SkipWhitespace() { while (_i < _s.Length) { char c = _s[_i]; if (c == ' ' || c == '\t' || c == '\n' || c == '\r') _i++; else break; } } public object ParseValue() { SkipWhitespace(); if (_i >= _s.Length) throw new JsonParseException("Unexpected end of input."); char c = _s[_i]; switch (c) { case '{': return ParseObject(); case '[': return ParseArray(); case '"': return ParseString(); case 't': return ParseLiteral("true", true); case 'f': return ParseLiteral("false", false); case 'n': return ParseLiteral("null", null); default: if (c == '-' || (c >= '0' && c <= '9')) return ParseNumber(); throw new JsonParseException("Unexpected character '" + c + "'."); } } private object ParseLiteral(string lit, object value) { if (_i + lit.Length > _s.Length || _s.Substring(_i, lit.Length) != lit) throw new JsonParseException("Invalid literal."); _i += lit.Length; return value; } private Dictionary ParseObject() { var dict = new Dictionary(StringComparer.Ordinal); _i++; // '{' SkipWhitespace(); if (_i < _s.Length && _s[_i] == '}') { _i++; return dict; } while (true) { SkipWhitespace(); if (_i >= _s.Length || _s[_i] != '"') throw new JsonParseException("Expected object key."); string key = ParseString(); SkipWhitespace(); if (_i >= _s.Length || _s[_i] != ':') throw new JsonParseException("Expected ':' in object."); _i++; // ':' object val = ParseValue(); dict[key] = val; SkipWhitespace(); if (_i >= _s.Length) throw new JsonParseException("Unterminated object."); char c = _s[_i]; if (c == ',') { _i++; continue; } if (c == '}') { _i++; break; } throw new JsonParseException("Expected ',' or '}' in object."); } return dict; } private List ParseArray() { var list = new List(); _i++; // '[' SkipWhitespace(); if (_i < _s.Length && _s[_i] == ']') { _i++; return list; } while (true) { object val = ParseValue(); list.Add(val); SkipWhitespace(); if (_i >= _s.Length) throw new JsonParseException("Unterminated array."); char c = _s[_i]; if (c == ',') { _i++; continue; } if (c == ']') { _i++; break; } throw new JsonParseException("Expected ',' or ']' in array."); } return list; } private string ParseString() { var sb = new StringBuilder(); _i++; // opening quote while (_i < _s.Length) { char c = _s[_i++]; if (c == '"') return sb.ToString(); if (c == '\\') { if (_i >= _s.Length) break; char e = _s[_i++]; switch (e) { case '"': sb.Append('"'); break; case '\\': sb.Append('\\'); break; case '/': sb.Append('/'); break; case 'b': sb.Append('\b'); break; case 'f': sb.Append('\f'); break; case 'n': sb.Append('\n'); break; case 'r': sb.Append('\r'); break; case 't': sb.Append('\t'); break; case 'u': if (_i + 4 > _s.Length) throw new JsonParseException("Bad \\u escape."); string hex = _s.Substring(_i, 4); int code; if (!int.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out code)) throw new JsonParseException("Bad \\u escape."); _i += 4; sb.Append((char)code); break; default: throw new JsonParseException("Bad escape '\\" + e + "'."); } } else { sb.Append(c); } } throw new JsonParseException("Unterminated string."); } private object ParseNumber() { int start = _i; if (_i < _s.Length && _s[_i] == '-') _i++; while (_i < _s.Length) { char c = _s[_i]; if ((c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-') _i++; else break; } string lit = _s.Substring(start, _i - start); return new JsonNumber(lit); } } } /// Thrown by the internal JSON reader on malformed input. internal sealed class JsonParseException : Exception { public JsonParseException(string message) : base(message) { } } /// /// Wraps a JSON numeric literal so both integer and double interpretations are /// available exactly (the raw literal is preserved). /// internal sealed class JsonNumber { public readonly string Literal; public JsonNumber(string literal) { Literal = literal; } public bool TryGetInt64(out long value) { return long.TryParse(Literal, NumberStyles.Integer, CultureInfo.InvariantCulture, out value); } public bool TryGetInt32(out int value) { return int.TryParse(Literal, NumberStyles.Integer, CultureInfo.InvariantCulture, out value); } } // ========================================================================= // AtlasClient // ========================================================================= /// /// Official AtlasAuth client. One instance == one session. Not designed for /// concurrent calls from multiple threads other than the internal heartbeat /// loop, which is mutually exclusive with foreground calls via an internal /// gate. Create it, , authenticate, then /// . /// public sealed class AtlasClient : IDisposable { // ===================================================================== // region CONSTANTS - FILL THESE IN (or pass to the constructor) // ===================================================================== /// /// Your application id (a UUID from the AtlasAuth dashboard). NOT secret. /// You may hard-code it here, or pass it to the constructor (constructor /// argument wins when non-empty). /// public const string APP_ID = "00000000-0000-0000-0000-000000000000"; /// /// Your app's PUBLIC key, SPKI DER encoded then base64 (NOT PEM, no /// "-----BEGIN" header - just the base64 body). Copy it from the /// dashboard. This is a PUBLIC key: shipping it in your binary is safe /// and required. NEVER paste a private key here. /// public const string PUBLIC_KEY = "REPLACE_WITH_BASE64_SPKI_PUBLIC_KEY"; // endregion // ===================================================================== // region Wire / transport configuration // ===================================================================== private const string BaseUrl = "https://atlasauth.cc/api/v1"; private const int ApiMaxSkewSeconds = 60; // clock-skew tolerance (hard-fail beyond this) private const int RequestTimeoutSeconds = 20; // per-request HTTP timeout private const int EnvelopeVersion = 2; // expected payload.v (v2 = identity-bound) private const string UserAgentString = "AtlasAuth-CSharp-SDK/1.0"; // TLS: negotiate a modern protocol on old .NET Framework, whose default // ServicePointManager.SecurityProtocol may still be SSL3/TLS1.0. Modern // .NET ignores this (the OS picks the protocol) and it is harmless there. // We only ADD Tls12/Tls11; we never clear existing/newer choices. static AtlasClient() { try { ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11; } catch { /* enum value unavailable on very old frameworks; ignore */ } } // Single BCL-only HTTP transport built on HttpWebRequest so the SDK needs // NO System.Net.Http assembly reference on .NET Framework. Per-request // timeout is applied via HttpWebRequest.Timeout/ReadWriteTimeout PLUS an // explicit watchdog (HttpWebRequest.Timeout is ignored on the async // BeginGetResponse path), and the supplied CancellationToken aborts the // request. // // Returns the FULL response body as a string. On an HTTP 4xx/5xx that // still carries a body (an unsigned { error, code }) the body is READ and // RETURNED so the caller can parse it - matching the previous transport, // which read the body regardless of status code. On a transport failure // with no response (timeout / network / abort) the exception is rethrown // so the caller treats the call as failed/hostile. private static async Task HttpSendAsync(string method, string url, string jsonBodyOrNull, int timeoutSeconds, CancellationToken ct) { var req = (HttpWebRequest)WebRequest.Create(url); req.Method = method; req.Accept = "application/json"; req.UserAgent = UserAgentString; int timeoutMs = timeoutSeconds * 1000; req.Timeout = timeoutMs; req.ReadWriteTimeout = timeoutMs; // HttpWebRequest.Timeout does not fire on the async BeginGetResponse // path, so drive it ourselves: a watchdog aborts the request on // timeout. We remember WHY we aborted so a resulting abort-WebException // can be reported as Timeout / RequestCanceled to match old behavior. bool timedOut = false; using (var timeoutCts = new CancellationTokenSource()) using (ct.Register(() => { try { req.Abort(); } catch { } })) using (timeoutCts.Token.Register(() => { timedOut = true; try { req.Abort(); } catch { } })) { timeoutCts.CancelAfter(timeoutMs); try { if (jsonBodyOrNull != null) { req.ContentType = "application/json"; byte[] payload = Encoding.UTF8.GetBytes(jsonBodyOrNull); req.ContentLength = payload.Length; using (var reqStream = await Task.Factory .FromAsync(req.BeginGetRequestStream, req.EndGetRequestStream, null) .ConfigureAwait(false)) { await reqStream.WriteAsync(payload, 0, payload.Length, ct).ConfigureAwait(false); } } using (var resp = (HttpWebResponse)await Task.Factory .FromAsync(req.BeginGetResponse, req.EndGetResponse, null) .ConfigureAwait(false)) using (var respStream = resp.GetResponseStream()) using (var reader = new StreamReader(respStream, Encoding.UTF8)) { return await reader.ReadToEndAsync().ConfigureAwait(false); } } catch (WebException wex) { // A 4xx/5xx surfaces here with a response attached: read and // return that body (the caller parses unsigned {error,code}). var httpResp = wex.Response as HttpWebResponse; if (httpResp != null) { using (httpResp) using (var respStream = httpResp.GetResponseStream()) using (var reader = new StreamReader(respStream, Encoding.UTF8)) { return await reader.ReadToEndAsync().ConfigureAwait(false); } } // No response body. If OUR watchdog aborted, surface a Timeout // WebException; the caller distinguishes timeout vs. cancel. if (timedOut && !ct.IsCancellationRequested) throw new WebException("Request to " + url + " timed out.", wex, WebExceptionStatus.Timeout, null); // Otherwise (network error, or caller-requested abort) let the // original exception propagate so the caller treats it as failed. throw; } } } // endregion // ===================================================================== // region Instance state // ===================================================================== private readonly string _appId; private readonly ECParameters _ecParameters; private readonly string _hwidSeed; private string _session; private string _cachedHwid; // Heartbeat machinery. private CancellationTokenSource _heartbeatCts; private Task _heartbeatTask; // Anti-tamper heartbeat state. // // MONOTONIC CLOCK: Stopwatch is backed by the OS high-resolution counter // (QueryPerformanceCounter on Windows) which keeps advancing while the // process/thread is SUSPENDED - unlike a plain Task.Delay, which does not // "notice" wall-clock time that elapsed while it was frozen. We use it to // measure REAL elapsed time between beats and to age the last-good check, // so a heartbeat thread that was paused (debugger, SIGSTOP, VM freeze) and // later resumed is detected. Ticks read from this single shared field are // comparable across the whole client. private static readonly System.Diagnostics.Stopwatch MonotonicClock = System.Diagnostics.Stopwatch.StartNew(); // Monotonic tick of the last SUCCESSFUL, signature-verified /check (or of // heartbeat start, so the session is alive immediately after login). // 0 means "heartbeat not started" => not alive. Read/written as a long so // it is torn-read-safe on 64-bit and updated atomically enough for a // single writer (the heartbeat loop) + many readers (foreground gates). private long _lastGoodCheckTicks; // True while the heartbeat loop considers the session live. Set true when // the heartbeat STARTS; forced false after ANY kick/stall/verify failure. private volatile bool _heartbeatAlive; // AUDIT: server-authoritative login state. Set ONLY from the "authed" bool // of the MOST RECENT signature-VERIFIED /check payload. Reset to false on // ANY kick / stall / verify failure / transport failure / stop, and never // set true from client-side state. SessionAlive requires this to be true, // so a patched client-side LoggedIn boolean cannot forge a live session. private volatile bool _serverAuthed; // Serializes foreground calls against the heartbeat loop so the session // token / properties are never mutated concurrently. private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1); // endregion // ===================================================================== // region Public properties // ===================================================================== /// Authenticated username (after a successful login/register), else null. public string Username { get; private set; } /// /// Subscription/key expiry. null means lifetime (the contract uses /// a null unix expiry for lifetime keys) OR not yet known. Use /// to distinguish "authed + lifetime" from "unknown". /// public DateTimeOffset? Expiry { get; private set; } /// Last observed app status: "active" | "maintenance" | "disabled" (or null pre-init). public string AppStatus { get; private set; } /// Human-readable last error message (from a signed ok:false or transport failure). public string LastError { get; private set; } /// Machine-readable last error code (e.g. "invalid_credentials", "rate_limited"). public string LastCode { get; private set; } /// True once has succeeded and a session token is held. public bool Initialized { get { return _session != null; } } /// True after a successful login / register / license activation. public bool LoggedIn { get; private set; } /// Server-dictated heartbeat interval in seconds (from /init). Default 10. public int HeartbeatSeconds { get; private set; } /// App display name reported by /init, if any. public string AppName { get; private set; } /// Status message to show the user when the app is not active. public string StatusMessage { get; private set; } /// Latest app version advertised by /init, if any. public string LatestVersion { get; private set; } /// Remaining seconds on the subscription if the server reports it, else null. public long? RemainingSeconds { get; private set; } /// User level from /login, if reported. public int? Level { get; private set; } /// /// The anti-tamper stale window in seconds: /// max(HeartbeatSeconds*3, HeartbeatSeconds+20). If more REAL /// (monotonic) wall-clock time than this elapses without a successful, /// signature-verified /check, the heartbeat is considered stalled/frozen /// and the session is no longer alive. /// public int StaleWindowSeconds { get { int hb = Math.Max(1, HeartbeatSeconds); int a = hb * 3; int b = hb + 20; return a > b ? a : b; } } /// /// REAL (monotonic-clock) seconds elapsed since the last successful, /// signature-verified heartbeat check - or since the heartbeat started if /// no check has completed yet. Returns when the /// heartbeat has never been started. The monotonic clock keeps advancing /// while the process/thread is suspended, so a frozen heartbeat thread /// makes this value grow without bound. /// public int SecondsSinceHeartbeat { get { long anchor = Interlocked.Read(ref _lastGoodCheckTicks); if (anchor == 0) return int.MaxValue; // heartbeat never started long elapsedTicks = MonotonicClock.ElapsedTicks - anchor; if (elapsedTicks < 0) elapsedTicks = 0; double seconds = (double)elapsedTicks / System.Diagnostics.Stopwatch.Frequency; if (seconds >= int.MaxValue) return int.MaxValue; return (int)seconds; } } /// /// The anti-tamper "session alive" accessor. True only when the SERVER's /// authoritative authed flag from the MOST RECENT signature-verified /// /check is true, the client is , the /// heartbeat is considered alive (no kick / stall / verify / transport /// failure has occurred), AND the REAL seconds since the last good verified /// check are within . /// /// AUDIT: the server-signed authed gate means a PATCHED client-side /// boolean can NOT force this true - only a fresh, /// signature-verified /check whose payload says authed:true /// can. Any kick, stall, verify failure, transport failure, or stop resets /// it to false. /// /// /// DEVELOPERS: GATE SENSITIVE WORK ON THIS PROPERTY. Check it immediately /// before performing any protected/licensed operation (decrypting content, /// enabling a paid feature, etc.). Because it is driven by a MONOTONIC /// clock, a heartbeat thread that an attacker suspends - even forever - /// cannot keep this true: once the stale window passes with no verified /// check, this goes false and the protected code stops working. Do NOT /// cache its value; read it fresh at each gate. /// /// public bool SessionAlive { get { if (!_serverAuthed) return false; // server-authoritative gate (audit) if (!LoggedIn) return false; if (!_heartbeatAlive) return false; return SecondsSinceHeartbeat <= StaleWindowSeconds; } } // endregion // ===================================================================== // region Construction // ===================================================================== /// /// Create a client. Pass your and your base64 /// SPKI public key. If you leave the constants / /// filled in, you may pass null/empty here and /// the constants are used instead. /// /// App UUID, or null to use the constant. /// Base64 SPKI DER public key, or null to use . /// /// Optional secret seed. When non-null/empty the HWID becomes /// base64(SHA256("atlasauth-hwid-seed|" + seed)) - reproducible on /// any machine that knows the seed (lets the legitimate owner move their /// own key) and unguessable to others. When null, a machine-derived HWID /// is used (see ). /// /// If the public key can't be decoded/imported. public AtlasClient(string appId = null, string spkiPublicKeyBase64 = null, string hwidSeed = null) { HeartbeatSeconds = 10; _appId = !string.IsNullOrWhiteSpace(appId) ? appId : APP_ID; string spki = !string.IsNullOrWhiteSpace(spkiPublicKeyBase64) ? spkiPublicKeyBase64 : PUBLIC_KEY; _hwidSeed = string.IsNullOrEmpty(hwidSeed) ? null : hwidSeed; if (string.IsNullOrWhiteSpace(_appId) || (_appId == APP_ID && APP_ID.StartsWith("00000000", StringComparison.Ordinal))) throw new AtlasAuthException("APP_ID is not configured - set the APP_ID constant or pass it to the constructor."); if (string.IsNullOrWhiteSpace(spki) || spki == "REPLACE_WITH_BASE64_SPKI_PUBLIC_KEY") throw new AtlasAuthException("PUBLIC_KEY is not configured - set the PUBLIC_KEY constant or pass it to the constructor."); byte[] spkiBytes; try { spkiBytes = Convert.FromBase64String(spki); } catch (FormatException ex) { throw new AtlasAuthException("PUBLIC_KEY is not valid base64.", ex); } // Parse the SPKI DER into ECParameters (see remarks in the header). // Fail fast: make sure the key actually imports as an EC public key. _ecParameters = ParseSpkiToEcParameters(spkiBytes); try { using (var probe = ECDsa.Create()) { probe.ImportParameters(_ecParameters); } } catch (Exception ex) { throw new AtlasAuthException("PUBLIC_KEY is not a valid SPKI DER ECDSA public key.", ex); } } /// /// Extract the P-256 public point from an SPKI DER blob and build /// . For P-256 from this server the SPKI is /// exactly 91 bytes and the uncompressed EC point (0x04 || X[32] || Y[32]) /// is the LAST 65 bytes. This avoids ImportSubjectPublicKeyInfo, which is /// modern-.NET-only and absent on .NET Framework. /// private static ECParameters ParseSpkiToEcParameters(byte[] spki) { if (spki == null || spki.Length != 91) throw new AtlasAuthException("PUBLIC_KEY is not a valid P-256 SPKI (expected 91 bytes)."); // Uncompressed point is the last 65 bytes. byte[] point = new byte[65]; Array.Copy(spki, spki.Length - 65, point, 0, 65); if (point[0] != 0x04) throw new AtlasAuthException("PUBLIC_KEY does not contain an uncompressed EC point (0x04 prefix)."); byte[] x = new byte[32]; byte[] y = new byte[32]; Array.Copy(point, 1, x, 0, 32); Array.Copy(point, 33, y, 0, 32); var p = new ECParameters { Curve = ECCurve.NamedCurves.nistP256, Q = new ECPoint { X = x, Y = y } }; return p; } // endregion // ===================================================================== // region Public API - session lifecycle // ===================================================================== /// /// POST /init. Establishes a session and reads app status / heartbeat /// interval. MUST be called before any other endpoint. /// /// Optional client app version string (server may force-update). /// Optional cancellation token. /// True if the session was established AND the app is "active". /// On verify failure / nonce mismatch / transport error. public async Task Init(string clientVersion = null, CancellationToken ct = default(CancellationToken)) { await _gate.WaitAsync(ct).ConfigureAwait(false); try { var req = new Dictionary(); req["version"] = clientVersion; var p = await SendSignedAsync("/init", req, false, ct).ConfigureAwait(false); _session = GetString(p, "session"); AppName = GetString(p, "app_name"); AppStatus = GetString(p, "app_status"); StatusMessage = GetString(p, "status_message"); LatestVersion = GetString(p, "latest_version"); int hb; if (TryGetInt(p, "heartbeat", out hb) && hb > 0) HeartbeatSeconds = hb; bool ok = GetBool(p, "ok"); bool versionOk = !IsFalse(p, "version_ok"); if (string.IsNullOrEmpty(_session)) throw new AtlasAuthException("init did not return a session token."); if (!ok) { CaptureError(p); return false; } if (!versionOk) { LastCode = "version_mismatch"; LastError = StatusMessage ?? ("A newer version (" + LatestVersion + ") is required."); return false; } if (!string.Equals(AppStatus, "active", StringComparison.Ordinal)) { LastCode = AppStatus; // "maintenance" | "disabled" LastError = StatusMessage ?? ("App is " + AppStatus + "."); return false; } LastError = null; LastCode = null; return true; } finally { _gate.Release(); } } /// /// POST /register. Self-signup with a license key. Requires first. /// /// Desired username. /// Desired password (sent over TLS; never stored by the SDK). /// License key to consume. /// Optional email. /// Optional cancellation token. /// True on a signed ok:true; false on a signed ok:false (see LastCode). public async Task Register(string username, string password, string license, string email = null, CancellationToken ct = default(CancellationToken)) { RequireInitialized(); await _gate.WaitAsync(ct).ConfigureAwait(false); try { var req = new Dictionary(); req["username"] = username; req["password"] = password; req["license"] = license; req["hwid"] = ComputeHwid(); if (!string.IsNullOrEmpty(email)) req["email"] = email; var p = await SendSignedAsync("/register", req, true, ct).ConfigureAwait(false); return HandleAuthResult(p, username); } finally { _gate.Release(); } } /// /// POST /login. Username + password. Requires first. /// /// True on a signed ok:true; false on a signed ok:false (see LastCode). public async Task Login(string username, string password, CancellationToken ct = default(CancellationToken)) { RequireInitialized(); await _gate.WaitAsync(ct).ConfigureAwait(false); try { var req = new Dictionary(); req["username"] = username; req["password"] = password; req["hwid"] = ComputeHwid(); var p = await SendSignedAsync("/login", req, true, ct).ConfigureAwait(false); bool ok = HandleAuthResult(p, username); if (ok) { int lvl; if (TryGetInt(p, "level", out lvl)) Level = lvl; } return ok; } finally { _gate.Release(); } } /// /// POST /license. License-key-only login (no username/password). Requires first. /// /// True on a signed ok:true; false on a signed ok:false (see LastCode). public async Task License(string key, CancellationToken ct = default(CancellationToken)) { RequireInitialized(); await _gate.WaitAsync(ct).ConfigureAwait(false); try { var req = new Dictionary(); req["license"] = key; req["hwid"] = ComputeHwid(); var p = await SendSignedAsync("/license", req, true, ct).ConfigureAwait(false); bool ok = HandleAuthResult(p, null); int lvl; if (ok && TryGetInt(p, "level", out lvl)) Level = lvl; return ok; } finally { _gate.Release(); } } /// /// POST /check - a single heartbeat. Usually you do not call this directly; /// use . Returns the parsed signed payload. /// /// This does NOT take the gate; callers that need exclusivity must hold it. private async Task CheckOnceAsync(CancellationToken ct) { RequireInitialized(); // AUDIT: bind this heartbeat to the device server-side by sending the // SDK's computed HWID - the SAME value sent on login/register/license. var req = new Dictionary(); req["hwid"] = ComputeHwid(); var p = await SendSignedAsync("/check", req, true, ct).ConfigureAwait(false); bool ok = GetBool(p, "ok"); bool valid = !IsFalse(p, "valid"); string appStatus = GetString(p, "app_status"); bool keyValid = !IsFalse(p, "key_valid"); bool banned = IsTrue(p, "banned"); string reason = GetString(p, "reason"); // AUDIT: server-authoritative login state from the VERIFIED payload. bool authed = IsTrue(p, "authed"); if (appStatus != null) AppStatus = appStatus; if (appStatus != null) StatusMessage = GetString(p, "status_message"); UpdateExpiryFrom(p); // NOTE: !authed 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 BELIEVE we are logged in but the // server revoked auth. `authed` still drives SessionAlive. bool kicked = !ok || !valid || (appStatus != null && !string.Equals(appStatus, "active", StringComparison.Ordinal)) || !keyValid || banned || (LoggedIn && !authed); if (kicked) { LastCode = string.IsNullOrEmpty(reason) ? ((LoggedIn && !authed) ? "not_authed" : DeriveKickCode(valid, appStatus, keyValid, banned)) : reason; LastError = StatusMessage ?? reason ?? "Session is no longer valid."; } return new CheckResult(!kicked, LastCode ?? reason ?? "", authed); } /// /// POST /var. Fetch an app variable by name. Returns its string value, or /// null if not found / not permitted (LastCode is set, e.g. "auth_required"). /// public async Task Var(string name, CancellationToken ct = default(CancellationToken)) { RequireInitialized(); await _gate.WaitAsync(ct).ConfigureAwait(false); try { var req = new Dictionary(); req["name"] = name; // Bind /var to this device: the server requires the SAME HWID we send // on login/check, or it refuses to hand over the (secret) value. req["hwid"] = ComputeHwid(); var p = await SendSignedAsync("/var", req, true, ct).ConfigureAwait(false); if (!GetBool(p, "ok")) { CaptureError(p); return null; } bool found = IsTrue(p, "found"); if (!found) { LastCode = "not_found"; LastError = "Variable '" + name + "' not found."; return null; } LastError = null; LastCode = null; return GetString(p, "value"); } finally { _gate.Release(); } } /// /// POST /log. Best-effort client-side logging. Never throws on a signed /// response; transport failures are swallowed (logging must not break the app). /// /// "info" | "warn" | "error". /// Free-text message. public async Task Log(string level, string message, CancellationToken ct = default(CancellationToken)) { if (_session == null) return; // session is optional per contract, but we need app_id+nonce; skip if not init'd await _gate.WaitAsync(ct).ConfigureAwait(false); try { string lvl = (level == "info" || level == "warn" || level == "error") ? level : "info"; var req = new Dictionary(); req["level"] = lvl; req["message"] = message; try { await SendSignedAsync("/log", req, true, ct).ConfigureAwait(false); } catch (AtlasAuthException) { // best-effort: never let logging break the client } catch (OperationCanceledException) { } } finally { _gate.Release(); } } /// /// POST /logout. Ends the session server-side and clears local auth state. /// Stops the heartbeat first. Safe to call multiple times. /// public async Task Logout(CancellationToken ct = default(CancellationToken)) { StopHeartbeat(); await _gate.WaitAsync(ct).ConfigureAwait(false); try { if (_session != null) { try { await SendSignedAsync("/logout", new Dictionary(), true, ct).ConfigureAwait(false); } catch (AtlasAuthException) { /* logout is best-effort */ } catch (OperationCanceledException) { } } _session = null; LoggedIn = false; Username = null; Expiry = null; RemainingSeconds = null; Level = null; } finally { _gate.Release(); } } // endregion // ===================================================================== // region Public pingable endpoints (UNSIGNED, informational) - CONTRACT §7 // ===================================================================== /// /// Public app status snapshot from GET /status/{app_id}. /// /// UNSIGNED / INFORMATIONAL ONLY. Unlike every other response this SDK /// handles, this is a plain GET with NO nonce and NO signature /// verification - it is display/monitoring data, not a security boundary. /// NEVER gate execution on it; enforcement still rides on the signed /// /check heartbeat. /// /// public sealed class StatusInfo { /// The app id this status describes. public string AppId; /// App display name. public string Name; /// "active" | "maintenance" | "disabled". public string Status; /// Status message to show users when the app is not active. public string StatusMessage; /// Active sessions in the last 5 minutes. public int Online; } /// /// A single published news item from GET /news/{app_id}. /// UNSIGNED / INFORMATIONAL ONLY (see ). /// public sealed class NewsItem { /// Opaque item id. public string Id; /// Headline. public string Title; /// Body text. public string Body; /// True when the item is pinned to the top. public bool Pinned; /// Creation time (unix seconds). public long CreatedAt; } /// /// GET /status/{app_id} - public, UNSIGNED, informational app status. /// /// This is a plain GET: there is NO nonce and NO signature verification /// (the data is for display/monitoring only - security still rides on the /// signed //check path). Does not take /// the gate and never throws: returns null on a 404 / error / /// malformed body and records /. /// /// /// Optional cancellation token. /// A on success, or null on 404/error. public async Task Status(CancellationToken ct = default(CancellationToken)) { var root = await GetUnsignedAsync("/status/" + _appId, ct).ConfigureAwait(false); if (root == null) return null; if (!GetBool(root, "ok")) { LastCode = "unknown_app"; LastError = GetString(root, "error") ?? "Unknown app."; return null; } int online; TryGetInt(root, "online", out online); LastError = null; LastCode = null; return new StatusInfo { AppId = GetString(root, "app_id"), Name = GetString(root, "name"), Status = GetString(root, "status"), StatusMessage = GetString(root, "status_message"), Online = online, }; } /// /// GET /news/{app_id} - public, UNSIGNED, informational news feed. /// /// Plain GET with NO nonce and NO signature verification (display data /// only; never gate execution on it). Does not take the gate and never /// throws: returns an EMPTY list on a 404 / error / malformed body and /// records /. Items arrive /// pinned-first then newest. /// /// /// Optional cancellation token. /// The parsed news items, or an empty list on 404/error. public async Task> News(CancellationToken ct = default(CancellationToken)) { var result = new List(); var root = await GetUnsignedAsync("/news/" + _appId, ct).ConfigureAwait(false); if (root == null) return result; if (!GetBool(root, "ok")) { LastCode = "unknown_app"; LastError = GetString(root, "error") ?? "Unknown app."; return result; } object arrObj; if (root.TryGetValue("news", out arrObj)) { var arr = arrObj as List; if (arr != null) { foreach (var itemObj in arr) { var item = itemObj as Dictionary; if (item == null) continue; long created = 0; TryGetLong(item, "created_at", out created); result.Add(new NewsItem { Id = GetString(item, "id"), Title = GetString(item, "title"), Body = GetString(item, "body"), Pinned = GetBool(item, "pinned"), CreatedAt = created, }); } } } LastError = null; LastCode = null; return result; } /// /// Shared transport for the UNSIGNED informational GETs ( /// / ). Uses the same HttpWebRequest transport and the /// same per-request timeout as the signed path, but performs NO signature /// verification - these endpoints are not security boundaries. Returns the /// parsed root object, or null on any transport/parse failure (with /// / set); never throws. /// private async Task> GetUnsignedAsync(string path, CancellationToken ct) { string text; try { // 404 carries { ok:false, error:"unknown_app" }; HttpSendAsync // returns that body so the caller can read it. text = await HttpSendAsync("GET", BaseUrl + path, null, RequestTimeoutSeconds, ct).ConfigureAwait(false); } catch (OperationCanceledException) { return null; // caller-requested cancellation } catch (WebException wex) when (wex.Status == WebExceptionStatus.Timeout && !ct.IsCancellationRequested) { LastError = "Request to " + path + " timed out."; LastCode = "timeout"; return null; } catch (WebException wex) when (wex.Status == WebExceptionStatus.RequestCanceled && ct.IsCancellationRequested) { return null; // caller-requested cancellation (abort) } catch (WebException ex) { LastError = "Network error calling " + path + ": " + ex.Message; LastCode = "network_error"; return null; } try { object root = Json.Parse(text); return root as Dictionary; } catch (JsonParseException) { LastError = "Malformed response from " + path + "."; LastCode = "bad_response"; return null; } } // endregion // ===================================================================== // region Heartbeat // ===================================================================== /// /// Start the background ANTI-TAMPER heartbeat loop. Every /// seconds it calls /check; the moment a /// signed reply says the session is no longer valid (ok/valid false, /// app_status != active, key_valid false, or banned true) it invokes /// with the reason and stops. It also stops /// (and reports) if a signature can't be verified - an unverifiable /// heartbeat is treated as hostile. /// /// SECURITY (anti-suspend): the loop measures REAL elapsed wall-clock time /// between beats with a MONOTONIC clock (, /// which keeps advancing while the process/thread is suspended). If more /// than of real time passed since the /// previous beat, the thread was frozen then resumed: it kicks with reason /// "heartbeat stalled", marks the session not-alive, and STOPS the loop. It /// also tracks the time of the last successful verified check so /// goes false if the thread is frozen FOREVER. /// /// /// DEVELOPERS: gate sensitive/protected work on /// (read fresh at each gate). That is what makes a frozen heartbeat thread /// actually stop the app from working, rather than relying on the kick /// callback (which a suspended thread can never deliver). /// /// /// /// Callback invoked exactly once when the user is kicked, a heartbeat fails /// verification, or the heartbeat is detected as stalled/frozen. Receives a /// reason string. Invoked on a background thread - marshal to the UI thread /// yourself if needed. The library never writes to the console. /// public void StartHeartbeat(Action onKicked) { if (onKicked == null) throw new ArgumentNullException("onKicked"); RequireInitialized(); if (_heartbeatTask != null && !_heartbeatTask.IsCompleted) return; // already running _heartbeatCts = new CancellationTokenSource(); var token = _heartbeatCts.Token; int intervalMs = Math.Max(1, HeartbeatSeconds) * 1000; // (b) Anchor "last good check" to NOW so SessionAlive is true right // after login, and mark the heartbeat alive. This is set BEFORE the // loop starts so there is no window where the session reads not-alive. Interlocked.Exchange(ref _lastGoodCheckTicks, MonotonicClock.ElapsedTicks); _heartbeatAlive = true; // AUDIT: the immediately-post-login /login|/register|/license response // already proved authed:true; carry that forward so SessionAlive is // true right after login. It will be re-confirmed (or dropped) by the // first signature-verified /check and reset to false on any failure. _serverAuthed = true; _heartbeatTask = Task.Run(async () => { try { // (a) Monotonic tick of the PREVIOUS beat, used to measure real // elapsed wall-clock time across each Delay. Seeded to "now". long prevBeatTicks = MonotonicClock.ElapsedTicks; // AUDIT (fail-closed): count consecutive heartbeat FAILURES // (transport or verify). After 2 in a row, kick with // "connection lost" and stop. Reset to 0 on every good check. int consecutiveFailures = 0; while (!token.IsCancellationRequested) { try { await Task.Delay(intervalMs, token).ConfigureAwait(false); } catch (OperationCanceledException) { break; } if (token.IsCancellationRequested) break; // (a) STALL DETECTION: how much REAL time actually elapsed // across the Delay above? Task.Delay is unreliable when the // thread/process was suspended (it under-counts), but the // monotonic Stopwatch kept ticking through the freeze. If // the real gap exceeds the stale window the thread was // frozen then resumed -> treat as tamper. long nowTicks = MonotonicClock.ElapsedTicks; double realElapsedSeconds = (double)(nowTicks - prevBeatTicks) / System.Diagnostics.Stopwatch.Frequency; prevBeatTicks = nowTicks; if (realElapsedSeconds > StaleWindowSeconds) { _heartbeatAlive = false; // SessionAlive now false _serverAuthed = false; // AUDIT: drop server-authed gate LoggedIn = false; LastCode = "stale"; LastError = "heartbeat stalled"; SafeInvoke(onKicked, "heartbeat stalled"); return; // STOP the loop } CheckResult result; try { // Hold the gate so a heartbeat never races a foreground call. await _gate.WaitAsync(token).ConfigureAwait(false); try { result = await CheckOnceAsync(token).ConfigureAwait(false); } finally { _gate.Release(); } } catch (OperationCanceledException) { break; } catch (AtlasAuthException ex) { // AUDIT (fail-closed): verify/transport failure surfaced // as an AtlasAuthException. Mark not-alive IMMEDIATELY, // drop the server-authed gate, and DO NOT refresh the // last-good anchor. Count it; kick only after 2 in a row. _heartbeatAlive = false; _serverAuthed = false; LastError = ex.Message; if (LastCode == null) LastCode = "verify_failed"; consecutiveFailures++; if (consecutiveFailures >= 2) { SafeInvoke(onKicked, "connection lost"); return; // STOP the loop } continue; // retry on the next beat } catch (Exception ex) { // AUDIT (fail-closed): transport-level failure (network, // timeout, etc). Mark not-alive IMMEDIATELY, drop the // server-authed gate, and DO NOT refresh the last-good // anchor. Count it; kick only after 2 in a row. _heartbeatAlive = false; _serverAuthed = false; LastError = ex.Message; consecutiveFailures++; if (consecutiveFailures >= 2) { SafeInvoke(onKicked, "connection lost"); return; // STOP the loop } continue; // retry on the next beat } if (!result.StillValid) { _heartbeatAlive = false; // (b) not alive after any kick _serverAuthed = false; // AUDIT: drop server-authed gate LoggedIn = false; SafeInvoke(onKicked, result.Reason); return; } // AUDIT: successful, signature-verified check. Reset the // failure counter, mark alive, record the SERVER's authed // value (the ONLY place _serverAuthed is set true), and // refresh the last-good-check anchor with the monotonic // clock. This is what keeps SessionAlive true while the // heartbeat is genuinely running (and lets it decay to // false if the thread is ever frozen forever). consecutiveFailures = 0; _heartbeatAlive = true; _serverAuthed = result.Authed; Interlocked.Exchange(ref _lastGoodCheckTicks, MonotonicClock.ElapsedTicks); } } catch (Exception ex) { _heartbeatAlive = false; // (b) not alive after any kick _serverAuthed = false; // AUDIT: drop server-authed gate LastError = ex.Message; SafeInvoke(onKicked, "heartbeat_error"); } }, token); } /// Stop the background heartbeat loop if running. Safe to call any time. public void StopHeartbeat() { // The heartbeat is no longer beating, so the session can no longer be // proven alive: force the anti-tamper accessor false. (SessionAlive is // also false via LoggedIn once Logout runs, but StopHeartbeat may be // called on its own.) _heartbeatAlive = false; _serverAuthed = false; // AUDIT: drop server-authed gate on stop try { if (_heartbeatCts != null) _heartbeatCts.Cancel(); } catch { /* ignore */ } if (_heartbeatCts != null) _heartbeatCts.Dispose(); _heartbeatCts = null; _heartbeatTask = null; } private static void SafeInvoke(Action cb, string reason) { try { cb(reason); } catch { /* never let the callback crash the loop owner */ } } private struct CheckResult { public readonly bool StillValid; public readonly string Reason; // AUDIT: server-authoritative "authed" bool from the VERIFIED /check payload. public readonly bool Authed; public CheckResult(bool stillValid, string reason, bool authed) { StillValid = stillValid; Reason = reason; Authed = authed; } } // endregion // ===================================================================== // region Core: send + verify // ===================================================================== /// /// Build the request body (always app_id + fresh nonce, plus session when /// asked), POST it, then verify the signed envelope and return the parsed /// payload as a dictionary. Throws AtlasAuthException on any unverifiable /// outcome. /// private async Task> SendSignedAsync(string path, Dictionary extra, bool includeSession, CancellationToken ct) { string nonce = NewNonce(); var body = new Dictionary(); body["app_id"] = _appId; body["nonce"] = nonce; if (includeSession) { if (_session == null) throw new AtlasAuthException("No session - call Init() first."); body["session"] = _session; } foreach (var kv in extra) if (kv.Value != null) body[kv.Key] = kv.Value; string json = Json.SerializeObject(body); string text; try { // On a 4xx/5xx HttpSendAsync returns the (unsigned) error body; on a // transport failure with no response it throws. Either way we then // run VerifyAndParse, which refuses to trust any body lacking a // valid signed envelope (surfacing {error,code} and throwing). text = await HttpSendAsync("POST", BaseUrl + path, json, RequestTimeoutSeconds, ct).ConfigureAwait(false); } catch (OperationCanceledException) { throw; // caller-requested cancellation } catch (WebException wex) when (wex.Status == WebExceptionStatus.Timeout && !ct.IsCancellationRequested) { throw new AtlasAuthException("Request to " + path + " timed out."); } catch (WebException wex) when (wex.Status == WebExceptionStatus.RequestCanceled && ct.IsCancellationRequested) { throw new OperationCanceledException(ct); // caller-requested cancellation (abort) } catch (WebException ex) { throw new AtlasAuthException("Network error calling " + path + ": " + ex.Message, ex); } // Transport-level (unsigned) error bodies { "error", "code" } and any // response lacking a valid signed envelope are rejected by // VerifyAndParse: it reads {error,code}, sets LastError/LastCode, and // throws. We MUST NOT trust an unsigned body as a signed truth. // // AUDIT (v2 identity binding): also require the signed payload to be // bound to THIS app + THIS endpoint (path) + THIS session, so a valid // response minted for another session/endpoint cannot be relayed here. // The session is only bound when we sent one (includeSession). return VerifyAndParse(text, nonce, path, includeSession ? _session : null); } /// /// The heart of the security model. Given the raw HTTP body and the nonce /// we sent, verify the signature over the EXACT payload bytes, then parse, /// then check the nonce and (advisory) timestamp. Returns the payload /// dictionary on success; throws on any failure. /// private Dictionary VerifyAndParse(string responseText, string expectedNonce, string expectedAud, string expectedSid) { object rootObj; try { rootObj = Json.Parse(responseText); } catch (JsonParseException ex) { throw new AtlasAuthException("Malformed response (not JSON).", ex); } var root = rootObj as Dictionary; // (1) Recover the EXACT signed payload bytes: pull the top-level // "payload" string and JSON-UNESCAPE it (done by the reader), WITHOUT // re-serializing. An unsigned {error,code} with HTTP 200 is still // untrusted. string payloadString = null; string sigString = null; if (root != null) { object pv, sv; if (root.TryGetValue("payload", out pv)) payloadString = pv as string; if (root.TryGetValue("sig", out sv)) sigString = sv as string; } if (payloadString == null || sigString == null) { string err, code; TryReadUnsignedError(responseText, out err, out code); LastError = err ?? "Unsigned/unstructured response - refusing to trust it."; LastCode = code; throw new AtlasAuthException(LastError, LastCode); } byte[] payloadBytes = Encoding.UTF8.GetBytes(payloadString); // (2) base64-decode sig -> 64 bytes (IEEE P1363 r||s). byte[] sig; try { sig = Convert.FromBase64String(sigString); } catch (FormatException ex) { throw new AtlasAuthException("signature verification failed", ex); } if (sig.Length != 64) throw new AtlasAuthException("signature verification failed"); // not a P1363 P-256 sig // (3) ECDSA-P256-SHA256 verify over the exact payload bytes. The 3-arg // overload consumes raw IEEE P1363 r||s on BOTH Framework and modern // .NET, which is exactly the server's 64-byte format. bool verified; try { using (var ecdsa = ECDsa.Create()) { ecdsa.ImportParameters(_ecParameters); verified = ecdsa.VerifyData(payloadBytes, sig, HashAlgorithmName.SHA256); } } catch (Exception ex) { throw new AtlasAuthException("signature verification failed", ex); } if (!verified) throw new AtlasAuthException("signature verification failed"); // (4) Only now parse the payload JSON. object payloadObj; try { payloadObj = Json.Parse(payloadString); } catch (JsonParseException ex) { throw new AtlasAuthException("Verified payload is not valid JSON.", ex); } var payload = payloadObj as Dictionary; if (payload == null) throw new AtlasAuthException("Verified payload is not a JSON object."); // Envelope version: v2 is REQUIRED (v2 binds the payload to app/session/ // endpoint). A missing or older version is rejected outright. int v; if (!TryGetInt(payload, "v", out v) || v != EnvelopeVersion) { throw new AtlasAuthException("Unsupported envelope version (expected " + EnvelopeVersion + ")."); } // (5) nonce MUST equal the one we sent (constant-time compare). string gotNonce = GetString(payload, "nonce"); if (!FixedEquals(gotNonce, expectedNonce)) throw new AtlasAuthException("nonce mismatch - possible replay/tamper"); // (5b) AUDIT - IDENTITY BINDING. Bind the response to THIS app, THIS // endpoint, and (for session calls) THIS session. Without this a valid // signed response for the ATTACKER's own paid session/endpoint could be // relayed to a victim client (a "universal signing oracle"); binding sid // makes such a response reject because it carries the wrong session. if (!FixedEquals(GetString(payload, "app_id"), _appId)) throw new AtlasAuthException("app_id mismatch - response is not for this app"); if (!string.Equals(GetString(payload, "aud"), expectedAud, StringComparison.Ordinal)) throw new AtlasAuthException("audience mismatch - response is for a different endpoint"); if (expectedSid != null && !FixedEquals(GetString(payload, "sid"), expectedSid)) throw new AtlasAuthException("session mismatch - response is not for this session"); // (6) AUDIT: HARD-FAIL clock-skew check. If the local clock and the // signed server timestamp differ by more than the tolerance, the // response is not trustworthy (possible replay / captured response): // reject it outright rather than merely recording it. // Require a numeric `t` and HARD-FAIL a stale/undatable response // (matches the C++ SDK and CONTRACT §freshness). The server always // emits `t`, so a missing/non-numeric one is itself untrustworthy. long serverT; if (!TryGetLong(payload, "t", out serverT)) { LastError = "verified payload missing timestamp 't'"; LastCode = "clock_skew"; throw new AtlasAuthException(LastError, LastCode); } long localT = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); long skew = Math.Abs(localT - serverT); if (skew > ApiMaxSkewSeconds) { LastError = "clock skew " + skew + "s exceeds " + ApiMaxSkewSeconds + "s"; LastCode = "clock_skew"; throw new AtlasAuthException(LastError, LastCode); } return payload; } // endregion // ===================================================================== // region Result handling helpers // ===================================================================== private bool HandleAuthResult(Dictionary p, string usernameIfOk) { bool ok = GetBool(p, "ok"); UpdateExpiryFrom(p); if (ok) { LoggedIn = true; // A verified, ok login/register/license IS a fresh server confirmation // of authed state, so SessionAlive is live immediately (no dead window // until the first /check beat). The heartbeat keeps it fresh after. _serverAuthed = true; Interlocked.Exchange(ref _lastGoodCheckTicks, MonotonicClock.ElapsedTicks); Username = GetString(p, "username") ?? usernameIfOk; LastError = null; LastCode = null; return true; } LoggedIn = false; CaptureError(p); return false; } private void UpdateExpiryFrom(Dictionary p) { object e; if (p.TryGetValue("expiry", out e)) { if (e == null) Expiry = null; // lifetime else { var num = e as JsonNumber; long unix; if (num != null && num.TryGetInt64(out unix)) Expiry = DateTimeOffset.FromUnixTimeSeconds(unix); } } object rs; if (p.TryGetValue("remaining_seconds", out rs)) { if (rs == null) RemainingSeconds = null; else { var num = rs as JsonNumber; long v; if (num != null && num.TryGetInt64(out v)) RemainingSeconds = v; } } } private void CaptureError(Dictionary p) { LastCode = GetString(p, "code"); LastError = GetString(p, "error") ?? (LastCode != null ? ("Request failed: " + LastCode) : "Request failed."); } private static string DeriveKickCode(bool valid, string appStatus, bool keyValid, bool banned) { if (banned) return "banned"; if (!keyValid) return "expired"; if (appStatus != null && !string.Equals(appStatus, "active", StringComparison.Ordinal)) return appStatus == "maintenance" ? "app_maintenance" : "app_disabled"; if (!valid) return "killed"; return "invalid"; } private void RequireInitialized() { if (_session == null) throw new AtlasAuthException("Not initialized - call Init() first."); } // endregion // ===================================================================== // region HWID // ===================================================================== /// /// Compute the opaque HWID for this machine/seed. /// /// If a seed was supplied to the constructor: /// hwid = base64(SHA256("atlasauth-hwid-seed|" + seed)) - reproducible /// wherever the seed is known. /// /// /// Otherwise (default, Windows): hash of stable machine identifiers - /// registry MachineGuid (HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid) /// + Environment.MachineName + ProcessorCount, SHA-256, base64. /// /// /// HOOK FOR A STRONGER HWID: if you want a more tamper-resistant fingerprint /// (e.g. motherboard serial, disk volume serial, MAC), gather those ids /// yourself and feed them through - keep the /// SAME inputs across runs or the user will be locked out. Do NOT pull in /// System.Management/WMI here unless you accept that dependency. /// /// public string ComputeHwid() { if (_cachedHwid != null) return _cachedHwid; if (_hwidSeed != null) { using (var sha = SHA256.Create()) { var hash = sha.ComputeHash(Encoding.UTF8.GetBytes("atlasauth-hwid-seed|" + _hwidSeed)); _cachedHwid = Convert.ToBase64String(hash); return _cachedHwid; } } var parts = new List(); parts.Add(ReadMachineGuid() ?? "no-guid"); parts.Add(SafeMachineName()); parts.Add(Environment.ProcessorCount.ToString(CultureInfo.InvariantCulture)); _cachedHwid = HashIdsToHwid(parts); return _cachedHwid; } /// /// Hash an ordered list of stable identifier strings into the canonical /// HWID form: base64(SHA256(join("|", ids))). Use this from a custom /// stronger-HWID hook so the output format matches the default exactly. /// public static string HashIdsToHwid(IEnumerable ids) { string joined = string.Join("|", ids); using (var sha = SHA256.Create()) { var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(joined)); return Convert.ToBase64String(hash); } } private static string ReadMachineGuid() { // Windows-only stable id. Wrapped in try/catch; on non-Windows or when // the registry is unavailable we fall back to other stable ids. try { using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Cryptography")) { if (key == null) return null; var val = key.GetValue("MachineGuid") as string; return string.IsNullOrEmpty(val) ? null : val; } } catch { return null; // registry blocked / not present } } private static string SafeMachineName() { try { return Environment.MachineName; } catch { return "unknown-host"; } } // endregion // ===================================================================== // region JSON / crypto utilities // ===================================================================== private static string NewNonce() { // >=16 bytes random -> lowercase hex (contract allows hex or base64url). byte[] buf = new byte[16]; using (var rng = RandomNumberGenerator.Create()) { rng.GetBytes(buf); } return ToLowerHex(buf); } private static string ToLowerHex(byte[] bytes) { const string hexChars = "0123456789abcdef"; var sb = new StringBuilder(bytes.Length * 2); for (int i = 0; i < bytes.Length; i++) { byte b = bytes[i]; sb.Append(hexChars[b >> 4]); sb.Append(hexChars[b & 0x0F]); } return sb.ToString(); } private static void TryReadUnsignedError(string text, out string error, out string code) { error = null; code = null; try { var dict = Json.Parse(text) as Dictionary; if (dict == null) return; object e, c; if (dict.TryGetValue("error", out e)) error = e as string; if (dict.TryGetValue("code", out c)) code = c as string; } catch (JsonParseException) { /* leave nulls */ } } // ---- Typed getters over the parsed object tree ------------------------- private static string GetString(Dictionary obj, string name) { object v; if (obj != null && obj.TryGetValue(name, out v)) return v as string; return null; } private static bool GetBool(Dictionary obj, string name) { object v; if (obj != null && obj.TryGetValue(name, out v) && v is bool) return (bool)v; return false; } /// True iff the property exists AND is boolean true. private static bool IsTrue(Dictionary obj, string name) { object v; return obj != null && obj.TryGetValue(name, out v) && (v is bool) && (bool)v; } /// True iff the property exists AND is boolean false. private static bool IsFalse(Dictionary obj, string name) { object v; return obj != null && obj.TryGetValue(name, out v) && (v is bool) && !(bool)v; } private static bool TryGetInt(Dictionary obj, string name, out int value) { value = 0; object v; if (obj != null && obj.TryGetValue(name, out v)) { var num = v as JsonNumber; if (num != null) return num.TryGetInt32(out value); } return false; } private static bool TryGetLong(Dictionary obj, string name, out long value) { value = 0; object v; if (obj != null && obj.TryGetValue(name, out v)) { var num = v as JsonNumber; if (num != null) return num.TryGetInt64(out value); } return false; } /// /// Constant-time comparison for the nonce echo (avoids early-exit timing /// leaks). Hand-rolled so it compiles on Framework (no CryptographicOperations). /// private static bool FixedEquals(string a, string b) { if (a == null || b == null) return false; byte[] ba = Encoding.UTF8.GetBytes(a); byte[] bb = Encoding.UTF8.GetBytes(b); if (ba.Length != bb.Length) return false; // length is not secret for a nonce echo int diff = 0; for (int i = 0; i < ba.Length; i++) diff |= ba[i] ^ bb[i]; return diff == 0; } // endregion // ===================================================================== // region Secret channel - Level 2 (poison-on-invalid) + tamper hooks // ===================================================================== // Developer-registered tamper predicates. Each returns true when it // considers the environment hostile (debugger, failed integrity check, // ...). OFF by default (empty) so nothing trips on a normal build; the // developer opts in with AddIntegrityCheck(...). private readonly List> _integrityChecks = new List>(); /// /// Register a tamper/integrity predicate that returns true when the /// environment looks hostile. When ANY registered predicate returns true, /// the Secret* helpers below return POISON instead of the real value /// (see ) - so a tripped check makes your data wrong /// and the app breaks later, far from the check, rather than calling a /// findable exit. /// /// HONEST SCOPE: the STRONG, near-unpatchable guarantee is session validity /// - without a live valid session the server never sends the value, so no /// amount of local patching conjures it. These integrity checks are a /// SECONDARY layer: the branch that consults them (if (v != null && /// !Compromised())) can itself be patched out by someone who owns the /// binary, so their value is attacker-hours, not immunity. Opt-in; register /// nothing and only session validity drives the poison. /// /// Example: client.AddIntegrityCheck(AtlasAntiTamper.DebuggerPresent); /// public void AddIntegrityCheck(Func isSuspicious) { if (isSuspicious == null) return; lock (_integrityChecks) _integrityChecks.Add(isSuspicious); } // True if ANY registered integrity predicate reports hostile. A predicate // that THROWS is treated as NOT hostile (avoids false positives from a // buggy check breaking legit users). private bool Compromised() { Func[] checks; lock (_integrityChecks) checks = _integrityChecks.ToArray(); for (int i = 0; i < checks.Length; i++) { try { if (checks[i]()) return true; } catch { /* ignore */ } } return false; } /// /// Fetch a secret variable's value as raw bytes over the secret channel - /// or, if the session is not valid (no live authed HWID-bound session) or a /// registered integrity check trips, return deterministic POISON bytes. /// NEVER throws for an invalid session and exposes NO boolean: you just use /// the result, and a cracked/tampered client silently gets garbage. /// The stored variable value must be base64 (of your bytes). /// public async Task SecretBytes(string name, CancellationToken ct = default(CancellationToken)) { string v = await FetchSecretOrNull(name, ct).ConfigureAwait(false); if (v != null && !Compromised()) { try { return Convert.FromBase64String(v); } catch (FormatException) { /* poison */ } } return Poison(name, 32); } /// /// Fetch a secret variable's value as a string over the secret channel - or /// return a POISON string if the session is invalid or a tamper check trips. /// Never throws for an invalid session; no boolean to flip. Use the result /// directly (a URL, token, connection string): a cracked client gets junk. /// public async Task SecretString(string name, CancellationToken ct = default(CancellationToken)) { string v = await FetchSecretOrNull(name, ct).ConfigureAwait(false); if (v != null && !Compromised()) return v; return Convert.ToBase64String(Poison(name, 24)); } /// /// Fetch a secret variable parsed as a 64-bit integer over the secret /// channel - or return a POISON (nonzero, wrong) number if the session is /// invalid or a tamper check trips. Never throws for an invalid session; no /// boolean to flip. Ideal for values you USE directly (a memory offset, a /// table index, a divisor): a cracked client gets a wrong number and the /// code that consumes it produces garbage or crashes - far from any check. /// public async Task SecretLong(string name, CancellationToken ct = default(CancellationToken)) { string v = await FetchSecretOrNull(name, ct).ConfigureAwait(false); long real; if (v != null && !Compromised() && long.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out real)) return real; byte[] p = Poison(name, 8); long g = BitConverter.ToInt64(p, 0); return g == 0 ? unchecked((long)0x9E3779B97F4A7C15) : g; // never 0 } // Fetch a var value, mapping ANY failure (invalid session, transport, // verify, parse, disposed gate, mid-stream IOException) to null so the // caller poisons. This is fail-CLOSED and matches the C++ SDK (var() // returns nullopt on any failure). Only cancellation is allowed to escape. private async Task FetchSecretOrNull(string name, CancellationToken ct) { try { return await Var(name, ct).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } catch (Exception) { return null; } } // Deterministic, nonzero garbage derived from the name (stable per name so // behavior is reproducible, but unrelated to the real value the attacker // never received). private static byte[] Poison(string name, int len) { var outb = new byte[len]; using (var sha = SHA256.Create()) { int filled = 0, ctr = 0; while (filled < len) { byte[] blk = sha.ComputeHash(Encoding.UTF8.GetBytes( "atlasauth-poison|" + name + "|" + ctr.ToString(CultureInfo.InvariantCulture))); int take = Math.Min(blk.Length, len - filled); Buffer.BlockCopy(blk, 0, outb, filled, take); filled += take; ctr++; } } return outb; } /// /// Decrypt a v2 blob you shipped inside your app. Fetches the 32-byte master /// key stored under the secret variable over /// the signed HWID-bound channel, and binds this app id + variable name into /// the decryption, so a blob can only be opened by a live valid session in /// the right app/slot. Throws a single opaque failure otherwise. Encrypt the /// matching blob with the dashboard "Encrypt a value" tool (same variable /// name). See . /// public async Task DecryptSecret(string keyVarName, string blobBase64, CancellationToken ct = default(CancellationToken)) { string master = await FetchSecretOrNull(keyVarName, ct).ConfigureAwait(false); return AtlasCrypto.DecryptString(blobBase64, master, _appId, keyVarName); } /// /// Raw-bytes form of . /// public async Task DecryptSecretBytes(string keyVarName, string blobBase64, CancellationToken ct = default(CancellationToken)) { string master = await FetchSecretOrNull(keyVarName, ct).ConfigureAwait(false); return AtlasCrypto.DecryptBytes(blobBase64, master, _appId, keyVarName); } // endregion /// Stops the heartbeat and releases resources. Does not call /logout. public void Dispose() { StopHeartbeat(); _gate.Dispose(); } } // ========================================================================= // AtlasCrypto - the "secret channel" decrypt helper // ========================================================================= /// /// Authenticated-decryption helper for the AtlasAuth SECRET CHANNEL - the /// single most effective way to make a cracked copy of your app useless. /// /// 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 /// (which fetches the key over the /// signed, HWID-bound channel and binds the app + variable name for you). A /// patched, emulated, or bypassed client never receives a genuine key, so the /// decrypt fails. There is no "isValid" boolean to flip: the bytes that make /// your app work do not exist until a real signed session delivers the key. /// Gate by CONSUMING the plaintext, not by branching on a flag. /// /// /// FORMAT v2 (byte-identical across the dashboard "Encrypt a value" tool, this /// SDK, and the C++ SDK): /// blob = base64( 0x02 || iv[16] || AES-256-CBC(plaintext) || HMAC-SHA256[32] ), /// encrypt-then-MAC. Keys come from HKDF-SHA256 (RFC 5869) over the master, /// bound to the app id and variable name so a blob cannot be replayed into /// another slot: salt = SHA256("atlasauth-hkdf-salt-v2"), /// PRK = HMAC(salt, master), /// context = LP("atlasauth")||0x02||LP(app_id)||LP(var_name) (LP = /// 4-byte big-endian length prefix), 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. Every failure is a single opaque error (no oracle). /// /// /// PORTABILITY. AES-CBC + HMAC-SHA256 + SHA-256 from the BCL only, so it works /// on .NET Framework 4.7/4.8/4.8.1 (no AesGcm) and modern .NET. Key /// buffers are zeroed after use (best-effort on the managed heap). /// /// public static class AtlasCrypto { private const byte Version = 0x02; private static readonly byte[] Salt = Sha256(Encoding.UTF8.GetBytes("atlasauth-hkdf-salt-v2")); /// /// Decrypt a v2 secret-channel blob to its UTF-8 plaintext string. Prefer /// , which supplies the key + context. /// /// Base64 blob from the dashboard "Encrypt a value" tool. /// The 32-byte master key (base64) stored as the secret variable. /// The app id the value was encrypted for (must match). /// The variable name the value was encrypted for (must match). /// A single opaque failure for any bad key / blob / authentication / decryption error. public static string DecryptString(string blobBase64, string masterKeyBase64, string appId, string varName) { byte[] pt = DecryptBytes(blobBase64, masterKeyBase64, appId, varName); try { return Encoding.UTF8.GetString(pt); } finally { Array.Clear(pt, 0, pt.Length); } } /// /// Decrypt a v2 secret-channel blob to raw bytes. See /// . /// /// A single opaque failure for any error. public static byte[] DecryptBytes(string blobBase64, string masterKeyBase64, string appId, string varName) { byte[] master = null, encKey = null, macKey = null; try { if (string.IsNullOrEmpty(blobBase64) || string.IsNullOrEmpty(masterKeyBase64)) throw Fail(); try { master = Convert.FromBase64String(masterKeyBase64); } catch (FormatException) { throw Fail(); } if (master.Length != 32) throw Fail(); byte[] blob; try { blob = Convert.FromBase64String(blobBase64); } catch (FormatException) { throw Fail(); } // version(1) + iv(16) + at least one AES block(16) + mac(32) if (blob.Length < 1 + 16 + 16 + 32) throw Fail(); if (blob[0] != Version) throw Fail(); byte[] iv = new byte[16]; Buffer.BlockCopy(blob, 1, iv, 0, 16); int ctLen = blob.Length - 1 - 16 - 32; if (ctLen <= 0 || (ctLen % 16) != 0) throw Fail(); byte[] ct = new byte[ctLen]; Buffer.BlockCopy(blob, 1 + 16, ct, 0, ctLen); byte[] mac = new byte[32]; Buffer.BlockCopy(blob, blob.Length - 32, mac, 0, 32); // HKDF-SHA256 key derivation, bound to (app_id, var_name). byte[] ctx = Context(appId, varName); byte[] prk = HkdfExtract(master); byte[] okm = HkdfExpand(prk, Concat(Lp(Encoding.UTF8.GetBytes("enc+mac")), ctx), 64); Array.Clear(prk, 0, prk.Length); encKey = new byte[32]; Buffer.BlockCopy(okm, 0, encKey, 0, 32); macKey = new byte[32]; Buffer.BlockCopy(okm, 32, macKey, 0, 32); Array.Clear(okm, 0, okm.Length); // Encrypt-then-MAC: authenticate (version||context||iv||ct) BEFORE // decrypting, in constant time. Same failure for a wrong key. byte[] macIn = Concat(Concat(new byte[] { Version }, ctx), Concat(iv, ct)); byte[] expected; using (var h = new HMACSHA256(macKey)) expected = h.ComputeHash(macIn); if (!FixedTimeEquals(mac, expected)) throw Fail(); try { using (var aes = Aes.Create()) { aes.Key = encKey; aes.IV = iv; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; using (var dec = aes.CreateDecryptor()) return dec.TransformFinalBlock(ct, 0, ct.Length); } } catch (CryptographicException) { throw Fail(); } } finally { // Best-effort scrub of key material. Array.Clear is not dead-store- // eliminated by the CLR. The GC may already have copied a buffer, so // this is best-effort; never expose keys/plaintext as System.String. if (master != null) Array.Clear(master, 0, master.Length); if (encKey != null) Array.Clear(encKey, 0, encKey.Length); if (macKey != null) Array.Clear(macKey, 0, macKey.Length); } } // One opaque failure for every error path so nothing is distinguishable // (no padding/MAC oracle even if checks are ever reordered). private static AtlasAuthException Fail() { return new AtlasAuthException("could not decrypt", "decrypt_failed"); } // ---- HKDF-SHA256 (RFC 5869) -------------------------------------------- private static byte[] HkdfExtract(byte[] ikm) { using (var h = new HMACSHA256(Salt)) return h.ComputeHash(ikm); } private static byte[] HkdfExpand(byte[] prk, byte[] info, int len) { byte[] result = new byte[len]; byte[] t = new byte[0]; int pos = 0; byte ctr = 1; using (var h = new HMACSHA256(prk)) { while (pos < len) { byte[] input = Concat(Concat(t, info), new byte[] { ctr }); t = h.ComputeHash(input); // ComputeHash re-inits state each call int take = Math.Min(t.Length, len - pos); Buffer.BlockCopy(t, 0, result, pos, take); pos += take; ctr++; } } return result; } // context = LP("atlasauth") || version || LP(app_id) || LP(var_name) private static byte[] Context(string appId, string varName) { byte[] head = Concat(Lp(Encoding.UTF8.GetBytes("atlasauth")), new byte[] { Version }); byte[] ids = Concat(Lp(Encoding.UTF8.GetBytes(appId ?? "")), Lp(Encoding.UTF8.GetBytes(varName ?? ""))); return Concat(head, ids); } // Length-prefix: 4-byte big-endian length || bytes (anti-canonicalization). private static byte[] Lp(byte[] x) { byte[] r = new byte[4 + x.Length]; r[0] = (byte)((x.Length >> 24) & 0xFF); r[1] = (byte)((x.Length >> 16) & 0xFF); r[2] = (byte)((x.Length >> 8) & 0xFF); r[3] = (byte)(x.Length & 0xFF); Buffer.BlockCopy(x, 0, r, 4, x.Length); return r; } private static byte[] Sha256(byte[] data) { using (var sha = SHA256.Create()) return sha.ComputeHash(data); } private static byte[] Concat(byte[] a, byte[] b) { byte[] r = new byte[a.Length + b.Length]; Buffer.BlockCopy(a, 0, r, 0, a.Length); Buffer.BlockCopy(b, 0, r, a.Length, b.Length); return r; } private static bool FixedTimeEquals(byte[] a, byte[] b) { if (a == null || b == null || a.Length != b.Length) return false; int diff = 0; for (int i = 0; i < a.Length; i++) diff |= a[i] ^ b[i]; return diff == 0; } } // ========================================================================= // NativeExit - abrupt, cleanup-free process termination (a LOUD last resort) // ========================================================================= /// /// Hard-kills the current process with the least ceremony Windows offers. /// /// HONEST SCOPE: this is a "Level 1" primitive. It is more abrupt than /// (no finalizers, no try/finally, no /// DLL_PROCESS_DETACH, cannot be swallowed by a catch), which /// helps against a debugger trying to continue past the exit. It does /// NOT make you crack-resistant: the instruction that calls it is /// still one branch an attacker can patch. Do not gate protection on /// if (!valid) HardExit(). Use the secret channel / Secret* /// poison as the real defense, and fire this only as a redundant, non-obvious /// consequence of already-corrupted state. /// /// public static class NativeExit { private static readonly IntPtr NtCurrentProcess = new IntPtr(-1); // ntdll is present in every Windows process; Nt*/Zw* resolve to the same // user-mode syscall stub. On non-Windows the P/Invoke throws at call time // and we fall through to FailFast. [DllImport("ntdll.dll", SetLastError = false, ExactSpelling = true)] private static extern int NtTerminateProcess(IntPtr processHandle, int exitStatus); /// /// Terminate the current process immediately. Does not return on success. /// public static void HardExit(int exitCode) { // Pass -1 (NtCurrentProcess): kills the whole process now, no return. // (IntPtr.Zero/NULL would only kill OTHER threads and keep running.) try { NtTerminateProcess(NtCurrentProcess, exitCode); } catch { /* not Windows */ } // Belt-and-suspenders: uncatchable, cannot be swallowed by try/catch. Environment.FailFast(null); } } // ========================================================================= // AtlasAntiTamper - optional environment checks to feed into the poison // ========================================================================= /// /// Optional, low-false-positive tamper signals you can register via /// so a hostile environment /// POISONS your Secret* values instead of tripping a patchable branch. /// /// HONEST SCOPE: these are user-mode checks a determined attacker who owns the /// machine can defeat; their value is attacker-hours, and they should feed the /// data path (poison), never a bool. They can also cause false positives while /// YOU debug your own build - guard registration behind a release flag. /// /// public static class AtlasAntiTamper { [DllImport("kernel32.dll", SetLastError = true)] private static extern bool CheckRemoteDebuggerPresent(IntPtr hProcess, ref bool present); [DllImport("kernel32.dll")] private static extern IntPtr GetCurrentProcess(); /// /// True if a managed or native debugger is attached. Uses only documented /// APIs (low AV risk). Returns false on non-Windows / when the check can't /// run, so it never false-trips off-platform. /// public static bool DebuggerPresent() { try { if (System.Diagnostics.Debugger.IsAttached) return true; bool present = false; if (CheckRemoteDebuggerPresent(GetCurrentProcess(), ref present) && present) return true; } catch { /* non-Windows / API unavailable: no signal */ } return false; } } }