// AppsXi Level 1 license helper for .NET apps (PRD 15.4, LIC-5). .NET 6 or later. // One dependency for Ed25519: the BouncyCastle.Cryptography NuGet package (.NET has no built-in Ed25519). // // var lic = new AppXiLicense("pk_live_…", new[] { "" }, "my-app", // Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MyApp")); // var r = await lic.ActivateAsync("AXI-…"); // once, online // var e = lic.Current(); // every launch, offline // if (lic.NeedsRefresh()) await lic.RefreshAsync(); // // WinSparkle: win_sparkle_set_appcast_url(lic.UpdateFeedUrl("windows")). using System; using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; using System.Threading.Tasks; using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Crypto.Signers; namespace AppsXi { public sealed record Entitlement(string Status, string License, string Buyer, string Environment, DateTimeOffset ExpiresAt, DateTimeOffset? TrialExpiresAt, int Seats); public sealed record Validation(bool Valid, string? Reason, int SeatsUsed, int SeatsMax, Entitlement? Entitlement); public sealed record TrialStatus(bool InTrial, int DaysLeft, bool Expired); public sealed class AppXiLicense { private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(15) }; private readonly string _productKey; private readonly byte[][] _publicKeys; private readonly string _appSlug; private readonly string _api; private readonly string _file; private sealed class Cache { public string key { get; set; } = ""; public string? token { get; set; } public string? trial_expires_at { get; set; } public string? feed_base { get; set; } public string? feed_token { get; set; } } public AppXiLicense(string productKey, string[] publicKeys, string appSlug, string storageDir, string apiBase = "https://appsxi.com") { if (!Regex.IsMatch(productKey, "^pk_(live|test)_[0-9a-f]{32}$")) throw new ArgumentException("productKey must be a pk_live_ or pk_test_ key"); _productKey = productKey; _publicKeys = publicKeys.Select(B64Url).Where(k => k.Length == 32).ToArray(); _appSlug = appSlug; _api = apiBase.TrimEnd('/'); _file = Path.Combine(storageDir, "appxi-license.json"); } /// SHA-256 of the machine id with this app's product key and slug. Never the raw id. public string DeviceId() { var raw = MachineId(); if (string.IsNullOrEmpty(raw)) raw = System.Environment.MachineName; var digest = SHA256.HashData(Encoding.UTF8.GetBytes($"{raw}:{_productKey[..8]}:{_appSlug}")); return Convert.ToHexString(digest).ToLowerInvariant(); } /// Validates a key online, takes a seat on this machine and caches the signed token. public async Task ActivateAsync(string licenseKey) { var key = licenseKey.Trim(); var body = new JsonObject { ["license_key"] = key, ["device_id"] = DeviceId(), ["device_name"] = Truncate(System.Environment.MachineName, 80), ["platform"] = Platform(), }; using var req = new HttpRequestMessage(HttpMethod.Post, $"{_api}/api/v1/licenses/validate") { Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"), }; req.Headers.TryAddWithoutValidation("Authorization", $"Bearer {_productKey}"); JsonNode? j; try { using var res = await Http.SendAsync(req).ConfigureAwait(false); j = JsonNode.Parse(await res.Content.ReadAsStringAsync().ConfigureAwait(false)); } catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return new Validation(false, "network", 0, 0, null); } int used = (int?)j?["seats_used"] ?? 0, max = (int?)j?["seats_max"] ?? 0; if ((bool?)j?["valid"] != true) { var reason = (string?)j?["reason"] ?? "invalid"; if (reason is "revoked" or "expired" or "unknown_license") Forget(); return new Validation(false, reason, used, max, null); } Save(new Cache { key = key, token = (string?)j!["token"], trial_expires_at = (string?)j["trial_expires_at"], feed_base = (string?)j["updates"]?["feed_base"], feed_token = (string?)j["updates"]?["feed_token"], }); return new Validation(true, null, used, max, Current()); } /// Validates the cached key again. public Task RefreshAsync() { var c = Load(); return c is null ? Task.FromResult(new Validation(false, "no_license", 0, 0, null)) : ActivateAsync(c.key); } /// The verified entitlement from the cached token, offline. Null if missing, tampered or expired. public Entitlement? Current(DateTimeOffset? at = null) { var now = at ?? DateTimeOffset.UtcNow; var c = Load(); if (c?.token is null) return null; var claims = VerifyToken(c.token, _publicKeys); if (claims is null) return null; var exp = (long?)claims["exp"] ?? 0; var status = (string?)claims["status"]; var env = _productKey.StartsWith("pk_test_", StringComparison.Ordinal) ? "sandbox" : "live"; if (exp <= now.ToUnixTimeSeconds() || (status != "active" && status != "trial") || (string?)claims["env"] != env) return null; DateTimeOffset? trialEnd = status == "trial" && DateTimeOffset.TryParse(c.trial_expires_at, out var t) ? t : null; return new Entitlement(status!, (string?)claims["license"] ?? "", (string?)claims["sub"] ?? "", env, DateTimeOffset.FromUnixTimeSeconds(exp), trialEnd, (int?)claims["seats"]?["max"] ?? 1); } /// True when the token is missing or has less than seven days left. public bool NeedsRefresh(DateTimeOffset? at = null) { var now = at ?? DateTimeOffset.UtcNow; var e = Current(now); return e is null || e.ExpiresAt - now < TimeSpan.FromDays(7); } public TrialStatus GetTrialStatus(DateTimeOffset? at = null) { var now = at ?? DateTimeOffset.UtcNow; var e = Current(now); if (e is { Status: "trial" }) { var end = e.TrialExpiresAt ?? e.ExpiresAt; return new TrialStatus(true, Math.Max(0, (int)Math.Ceiling((end - now).TotalDays)), end <= now); } var c = Load(); var expired = e is null && DateTimeOffset.TryParse(c?.trial_expires_at, out var t) && t <= now; return new TrialStatus(false, 0, expired); } public string CheckoutUrl => $"{_api}/checkout/{Uri.EscapeDataString(_appSlug)}"; /// Opens your AppsXi checkout in the default browser. public void OpenCheckout() => Process.Start(new ProcessStartInfo(CheckoutUrl) { UseShellExecute = true }); /// The license-bound appcast for paid apps, or null before activation. public string? UpdateFeedUrl(string platform = "windows", string channel = "stable") { var c = Load(); return c?.feed_base is null || c.feed_token is null ? null : $"{c.feed_base}/{platform}/{channel}/appcast.xml?token={c.feed_token}"; } /// Removes the cached key and token. public void Forget() { try { File.WriteAllText(_file, "{}"); } catch (IOException) { } } /// Verifies a compact EdDSA JWT and returns its claims, or null. public static JsonNode? VerifyToken(string token, byte[][] publicKeys) { var parts = token.Split('.'); if (parts.Length != 3) return null; try { var header = JsonNode.Parse(B64Url(parts[0])); if ((string?)header?["alg"] != "EdDSA") return null; var data = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}"); var sig = B64Url(parts[2]); foreach (var k in publicKeys) { var v = new Ed25519Signer(); v.Init(false, new Ed25519PublicKeyParameters(k, 0)); v.BlockUpdate(data, 0, data.Length); if (v.VerifySignature(sig)) return JsonNode.Parse(B64Url(parts[1])); } } catch (Exception e) when (e is FormatException or JsonException or ArgumentException) { } return null; } private Cache? Load() { try { var c = JsonSerializer.Deserialize(File.ReadAllText(_file)); return string.IsNullOrEmpty(c?.key) ? null : c; } catch (Exception e) when (e is IOException or JsonException or UnauthorizedAccessException) { return null; } } private void Save(Cache c) { Directory.CreateDirectory(Path.GetDirectoryName(_file)!); File.WriteAllText(_file, JsonSerializer.Serialize(c)); } private static string Platform() => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "windows" : "macos"; private static string MachineId() { try { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { #pragma warning disable CA1416 // guarded by the platform check above using var k = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Cryptography"); return k?.GetValue("MachineGuid") as string ?? ""; #pragma warning restore CA1416 } if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { using var p = Process.Start(new ProcessStartInfo("ioreg", "-rd1 -c IOPlatformExpertDevice") { RedirectStandardOutput = true, UseShellExecute = false }); var output = p!.StandardOutput.ReadToEnd(); p.WaitForExit(); return Regex.Match(output, "\"IOPlatformUUID\" = \"([^\"]+)\"").Groups[1].Value; } return File.Exists("/etc/machine-id") ? File.ReadAllText("/etc/machine-id").Trim() : ""; } catch (Exception) { return ""; } } private static byte[] B64Url(string s) { var t = s.Replace('-', '+').Replace('_', '/'); return Convert.FromBase64String(t.PadRight(t.Length + (4 - t.Length % 4) % 4, '=')); } private static string Truncate(string s, int n) => s.Length <= n ? s : s[..n]; } }