commit 7d453f8f63453873f9fffdedf4fc3ad8601ba93d Author: David Friedel Date: Thu Dec 25 09:07:58 2025 +0000 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 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; + } +}