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
This commit is contained in:
David Friedel 2025-12-25 09:07:58 +00:00
commit 7d453f8f63
22 changed files with 1451 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
bin/
obj/
*.user
*.suo
.vs/
*.DotSettings.user

View File

@ -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;
}
}

View File

@ -0,0 +1,63 @@
using Microsoft.Extensions.DependencyInjection;
namespace IronLicensing.Client.Extensions;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds IronLicensing client services to the service collection
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configure">Configuration action for licensing options</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddIronLicensing(this IServiceCollection services, Action<LicensingOptions> configure)
{
services.Configure(configure);
// Core services
services.AddHttpClient<ILicenseManager, LicenseManager>();
services.AddSingleton<IMachineIdentifier, MachineIdentifier>();
services.AddSingleton<ILicenseCache, SecureStorageLicenseCache>();
services.AddSingleton<ISignatureVerifier, RsaSignatureVerifier>();
return services;
}
/// <summary>
/// Adds IronLicensing client services with simplified configuration
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="publicKey">Your public API key (pk_live_xxx or pk_test_xxx)</param>
/// <param name="productSlug">Your product slug identifier</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddIronLicensing(this IServiceCollection services, string publicKey, string productSlug)
{
return services.AddIronLicensing(options =>
{
options.PublicKey = publicKey;
options.ProductSlug = productSlug;
});
}
/// <summary>
/// Adds IronLicensing client services with custom API URL (for self-hosted installations)
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="publicKey">Your public API key</param>
/// <param name="productSlug">Your product slug identifier</param>
/// <param name="apiBaseUrl">Custom API base URL</param>
/// <returns>The service collection for chaining</returns>
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;
});
}
}

42
ILicenseCache.cs Normal file
View File

@ -0,0 +1,42 @@
using IronLicensing.Client.Models;
namespace IronLicensing.Client;
public interface ILicenseCache
{
/// <summary>
/// Gets the cached license information
/// </summary>
Task<CachedLicenseData?> GetCachedLicenseAsync();
/// <summary>
/// Saves license information with optional signature for offline verification
/// </summary>
Task SaveLicenseAsync(LicenseInfo license, string? signature, string? signingPublicKey);
/// <summary>
/// Clears all cached license data
/// </summary>
Task ClearAsync();
/// <summary>
/// Gets the cached signing public key
/// </summary>
Task<string?> GetSigningPublicKeyAsync();
/// <summary>
/// Saves the signing public key
/// </summary>
Task SaveSigningPublicKeyAsync(string publicKey);
}
/// <summary>
/// Cached license data including signature for offline verification
/// </summary>
public class CachedLicenseData
{
public LicenseInfo License { get; set; } = null!;
public string? Signature { get; set; }
public string? SigningPublicKey { get; set; }
public string? RawLicenseJson { get; set; }
}

48
ILicenseManager.cs Normal file
View File

@ -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<LicenseChangedEventArgs>? LicenseChanged;
event EventHandler<LicenseValidationEventArgs>? ValidationCompleted;
// Operations
Task<LicenseResult> ValidateAsync(string? licenseKey = null);
Task<LicenseResult> ActivateAsync(string licenseKey);
Task<bool> DeactivateAsync();
Task<CheckoutResult> StartPurchaseAsync(string tierId, string customerEmail);
// Feature Gating
bool HasFeature(string featureKey);
Task<bool> HasFeatureAsync(string featureKey);
void RequireFeature(string featureKey);
// Trial
Task<bool> 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; }
}

8
IMachineIdentifier.cs Normal file
View File

@ -0,0 +1,8 @@
namespace IronLicensing.Client;
public interface IMachineIdentifier
{
string GetMachineId();
string GetMachineName();
string GetPlatform();
}

16
ISignatureVerifier.cs Normal file
View File

@ -0,0 +1,16 @@
namespace IronLicensing.Client;
/// <summary>
/// Verifies license signatures for offline validation
/// </summary>
public interface ISignatureVerifier
{
/// <summary>
/// Verifies that the data was signed with the corresponding private key
/// </summary>
/// <param name="publicKeyPem">The RSA public key in PEM format</param>
/// <param name="data">The original data that was signed</param>
/// <param name="signature">The base64-encoded signature</param>
/// <returns>True if signature is valid</returns>
bool Verify(string publicKeyPem, string data, string signature);
}

32
IronLicensing.Client.csproj Executable file
View File

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net9.0-android;net9.0-ios;net9.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net9.0-windows10.0.19041.0</TargetFrameworks>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- NuGet Package Properties -->
<PackageId>IronLicensing.Client</PackageId>
<Version>1.0.0</Version>
<Authors>David H Friedel Jr</Authors>
<Company>MarketAlly</Company>
<Description>Client SDK for IronLicensing - Software Licensing Platform</Description>
<PackageTags>licensing;software;activation;license-key</PackageTags>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
<PackageReference Include="System.Text.Json" Version="9.0.0" />
</ItemGroup>
</Project>

21
LICENSE Normal file
View File

@ -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.

448
LicenseManager.cs Normal file
View File

@ -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<LicenseChangedEventArgs>? LicenseChanged;
public event EventHandler<LicenseValidationEventArgs>? ValidationCompleted;
public LicenseManager(
HttpClient httpClient,
IOptions<LicensingOptions> 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<LicenseResult> 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<ValidationResponse>();
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<LicenseResult> 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<LicenseResult> 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<ActivationResponse>();
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<bool> 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<ActivationResponse>();
if (result?.Success == true)
{
await ClearCacheAsync();
await UpdateLicense(null);
return true;
}
return false;
}
catch
{
return false;
}
finally
{
_syncLock.Release();
}
}
public async Task<CheckoutResult> StartPurchaseAsync(string tierId, string customerEmail)
{
try
{
var request = new
{
tierId,
customerEmail,
successUrl = _options.SuccessUrl,
cancelUrl = _options.CancelUrl,
metadata = new Dictionary<string, string>
{
["machineId"] = _machineIdentifier.GetMachineId()
}
};
var response = await _httpClient.PostAsJsonAsync("checkout/create", request);
var result = await response.Content.ReadFromJsonAsync<CheckoutApiResponse>();
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<bool> 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<JsonElement>();
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<bool> 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<TrialResponse>();
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";
}
}

56
LicensingOptions.cs Normal file
View File

@ -0,0 +1,56 @@
namespace IronLicensing.Client;
public class LicensingOptions
{
/// <summary>
/// Public API key for client authentication (pk_live_xxx or pk_test_xxx)
/// </summary>
public string PublicKey { get; set; } = string.Empty;
/// <summary>
/// Product identifier slug (e.g., "git-cleaner")
/// </summary>
public string ProductSlug { get; set; } = string.Empty;
/// <summary>
/// Base URL for the IronLicensing API
/// </summary>
public string ApiBaseUrl { get; set; } = "https://ironlicensing.com/api/v1";
/// <summary>
/// Number of days to allow offline usage before requiring online validation
/// </summary>
public int OfflineGraceDays { get; set; } = 7;
/// <summary>
/// How often to re-validate license when online (in minutes)
/// </summary>
public int CacheValidationMinutes { get; set; } = 60;
/// <summary>
/// HTTP request timeout
/// </summary>
public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Optional signing public key for offline signature verification.
/// If not provided, will be fetched from server and cached.
/// </summary>
public string? SigningPublicKey { get; set; }
/// <summary>
/// Enable strict offline validation requiring valid signatures.
/// If false, allows offline validation without signature verification.
/// </summary>
public bool RequireSignatureForOffline { get; set; } = true;
/// <summary>
/// Custom success URL for purchase redirects (deep link)
/// </summary>
public string SuccessUrl { get; set; } = "ironlicensing://purchase-success";
/// <summary>
/// Custom cancel URL for purchase redirects (deep link)
/// </summary>
public string CancelUrl { get; set; } = "ironlicensing://purchase-cancelled";
}

38
MachineIdentifier.cs Normal file
View File

@ -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<string> GetMachineComponents();
}

120
Models/ApiResponses.cs Normal file
View File

@ -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<string> 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; }
}

23
Models/LicenseInfo.cs Normal file
View File

@ -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<string> 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
}

21
Models/LicenseResult.cs Normal file
View File

@ -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; }
}

View File

@ -0,0 +1,32 @@
using Android.Provider;
namespace IronLicensing.Client;
public partial class MachineIdentifier
{
private partial List<string> GetMachineComponents()
{
var components = new List<string>();
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;
}
}

View File

@ -0,0 +1,30 @@
using UIKit;
namespace IronLicensing.Client;
public partial class MachineIdentifier
{
private partial List<string> GetMachineComponents()
{
var components = new List<string>();
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;
}
}

View File

@ -0,0 +1,32 @@
namespace IronLicensing.Client;
public partial class MachineIdentifier
{
private partial List<string> GetMachineComponents()
{
var components = new List<string>();
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;
}
}

View File

@ -0,0 +1,30 @@
using UIKit;
namespace IronLicensing.Client;
public partial class MachineIdentifier
{
private partial List<string> GetMachineComponents()
{
var components = new List<string>();
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;
}
}

207
README.md Normal file
View File

@ -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.

59
RsaSignatureVerifier.cs Normal file
View File

@ -0,0 +1,59 @@
using System.Security.Cryptography;
using System.Text;
namespace IronLicensing.Client;
/// <summary>
/// RSA signature verifier for offline license validation
/// </summary>
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);
}
}

View File

@ -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<CachedLicenseData?> GetCachedLicenseAsync()
{
try
{
var cached = await SecureStorage.Default.GetAsync(LicenseCacheKey);
if (string.IsNullOrEmpty(cached))
return null;
var license = JsonSerializer.Deserialize<LicenseInfo>(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<string?> 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;
}
}