From 7d453f8f63453873f9fffdedf4fc3ad8601ba93d Mon Sep 17 00:00:00 2001 From: David Friedel Date: Thu, 25 Dec 2025 09:07:58 +0000 Subject: [PATCH] Initial commit: IronLicensing.Client SDK Software licensing SDK for .NET MAUI applications with: - License validation and activation - Feature gating - Trial support - Offline validation with signature verification - In-app purchase integration --- .gitignore | 6 + Exceptions/LicenseRequiredException.cs | 12 + Extensions/ServiceCollectionExtensions.cs | 63 +++ ILicenseCache.cs | 42 ++ ILicenseManager.cs | 48 +++ IMachineIdentifier.cs | 8 + ISignatureVerifier.cs | 16 + IronLicensing.Client.csproj | 32 ++ LICENSE | 21 + LicenseManager.cs | 448 +++++++++++++++++++++ LicensingOptions.cs | 56 +++ MachineIdentifier.cs | 38 ++ Models/ApiResponses.cs | 120 ++++++ Models/LicenseInfo.cs | 23 ++ Models/LicenseResult.cs | 21 + Platforms/Android/MachineIdentifier.cs | 32 ++ Platforms/MacCatalyst/MachineIdentifier.cs | 30 ++ Platforms/Windows/MachineIdentifier.cs | 32 ++ Platforms/iOS/MachineIdentifier.cs | 30 ++ README.md | 207 ++++++++++ RsaSignatureVerifier.cs | 59 +++ SecureStorageLicenseCache.cs | 107 +++++ 22 files changed, 1451 insertions(+) create mode 100644 .gitignore create mode 100644 Exceptions/LicenseRequiredException.cs create mode 100644 Extensions/ServiceCollectionExtensions.cs create mode 100644 ILicenseCache.cs create mode 100644 ILicenseManager.cs create mode 100644 IMachineIdentifier.cs create mode 100644 ISignatureVerifier.cs create mode 100755 IronLicensing.Client.csproj create mode 100644 LICENSE create mode 100644 LicenseManager.cs create mode 100644 LicensingOptions.cs create mode 100644 MachineIdentifier.cs create mode 100644 Models/ApiResponses.cs create mode 100644 Models/LicenseInfo.cs create mode 100644 Models/LicenseResult.cs create mode 100644 Platforms/Android/MachineIdentifier.cs create mode 100644 Platforms/MacCatalyst/MachineIdentifier.cs create mode 100644 Platforms/Windows/MachineIdentifier.cs create mode 100644 Platforms/iOS/MachineIdentifier.cs create mode 100644 README.md create mode 100644 RsaSignatureVerifier.cs create mode 100644 SecureStorageLicenseCache.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..776e6f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +*.user +*.suo +.vs/ +*.DotSettings.user diff --git a/Exceptions/LicenseRequiredException.cs b/Exceptions/LicenseRequiredException.cs new file mode 100644 index 0000000..ffdf80e --- /dev/null +++ b/Exceptions/LicenseRequiredException.cs @@ -0,0 +1,12 @@ +namespace IronLicensing.Client.Exceptions; + +public class LicenseRequiredException : Exception +{ + public string FeatureKey { get; } + + public LicenseRequiredException(string featureKey) + : base($"Feature '{featureKey}' requires a valid license") + { + FeatureKey = featureKey; + } +} diff --git a/Extensions/ServiceCollectionExtensions.cs b/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..919243e --- /dev/null +++ b/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace IronLicensing.Client.Extensions; + +public static class ServiceCollectionExtensions +{ + /// + /// Adds IronLicensing client services to the service collection + /// + /// The service collection + /// Configuration action for licensing options + /// The service collection for chaining + public static IServiceCollection AddIronLicensing(this IServiceCollection services, Action configure) + { + services.Configure(configure); + + // Core services + services.AddHttpClient(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } + + /// + /// Adds IronLicensing client services with simplified configuration + /// + /// The service collection + /// Your public API key (pk_live_xxx or pk_test_xxx) + /// Your product slug identifier + /// The service collection for chaining + public static IServiceCollection AddIronLicensing(this IServiceCollection services, string publicKey, string productSlug) + { + return services.AddIronLicensing(options => + { + options.PublicKey = publicKey; + options.ProductSlug = productSlug; + }); + } + + /// + /// Adds IronLicensing client services with custom API URL (for self-hosted installations) + /// + /// The service collection + /// Your public API key + /// Your product slug identifier + /// Custom API base URL + /// The service collection for chaining + public static IServiceCollection AddIronLicensing( + this IServiceCollection services, + string publicKey, + string productSlug, + string apiBaseUrl) + { + return services.AddIronLicensing(options => + { + options.PublicKey = publicKey; + options.ProductSlug = productSlug; + options.ApiBaseUrl = apiBaseUrl; + }); + } +} diff --git a/ILicenseCache.cs b/ILicenseCache.cs new file mode 100644 index 0000000..17c0742 --- /dev/null +++ b/ILicenseCache.cs @@ -0,0 +1,42 @@ +using IronLicensing.Client.Models; + +namespace IronLicensing.Client; + +public interface ILicenseCache +{ + /// + /// Gets the cached license information + /// + Task GetCachedLicenseAsync(); + + /// + /// Saves license information with optional signature for offline verification + /// + Task SaveLicenseAsync(LicenseInfo license, string? signature, string? signingPublicKey); + + /// + /// Clears all cached license data + /// + Task ClearAsync(); + + /// + /// Gets the cached signing public key + /// + Task GetSigningPublicKeyAsync(); + + /// + /// Saves the signing public key + /// + Task SaveSigningPublicKeyAsync(string publicKey); +} + +/// +/// Cached license data including signature for offline verification +/// +public class CachedLicenseData +{ + public LicenseInfo License { get; set; } = null!; + public string? Signature { get; set; } + public string? SigningPublicKey { get; set; } + public string? RawLicenseJson { get; set; } +} diff --git a/ILicenseManager.cs b/ILicenseManager.cs new file mode 100644 index 0000000..8e684bb --- /dev/null +++ b/ILicenseManager.cs @@ -0,0 +1,48 @@ +using IronLicensing.Client.Models; + +namespace IronLicensing.Client; + +public interface ILicenseManager +{ + // State + LicenseInfo? CurrentLicense { get; } + LicenseStatus Status { get; } + bool IsLicensed { get; } + bool IsTrial { get; } + int TrialDaysRemaining { get; } + + // Events + event EventHandler? LicenseChanged; + event EventHandler? ValidationCompleted; + + // Operations + Task ValidateAsync(string? licenseKey = null); + Task ActivateAsync(string licenseKey); + Task DeactivateAsync(); + Task StartPurchaseAsync(string tierId, string customerEmail); + + // Feature Gating + bool HasFeature(string featureKey); + Task HasFeatureAsync(string featureKey); + void RequireFeature(string featureKey); + + // Trial + Task StartTrialAsync(string email); + + // Cache management + Task ClearCacheAsync(); +} + +public class LicenseChangedEventArgs : EventArgs +{ + public LicenseInfo? OldLicense { get; init; } + public LicenseInfo? NewLicense { get; init; } +} + +public class LicenseValidationEventArgs : EventArgs +{ + public bool Success { get; init; } + public LicenseInfo? License { get; init; } + public string? ErrorCode { get; init; } + public string? ErrorMessage { get; init; } +} diff --git a/IMachineIdentifier.cs b/IMachineIdentifier.cs new file mode 100644 index 0000000..bd14ec1 --- /dev/null +++ b/IMachineIdentifier.cs @@ -0,0 +1,8 @@ +namespace IronLicensing.Client; + +public interface IMachineIdentifier +{ + string GetMachineId(); + string GetMachineName(); + string GetPlatform(); +} diff --git a/ISignatureVerifier.cs b/ISignatureVerifier.cs new file mode 100644 index 0000000..b8968da --- /dev/null +++ b/ISignatureVerifier.cs @@ -0,0 +1,16 @@ +namespace IronLicensing.Client; + +/// +/// Verifies license signatures for offline validation +/// +public interface ISignatureVerifier +{ + /// + /// Verifies that the data was signed with the corresponding private key + /// + /// The RSA public key in PEM format + /// The original data that was signed + /// The base64-encoded signature + /// True if signature is valid + bool Verify(string publicKeyPem, string data, string signature); +} diff --git a/IronLicensing.Client.csproj b/IronLicensing.Client.csproj new file mode 100755 index 0000000..3edd4ea --- /dev/null +++ b/IronLicensing.Client.csproj @@ -0,0 +1,32 @@ + + + + net9.0-android;net9.0-ios;net9.0-maccatalyst + $(TargetFrameworks);net9.0-windows10.0.19041.0 + true + true + enable + enable + + + IronLicensing.Client + 1.0.0 + David H Friedel Jr + MarketAlly + Client SDK for IronLicensing - Software Licensing Platform + licensing;software;activation;license-key + + 15.0 + 15.0 + 21.0 + 10.0.17763.0 + 10.0.17763.0 + + + + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..79f68de --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 IronServices + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LicenseManager.cs b/LicenseManager.cs new file mode 100644 index 0000000..2d6e923 --- /dev/null +++ b/LicenseManager.cs @@ -0,0 +1,448 @@ +using System.Net.Http.Json; +using System.Text.Json; +using IronLicensing.Client.Exceptions; +using IronLicensing.Client.Models; +using Microsoft.Extensions.Options; + +namespace IronLicensing.Client; + +public class LicenseManager : ILicenseManager +{ + private readonly HttpClient _httpClient; + private readonly LicensingOptions _options; + private readonly IMachineIdentifier _machineIdentifier; + private readonly ILicenseCache _cache; + private readonly ISignatureVerifier _signatureVerifier; + + private LicenseInfo? _currentLicense; + private readonly SemaphoreSlim _syncLock = new(1, 1); + + public LicenseInfo? CurrentLicense => _currentLicense; + + public LicenseStatus Status => _currentLicense?.Status ?? LicenseStatus.Unknown; + + public bool IsLicensed => _currentLicense != null && + (_currentLicense.Status == LicenseStatus.Active || _currentLicense.Status == LicenseStatus.Trial); + + public bool IsTrial => _currentLicense?.Status == LicenseStatus.Trial; + + public int TrialDaysRemaining + { + get + { + if (_currentLicense?.Status != LicenseStatus.Trial || !_currentLicense.ExpiresAt.HasValue) + return 0; + var remaining = (_currentLicense.ExpiresAt.Value - DateTime.UtcNow).Days; + return Math.Max(0, remaining); + } + } + + public event EventHandler? LicenseChanged; + public event EventHandler? ValidationCompleted; + + public LicenseManager( + HttpClient httpClient, + IOptions options, + IMachineIdentifier machineIdentifier, + ILicenseCache cache, + ISignatureVerifier signatureVerifier) + { + _httpClient = httpClient; + _options = options.Value; + _machineIdentifier = machineIdentifier; + _cache = cache; + _signatureVerifier = signatureVerifier; + + ConfigureHttpClient(); + } + + private void ConfigureHttpClient() + { + _httpClient.BaseAddress = new Uri(_options.ApiBaseUrl.TrimEnd('/') + "/"); + _httpClient.DefaultRequestHeaders.Add("X-Public-Key", _options.PublicKey); + _httpClient.DefaultRequestHeaders.Add("X-Product-Slug", _options.ProductSlug); + _httpClient.Timeout = _options.HttpTimeout; + } + + public async Task ValidateAsync(string? licenseKey = null) + { + await _syncLock.WaitAsync(); + try + { + var key = licenseKey ?? _currentLicense?.LicenseKey; + if (string.IsNullOrEmpty(key)) + { + // Try to load from cache + var cached = await _cache.GetCachedLicenseAsync(); + if (cached != null) + { + key = cached.License.LicenseKey; + } + } + + if (string.IsNullOrEmpty(key)) + { + return LicenseResult.Fail("NO_LICENSE", "No license key provided"); + } + + try + { + var request = new + { + licenseKey = key, + machineId = _machineIdentifier.GetMachineId(), + machineName = _machineIdentifier.GetMachineName(), + platform = _machineIdentifier.GetPlatform(), + appVersion = GetAppVersion() + }; + + var response = await _httpClient.PostAsJsonAsync("licenses/validate", request); + var result = await response.Content.ReadFromJsonAsync(); + + if (result == null) + { + return await HandleOfflineValidation(key); + } + + if (result.Valid && result.License != null) + { + var licenseInfo = MapToLicenseInfo(key, result.License); + await UpdateLicense(licenseInfo); + + // Cache with signature for offline validation + var rawJson = JsonSerializer.Serialize(result.License); + await _cache.SaveLicenseAsync(licenseInfo, result.Signature, result.SigningPublicKey); + + // Save signing public key if provided + if (!string.IsNullOrEmpty(result.SigningPublicKey)) + { + await _cache.SaveSigningPublicKeyAsync(result.SigningPublicKey); + } + + RaiseValidationCompleted(true, licenseInfo, null, null); + return LicenseResult.Ok(licenseInfo); + } + else + { + RaiseValidationCompleted(false, null, result.Error?.Code, result.Error?.Message); + return LicenseResult.Fail(result.Error?.Code ?? "UNKNOWN", result.Error?.Message ?? "Validation failed"); + } + } + catch (HttpRequestException) + { + return await HandleOfflineValidation(key); + } + catch (TaskCanceledException) + { + return await HandleOfflineValidation(key); + } + } + finally + { + _syncLock.Release(); + } + } + + private async Task HandleOfflineValidation(string licenseKey) + { + var cached = await _cache.GetCachedLicenseAsync(); + if (cached == null || cached.License.LicenseKey != licenseKey) + { + RaiseValidationCompleted(false, null, "OFFLINE", "Cannot validate license while offline"); + return LicenseResult.Fail("OFFLINE", "Cannot validate license while offline"); + } + + // Check offline grace period + var daysSinceValidation = (DateTime.UtcNow - cached.License.LastValidated).TotalDays; + if (daysSinceValidation > _options.OfflineGraceDays) + { + RaiseValidationCompleted(false, null, "OFFLINE_EXPIRED", "Offline grace period expired. Please connect to the internet to validate your license."); + return LicenseResult.Fail("OFFLINE_EXPIRED", "Offline grace period expired. Please connect to the internet to validate your license."); + } + + // Verify signature if required + if (_options.RequireSignatureForOffline) + { + var publicKey = _options.SigningPublicKey ?? cached.SigningPublicKey ?? await _cache.GetSigningPublicKeyAsync(); + + if (string.IsNullOrEmpty(publicKey) || string.IsNullOrEmpty(cached.Signature) || string.IsNullOrEmpty(cached.RawLicenseJson)) + { + // No signature available - check if we allow this + if (_options.RequireSignatureForOffline) + { + RaiseValidationCompleted(false, null, "NO_SIGNATURE", "Offline validation requires a signed license"); + return LicenseResult.Fail("NO_SIGNATURE", "Offline validation requires a signed license"); + } + } + else + { + // Verify signature + var isValid = _signatureVerifier.Verify(publicKey, cached.RawLicenseJson, cached.Signature); + if (!isValid) + { + RaiseValidationCompleted(false, null, "INVALID_SIGNATURE", "License signature verification failed"); + return LicenseResult.Fail("INVALID_SIGNATURE", "License signature verification failed"); + } + } + } + + // Valid offline license + await UpdateLicense(cached.License); + RaiseValidationCompleted(true, cached.License, null, null); + return LicenseResult.Ok(cached.License); + } + + public async Task ActivateAsync(string licenseKey) + { + await _syncLock.WaitAsync(); + try + { + var request = new + { + licenseKey, + machineId = _machineIdentifier.GetMachineId(), + machineName = _machineIdentifier.GetMachineName(), + platform = _machineIdentifier.GetPlatform(), + appVersion = GetAppVersion() + }; + + var response = await _httpClient.PostAsJsonAsync("licenses/activate", request); + var result = await response.Content.ReadFromJsonAsync(); + + if (result?.Success == true) + { + // Release lock before calling ValidateAsync (it acquires its own lock) + _syncLock.Release(); + try + { + return await ValidateAsync(licenseKey); + } + finally + { + await _syncLock.WaitAsync(); + } + } + + return LicenseResult.Fail(result?.Error?.Code ?? "UNKNOWN", result?.Error?.Message ?? "Activation failed"); + } + catch (Exception ex) + { + return LicenseResult.Fail("ERROR", ex.Message); + } + finally + { + _syncLock.Release(); + } + } + + public async Task DeactivateAsync() + { + if (_currentLicense == null) + return false; + + await _syncLock.WaitAsync(); + try + { + var request = new + { + licenseKey = _currentLicense.LicenseKey, + machineId = _machineIdentifier.GetMachineId() + }; + + var response = await _httpClient.PostAsJsonAsync("licenses/deactivate", request); + var result = await response.Content.ReadFromJsonAsync(); + + if (result?.Success == true) + { + await ClearCacheAsync(); + await UpdateLicense(null); + return true; + } + + return false; + } + catch + { + return false; + } + finally + { + _syncLock.Release(); + } + } + + public async Task StartPurchaseAsync(string tierId, string customerEmail) + { + try + { + var request = new + { + tierId, + customerEmail, + successUrl = _options.SuccessUrl, + cancelUrl = _options.CancelUrl, + metadata = new Dictionary + { + ["machineId"] = _machineIdentifier.GetMachineId() + } + }; + + var response = await _httpClient.PostAsJsonAsync("checkout/create", request); + var result = await response.Content.ReadFromJsonAsync(); + + if (result != null) + { + return new CheckoutResult + { + Success = true, + SessionId = result.SessionId, + CheckoutUrl = result.CheckoutUrl, + ExpiresAt = result.ExpiresAt + }; + } + + return new CheckoutResult { Success = false, ErrorMessage = "Failed to create checkout session" }; + } + catch (Exception ex) + { + return new CheckoutResult { Success = false, ErrorMessage = ex.Message }; + } + } + + public bool HasFeature(string featureKey) + { + if (_currentLicense == null) + return false; + + return _currentLicense.Features.Contains(featureKey) || _currentLicense.Features.Contains("*"); + } + + public async Task HasFeatureAsync(string featureKey) + { + if (_currentLicense == null) + return false; + + // Check local first + if (HasFeature(featureKey)) + return true; + + // Check with server + try + { + var response = await _httpClient.GetAsync($"licenses/{_currentLicense.LicenseKey}/features/{featureKey}"); + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync(); + return result.TryGetProperty("enabled", out var enabled) && enabled.GetBoolean(); + } + } + catch + { + // Fall back to local check + } + + return false; + } + + public void RequireFeature(string featureKey) + { + if (!HasFeature(featureKey)) + { + throw new LicenseRequiredException(featureKey); + } + } + + public async Task StartTrialAsync(string email) + { + try + { + var request = new + { + email, + machineId = _machineIdentifier.GetMachineId(), + machineName = _machineIdentifier.GetMachineName(), + platform = _machineIdentifier.GetPlatform() + }; + + var response = await _httpClient.PostAsJsonAsync("trial/start", request); + var result = await response.Content.ReadFromJsonAsync(); + + if (result?.Success == true && !string.IsNullOrEmpty(result.LicenseKey)) + { + // Activate the trial license + var activationResult = await ActivateAsync(result.LicenseKey); + return activationResult.Success; + } + + return false; + } + catch + { + return false; + } + } + + public async Task ClearCacheAsync() + { + await _cache.ClearAsync(); + } + + private async Task UpdateLicense(LicenseInfo? license) + { + var oldLicense = _currentLicense; + _currentLicense = license; + + LicenseChanged?.Invoke(this, new LicenseChangedEventArgs + { + OldLicense = oldLicense, + NewLicense = license + }); + + await Task.CompletedTask; + } + + private void RaiseValidationCompleted(bool success, LicenseInfo? license, string? errorCode, string? errorMessage) + { + ValidationCompleted?.Invoke(this, new LicenseValidationEventArgs + { + Success = success, + License = license, + ErrorCode = errorCode, + ErrorMessage = errorMessage + }); + } + + private static LicenseInfo MapToLicenseInfo(string licenseKey, ValidationLicenseInfo validation) + { + return new LicenseInfo + { + LicenseKey = licenseKey, + Tier = validation.Tier, + Status = ParseStatus(validation.Status), + ExpiresAt = validation.ExpiresAt, + Features = validation.Features, + ActivationsUsed = validation.ActivationsUsed, + ActivationsMax = validation.ActivationsMax, + LastValidated = DateTime.UtcNow + }; + } + + private static LicenseStatus ParseStatus(string status) + { + return status.ToLowerInvariant() switch + { + "trial" => LicenseStatus.Trial, + "active" => LicenseStatus.Active, + "expired" => LicenseStatus.Expired, + "suspended" => LicenseStatus.Suspended, + "revoked" => LicenseStatus.Revoked, + _ => LicenseStatus.Unknown + }; + } + + private static string GetAppVersion() + { + var assembly = System.Reflection.Assembly.GetEntryAssembly(); + return assembly?.GetName().Version?.ToString() ?? "1.0.0"; + } +} diff --git a/LicensingOptions.cs b/LicensingOptions.cs new file mode 100644 index 0000000..67330ad --- /dev/null +++ b/LicensingOptions.cs @@ -0,0 +1,56 @@ +namespace IronLicensing.Client; + +public class LicensingOptions +{ + /// + /// Public API key for client authentication (pk_live_xxx or pk_test_xxx) + /// + public string PublicKey { get; set; } = string.Empty; + + /// + /// Product identifier slug (e.g., "git-cleaner") + /// + public string ProductSlug { get; set; } = string.Empty; + + /// + /// Base URL for the IronLicensing API + /// + public string ApiBaseUrl { get; set; } = "https://ironlicensing.com/api/v1"; + + /// + /// Number of days to allow offline usage before requiring online validation + /// + public int OfflineGraceDays { get; set; } = 7; + + /// + /// How often to re-validate license when online (in minutes) + /// + public int CacheValidationMinutes { get; set; } = 60; + + /// + /// HTTP request timeout + /// + public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Optional signing public key for offline signature verification. + /// If not provided, will be fetched from server and cached. + /// + public string? SigningPublicKey { get; set; } + + /// + /// Enable strict offline validation requiring valid signatures. + /// If false, allows offline validation without signature verification. + /// + public bool RequireSignatureForOffline { get; set; } = true; + + /// + /// Custom success URL for purchase redirects (deep link) + /// + public string SuccessUrl { get; set; } = "ironlicensing://purchase-success"; + + /// + /// Custom cancel URL for purchase redirects (deep link) + /// + public string CancelUrl { get; set; } = "ironlicensing://purchase-cancelled"; +} diff --git a/MachineIdentifier.cs b/MachineIdentifier.cs new file mode 100644 index 0000000..4a0e169 --- /dev/null +++ b/MachineIdentifier.cs @@ -0,0 +1,38 @@ +using System.Security.Cryptography; +using System.Text; + +namespace IronLicensing.Client; + +public partial class MachineIdentifier : IMachineIdentifier +{ + public string GetMachineId() + { + var components = GetMachineComponents(); + var combined = string.Join("|", components.Where(c => !string.IsNullOrEmpty(c))); + + if (string.IsNullOrEmpty(combined)) + { + // Fallback to a device-specific identifier + combined = DeviceInfo.Current.Idiom.ToString() + "|" + + DeviceInfo.Current.Manufacturer + "|" + + DeviceInfo.Current.Model; + } + + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(combined)); + return Convert.ToBase64String(hash)[..32]; + } + + public string GetMachineName() + { + return DeviceInfo.Current.Name ?? Environment.MachineName ?? "Unknown"; + } + + public string GetPlatform() + { + return DeviceInfo.Current.Platform.ToString(); + } + + // Platform-specific implementation + private partial List GetMachineComponents(); +} diff --git a/Models/ApiResponses.cs b/Models/ApiResponses.cs new file mode 100644 index 0000000..98627a2 --- /dev/null +++ b/Models/ApiResponses.cs @@ -0,0 +1,120 @@ +using System.Text.Json.Serialization; + +namespace IronLicensing.Client.Models; + +internal class ValidationResponse +{ + [JsonPropertyName("valid")] + public bool Valid { get; set; } + + [JsonPropertyName("license")] + public ValidationLicenseInfo? License { get; set; } + + [JsonPropertyName("signature")] + public string? Signature { get; set; } + + [JsonPropertyName("signingPublicKey")] + public string? SigningPublicKey { get; set; } + + [JsonPropertyName("signedAt")] + public DateTime? SignedAt { get; set; } + + [JsonPropertyName("error")] + public ApiError? Error { get; set; } +} + +internal class ValidationLicenseInfo +{ + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("tier")] + public string Tier { get; set; } = string.Empty; + + [JsonPropertyName("customerEmail")] + public string CustomerEmail { get; set; } = string.Empty; + + [JsonPropertyName("expiresAt")] + public DateTime? ExpiresAt { get; set; } + + [JsonPropertyName("features")] + public List Features { get; set; } = []; + + [JsonPropertyName("activationsUsed")] + public int ActivationsUsed { get; set; } + + [JsonPropertyName("activationsMax")] + public int ActivationsMax { get; set; } +} + +internal class ActivationResponse +{ + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("activation")] + public ActivationInfo? Activation { get; set; } + + [JsonPropertyName("license")] + public ActivationLicenseInfo? License { get; set; } + + [JsonPropertyName("error")] + public ApiError? Error { get; set; } +} + +internal class ActivationInfo +{ + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("activatedAt")] + public DateTime ActivatedAt { get; set; } +} + +internal class ActivationLicenseInfo +{ + [JsonPropertyName("activationsUsed")] + public int ActivationsUsed { get; set; } + + [JsonPropertyName("activationsMax")] + public int ActivationsMax { get; set; } +} + +internal class ApiError +{ + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; +} + +internal class CheckoutApiResponse +{ + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + [JsonPropertyName("checkoutUrl")] + public string CheckoutUrl { get; set; } = string.Empty; + + [JsonPropertyName("expiresAt")] + public DateTime ExpiresAt { get; set; } +} + +internal class TrialResponse +{ + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("licenseKey")] + public string? LicenseKey { get; set; } + + [JsonPropertyName("trialEndsAt")] + public DateTime? TrialEndsAt { get; set; } + + [JsonPropertyName("error")] + public ApiError? Error { get; set; } +} diff --git a/Models/LicenseInfo.cs b/Models/LicenseInfo.cs new file mode 100644 index 0000000..e42b36f --- /dev/null +++ b/Models/LicenseInfo.cs @@ -0,0 +1,23 @@ +namespace IronLicensing.Client.Models; + +public class LicenseInfo +{ + public string LicenseKey { get; set; } = string.Empty; + public string Tier { get; set; } = string.Empty; + public LicenseStatus Status { get; set; } + public DateTime? ExpiresAt { get; set; } + public List Features { get; set; } = []; + public int ActivationsUsed { get; set; } + public int ActivationsMax { get; set; } + public DateTime LastValidated { get; set; } +} + +public enum LicenseStatus +{ + Unknown, + Trial, + Active, + Expired, + Suspended, + Revoked +} diff --git a/Models/LicenseResult.cs b/Models/LicenseResult.cs new file mode 100644 index 0000000..103d426 --- /dev/null +++ b/Models/LicenseResult.cs @@ -0,0 +1,21 @@ +namespace IronLicensing.Client.Models; + +public class LicenseResult +{ + public bool Success { get; set; } + public LicenseInfo? License { get; set; } + public string? ErrorCode { get; set; } + public string? ErrorMessage { get; set; } + + public static LicenseResult Ok(LicenseInfo license) => new() { Success = true, License = license }; + public static LicenseResult Fail(string code, string message) => new() { Success = false, ErrorCode = code, ErrorMessage = message }; +} + +public class CheckoutResult +{ + public bool Success { get; set; } + public string? SessionId { get; set; } + public string? CheckoutUrl { get; set; } + public DateTime? ExpiresAt { get; set; } + public string? ErrorMessage { get; set; } +} diff --git a/Platforms/Android/MachineIdentifier.cs b/Platforms/Android/MachineIdentifier.cs new file mode 100644 index 0000000..a2e04be --- /dev/null +++ b/Platforms/Android/MachineIdentifier.cs @@ -0,0 +1,32 @@ +using Android.Provider; + +namespace IronLicensing.Client; + +public partial class MachineIdentifier +{ + private partial List GetMachineComponents() + { + var components = new List(); + + try + { + // Android ID - unique to each app/device combination + var context = Android.App.Application.Context; + var androidId = Settings.Secure.GetString(context.ContentResolver, Settings.Secure.AndroidId); + if (!string.IsNullOrEmpty(androidId)) + { + components.Add(androidId); + } + + components.Add(Android.OS.Build.Brand ?? ""); + components.Add(Android.OS.Build.Model ?? ""); + components.Add(Android.OS.Build.Serial ?? ""); + } + catch + { + // Fallback + } + + return components; + } +} diff --git a/Platforms/MacCatalyst/MachineIdentifier.cs b/Platforms/MacCatalyst/MachineIdentifier.cs new file mode 100644 index 0000000..51e9702 --- /dev/null +++ b/Platforms/MacCatalyst/MachineIdentifier.cs @@ -0,0 +1,30 @@ +using UIKit; + +namespace IronLicensing.Client; + +public partial class MachineIdentifier +{ + private partial List GetMachineComponents() + { + var components = new List(); + + try + { + // macOS/Catalyst vendor identifier + var vendorId = UIDevice.CurrentDevice.IdentifierForVendor?.ToString(); + if (!string.IsNullOrEmpty(vendorId)) + { + components.Add(vendorId); + } + + components.Add(UIDevice.CurrentDevice.Model); + components.Add(UIDevice.CurrentDevice.SystemName); + } + catch + { + // Fallback + } + + return components; + } +} diff --git a/Platforms/Windows/MachineIdentifier.cs b/Platforms/Windows/MachineIdentifier.cs new file mode 100644 index 0000000..fec5c96 --- /dev/null +++ b/Platforms/Windows/MachineIdentifier.cs @@ -0,0 +1,32 @@ +namespace IronLicensing.Client; + +public partial class MachineIdentifier +{ + private partial List GetMachineComponents() + { + var components = new List(); + + try + { + // Try to get Windows-specific identifiers + // Note: Full WMI access requires additional setup + components.Add(Environment.MachineName); + components.Add(Environment.OSVersion.ToString()); + + // Use registry for additional machine identification + using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Cryptography"); + var machineGuid = key?.GetValue("MachineGuid")?.ToString(); + if (!string.IsNullOrEmpty(machineGuid)) + { + components.Add(machineGuid); + } + } + catch + { + // Fallback if registry access fails + components.Add(Environment.MachineName); + } + + return components; + } +} diff --git a/Platforms/iOS/MachineIdentifier.cs b/Platforms/iOS/MachineIdentifier.cs new file mode 100644 index 0000000..f0b211a --- /dev/null +++ b/Platforms/iOS/MachineIdentifier.cs @@ -0,0 +1,30 @@ +using UIKit; + +namespace IronLicensing.Client; + +public partial class MachineIdentifier +{ + private partial List GetMachineComponents() + { + var components = new List(); + + try + { + // iOS vendor identifier + var vendorId = UIDevice.CurrentDevice.IdentifierForVendor?.ToString(); + if (!string.IsNullOrEmpty(vendorId)) + { + components.Add(vendorId); + } + + components.Add(UIDevice.CurrentDevice.Model); + components.Add(UIDevice.CurrentDevice.SystemName); + } + catch + { + // Fallback + } + + return components; + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb69e78 --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +# IronLicensing.Client + +Software licensing SDK for .NET MAUI applications. Add license validation, activation management, feature gating, and trial support to your apps. + +[![NuGet](https://img.shields.io/nuget/v/IronLicensing.Client.svg)](https://www.nuget.org/packages/IronLicensing.Client) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +## Installation + +```bash +dotnet add package IronLicensing.Client +``` + +## Quick Start + +### 1. Register Services + +```csharp +using IronLicensing.Client.Extensions; + +builder.Services.AddIronLicensing(options => +{ + options.PublicKey = "pk_live_xxxxxxxxxx"; + options.ProductSlug = "my-product"; + options.OfflineGraceDays = 7; +}); +``` + +### 2. Validate a License + +```csharp +using IronLicensing.Client; + +public class MyViewModel +{ + private readonly ILicenseManager _license; + + public MyViewModel(ILicenseManager license) + { + _license = license; + } + + public async Task ValidateLicenseAsync() + { + var result = await _license.ValidateAsync("IRON-XXXX-XXXX-XXXX-XXXX"); + + if (result.Success) + { + Console.WriteLine($"Licensed! Tier: {result.License.Tier}"); + } + else + { + Console.WriteLine($"Error: {result.ErrorMessage}"); + } + } +} +``` + +### 3. Feature Gating + +```csharp +// Check if feature is available +if (_license.HasFeature("advanced-reports")) +{ + ShowAdvancedReports(); +} + +// Or throw if feature is required +_license.RequireFeature("advanced-reports"); // throws LicenseRequiredException +``` + +## Configuration Options + +```csharp +builder.Services.AddIronLicensing(options => +{ + // Required + options.PublicKey = "pk_live_xxxxxxxxxx"; + options.ProductSlug = "my-product"; + + // Offline support + options.OfflineGraceDays = 7; // Days license is valid offline + options.RequireSignatureForOffline = true; // Require signed license for offline + + // API settings + options.ApiBaseUrl = "https://ironlicensing.com"; + options.HttpTimeout = TimeSpan.FromSeconds(30); + + // In-app purchase URLs + options.SuccessUrl = "myapp://purchase/success"; + options.CancelUrl = "myapp://purchase/cancel"; +}); +``` + +## License Activation + +```csharp +// Activate a license key +var result = await _license.ActivateAsync("IRON-XXXX-XXXX-XXXX-XXXX"); + +if (result.Success) +{ + // License is now active on this device + Console.WriteLine($"Activated! {_license.CurrentLicense.ActivationsUsed}/{_license.CurrentLicense.ActivationsMax}"); +} + +// Deactivate (free up activation slot) +await _license.DeactivateAsync(); +``` + +## Trial Support + +```csharp +// Start a trial +var success = await _license.StartTrialAsync("user@example.com"); + +if (success) +{ + Console.WriteLine($"Trial started! {_license.TrialDaysRemaining} days remaining"); +} + +// Check trial status +if (_license.IsTrial) +{ + ShowTrialBanner(_license.TrialDaysRemaining); +} +``` + +## In-App Purchase + +```csharp +// Start checkout flow +var checkout = await _license.StartPurchaseAsync("tier-id", "customer@example.com"); + +if (checkout.Success) +{ + // Open checkout URL in browser + await Launcher.OpenAsync(checkout.CheckoutUrl); +} +``` + +## Events + +```csharp +// Listen for license changes +_license.LicenseChanged += (sender, args) => +{ + Console.WriteLine($"License changed from {args.OldLicense?.Status} to {args.NewLicense?.Status}"); +}; + +// Listen for validation results +_license.ValidationCompleted += (sender, args) => +{ + if (!args.Success) + { + Console.WriteLine($"Validation failed: {args.ErrorCode} - {args.ErrorMessage}"); + } +}; +``` + +## License Status + +```csharp +// Quick status checks +bool isLicensed = _license.IsLicensed; // Active or Trial +bool isTrial = _license.IsTrial; +LicenseStatus status = _license.Status; // Active, Trial, Expired, Suspended, Revoked + +// Full license info +var license = _license.CurrentLicense; +Console.WriteLine($"Tier: {license.Tier}"); +Console.WriteLine($"Expires: {license.ExpiresAt}"); +Console.WriteLine($"Features: {string.Join(", ", license.Features)}"); +``` + +## Offline Support + +Licenses are cached locally with cryptographic signatures for secure offline validation: + +- **Grace Period**: Configurable days the license is valid without server contact +- **Signature Verification**: Optional RSA signature verification for offline licenses +- **Automatic Sync**: License is re-validated when connectivity is restored + +```csharp +// Clear cached license +await _license.ClearCacheAsync(); +``` + +## Platform Support + +| Platform | Minimum Version | +|----------|-----------------| +| Android | 5.0 (API 21) | +| iOS | 15.0 | +| macOS (Catalyst) | 15.0 | +| Windows | 10.0.17763.0 | + +## Links + +- [Documentation](https://www.ironlicensing.com/docs) +- [Dashboard](https://www.ironlicensing.com) +- [API Reference](https://www.ironlicensing.com/docs/api) +- [Support](https://www.ironlicensing.com/app/tickets) + +## License + +MIT License - see [LICENSE](LICENSE) for details. diff --git a/RsaSignatureVerifier.cs b/RsaSignatureVerifier.cs new file mode 100644 index 0000000..1ba182e --- /dev/null +++ b/RsaSignatureVerifier.cs @@ -0,0 +1,59 @@ +using System.Security.Cryptography; +using System.Text; + +namespace IronLicensing.Client; + +/// +/// RSA signature verifier for offline license validation +/// +public class RsaSignatureVerifier : ISignatureVerifier +{ + public bool Verify(string publicKeyPem, string data, string signature) + { + if (string.IsNullOrEmpty(publicKeyPem) || + string.IsNullOrEmpty(data) || + string.IsNullOrEmpty(signature)) + { + return false; + } + + try + { + using var rsa = RSA.Create(); + + // Import the public key + var keyBytes = ParsePemPublicKey(publicKeyPem); + rsa.ImportSubjectPublicKeyInfo(keyBytes, out _); + + // Verify the signature + var dataBytes = Encoding.UTF8.GetBytes(data); + var signatureBytes = Convert.FromBase64String(signature); + + return rsa.VerifyData( + dataBytes, + signatureBytes, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + } + catch + { + return false; + } + } + + private static byte[] ParsePemPublicKey(string pem) + { + // Remove PEM headers/footers and whitespace + var base64 = pem + .Replace("-----BEGIN PUBLIC KEY-----", "") + .Replace("-----END PUBLIC KEY-----", "") + .Replace("-----BEGIN RSA PUBLIC KEY-----", "") + .Replace("-----END RSA PUBLIC KEY-----", "") + .Replace("\n", "") + .Replace("\r", "") + .Replace(" ", "") + .Trim(); + + return Convert.FromBase64String(base64); + } +} diff --git a/SecureStorageLicenseCache.cs b/SecureStorageLicenseCache.cs new file mode 100644 index 0000000..b8cdc13 --- /dev/null +++ b/SecureStorageLicenseCache.cs @@ -0,0 +1,107 @@ +using System.Text.Json; +using IronLicensing.Client.Models; + +namespace IronLicensing.Client; + +public class SecureStorageLicenseCache : ILicenseCache +{ + private const string LicenseCacheKey = "iron_license_cache"; + private const string SignatureCacheKey = "iron_license_signature"; + private const string PublicKeyCacheKey = "iron_signing_public_key"; + private const string RawJsonCacheKey = "iron_license_raw_json"; + + public async Task GetCachedLicenseAsync() + { + try + { + var cached = await SecureStorage.Default.GetAsync(LicenseCacheKey); + if (string.IsNullOrEmpty(cached)) + return null; + + var license = JsonSerializer.Deserialize(cached); + if (license == null) + return null; + + var signature = await SecureStorage.Default.GetAsync(SignatureCacheKey); + var publicKey = await SecureStorage.Default.GetAsync(PublicKeyCacheKey); + var rawJson = await SecureStorage.Default.GetAsync(RawJsonCacheKey); + + return new CachedLicenseData + { + License = license, + Signature = signature, + SigningPublicKey = publicKey, + RawLicenseJson = rawJson + }; + } + catch + { + return null; + } + } + + public async Task SaveLicenseAsync(LicenseInfo license, string? signature, string? signingPublicKey) + { + try + { + var json = JsonSerializer.Serialize(license); + await SecureStorage.Default.SetAsync(LicenseCacheKey, json); + await SecureStorage.Default.SetAsync(RawJsonCacheKey, json); + + if (!string.IsNullOrEmpty(signature)) + { + await SecureStorage.Default.SetAsync(SignatureCacheKey, signature); + } + + if (!string.IsNullOrEmpty(signingPublicKey)) + { + await SecureStorage.Default.SetAsync(PublicKeyCacheKey, signingPublicKey); + } + } + catch + { + // Ignore storage errors + } + } + + public async Task GetSigningPublicKeyAsync() + { + try + { + return await SecureStorage.Default.GetAsync(PublicKeyCacheKey); + } + catch + { + return null; + } + } + + public async Task SaveSigningPublicKeyAsync(string publicKey) + { + try + { + await SecureStorage.Default.SetAsync(PublicKeyCacheKey, publicKey); + } + catch + { + // Ignore storage errors + } + } + + public Task ClearAsync() + { + try + { + SecureStorage.Default.Remove(LicenseCacheKey); + SecureStorage.Default.Remove(SignatureCacheKey); + SecureStorage.Default.Remove(PublicKeyCacheKey); + SecureStorage.Default.Remove(RawJsonCacheKey); + } + catch + { + // Ignore storage errors + } + + return Task.CompletedTask; + } +}