From 811fb6ceec752c6994427993961fd7e1fd0cb592 Mon Sep 17 00:00:00 2001 From: logikonline Date: Sat, 3 Jan 2026 20:52:08 -0500 Subject: [PATCH] Add net9.0 support for server/console applications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add net9.0 to TargetFrameworks for non-MAUI apps - Create FileSystemLicenseCache for file-based license storage - Create Platforms/Net9/MachineIdentifier using Environment APIs - Add conditional compilation for MAUI vs non-MAUI platforms - Update ServiceCollectionExtensions for conditional DI registration - Bump version to 1.2.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Extensions/ServiceCollectionExtensions.cs | 9 +- FileSystemLicenseCache.cs | 241 ++++++++++++++++++++++ IronLicensing.Client.csproj | 42 +++- MachineIdentifier.cs | 24 ++- Platforms/Net9/MachineIdentifier.cs | 44 ++++ 5 files changed, 350 insertions(+), 10 deletions(-) create mode 100644 FileSystemLicenseCache.cs create mode 100644 Platforms/Net9/MachineIdentifier.cs diff --git a/Extensions/ServiceCollectionExtensions.cs b/Extensions/ServiceCollectionExtensions.cs index 919243e..4697ca5 100644 --- a/Extensions/ServiceCollectionExtensions.cs +++ b/Extensions/ServiceCollectionExtensions.cs @@ -17,9 +17,16 @@ public static class ServiceCollectionExtensions // Core services services.AddHttpClient(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); +#if MAUI + // Use SecureStorage on MAUI platforms + services.AddSingleton(); +#else + // Use file system cache on server/console apps + services.AddSingleton(); +#endif + return services; } diff --git a/FileSystemLicenseCache.cs b/FileSystemLicenseCache.cs new file mode 100644 index 0000000..f53753e --- /dev/null +++ b/FileSystemLicenseCache.cs @@ -0,0 +1,241 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using IronLicensing.Client.Models; +using Microsoft.Extensions.Options; + +namespace IronLicensing.Client; + +/// +/// File system-based license cache for server/console applications. +/// Uses DPAPI on Windows or file encryption on other platforms. +/// +public class FileSystemLicenseCache : ILicenseCache +{ + private readonly string _cacheDirectory; + private readonly string _licenseFile; + private readonly string _signatureFile; + private readonly string _publicKeyFile; + private readonly string _rawJsonFile; + private readonly byte[] _entropy; + + public FileSystemLicenseCache(IOptions options) + { + var productSlug = options.Value.ProductSlug ?? "ironlicensing"; + + // Use a product-specific directory in the user's local app data + var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + _cacheDirectory = Path.Combine(appDataPath, "IronLicensing", productSlug); + + Directory.CreateDirectory(_cacheDirectory); + + _licenseFile = Path.Combine(_cacheDirectory, "license.dat"); + _signatureFile = Path.Combine(_cacheDirectory, "signature.dat"); + _publicKeyFile = Path.Combine(_cacheDirectory, "pubkey.dat"); + _rawJsonFile = Path.Combine(_cacheDirectory, "raw.dat"); + + // Use product slug as additional entropy for encryption + _entropy = Encoding.UTF8.GetBytes(productSlug + "_ironlicensing_cache"); + } + + public Task GetCachedLicenseAsync() + { + try + { + if (!File.Exists(_licenseFile)) + return Task.FromResult(null); + + var encryptedData = File.ReadAllBytes(_licenseFile); + var decryptedData = Unprotect(encryptedData); + if (decryptedData == null) + return Task.FromResult(null); + + var json = Encoding.UTF8.GetString(decryptedData); + var license = JsonSerializer.Deserialize(json); + if (license == null) + return Task.FromResult(null); + + var signature = ReadProtectedString(_signatureFile); + var publicKey = ReadProtectedString(_publicKeyFile); + var rawJson = ReadProtectedString(_rawJsonFile); + + return Task.FromResult(new CachedLicenseData + { + License = license, + Signature = signature, + SigningPublicKey = publicKey, + RawLicenseJson = rawJson + }); + } + catch + { + return Task.FromResult(null); + } + } + + public Task SaveLicenseAsync(LicenseInfo license, string? signature, string? signingPublicKey) + { + try + { + var json = JsonSerializer.Serialize(license); + WriteProtectedString(_licenseFile, json); + WriteProtectedString(_rawJsonFile, json); + + if (!string.IsNullOrEmpty(signature)) + { + WriteProtectedString(_signatureFile, signature); + } + + if (!string.IsNullOrEmpty(signingPublicKey)) + { + WriteProtectedString(_publicKeyFile, signingPublicKey); + } + } + catch + { + // Ignore storage errors + } + + return Task.CompletedTask; + } + + public Task GetSigningPublicKeyAsync() + { + try + { + return Task.FromResult(ReadProtectedString(_publicKeyFile)); + } + catch + { + return Task.FromResult(null); + } + } + + public Task SaveSigningPublicKeyAsync(string publicKey) + { + try + { + WriteProtectedString(_publicKeyFile, publicKey); + } + catch + { + // Ignore storage errors + } + + return Task.CompletedTask; + } + + public Task ClearAsync() + { + try + { + if (File.Exists(_licenseFile)) File.Delete(_licenseFile); + if (File.Exists(_signatureFile)) File.Delete(_signatureFile); + if (File.Exists(_publicKeyFile)) File.Delete(_publicKeyFile); + if (File.Exists(_rawJsonFile)) File.Delete(_rawJsonFile); + } + catch + { + // Ignore storage errors + } + + return Task.CompletedTask; + } + + private void WriteProtectedString(string filePath, string value) + { + var data = Encoding.UTF8.GetBytes(value); + var protectedData = Protect(data); + File.WriteAllBytes(filePath, protectedData); + } + + private string? ReadProtectedString(string filePath) + { + if (!File.Exists(filePath)) + return null; + + var protectedData = File.ReadAllBytes(filePath); + var data = Unprotect(protectedData); + if (data == null) + return null; + + return Encoding.UTF8.GetString(data); + } + + private byte[] Protect(byte[] data) + { +#if WINDOWS + // Use DPAPI on Windows + return ProtectedData.Protect(data, _entropy, DataProtectionScope.CurrentUser); +#else + // On other platforms, use AES encryption with a machine-derived key + return EncryptWithDerivedKey(data); +#endif + } + + private byte[]? Unprotect(byte[] data) + { + try + { +#if WINDOWS + return ProtectedData.Unprotect(data, _entropy, DataProtectionScope.CurrentUser); +#else + return DecryptWithDerivedKey(data); +#endif + } + catch + { + return null; + } + } + +#if !WINDOWS + private byte[] EncryptWithDerivedKey(byte[] data) + { + using var aes = Aes.Create(); + aes.Key = DeriveKey(); + aes.GenerateIV(); + + using var encryptor = aes.CreateEncryptor(); + var encrypted = encryptor.TransformFinalBlock(data, 0, data.Length); + + // Prepend IV to encrypted data + var result = new byte[aes.IV.Length + encrypted.Length]; + Array.Copy(aes.IV, 0, result, 0, aes.IV.Length); + Array.Copy(encrypted, 0, result, aes.IV.Length, encrypted.Length); + + return result; + } + + private byte[]? DecryptWithDerivedKey(byte[] data) + { + if (data.Length < 16) + return null; + + using var aes = Aes.Create(); + aes.Key = DeriveKey(); + + // Extract IV from data + var iv = new byte[16]; + Array.Copy(data, 0, iv, 0, 16); + aes.IV = iv; + + var encrypted = new byte[data.Length - 16]; + Array.Copy(data, 16, encrypted, 0, encrypted.Length); + + using var decryptor = aes.CreateDecryptor(); + return decryptor.TransformFinalBlock(encrypted, 0, encrypted.Length); + } + + private byte[] DeriveKey() + { + // Derive a key from machine-specific information + var keyMaterial = Environment.MachineName + "|" + + Environment.UserName + "|" + + Convert.ToBase64String(_entropy); + + using var sha256 = SHA256.Create(); + return sha256.ComputeHash(Encoding.UTF8.GetBytes(keyMaterial)); + } +#endif +} diff --git a/IronLicensing.Client.csproj b/IronLicensing.Client.csproj index a9d1a85..b21a792 100644 --- a/IronLicensing.Client.csproj +++ b/IronLicensing.Client.csproj @@ -1,20 +1,29 @@ - net9.0-android;net9.0-ios;net9.0-maccatalyst + + net9.0;net9.0-android;net9.0-ios;net9.0-maccatalyst $(TargetFrameworks);net9.0-windows10.0.19041.0 - true + + + true true enable enable - C:\Users\logik\Dropbox\Nugets + C:\Users\logik\Dropbox\Nugets + $(HOME)/Library/CloudStorage/Dropbox/Nugets + + + $(DefineConstants);MAUI + + $(DefineConstants);WINDOWS IronLicensing.Client - 1.0.0 + 1.2.0 David H Friedel Jr MarketAlly - Client SDK for IronLicensing - Software Licensing Platform + Client SDK for IronLicensing - Software Licensing Platform. Supports .NET 9.0 server/console apps and MAUI mobile apps. licensing;software;activation;license-key README.md nuget_il.png @@ -27,9 +36,26 @@ - - - + + + + + + + + + + + + + + + + + + + + diff --git a/MachineIdentifier.cs b/MachineIdentifier.cs index 4a0e169..4396724 100644 --- a/MachineIdentifier.cs +++ b/MachineIdentifier.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using System.Text; +using System.Runtime.InteropServices; namespace IronLicensing.Client; @@ -12,10 +13,17 @@ public partial class MachineIdentifier : IMachineIdentifier if (string.IsNullOrEmpty(combined)) { - // Fallback to a device-specific identifier +#if MAUI + // Fallback to a device-specific identifier (MAUI platforms) combined = DeviceInfo.Current.Idiom.ToString() + "|" + DeviceInfo.Current.Manufacturer + "|" + DeviceInfo.Current.Model; +#else + // Fallback for server/console apps + combined = Environment.MachineName + "|" + + Environment.UserName + "|" + + RuntimeInformation.OSDescription; +#endif } using var sha256 = SHA256.Create(); @@ -25,12 +33,26 @@ public partial class MachineIdentifier : IMachineIdentifier public string GetMachineName() { +#if MAUI return DeviceInfo.Current.Name ?? Environment.MachineName ?? "Unknown"; +#else + return Environment.MachineName ?? "Unknown"; +#endif } public string GetPlatform() { +#if MAUI return DeviceInfo.Current.Platform.ToString(); +#else + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return "Windows"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return "macOS"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return "Linux"; + return RuntimeInformation.OSDescription; +#endif } // Platform-specific implementation diff --git a/Platforms/Net9/MachineIdentifier.cs b/Platforms/Net9/MachineIdentifier.cs new file mode 100644 index 0000000..93c138d --- /dev/null +++ b/Platforms/Net9/MachineIdentifier.cs @@ -0,0 +1,44 @@ +using System.Runtime.InteropServices; + +namespace IronLicensing.Client; + +public partial class MachineIdentifier +{ + private partial List GetMachineComponents() + { + var components = new List(); + + try + { + // Machine name + var machineName = Environment.MachineName; + if (!string.IsNullOrEmpty(machineName)) + { + components.Add(machineName); + } + + // User name (for additional uniqueness) + var userName = Environment.UserName; + if (!string.IsNullOrEmpty(userName)) + { + components.Add(userName); + } + + // OS description + components.Add(RuntimeInformation.OSDescription); + + // Processor architecture + components.Add(RuntimeInformation.ProcessArchitecture.ToString()); + + // Runtime identifier + components.Add(RuntimeInformation.RuntimeIdentifier); + } + catch + { + // Fallback - at minimum use machine name + components.Add(Environment.MachineName ?? "unknown"); + } + + return components; + } +}