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:
commit
7d453f8f63
|
|
@ -0,0 +1,6 @@
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
.vs/
|
||||||
|
*.DotSettings.user
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace IronLicensing.Client;
|
||||||
|
|
||||||
|
public interface IMachineIdentifier
|
||||||
|
{
|
||||||
|
string GetMachineId();
|
||||||
|
string GetMachineName();
|
||||||
|
string GetPlatform();
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
[](https://www.nuget.org/packages/IronLicensing.Client)
|
||||||
|
[](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.
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue