Add net9.0 support for server/console applications

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-01-03 20:52:08 -05:00
parent c962ddb635
commit 811fb6ceec
5 changed files with 350 additions and 10 deletions

View File

@@ -17,9 +17,16 @@ public static class ServiceCollectionExtensions
// Core services // Core services
services.AddHttpClient<ILicenseManager, LicenseManager>(); services.AddHttpClient<ILicenseManager, LicenseManager>();
services.AddSingleton<IMachineIdentifier, MachineIdentifier>(); services.AddSingleton<IMachineIdentifier, MachineIdentifier>();
services.AddSingleton<ILicenseCache, SecureStorageLicenseCache>();
services.AddSingleton<ISignatureVerifier, RsaSignatureVerifier>(); services.AddSingleton<ISignatureVerifier, RsaSignatureVerifier>();
#if MAUI
// Use SecureStorage on MAUI platforms
services.AddSingleton<ILicenseCache, SecureStorageLicenseCache>();
#else
// Use file system cache on server/console apps
services.AddSingleton<ILicenseCache, FileSystemLicenseCache>();
#endif
return services; return services;
} }

241
FileSystemLicenseCache.cs Normal file
View File

@@ -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;
/// <summary>
/// File system-based license cache for server/console applications.
/// Uses DPAPI on Windows or file encryption on other platforms.
/// </summary>
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<LicensingOptions> 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<CachedLicenseData?> GetCachedLicenseAsync()
{
try
{
if (!File.Exists(_licenseFile))
return Task.FromResult<CachedLicenseData?>(null);
var encryptedData = File.ReadAllBytes(_licenseFile);
var decryptedData = Unprotect(encryptedData);
if (decryptedData == null)
return Task.FromResult<CachedLicenseData?>(null);
var json = Encoding.UTF8.GetString(decryptedData);
var license = JsonSerializer.Deserialize<LicenseInfo>(json);
if (license == null)
return Task.FromResult<CachedLicenseData?>(null);
var signature = ReadProtectedString(_signatureFile);
var publicKey = ReadProtectedString(_publicKeyFile);
var rawJson = ReadProtectedString(_rawJsonFile);
return Task.FromResult<CachedLicenseData?>(new CachedLicenseData
{
License = license,
Signature = signature,
SigningPublicKey = publicKey,
RawLicenseJson = rawJson
});
}
catch
{
return Task.FromResult<CachedLicenseData?>(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<string?> GetSigningPublicKeyAsync()
{
try
{
return Task.FromResult(ReadProtectedString(_publicKeyFile));
}
catch
{
return Task.FromResult<string?>(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
}

View File

@@ -1,20 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net9.0-android;net9.0-ios;net9.0-maccatalyst</TargetFrameworks> <!-- Include net9.0 for server/console apps, plus MAUI platforms -->
<TargetFrameworks>net9.0;net9.0-android;net9.0-ios;net9.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net9.0-windows10.0.19041.0</TargetFrameworks> <TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net9.0-windows10.0.19041.0</TargetFrameworks>
<UseMaui>true</UseMaui>
<!-- Only use MAUI for platform-specific targets -->
<UseMaui Condition="$(TargetFramework.Contains('-'))">true</UseMaui>
<SingleProject>true</SingleProject> <SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<PackageOutputPath>C:\Users\logik\Dropbox\Nugets</PackageOutputPath> <PackageOutputPath Condition="$([MSBuild]::IsOSPlatform('windows'))">C:\Users\logik\Dropbox\Nugets</PackageOutputPath>
<PackageOutputPath Condition="$([MSBuild]::IsOSPlatform('osx'))">$(HOME)/Library/CloudStorage/Dropbox/Nugets</PackageOutputPath>
<!-- Define MAUI constant for MAUI platforms -->
<DefineConstants Condition="$(TargetFramework.Contains('-'))">$(DefineConstants);MAUI</DefineConstants>
<!-- Define WINDOWS constant for Windows -->
<DefineConstants Condition="$(TargetFramework.Contains('windows'))">$(DefineConstants);WINDOWS</DefineConstants>
<!-- NuGet Package Properties --> <!-- NuGet Package Properties -->
<PackageId>IronLicensing.Client</PackageId> <PackageId>IronLicensing.Client</PackageId>
<Version>1.0.0</Version> <Version>1.2.0</Version>
<Authors>David H Friedel Jr</Authors> <Authors>David H Friedel Jr</Authors>
<Company>MarketAlly</Company> <Company>MarketAlly</Company>
<Description>Client SDK for IronLicensing - Software Licensing Platform</Description> <Description>Client SDK for IronLicensing - Software Licensing Platform. Supports .NET 9.0 server/console apps and MAUI mobile apps.</Description>
<PackageTags>licensing;software;activation;license-key</PackageTags> <PackageTags>licensing;software;activation;license-key</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
<PackageIcon>nuget_il.png</PackageIcon> <PackageIcon>nuget_il.png</PackageIcon>
@@ -27,9 +36,26 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" /> <!-- MAUI only for platform-specific targets -->
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" /> <PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" Condition="$(TargetFramework.Contains('-'))" />
<PackageReference Include="System.Text.Json" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.*" />
<PackageReference Include="System.Text.Json" Version="9.0.*" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.*" />
</ItemGroup>
<!-- Exclude MAUI-specific files from net9.0 -->
<ItemGroup Condition="!$(TargetFramework.Contains('-'))">
<Compile Remove="SecureStorageLicenseCache.cs" />
<Compile Remove="Platforms\Android\**" />
<Compile Remove="Platforms\iOS\**" />
<Compile Remove="Platforms\MacCatalyst\**" />
<Compile Remove="Platforms\Windows\**" />
</ItemGroup>
<!-- Exclude net9.0-specific files from MAUI platforms -->
<ItemGroup Condition="$(TargetFramework.Contains('-'))">
<Compile Remove="FileSystemLicenseCache.cs" />
<Compile Remove="Platforms\Net9\**" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,5 +1,6 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Runtime.InteropServices;
namespace IronLicensing.Client; namespace IronLicensing.Client;
@@ -12,10 +13,17 @@ public partial class MachineIdentifier : IMachineIdentifier
if (string.IsNullOrEmpty(combined)) 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() + "|" + combined = DeviceInfo.Current.Idiom.ToString() + "|" +
DeviceInfo.Current.Manufacturer + "|" + DeviceInfo.Current.Manufacturer + "|" +
DeviceInfo.Current.Model; DeviceInfo.Current.Model;
#else
// Fallback for server/console apps
combined = Environment.MachineName + "|" +
Environment.UserName + "|" +
RuntimeInformation.OSDescription;
#endif
} }
using var sha256 = SHA256.Create(); using var sha256 = SHA256.Create();
@@ -25,12 +33,26 @@ public partial class MachineIdentifier : IMachineIdentifier
public string GetMachineName() public string GetMachineName()
{ {
#if MAUI
return DeviceInfo.Current.Name ?? Environment.MachineName ?? "Unknown"; return DeviceInfo.Current.Name ?? Environment.MachineName ?? "Unknown";
#else
return Environment.MachineName ?? "Unknown";
#endif
} }
public string GetPlatform() public string GetPlatform()
{ {
#if MAUI
return DeviceInfo.Current.Platform.ToString(); 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 // Platform-specific implementation

View File

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