feat(license): implement Hardware-ID v2, multi-platform Linux support, StateStore LLS2 hardening, and AI Agent docs
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<RootNamespace>Deploymentcenter.Client</RootNamespace>
|
||||
<AssemblyName>Deploymentcenter.Client</AssemblyName>
|
||||
<NoWarn>$(NoWarn);CA1416</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,348 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace Deploymentcenter.Client;
|
||||
|
||||
public class HardwareIdResult
|
||||
{
|
||||
public string HardwareId { get; set; } = string.Empty;
|
||||
public int HwidVersion { get; set; } = 2;
|
||||
public string HwidSource { get; set; } = string.Empty;
|
||||
public string Platform { get; set; } = string.Empty;
|
||||
public string LegacyHardwareId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public static class HardwareId
|
||||
{
|
||||
private static readonly string[] MacStopWords = new[]
|
||||
{
|
||||
"docker", "veth", "br-", "virbr", "cni", "flannel", "cali", "weave",
|
||||
"zt", "tailscale", "ipsec", "sit", "gre", "dummy", "bond", "macvlan", "ovs"
|
||||
};
|
||||
|
||||
public static HardwareIdResult GetHardwareId(string productSlug = "default_app")
|
||||
{
|
||||
string platform = OperatingSystemHelpers.GetPlatformCode();
|
||||
string rawValue = string.Empty;
|
||||
string source = string.Empty;
|
||||
|
||||
// 3.1 Override (all platforms, highest priority)
|
||||
var overrideValue = LicenseConfig.HardwareIdOverride;
|
||||
if (!string.IsNullOrWhiteSpace(overrideValue))
|
||||
{
|
||||
rawValue = overrideValue!.Trim();
|
||||
source = "override";
|
||||
}
|
||||
else if (OperatingSystemHelpers.IsWindows())
|
||||
{
|
||||
// 3.2 Windows Sources
|
||||
string? machineGuid = GetWindowsMachineGuid();
|
||||
if (IsPlausibleMachineId(machineGuid))
|
||||
{
|
||||
rawValue = machineGuid!;
|
||||
source = "machine-guid";
|
||||
}
|
||||
else
|
||||
{
|
||||
string? macs = GetStablePhysicalMacs();
|
||||
if (!string.IsNullOrWhiteSpace(macs))
|
||||
{
|
||||
rawValue = macs!;
|
||||
source = "mac";
|
||||
}
|
||||
else
|
||||
{
|
||||
rawValue = GetOrCreateKeyfile(productSlug);
|
||||
source = "keyfile";
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (OperatingSystemHelpers.IsLinux())
|
||||
{
|
||||
// 3.3 Linux Sources
|
||||
string? systemdId = ReadTextFileFile("/etc/machine-id");
|
||||
if (IsPlausibleMachineId(systemdId))
|
||||
{
|
||||
rawValue = systemdId!;
|
||||
source = "machine-id";
|
||||
}
|
||||
else
|
||||
{
|
||||
string? dbusId = ReadTextFileFile("/var/lib/dbus/machine-id");
|
||||
if (IsPlausibleMachineId(dbusId))
|
||||
{
|
||||
rawValue = dbusId!;
|
||||
source = "dbus-machine-id";
|
||||
}
|
||||
else
|
||||
{
|
||||
string? dmiUuid = ReadTextFileFile("/sys/class/dmi/id/product_uuid");
|
||||
if (IsPlausibleMachineId(dmiUuid))
|
||||
{
|
||||
rawValue = dmiUuid!;
|
||||
source = "dmi-uuid";
|
||||
}
|
||||
else
|
||||
{
|
||||
string? macs = GetStablePhysicalMacs();
|
||||
if (!string.IsNullOrWhiteSpace(macs))
|
||||
{
|
||||
rawValue = macs!;
|
||||
source = "mac";
|
||||
}
|
||||
else
|
||||
{
|
||||
rawValue = GetOrCreateKeyfile(productSlug);
|
||||
source = "keyfile";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Generic fallback
|
||||
string? macs = GetStablePhysicalMacs();
|
||||
if (!string.IsNullOrWhiteSpace(macs))
|
||||
{
|
||||
rawValue = macs!;
|
||||
source = "mac";
|
||||
}
|
||||
else
|
||||
{
|
||||
rawValue = GetOrCreateKeyfile(productSlug);
|
||||
source = "keyfile";
|
||||
}
|
||||
}
|
||||
|
||||
string v2Hash = ComputeV2Hash(platform, source, rawValue);
|
||||
string v2HardwareId = $"2:{platform}:{v2Hash}";
|
||||
string legacyHardwareId = GetLegacyHardwareId();
|
||||
|
||||
return new HardwareIdResult
|
||||
{
|
||||
HardwareId = v2HardwareId,
|
||||
HwidVersion = 2,
|
||||
HwidSource = source,
|
||||
Platform = platform,
|
||||
LegacyHardwareId = legacyHardwareId
|
||||
};
|
||||
}
|
||||
|
||||
public static string ComputeV2Hash(string platform, string source, string rawValue)
|
||||
{
|
||||
string domainString = "LicenseLabrador-HWID-v2";
|
||||
string payload = $"{domainString}\n{platform}\n{source}\n{rawValue}";
|
||||
|
||||
using var sha256 = SHA256.Create();
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(payload);
|
||||
byte[] hash = sha256.ComputeHash(bytes);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string GetLegacyHardwareId()
|
||||
{
|
||||
// Legacy v1 algorithm: SHA256 of MachineName + First MAC
|
||||
string firstMac = GetFirstPhysicalMacLegacy();
|
||||
string raw = $"{Environment.MachineName}:{firstMac}";
|
||||
|
||||
using var sha256 = SHA256.Create();
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(raw);
|
||||
byte[] hash = sha256.ComputeHash(bytes);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static bool IsPlausibleMachineId(string? v)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(v))
|
||||
return false;
|
||||
|
||||
string trimmed = v!.Trim();
|
||||
if (trimmed.Length < 16)
|
||||
return false;
|
||||
|
||||
if (trimmed.Equals("uninitialized", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
if (trimmed.Trim('0').Length == 0)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? GetWindowsMachineGuid()
|
||||
{
|
||||
if (!OperatingSystemHelpers.IsWindows())
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
|
||||
using var subKey = baseKey.OpenSubKey(@"SOFTWARE\Microsoft\Cryptography");
|
||||
return subKey?.GetValue("MachineGuid")?.ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetStablePhysicalMacs()
|
||||
{
|
||||
try
|
||||
{
|
||||
var validMacs = new List<string>();
|
||||
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
|
||||
|
||||
foreach (var ni in interfaces)
|
||||
{
|
||||
if (ni.OperationalStatus != OperationalStatus.Up && ni.OperationalStatus != OperationalStatus.Unknown)
|
||||
continue;
|
||||
|
||||
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
|
||||
ni.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
|
||||
continue;
|
||||
|
||||
string name = ni.Name.ToLowerInvariant();
|
||||
string desc = ni.Description.ToLowerInvariant();
|
||||
|
||||
if (MacStopWords.Any(sw => name.Contains(sw) || desc.Contains(sw)))
|
||||
continue;
|
||||
|
||||
var physAddr = ni.GetPhysicalAddress();
|
||||
var bytes = physAddr.GetAddressBytes();
|
||||
if (bytes.Length == 0)
|
||||
continue;
|
||||
|
||||
// Check locally administered bit (mac[0] & 0x02) != 0
|
||||
if ((bytes[0] & 0x02) != 0)
|
||||
continue;
|
||||
|
||||
// On Linux, check /sys/class/net/<name>/device
|
||||
if (OperatingSystemHelpers.IsLinux())
|
||||
{
|
||||
string sysDevicePath = $"/sys/class/net/{ni.Name}/device";
|
||||
if (!Directory.Exists(sysDevicePath) && !File.Exists(sysDevicePath))
|
||||
continue;
|
||||
}
|
||||
|
||||
string macHex = BitConverter.ToString(bytes).Replace("-", "").ToUpperInvariant();
|
||||
if (!string.IsNullOrWhiteSpace(macHex) && macHex.Trim('0').Length > 0)
|
||||
{
|
||||
validMacs.Add(macHex);
|
||||
}
|
||||
}
|
||||
|
||||
if (validMacs.Count == 0)
|
||||
return null;
|
||||
|
||||
validMacs.Sort();
|
||||
return string.Join(":", validMacs);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetFirstPhysicalMacLegacy()
|
||||
{
|
||||
try
|
||||
{
|
||||
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
|
||||
foreach (var ni in interfaces)
|
||||
{
|
||||
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback)
|
||||
continue;
|
||||
|
||||
var bytes = ni.GetPhysicalAddress().GetAddressBytes();
|
||||
if (bytes.Length > 0)
|
||||
{
|
||||
return BitConverter.ToString(bytes).Replace("-", "").ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return "000000000000";
|
||||
}
|
||||
|
||||
private static string? ReadTextFileFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return null;
|
||||
return File.ReadAllText(path).Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetOrCreateKeyfile(string productSlug)
|
||||
{
|
||||
string dir = LicenseConfig.GetStorageDirectory(productSlug);
|
||||
Directory.CreateDirectory(dir);
|
||||
string keyfilePath = Path.Combine(dir, "machine.key");
|
||||
|
||||
if (File.Exists(keyfilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
string existing = File.ReadAllText(keyfilePath).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(existing))
|
||||
return existing;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
byte[] randomBytes = new byte[32];
|
||||
using (var rng = RandomNumberGenerator.Create())
|
||||
{
|
||||
rng.GetBytes(randomBytes);
|
||||
}
|
||||
string keyBase64 = Convert.ToBase64String(randomBytes);
|
||||
|
||||
File.WriteAllText(keyfilePath, keyBase64, Encoding.UTF8);
|
||||
|
||||
// Set 0600 permissions on Linux/macOS
|
||||
SetUnixPermissions(keyfilePath, "600");
|
||||
|
||||
return keyBase64;
|
||||
}
|
||||
|
||||
private static void SetUnixPermissions(string filePath, string mode)
|
||||
{
|
||||
if (OperatingSystemHelpers.IsWindows())
|
||||
return;
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
catch { }
|
||||
#else
|
||||
try
|
||||
{
|
||||
var proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "chmod",
|
||||
Arguments = $"{mode} \"{filePath}\"",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
});
|
||||
proc?.WaitForExit();
|
||||
}
|
||||
catch { }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Deploymentcenter.Client;
|
||||
|
||||
public interface ILicensePrompt
|
||||
{
|
||||
Task<string?> RequestLicenseKeyAsync(string productSlug);
|
||||
void ShowLicenseError(string title, string message);
|
||||
void ShowLicenseInfo(string title, string message);
|
||||
}
|
||||
|
||||
public class ConsoleLicensePrompt : ILicensePrompt
|
||||
{
|
||||
public Task<string?> RequestLicenseKeyAsync(string productSlug)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"\n[🔑 Lizenzschlüssel erforderlich für '{productSlug}']");
|
||||
Console.ResetColor();
|
||||
Console.Write("Bitte Lizenzschlüssel eingeben: ");
|
||||
string? input = Console.ReadLine();
|
||||
return Task.FromResult(input?.Trim());
|
||||
}
|
||||
|
||||
public void ShowLicenseError(string title, string message)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"\n[✖ {title}] {message}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
public void ShowLicenseInfo(string title, string message)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine($"\n[✔ {title}] {message}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Deploymentcenter.Client;
|
||||
|
||||
public class LicenseValidationResult
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
public string Status { get; set; } = "unknown";
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public string HardwareId { get; set; } = string.Empty;
|
||||
public bool IsCached { get; set; }
|
||||
public long? ExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
public class LicenseClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILicensePrompt _prompt;
|
||||
|
||||
public LicenseClient(HttpClient? httpClient = null, ILicensePrompt? prompt = null)
|
||||
{
|
||||
_httpClient = httpClient ?? new HttpClient();
|
||||
_prompt = prompt ?? new ConsoleLicensePrompt();
|
||||
}
|
||||
|
||||
public async Task<LicenseValidationResult> ValidateAsync(string productSlug, string licenseKey, string serverBaseUrl)
|
||||
{
|
||||
var hwInfo = HardwareId.GetHardwareId(productSlug);
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
try
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
product = productSlug,
|
||||
license_key = licenseKey,
|
||||
hardware_id = hwInfo.HardwareId,
|
||||
legacy_hardware_id = hwInfo.LegacyHardwareId,
|
||||
hwid_version = hwInfo.HwidVersion,
|
||||
hwid_source = hwInfo.HwidSource,
|
||||
platform = hwInfo.Platform,
|
||||
hostname = Environment.MachineName,
|
||||
app_version = "1.0.0",
|
||||
nonce = Guid.NewGuid().ToString("N")
|
||||
};
|
||||
|
||||
string jsonStr = JsonSerializer.Serialize(payload);
|
||||
var content = new StringContent(jsonStr, Encoding.UTF8, "application/json");
|
||||
string endpoint = $"{serverBaseUrl.TrimEnd('/')}/api/license/v1/validate";
|
||||
|
||||
HttpResponseMessage response = await _httpClient.PostAsync(endpoint, content);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
string resBody = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(resBody);
|
||||
var root = doc.RootElement;
|
||||
|
||||
string status = root.TryGetProperty("status", out var sProp) ? sProp.GetString() ?? "unknown" : "unknown";
|
||||
string message = root.TryGetProperty("message", out var mProp) ? mProp.GetString() ?? "" : "";
|
||||
long? expiresAt = root.TryGetProperty("expires_at", out var eProp) && eProp.ValueKind == JsonValueKind.Number ? eProp.GetInt64() : null;
|
||||
|
||||
if (status.Equals("valid", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Save encrypted local cache
|
||||
var cache = new LocalCacheData
|
||||
{
|
||||
SchemaVersion = 2,
|
||||
ProductSlug = productSlug,
|
||||
LicenseKey = licenseKey,
|
||||
HardwareId = hwInfo.HardwareId,
|
||||
Status = "valid",
|
||||
IssuedAt = now,
|
||||
ExpiresAt = expiresAt ?? (now + 7 * 86400),
|
||||
MaxSeenTime = now,
|
||||
Checksum = hwInfo.HardwareId
|
||||
};
|
||||
|
||||
StateStore.Save(productSlug, hwInfo.HardwareId, cache);
|
||||
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
Status = status,
|
||||
Message = message,
|
||||
HardwareId = hwInfo.HardwareId,
|
||||
IsCached = false,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = status,
|
||||
Message = message,
|
||||
HardwareId = hwInfo.HardwareId,
|
||||
IsCached = false
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Server request failed -> Fall back to encrypted offline cache
|
||||
var cache = StateStore.Load(productSlug, hwInfo.HardwareId);
|
||||
if (cache != null && cache.Status == "valid")
|
||||
{
|
||||
// Check time-rollback protection
|
||||
if (now < cache.MaxSeenTime)
|
||||
{
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = "clock_rollback",
|
||||
Message = "System clock rollback detected! Online verification required.",
|
||||
HardwareId = hwInfo.HardwareId
|
||||
};
|
||||
}
|
||||
|
||||
// Check cache expiry
|
||||
if (cache.ExpiresAt > 0 && now > cache.ExpiresAt)
|
||||
{
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = "cache_expired",
|
||||
Message = "Cached license has expired.",
|
||||
HardwareId = hwInfo.HardwareId
|
||||
};
|
||||
}
|
||||
|
||||
// Update max_seen_time
|
||||
cache.MaxSeenTime = now;
|
||||
StateStore.Save(productSlug, hwInfo.HardwareId, cache);
|
||||
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
Status = "valid_offline",
|
||||
Message = "License validated via secure offline cache",
|
||||
HardwareId = hwInfo.HardwareId,
|
||||
IsCached = true,
|
||||
ExpiresAt = cache.ExpiresAt
|
||||
};
|
||||
}
|
||||
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = "network_error",
|
||||
Message = $"Server communication error and no valid cache available: {ex.Message}",
|
||||
HardwareId = hwInfo.HardwareId
|
||||
};
|
||||
}
|
||||
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = "unknown_error",
|
||||
Message = "Validation failed.",
|
||||
HardwareId = hwInfo.HardwareId
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> DeactivateAsync(string productSlug, string licenseKey, string serverBaseUrl, string authToken = "")
|
||||
{
|
||||
var hwInfo = HardwareId.GetHardwareId(productSlug);
|
||||
var payload = new
|
||||
{
|
||||
product = productSlug,
|
||||
license_key = licenseKey,
|
||||
hardware_id = hwInfo.HardwareId,
|
||||
nonce = Guid.NewGuid().ToString("N")
|
||||
};
|
||||
|
||||
string jsonStr = JsonSerializer.Serialize(payload);
|
||||
var content = new StringContent(jsonStr, Encoding.UTF8, "application/json");
|
||||
string endpoint = $"{serverBaseUrl.TrimEnd('/')}/api/license/v1/deactivate";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(authToken))
|
||||
{
|
||||
request.Headers.Add("X-Watchdog-Key", authToken);
|
||||
request.Headers.Add("Authorization", $"Bearer {authToken}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace Deploymentcenter.Client;
|
||||
|
||||
public static class LicenseConfig
|
||||
{
|
||||
private static string? _storageDirectoryOverride;
|
||||
private static string? _hardwareIdOverride;
|
||||
|
||||
public static string? HardwareIdOverride
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_hardwareIdOverride))
|
||||
return _hardwareIdOverride;
|
||||
|
||||
var envHwid = Environment.GetEnvironmentVariable("DEPLOYMENTCENTER_HWID")
|
||||
?? Environment.GetEnvironmentVariable("LICENSELABRADOR_HWID");
|
||||
if (!string.IsNullOrWhiteSpace(envHwid))
|
||||
return envHwid;
|
||||
|
||||
return null;
|
||||
}
|
||||
set => _hardwareIdOverride = value;
|
||||
}
|
||||
|
||||
public static string GetStorageDirectory(string productSlug)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(productSlug))
|
||||
productSlug = "default_app";
|
||||
|
||||
// 1. Explicitly set property
|
||||
if (!string.IsNullOrWhiteSpace(_storageDirectoryOverride))
|
||||
return ValidateNonEmpty(_storageDirectoryOverride!);
|
||||
|
||||
// 2. Environment Variable
|
||||
var envDir = Environment.GetEnvironmentVariable("DEPLOYMENTCENTER_STORAGE_DIR")
|
||||
?? Environment.GetEnvironmentVariable("LICENSELABRADOR_STORAGE_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(envDir))
|
||||
return ValidateNonEmpty(Path.Combine(envDir!, productSlug, "license"));
|
||||
|
||||
// 3. Platform specific resolution
|
||||
if (OperatingSystemHelpers.IsLinux() || OperatingSystemHelpers.IsMacOS())
|
||||
{
|
||||
var xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME");
|
||||
if (!string.IsNullOrWhiteSpace(xdg))
|
||||
return ValidateNonEmpty(Path.Combine(xdg!, productSlug, "license"));
|
||||
|
||||
var home = Environment.GetEnvironmentVariable("HOME");
|
||||
if (!string.IsNullOrWhiteSpace(home))
|
||||
return ValidateNonEmpty(Path.Combine(home!, ".config", productSlug, "license"));
|
||||
}
|
||||
else if (OperatingSystemHelpers.IsWindows())
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
if (!string.IsNullOrWhiteSpace(appData))
|
||||
return ValidateNonEmpty(Path.Combine(appData, productSlug, "license"));
|
||||
}
|
||||
|
||||
// 4. Fallback to AppContext.BaseDirectory
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
if (!string.IsNullOrWhiteSpace(baseDir))
|
||||
return ValidateNonEmpty(Path.Combine(baseDir, "license"));
|
||||
|
||||
throw new InvalidOperationException("Could not resolve valid non-empty StorageDirectory for licensing.");
|
||||
}
|
||||
|
||||
public static void SetStorageDirectory(string path)
|
||||
{
|
||||
_storageDirectoryOverride = path;
|
||||
}
|
||||
|
||||
private static string ValidateNonEmpty(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
throw new InvalidOperationException("StorageDirectory resolved to an empty or invalid path.");
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Deploymentcenter.Client;
|
||||
|
||||
public static class OperatingSystemHelpers
|
||||
{
|
||||
public static bool IsWindows()
|
||||
{
|
||||
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
|
||||
}
|
||||
|
||||
public static bool IsLinux()
|
||||
{
|
||||
return RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
|
||||
}
|
||||
|
||||
public static bool IsMacOS()
|
||||
{
|
||||
return RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
|
||||
}
|
||||
|
||||
public static string GetPlatformCode()
|
||||
{
|
||||
if (IsWindows()) return "win";
|
||||
if (IsLinux()) return "lin";
|
||||
if (IsMacOS()) return "mac";
|
||||
return "unk";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
#if NETSTANDARD2_0
|
||||
using Org.BouncyCastle.Crypto.Engines;
|
||||
using Org.BouncyCastle.Crypto.Generators;
|
||||
using Org.BouncyCastle.Crypto.Modes;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
#endif
|
||||
|
||||
namespace Deploymentcenter.Client;
|
||||
|
||||
public class LocalCacheData
|
||||
{
|
||||
public int SchemaVersion { get; set; } = 2;
|
||||
public string ProductSlug { get; set; } = string.Empty;
|
||||
public string LicenseKey { get; set; } = string.Empty;
|
||||
public string HardwareId { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = "invalid";
|
||||
public long IssuedAt { get; set; }
|
||||
public long ExpiresAt { get; set; }
|
||||
public long MaxSeenTime { get; set; }
|
||||
public string Checksum { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public static class StateStore
|
||||
{
|
||||
private static readonly byte[] Magic = Encoding.UTF8.GetBytes("LLS2"); // 4 bytes: 0x4C, 0x4C, 0x53, 0x32
|
||||
|
||||
public static LocalCacheData? Load(string productSlug, string hardwareId)
|
||||
{
|
||||
try
|
||||
{
|
||||
string dir = LicenseConfig.GetStorageDirectory(productSlug);
|
||||
string statePath = Path.Combine(dir, "state.dat");
|
||||
|
||||
if (!File.Exists(statePath))
|
||||
return null;
|
||||
|
||||
byte[] rawFileContent = File.ReadAllBytes(statePath);
|
||||
if (rawFileContent.Length == 0)
|
||||
return null;
|
||||
|
||||
byte[] payloadBytes = rawFileContent;
|
||||
|
||||
// Try DPAPI unwrap on Windows
|
||||
if (OperatingSystemHelpers.IsWindows())
|
||||
{
|
||||
try
|
||||
{
|
||||
payloadBytes = ProtectedData.Unprotect(rawFileContent, null, DataProtectionScope.CurrentUser);
|
||||
}
|
||||
catch
|
||||
{
|
||||
payloadBytes = rawFileContent;
|
||||
}
|
||||
}
|
||||
|
||||
// Check LLS2 Magic Header
|
||||
if (payloadBytes.Length >= 4 + 12 + 16 && StartsWithMagic(payloadBytes, Magic))
|
||||
{
|
||||
byte[] key = DeriveKey(hardwareId, productSlug);
|
||||
byte[]? jsonBytes = DecryptAesGcmEnvelope(payloadBytes, key);
|
||||
if (jsonBytes == null)
|
||||
return null; // Decryption/Auth failed -> Treat strictly as Cache Miss
|
||||
|
||||
string jsonStr = Encoding.UTF8.GetString(jsonBytes);
|
||||
var data = JsonSerializer.Deserialize<LocalCacheData>(jsonStr);
|
||||
|
||||
if (data == null || data.SchemaVersion != 2)
|
||||
return null; // Incompatible schema -> Treat as Cache Miss
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Legacy Migration Check (non-LLS2 file)
|
||||
try
|
||||
{
|
||||
string legacyJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
var legacyData = JsonSerializer.Deserialize<LocalCacheData>(legacyJson);
|
||||
if (legacyData != null)
|
||||
{
|
||||
legacyData.SchemaVersion = 2;
|
||||
Save(productSlug, hardwareId, legacyData);
|
||||
return legacyData;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool Save(string productSlug, string hardwareId, LocalCacheData cacheData)
|
||||
{
|
||||
try
|
||||
{
|
||||
cacheData.SchemaVersion = 2;
|
||||
string dir = LicenseConfig.GetStorageDirectory(productSlug);
|
||||
Directory.CreateDirectory(dir);
|
||||
string statePath = Path.Combine(dir, "state.dat");
|
||||
|
||||
string jsonStr = JsonSerializer.Serialize(cacheData);
|
||||
byte[] jsonBytes = Encoding.UTF8.GetBytes(jsonStr);
|
||||
|
||||
byte[] key = DeriveKey(hardwareId, productSlug);
|
||||
byte[] envelopeBytes = EncryptAesGcmEnvelope(jsonBytes, key);
|
||||
|
||||
byte[] finalFileBytes = envelopeBytes;
|
||||
|
||||
// Wrap with DPAPI on Windows
|
||||
if (OperatingSystemHelpers.IsWindows())
|
||||
{
|
||||
try
|
||||
{
|
||||
finalFileBytes = ProtectedData.Protect(envelopeBytes, null, DataProtectionScope.CurrentUser);
|
||||
}
|
||||
catch
|
||||
{
|
||||
finalFileBytes = envelopeBytes;
|
||||
}
|
||||
}
|
||||
|
||||
File.WriteAllBytes(statePath, finalFileBytes);
|
||||
SetUnixPermissions(statePath, "600");
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool StartsWithMagic(byte[] data, byte[] magic)
|
||||
{
|
||||
if (data.Length < magic.Length) return false;
|
||||
for (int i = 0; i < magic.Length; i++)
|
||||
{
|
||||
if (data[i] != magic[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static byte[] DeriveKey(string hardwareId, string productSlug)
|
||||
{
|
||||
string ikmStr = $"{hardwareId}:{productSlug}";
|
||||
byte[] ikm = Encoding.UTF8.GetBytes(ikmStr);
|
||||
byte[] salt = Encoding.UTF8.GetBytes("LicenseLabradorHKDFSaltV2");
|
||||
byte[] info = Encoding.UTF8.GetBytes("StateStoreEncryptionKey");
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
return HKDF.DeriveKey(HashAlgorithmName.SHA256, ikm, 32, salt, info);
|
||||
#else
|
||||
var hkdf = new HkdfBytesGenerator(new Org.BouncyCastle.Crypto.Digests.Sha256Digest());
|
||||
hkdf.Init(new HkdfParameters(ikm, salt, info));
|
||||
byte[] key = new byte[32];
|
||||
hkdf.GenerateBytes(key, 0, 32);
|
||||
return key;
|
||||
#endif
|
||||
}
|
||||
|
||||
private static byte[] EncryptAesGcmEnvelope(byte[] plaintext, byte[] key)
|
||||
{
|
||||
byte[] nonce = new byte[12];
|
||||
using (var rng = RandomNumberGenerator.Create())
|
||||
{
|
||||
rng.GetBytes(nonce);
|
||||
}
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
byte[] ciphertext = new byte[plaintext.Length];
|
||||
byte[] tag = new byte[16];
|
||||
using (var aes = new AesGcm(key, 16))
|
||||
{
|
||||
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||
}
|
||||
|
||||
byte[] envelope = new byte[4 + 12 + ciphertext.Length + 16];
|
||||
Buffer.BlockCopy(Magic, 0, envelope, 0, 4);
|
||||
Buffer.BlockCopy(nonce, 0, envelope, 4, 12);
|
||||
Buffer.BlockCopy(ciphertext, 0, envelope, 16, ciphertext.Length);
|
||||
Buffer.BlockCopy(tag, 0, envelope, 16 + ciphertext.Length, 16);
|
||||
return envelope;
|
||||
#else
|
||||
var cipher = new GcmBlockCipher(new AesEngine());
|
||||
var parameters = new AeadParameters(new KeyParameter(key), 128, nonce);
|
||||
cipher.Init(true, parameters);
|
||||
|
||||
byte[] output = new byte[cipher.GetOutputSize(plaintext.Length)];
|
||||
int len = cipher.ProcessBytes(plaintext, 0, plaintext.Length, output, 0);
|
||||
cipher.DoFinal(output, len);
|
||||
|
||||
// output in BouncyCastle contains Ciphertext + 16-byte Tag
|
||||
int cipherLen = output.Length - 16;
|
||||
byte[] ciphertext = new byte[cipherLen];
|
||||
byte[] tag = new byte[16];
|
||||
Buffer.BlockCopy(output, 0, ciphertext, 0, cipherLen);
|
||||
Buffer.BlockCopy(output, cipherLen, tag, 0, 16);
|
||||
|
||||
byte[] envelope = new byte[4 + 12 + ciphertext.Length + 16];
|
||||
Buffer.BlockCopy(Magic, 0, envelope, 0, 4);
|
||||
Buffer.BlockCopy(nonce, 0, envelope, 4, 12);
|
||||
Buffer.BlockCopy(ciphertext, 0, envelope, 16, ciphertext.Length);
|
||||
Buffer.BlockCopy(tag, 0, envelope, 16 + ciphertext.Length, 16);
|
||||
return envelope;
|
||||
#endif
|
||||
}
|
||||
|
||||
private static byte[]? DecryptAesGcmEnvelope(byte[] envelope, byte[] key)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (envelope.Length < 4 + 12 + 16)
|
||||
return null;
|
||||
|
||||
byte[] nonce = new byte[12];
|
||||
Buffer.BlockCopy(envelope, 4, nonce, 0, 12);
|
||||
|
||||
int cipherLen = envelope.Length - 4 - 12 - 16;
|
||||
byte[] ciphertext = new byte[cipherLen];
|
||||
Buffer.BlockCopy(envelope, 16, ciphertext, 0, cipherLen);
|
||||
|
||||
byte[] tag = new byte[16];
|
||||
Buffer.BlockCopy(envelope, 16 + cipherLen, tag, 0, 16);
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
byte[] plaintext = new byte[cipherLen];
|
||||
using (var aes = new AesGcm(key, 16))
|
||||
{
|
||||
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||
}
|
||||
return plaintext;
|
||||
#else
|
||||
var cipher = new GcmBlockCipher(new AesEngine());
|
||||
var parameters = new AeadParameters(new KeyParameter(key), 128, nonce);
|
||||
cipher.Init(false, parameters);
|
||||
|
||||
byte[] input = new byte[ciphertext.Length + 16];
|
||||
Buffer.BlockCopy(ciphertext, 0, input, 0, ciphertext.Length);
|
||||
Buffer.BlockCopy(tag, 0, input, ciphertext.Length, 16);
|
||||
|
||||
byte[] plaintext = new byte[cipher.GetOutputSize(input.Length)];
|
||||
int len = cipher.ProcessBytes(input, 0, input.Length, plaintext, 0);
|
||||
cipher.DoFinal(plaintext, len);
|
||||
|
||||
return plaintext;
|
||||
#endif
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null; // Auth/Decryption failed
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetUnixPermissions(string filePath, string mode)
|
||||
{
|
||||
if (OperatingSystemHelpers.IsWindows())
|
||||
return;
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
catch { }
|
||||
#else
|
||||
try
|
||||
{
|
||||
var proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "chmod",
|
||||
Arguments = $"{mode} \"{filePath}\"",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
});
|
||||
proc?.WaitForExit();
|
||||
}
|
||||
catch { }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user