// ============================================================================= // AtlasAuth - Official C# SDK (single file, dependency-free) // Wire contract: AtlasAuth API & Signing Contract v1 (CONTRACT.md) // ============================================================================= // // 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. 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. // Mismatch -> replay/tamper -> abort. // 6. (Advisory) Check 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 // --------------------------------------------------------------------------- // * Target: .NET 6+ (also fine on .NET 5). These runtimes expose // DSASignatureFormat.IeeeP1363FixedFieldConcatenation (the contract calls it // "IeeeP1363"), which lets us verify the raw 64-byte r||s signature directly // - no DER conversion needed. NOTE: this file also uses Convert.ToHexString // (nonce generation), added in .NET 5, so .NET Core 3.x is NOT supported // despite that runtime having the DSASignatureFormat overload. // * On legacy .NET Framework (4.x) there is no DSASignatureFormat overload. // If you must target it, add the BouncyCastle.Cryptography NuGet package and // verify with Org.BouncyCastle.Crypto.Signers.ECDsaSigner over the same // payload bytes, splitting the 64-byte signature into BigInteger r,s. This // file deliberately does NOT take that dependency; the hook is documented // only. (Do not add BouncyCastle unless you actually target Framework.) // // DEPENDENCIES: only the BCL - // System.Security.Cryptography, System.Net.Http, System.Text.Json, // System.Text, Microsoft.Win32 (registry, Windows-only HWID). // 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.Net.Http; using System.Net.Http.Headers; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Text.Json; 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; } /// 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) { } } // ========================================================================= // 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; // advisory clock-skew tolerance private const int RequestTimeoutSeconds = 20; // per-request HTTP timeout private const int EnvelopeVersion = 1; // expected payload.v // A single static HttpClient for the whole process (the documented // pattern that avoids socket exhaustion). Per-request timeout is applied // via a CancellationToken, NOT HttpClient.Timeout, so it stays per-call. private static readonly HttpClient Http = CreateHttpClient(); private static HttpClient CreateHttpClient() { var c = new HttpClient { // We enforce timeouts per request with a CTS; keep the global // timeout generous so it never fires before our token does. Timeout = TimeSpan.FromSeconds(RequestTimeoutSeconds * 3) }; c.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); c.DefaultRequestHeaders.UserAgent.ParseAdd("AtlasAuth-CSharp-SDK/1.0"); return c; } private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = false }; // endregion // ===================================================================== // region Instance state // ===================================================================== private readonly string _appId; private readonly byte[] _spkiPublicKey; private readonly string? _hwidSeed; private string? _session; private string? _cachedHwid; // Heartbeat machinery. private CancellationTokenSource? _heartbeatCts; private Task? _heartbeatTask; // Serializes foreground calls against the heartbeat loop so the session // token / properties are never mutated concurrently. private readonly SemaphoreSlim _gate = new(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 => _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; } = 10; /// 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; } // 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) { _appId = !string.IsNullOrWhiteSpace(appId) ? appId! : APP_ID; var spki = !string.IsNullOrWhiteSpace(spkiPublicKeyBase64) ? spkiPublicKeyBase64! : PUBLIC_KEY; _hwidSeed = string.IsNullOrEmpty(hwidSeed) ? null : hwidSeed; if (string.IsNullOrWhiteSpace(_appId) || _appId == APP_ID && APP_ID.StartsWith("00000000")) 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."); try { _spkiPublicKey = Convert.FromBase64String(spki); } catch (FormatException ex) { throw new AtlasAuthException("PUBLIC_KEY is not valid base64.", ex); } // Fail fast: make sure the key actually imports as an EC public key. try { using var probe = ECDsa.Create(); probe.ImportSubjectPublicKeyInfo(_spkiPublicKey, out _); } catch (Exception ex) { throw new AtlasAuthException("PUBLIC_KEY is not a valid SPKI DER ECDSA public key.", ex); } } // 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) { await _gate.WaitAsync(ct).ConfigureAwait(false); try { var req = new Dictionary { ["version"] = clientVersion }; var p = await SendSignedAsync("/init", req, includeSession: 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"); if (TryGetInt(p, "heartbeat", out var hb) && hb > 0) HeartbeatSeconds = hb; bool ok = GetBool(p, "ok"); bool versionOk = !p.TryGetProperty("version_ok", out var vEl) || vEl.ValueKind != JsonValueKind.False; 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) { RequireInitialized(); await _gate.WaitAsync(ct).ConfigureAwait(false); try { var req = new Dictionary { ["username"] = username, ["password"] = password, ["license"] = license, ["hwid"] = ComputeHwid(), }; if (!string.IsNullOrEmpty(email)) req["email"] = email; var p = await SendSignedAsync("/register", req, includeSession: 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) { RequireInitialized(); await _gate.WaitAsync(ct).ConfigureAwait(false); try { var req = new Dictionary { ["username"] = username, ["password"] = password, ["hwid"] = ComputeHwid(), }; var p = await SendSignedAsync("/login", req, includeSession: true, ct).ConfigureAwait(false); bool ok = HandleAuthResult(p, username); if (ok) { if (TryGetInt(p, "level", out var 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) { RequireInitialized(); await _gate.WaitAsync(ct).ConfigureAwait(false); try { var req = new Dictionary { ["license"] = key, ["hwid"] = ComputeHwid(), }; var p = await SendSignedAsync("/license", req, includeSession: true, ct).ConfigureAwait(false); bool ok = HandleAuthResult(p, usernameIfOk: null); if (ok && TryGetInt(p, "level", out var 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(); var p = await SendSignedAsync("/check", new Dictionary(), includeSession: true, ct).ConfigureAwait(false); bool ok = GetBool(p, "ok"); bool valid = !p.TryGetProperty("valid", out var vEl) || vEl.ValueKind != JsonValueKind.False; string? appStatus = GetString(p, "app_status"); bool keyValid = !p.TryGetProperty("key_valid", out var kEl) || kEl.ValueKind != JsonValueKind.False; bool banned = p.TryGetProperty("banned", out var bEl) && bEl.ValueKind == JsonValueKind.True; string? reason = GetString(p, "reason"); if (appStatus != null) AppStatus = appStatus; if (appStatus != null) StatusMessage = GetString(p, "status_message"); UpdateExpiryFrom(p); bool kicked = !ok || !valid || (appStatus != null && !string.Equals(appStatus, "active", StringComparison.Ordinal)) || !keyValid || banned; if (kicked) { LastCode = string.IsNullOrEmpty(reason) ? DeriveKickCode(valid, appStatus, keyValid, banned) : reason; LastError = StatusMessage ?? reason ?? "Session is no longer valid."; } return new CheckResult(stillValid: !kicked, reason: LastCode ?? reason ?? ""); } /// /// 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) { RequireInitialized(); await _gate.WaitAsync(ct).ConfigureAwait(false); try { var req = new Dictionary { ["name"] = name }; var p = await SendSignedAsync("/var", req, includeSession: true, ct).ConfigureAwait(false); if (!GetBool(p, "ok")) { CaptureError(p); return null; } bool found = p.TryGetProperty("found", out var fEl) && fEl.ValueKind == JsonValueKind.True; 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) { 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 { var lvl = level is "info" or "warn" or "error" ? level : "info"; var req = new Dictionary { ["level"] = lvl, ["message"] = message, }; try { await SendSignedAsync("/log", req, includeSession: 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) { StopHeartbeat(); await _gate.WaitAsync(ct).ConfigureAwait(false); try { if (_session != null) { try { await SendSignedAsync("/logout", new Dictionary(), includeSession: 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) { var p = await GetUnsignedAsync($"/status/{_appId}", ct).ConfigureAwait(false); if (p == null) return null; var root = p.Value; if (!GetBool(root, "ok")) { LastCode = "unknown_app"; LastError = GetString(root, "error") ?? "Unknown app."; return null; } TryGetInt(root, "online", out var 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) { var result = new List(); var p = await GetUnsignedAsync($"/news/{_appId}", ct).ConfigureAwait(false); if (p == null) return result; var root = p.Value; if (!GetBool(root, "ok")) { LastCode = "unknown_app"; LastError = GetString(root, "error") ?? "Unknown app."; return result; } if (root.TryGetProperty("news", out var arr) && arr.ValueKind == JsonValueKind.Array) { foreach (var item in arr.EnumerateArray()) { if (item.ValueKind != JsonValueKind.Object) continue; long created = 0; if (item.TryGetProperty("created_at", out var cEl) && cEl.ValueKind == JsonValueKind.Number) cEl.TryGetInt64(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 ( /// / ). Reuses the static client 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 element, or null on any transport/parse failure (with /// / set); never throws. /// private async Task GetUnsignedAsync(string path, CancellationToken ct) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(RequestTimeoutSeconds)); string text; try { using var resp = await Http.GetAsync(BaseUrl + path, cts.Token).ConfigureAwait(false); text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); // 404 carries { ok:false, error:"unknown_app" }; let the caller read it. // Other non-success with no usable body -> treat as error below. if (!resp.IsSuccessStatusCode && string.IsNullOrWhiteSpace(text)) { LastError = $"HTTP {(int)resp.StatusCode} from {path}."; LastCode = ((int)resp.StatusCode).ToString(); return null; } } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { LastError = $"Request to {path} timed out."; LastCode = "timeout"; return null; } catch (OperationCanceledException) { return null; // caller-requested cancellation } catch (HttpRequestException ex) { LastError = $"Network error calling {path}: {ex.Message}"; LastCode = "network_error"; return null; } try { using var doc = JsonDocument.Parse(text); return doc.RootElement.Clone(); } catch (JsonException) { LastError = $"Malformed response from {path}."; LastCode = "bad_response"; return null; } } // endregion // ===================================================================== // region Heartbeat // ===================================================================== /// /// Start the background 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. /// /// /// Callback invoked exactly once when the user is kicked or a heartbeat /// fails verification. 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(nameof(onKicked)); RequireInitialized(); if (_heartbeatTask != null && !_heartbeatTask.IsCompleted) return; // already running _heartbeatCts = new CancellationTokenSource(); var token = _heartbeatCts.Token; int intervalMs = Math.Max(1, HeartbeatSeconds) * 1000; _heartbeatTask = Task.Run(async () => { try { while (!token.IsCancellationRequested) { try { await Task.Delay(intervalMs, token).ConfigureAwait(false); } catch (OperationCanceledException) { break; } if (token.IsCancellationRequested) break; 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) { // Unverifiable / hostile heartbeat -> kick. LastError = ex.Message; LastCode ??= "verify_failed"; SafeInvoke(onKicked, ex.Code ?? "verify_failed"); return; } catch (Exception ex) { // Transient network error: do not kick on a single // failure; record and retry on the next beat. LastError = ex.Message; continue; } if (!result.StillValid) { LoggedIn = false; SafeInvoke(onKicked, result.Reason); return; } } } catch (Exception ex) { LastError = ex.Message; SafeInvoke(onKicked, "heartbeat_error"); } }, token); } /// Stop the background heartbeat loop if running. Safe to call any time. public void StopHeartbeat() { try { _heartbeatCts?.Cancel(); } catch { /* ignore */ } _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 readonly struct CheckResult { public readonly bool StillValid; public readonly string Reason; public CheckResult(bool stillValid, string reason) { StillValid = stillValid; Reason = reason; } } // 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 JsonElement. 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 { ["app_id"] = _appId, ["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 = JsonSerializer.Serialize(body, JsonOpts); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(RequestTimeoutSeconds)); HttpResponseMessage resp; string text; try { using var content = new StringContent(json, Encoding.UTF8, "application/json"); resp = await Http.PostAsync(BaseUrl + path, content, cts.Token).ConfigureAwait(false); text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { throw new AtlasAuthException($"Request to {path} timed out."); } catch (HttpRequestException ex) { throw new AtlasAuthException($"Network error calling {path}: {ex.Message}", ex); } // Transport-level (unsigned) error bodies: { "error", "code" } with 4xx/5xx. // We MUST NOT trust these as a signed truth - surface and fail. if (!resp.IsSuccessStatusCode) { var (err, code) = TryReadUnsignedError(text); LastError = err ?? $"HTTP {(int)resp.StatusCode} from {path}."; LastCode = code ?? ((int)resp.StatusCode).ToString(); throw new AtlasAuthException(LastError, LastCode); } return VerifyAndParse(text, nonce); } /// /// 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 /// element on success; throws on any failure. /// private JsonElement VerifyAndParse(string responseText, string expectedNonce) { JsonDocument envelope; try { envelope = JsonDocument.Parse(responseText); } catch (JsonException ex) { throw new AtlasAuthException("Malformed response (not JSON).", ex); } using (envelope) { var root = envelope.RootElement; // An unsigned {error,code} with HTTP 200 is still untrusted. if (!root.TryGetProperty("payload", out var payloadEl) || !root.TryGetProperty("sig", out var sigEl) || payloadEl.ValueKind != JsonValueKind.String || sigEl.ValueKind != JsonValueKind.String) { var (err, code) = TryReadUnsignedError(responseText); LastError = err ?? "Unsigned/unstructured response - refusing to trust it."; LastCode = code; throw new AtlasAuthException(LastError, LastCode); } // (1) payload as opaque UTF-8 string - these are the signed bytes. string payloadString = payloadEl.GetString()!; byte[] payloadBytes = Encoding.UTF8.GetBytes(payloadString); // (2) base64-decode sig -> 64 bytes (IEEE P1363 r||s). byte[] sig; try { sig = Convert.FromBase64String(sigEl.GetString()!); } 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. bool verified; try { using var ecdsa = ECDsa.Create(); ecdsa.ImportSubjectPublicKeyInfo(_spkiPublicKey, out _); // NOTE: the .NET enum member for raw IEEE-P1363 r||s is // DSASignatureFormat.IeeeP1363FixedFieldConcatenation. (The // contract abbreviates it "IeeeP1363" - same format, the BCL // just spells it out.) verified = ecdsa.VerifyData( payloadBytes, sig, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); } 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. JsonDocument payloadDoc; try { payloadDoc = JsonDocument.Parse(payloadString); } catch (JsonException ex) { throw new AtlasAuthException("Verified payload is not valid JSON.", ex); } // Clone so we can dispose the doc and still return a usable element. var payload = payloadDoc.RootElement.Clone(); payloadDoc.Dispose(); // Envelope version sanity (advisory but cheap). if (payload.TryGetProperty("v", out var vEl) && vEl.ValueKind == JsonValueKind.Number && vEl.TryGetInt32(out var v) && v != EnvelopeVersion) { throw new AtlasAuthException($"Unsupported envelope version {v} (expected {EnvelopeVersion})."); } // (5) nonce MUST equal the one we sent. string? gotNonce = GetString(payload, "nonce"); if (!FixedEquals(gotNonce, expectedNonce)) throw new AtlasAuthException("nonce mismatch - possible replay/tamper"); // (6) advisory clock-skew check. if (payload.TryGetProperty("t", out var tEl) && tEl.ValueKind == JsonValueKind.Number && tEl.TryGetInt64(out var serverT)) { long localT = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (Math.Abs(localT - serverT) > ApiMaxSkewSeconds) { // Advisory only: record it, do not hard-fail (contract §1.6). LastError = $"clock skew {Math.Abs(localT - serverT)}s exceeds {ApiMaxSkewSeconds}s"; LastCode = "clock_skew"; } } return payload; } } // endregion // ===================================================================== // region Result handling helpers // ===================================================================== private bool HandleAuthResult(JsonElement p, string? usernameIfOk) { bool ok = GetBool(p, "ok"); UpdateExpiryFrom(p); if (ok) { LoggedIn = true; Username = GetString(p, "username") ?? usernameIfOk; LastError = null; LastCode = null; return true; } LoggedIn = false; CaptureError(p); return false; } private void UpdateExpiryFrom(JsonElement p) { if (p.TryGetProperty("expiry", out var e)) { if (e.ValueKind == JsonValueKind.Null) Expiry = null; // lifetime else if (e.ValueKind == JsonValueKind.Number && e.TryGetInt64(out var unix)) Expiry = DateTimeOffset.FromUnixTimeSeconds(unix); } if (p.TryGetProperty("remaining_seconds", out var rs)) { if (rs.ValueKind == JsonValueKind.Null) RemainingSeconds = null; else if (rs.ValueKind == JsonValueKind.Number && rs.TryGetInt64(out var v)) RemainingSeconds = v; } } private void CaptureError(JsonElement 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 { ReadMachineGuid() ?? "no-guid", SafeMachineName(), Environment.ProcessorCount.ToString(), }; _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. On non-Windows we fall back to other stable ids below. if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return null; try { using var key = RegistryKey .OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64) .OpenSubKey(@"SOFTWARE\Microsoft\Cryptography"); 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]; RandomNumberGenerator.Fill(buf); return Convert.ToHexString(buf).ToLowerInvariant(); } private static (string? error, string? code) TryReadUnsignedError(string text) { try { using var doc = JsonDocument.Parse(text); var r = doc.RootElement; if (r.ValueKind != JsonValueKind.Object) return (null, null); string? err = r.TryGetProperty("error", out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null; string? code = r.TryGetProperty("code", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() : null; return (err, code); } catch { return (null, null); } } private static string? GetString(JsonElement obj, string name) => obj.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null; private static bool GetBool(JsonElement obj, string name) => obj.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.True; private static bool TryGetInt(JsonElement obj, string name, out int value) { value = 0; return obj.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out value); } /// Constant-time comparison for the nonce echo (avoids early-exit timing leaks). 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 return CryptographicOperations.FixedTimeEquals(ba, bb); } // endregion /// Stops the heartbeat and releases resources. Does not call /logout. public void Dispose() { StopHeartbeat(); _gate.Dispose(); } } }