// sellkeys Auth – C# SDK for Unity 2021.2+ (.NET Standard 2.1 / .NET Framework API level, Mono and IL2CPP). // Not for WebGL builds (HttpClient). For plain .NET 6+ use SellkeysAuth.cs instead. // // Dependencies: // - Newtonsoft.Json: Package Manager → Add package by name → com.unity.nuget.newtonsoft-json // - BouncyCastle: put BouncyCastle.Cryptography.dll (netstandard2.0, from nuget.org/packages/BouncyCastle.Cryptography) into Assets/Plugins // // var auth = new Sellkeys.SellkeysAuth("APP_ID", "PUBLIC_KEY", Application.version); // try { // await auth.InitAsync(); // var lic = await auth.LicenseAsync(key); // Debug.Log($"Welcome! Level {lic.Level}"); // } catch (Sellkeys.SellkeysAuthException e) { Debug.Log($"Login failed: {e.Code} {e.Message}"); } // // See SellkeysAuthExample.cs for a MonoBehaviour. Every answer is signed with your app's Ed25519 key; the SDK rejects // wrong signatures and replayed nonces, so a fake server cannot unlock your program. (It does not protect against // patching your program.) using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using Newtonsoft.Json.Linq; using Org.BouncyCastle.Math.EC.Rfc8032; namespace Sellkeys { public sealed class SellkeysAuthException : Exception { public string Code { get; } /// Full server answer (null for local errors), e.g. Answer["update_url"] for code "update_required". public JObject Answer { get; } public SellkeysAuthException(string code, string message, JObject data = null) : base(message) { Code = code; Answer = data; } } public sealed class SellkeysLicense { public int Level; public DateTimeOffset? ExpiresAt; // null = lifetime public int Devices, DeviceLimit; } public sealed class SellkeysUser { public string Username; public int? Level; public DateTimeOffset? ExpiresAt; public int Licenses, Devices, DeviceLimit; } public sealed class SellkeysAuth { public const string DefaultApiUrl = "https://auth.sellkeys.cc/v1"; private static readonly HttpClient Http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) }; private readonly string _appId, _version, _apiUrl; private readonly byte[] _publicKey; public string Hwid { get; } public string Session { get; private set; } public string UpdateUrl { get; private set; } /// Name and latest version of the app, set by InitAsync. public string AppName { get; private set; } public string AppVersion { get; private set; } public SellkeysLicense License { get; private set; } public SellkeysUser User { get; private set; } public SellkeysAuth(string appId, string publicKey, string version, string apiUrl = DefaultApiUrl, string hwid = null) { _appId = appId; _version = version; _apiUrl = apiUrl; try { _publicKey = Convert.FromBase64String(publicKey); } catch (FormatException) { _publicKey = new byte[0]; } if (_publicKey.Length != 32) throw new SellkeysAuthException("bad_public_key", "The public key must be 32 bytes, base64."); Hwid = hwid ?? DefaultHwid(); } private static string Hex(byte[] b) => string.Concat(b.Select(x => x.ToString("x2"))); private static string Sha256Hex(byte[] b) { using (var h = SHA256.Create()) return Hex(h.ComputeHash(b)); } /// Stable per-device id, hashed (the raw id never leaves the device). public static string DefaultHwid() { #if UNITY_5_3_OR_NEWER string raw = UnityEngine.SystemInfo.deviceUniqueIdentifier; #else string raw = ""; try { if (File.Exists("/etc/machine-id")) raw = File.ReadAllText("/etc/machine-id").Trim(); } catch { raw = ""; } if (raw.Length == 0) raw = Environment.MachineName + Environment.UserName; #endif return Sha256Hex(Encoding.UTF8.GetBytes(raw)); } /// SHA-256 of a file for the app's hash check (e.g. your game's main assembly). public static string FileHash(string path) => Sha256Hex(File.ReadAllBytes(path)); private async Task CallAsync(string type, Dictionary fields) { var nb = new byte[16]; using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(nb); var nonce = Hex(nb); var form = new Dictionary { ["type"] = type, ["app"] = _appId, ["nonce"] = nonce }; foreach (var kv in fields) if (kv.Value != null) form[kv.Key] = kv.Value; HttpResponseMessage res; byte[] bytes; try { using (var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15))) { res = await Http.PostAsync(_apiUrl, new FormUrlEncodedContent(form), cts.Token); bytes = await res.Content.ReadAsByteArrayAsync(); } } catch (Exception e) { throw new SellkeysAuthException("network_error", "Could not reach the license server: " + e.Message); } if (!res.Headers.TryGetValues("X-Sellkeys-Signature", out var sigs)) { // only errors before the app is known (rate limit, unknown app) are unsigned JObject e = null; try { e = JObject.Parse(Encoding.UTF8.GetString(bytes)); } catch { /* not json */ } throw new SellkeysAuthException((string)e?["code"] ?? "http_" + (int)res.StatusCode, (string)e?["message"] ?? "Unexpected server response."); } byte[] sig; try { sig = Convert.FromBase64String(sigs.First()); } catch (FormatException) { sig = new byte[0]; } if (sig.Length != 64 || !Ed25519.Verify(sig, 0, _publicKey, 0, bytes, 0, bytes.Length)) throw new SellkeysAuthException("bad_signature", "The server response is not signed by sellkeys."); var root = JObject.Parse(Encoding.UTF8.GetString(bytes)); if ((string)root["nonce"] != nonce) throw new SellkeysAuthException("bad_nonce", "The server response does not belong to this request."); if (root["success"]?.Type != JTokenType.Boolean || !(bool)root["success"]) { if (root["update_url"]?.Type == JTokenType.String) UpdateUrl = (string)root["update_url"]; throw new SellkeysAuthException((string)root["code"] ?? "error", (string)root["message"] ?? "Request failed.", root); } return root; } private static DateTimeOffset? Date(JToken t) => t != null && t.Type == JTokenType.Date ? new DateTimeOffset(((DateTime)t).ToUniversalTime()) : t != null && t.Type == JTokenType.String ? DateTimeOffset.Parse((string)t) : (DateTimeOffset?)null; private static SellkeysLicense ParseLicense(JToken l) => new SellkeysLicense { Level = (int)l["level"], ExpiresAt = Date(l["expires_at"]), Devices = (int)l["devices"], DeviceLimit = (int)l["device_limit"], }; private static SellkeysUser ParseUser(JToken u) => new SellkeysUser { Username = (string)u["username"], Level = u["level"]?.Type == JTokenType.Integer ? (int)u["level"] : (int?)null, ExpiresAt = Date(u["expires_at"]), Licenses = (int?)u["licenses"] ?? 0, Devices = (int)u["devices"], DeviceLimit = (int)u["device_limit"], }; private Dictionary F(params string[] kv) { var d = new Dictionary { ["session"] = Session }; for (int i = 0; i + 1 < kv.Length; i += 2) d[kv[i]] = kv[i + 1]; return d; } /// Starts a session. Code "update_required" means your version is too old (see UpdateUrl). public async Task InitAsync(string fileHash = null) { var r = await CallAsync("init", new Dictionary { ["version"] = _version, ["hash"] = fileHash }); Session = (string)r["session"]; AppName = (string)r["app"]?["name"]; AppVersion = (string)r["app"]?["version"]; if (r["app"]?["update_url"]?.Type == JTokenType.String) UpdateUrl = (string)r["app"]["update_url"]; } public async Task LicenseAsync(string key) { if (Session == null) await InitAsync(); var r = await CallAsync("license", F("key", key.Trim(), "hwid", Hwid)); return License = ParseLicense(r["license"]); } /// Call periodically: throws if the license/account was banned, expired or the session ended. public async Task CheckAsync() { var r = await CallAsync("check", F()); if (r["user"] != null) User = ParseUser(r["user"]); else License = ParseLicense(r["license"]); } // ---- accounts (username + password; the key is redeemed to the account) ---- public async Task RegisterAsync(string username, string password, string key) { if (Session == null) await InitAsync(); var r = await CallAsync("register", F("username", username, "password", password, "key", key.Trim(), "hwid", Hwid)); return User = ParseUser(r["user"]); } public async Task LoginAsync(string username, string password) { if (Session == null) await InitAsync(); var r = await CallAsync("login", F("username", username, "password", password, "hwid", Hwid)); return User = ParseUser(r["user"]); } /// Adds another license (renewal, higher level) to an existing account. public async Task UpgradeAsync(string username, string key) { if (Session == null) await InitAsync(); return ParseUser((await CallAsync("upgrade", F("username", username, "key", key.Trim())))["user"]); } // ---- content ---- /// Global app variable (set in the dashboard). public async Task VarAsync(string name) => (string)(await CallAsync("var", F("name", name)))["value"]; public async Task GetUserVarAsync(string name) => (string)(await CallAsync("getvar", F("name", name)))["value"]; public async Task SetUserVarAsync(string name, string value) => await CallAsync("setvar", F("name", name, "value", value)); /// Downloads an app file and checks it against the SHA-256 from the signed answer. public async Task DownloadAsync(string fileId) { var info = await CallAsync("file", F("file", fileId)); byte[] data; try { data = await Http.GetByteArrayAsync((string)info["url"]); } catch (Exception e) { throw new SellkeysAuthException("network_error", "Download failed: " + e.Message); } if (Sha256Hex(data) != (string)info["sha256"]) throw new SellkeysAuthException("bad_file_hash", "The downloaded file does not match its signed checksum."); return data; } /// Calls a webhook set in the dashboard through sellkeys (its URL and keys stay on the server). public async Task<(int Status, string Response)> WebhookAsync(string name, string parameters = "", string body = null, string contentType = null) { var r = await CallAsync("webhook", F("webid", name, "params", string.IsNullOrEmpty(parameters) ? null : parameters, "body", body, "content_type", contentType)); return ((int)r["status"], (string)r["response"]); } /// Writes a line to the app's logs in the sellkeys dashboard. public async Task LogAsync(string message) => await CallAsync("log", F("message", message.Length > 500 ? message.Substring(0, 500) : message)); public async Task LogoutAsync() { if (Session == null) return; await CallAsync("logout", F()); Session = null; } } }