feat(license): implement Hardware-ID v2, multi-platform Linux support, StateStore LLS2 hardening, and AI Agent docs

This commit is contained in:
Deploymentcenter Bot
2026-08-06 11:39:21 +02:00
parent e9dbe793e2
commit 70b35f7b8b
16 changed files with 1602 additions and 41 deletions
@@ -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
}
}
@@ -8,4 +8,8 @@
<AppName>Deploymentcenter Test Suite</AppName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
</ItemGroup>
</Project>
@@ -1,8 +1,10 @@
using System;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Deploymentcenter.Client;
namespace Deploymentcenter.TestClient;
@@ -17,6 +19,13 @@ class Program
static async Task Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
if (args.Length > 0)
{
await HandleCliArgsAsync(args);
return;
}
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("=================================================");
Console.WriteLine(" 🚀 Deploymentcenter Unified Client Test Suite ");
@@ -24,6 +33,8 @@ class Program
Console.ResetColor();
Console.WriteLine($" Server Base URL: {BaseUrl}\n");
await TestHardwareIdV2ModuleAsync();
await TestStateStoreHardeningAsync();
await TestLicenseModuleAsync();
await TestWatchdogModuleAsync();
await TestUpdateServiceModuleAsync();
@@ -33,42 +44,176 @@ class Program
Console.ResetColor();
}
private static async Task TestLicenseModuleAsync()
private static async Task HandleCliArgsAsync(string[] args)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("1. Teste Module: Lizenzen (License Validation)...");
Console.ResetColor();
string cmd = args[0].ToLowerInvariant();
try
if (cmd == "--license-status")
{
var payload = new
{
product = "myapp",
license_key = "LLAB1-98A72-B3C4D-5E6F7-89012",
hardware_id = "HWID-TEST-CLI-CSHARP-01",
nonce = Guid.NewGuid().ToString("N"),
hostname = Environment.MachineName,
app_version = "1.0.0"
};
var hwInfo = HardwareId.GetHardwareId("myapp");
string storageDir = LicenseConfig.GetStorageDirectory("myapp");
var cache = StateStore.Load("myapp", hwInfo.HardwareId);
string json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("=================================================");
Console.WriteLine(" 🔑 Deploymentcenter Lizenz & HWID Status ");
Console.WriteLine("=================================================");
Console.ResetColor();
Console.WriteLine($" Platform: {hwInfo.Platform.ToUpperInvariant()}");
Console.WriteLine($" HWID v2: {hwInfo.HardwareId}");
Console.WriteLine($" HWID Source: {hwInfo.HwidSource}");
Console.WriteLine($" Legacy HWID (v1): {hwInfo.LegacyHardwareId}");
Console.WriteLine($" Storage Directory:{storageDir}");
Console.WriteLine($" Local Cache: {(cache != null ? $"VALID (Status: {cache.Status}, Schema: {cache.SchemaVersion})" : "NONE / MISSING")}");
return;
}
HttpResponseMessage response = await Client.PostAsync($"{BaseUrl}/api/license/v1/validate", content);
string responseBody = await response.Content.ReadAsStringAsync();
if (cmd == "--license-deactivate")
{
string key = args.Length > 1 ? args[1] : "LLAB1-98A72-B3C4D-5E6F7-89012";
Console.WriteLine($"Deaktiviere Lizenz '{key}' für diesen Host...");
var licClient = new LicenseClient(Client);
bool success = await licClient.DeactivateAsync("myapp", key, BaseUrl, "dc_shared_master_secret");
Console.WriteLine($" HTTP Status: {(int)response.StatusCode} {response.StatusCode}");
Console.WriteLine($" Response Payload:\n {responseBody.Replace("\n", "\n ")}");
if (response.IsSuccessStatusCode && responseBody.Contains("\"status\": \"valid\""))
if (success)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine(" ✔ Lizenzen Modul Test: GÜLTIG!");
Console.WriteLine("✔ Lizenz-Aktivierung erfolgreich am Server aufgehoben!");
}
else
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine(" ✖ Lizenzen Modul Test: Fehler oder Ungültig!");
Console.WriteLine("✖ Fehler bei der Lizenz-Deaktivierung.");
}
Console.ResetColor();
return;
}
if (cmd == "--license-set-key")
{
if (args.Length < 2)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("Fehler: Bitte Lizenzschlüssel angeben. Beispiel: --license-set-key LLAB1-XXXXX");
Console.ResetColor();
return;
}
string key = args[1].Trim();
Console.WriteLine($"Prüfe neuen Lizenzschlüssel '{key}'...");
var licClient = new LicenseClient(Client);
var res = await licClient.ValidateAsync("myapp", key, BaseUrl);
if (res.IsValid)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"✔ Lizenz '{key}' erfolgreich aktiviert! Status: {res.Status}");
}
else
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"✖ Lizenzprüfung fehlgeschlagen: {res.Message} (Status: {res.Status})");
}
Console.ResetColor();
return;
}
Console.WriteLine($"Unbekanntes Argument: {args[0]}");
Console.WriteLine("Verfügbare Befehle:");
Console.WriteLine(" --license-status");
Console.WriteLine(" --license-deactivate [KEY]");
Console.WriteLine(" --license-set-key <KEY>");
}
private static Task TestHardwareIdV2ModuleAsync()
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("1. Teste Modul: HardwareId v2 Berechnungen & Quellenerkennung...");
Console.ResetColor();
var hwInfo = HardwareId.GetHardwareId("myapp");
Console.WriteLine($" Platform: {hwInfo.Platform}");
Console.WriteLine($" Hardware ID v2: {hwInfo.HardwareId}");
Console.WriteLine($" HWID Source: {hwInfo.HwidSource}");
Console.WriteLine($" Legacy HWID v1: {hwInfo.LegacyHardwareId}");
if (hwInfo.HardwareId.StartsWith("2:") && hwInfo.HardwareId.Length == 70)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine(" ✔ Hardware-ID v2 Format validiert! (2:<plat>:<64 hex>)");
}
else
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine(" ✖ Fehlerhaftes Hardware-ID v2 Format!");
}
Console.ResetColor();
Console.WriteLine();
return Task.CompletedTask;
}
private static Task TestStateStoreHardeningAsync()
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("2. Teste Modul: StateStore LLS2 AES-GCM Verschlüsselung & Integrität...");
Console.ResetColor();
string slug = "test_app_hardening";
var hwInfo = HardwareId.GetHardwareId(slug);
var testCache = new LocalCacheData
{
SchemaVersion = 2,
ProductSlug = slug,
LicenseKey = "LLAB-TEST-12345",
HardwareId = hwInfo.HardwareId,
Status = "valid",
IssuedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7).ToUnixTimeSeconds(),
MaxSeenTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
Checksum = hwInfo.HardwareId
};
bool saved = StateStore.Save(slug, hwInfo.HardwareId, testCache);
var loaded = StateStore.Load(slug, hwInfo.HardwareId);
if (saved && loaded != null && loaded.Status == "valid" && loaded.SchemaVersion == 2)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine(" ✔ StateStore LLS2 AES-GCM Speicher & Entschlüsselung OK!");
}
else
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine(" ✖ Fehler beim StateStore Test!");
}
Console.ResetColor();
Console.WriteLine();
return Task.CompletedTask;
}
private static async Task TestLicenseModuleAsync()
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("3. Teste Modul: Lizenzen (Online Validation & v1 Migration)...");
Console.ResetColor();
try
{
var licClient = new LicenseClient(Client);
var result = await licClient.ValidateAsync("myapp", "LLAB1-98A72-B3C4D-5E6F7-89012", BaseUrl);
Console.WriteLine($" Status: {result.Status}");
Console.WriteLine($" Hardware ID: {result.HardwareId}");
Console.WriteLine($" Message: {result.Message}");
if (result.IsValid)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine(" ✔ License Validation Server Test: ERFOLGREICH!");
}
else
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($" ✖ License Validation Server Test: {result.Message}");
}
}
catch (Exception ex)
@@ -83,7 +228,7 @@ class Program
private static async Task TestWatchdogModuleAsync()
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("2. Teste Module: Watchdog (Heartbeat Ping)...");
Console.WriteLine("4. Teste Modul: Watchdog (Heartbeat Ping)...");
Console.ResetColor();
try
@@ -97,7 +242,7 @@ class Program
message = "C# Client Test Suite running smoothly",
interval = 60,
group = "TestRunner",
os = Environment.OSVersion.ToString()
os = OperatingSystemHelpers.GetPlatformCode()
};
string json = JsonSerializer.Serialize(payload);
@@ -113,7 +258,6 @@ class Program
string responseBody = await response.Content.ReadAsStringAsync();
Console.WriteLine($" HTTP Status: {(int)response.StatusCode} {response.StatusCode}");
Console.WriteLine($" Response Payload:\n {responseBody.Replace("\n", "\n ")}");
if (response.IsSuccessStatusCode)
{
@@ -138,7 +282,7 @@ class Program
private static async Task TestUpdateServiceModuleAsync()
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("3. Teste Module: UpdateService (Release Check)...");
Console.WriteLine("5. Teste Modul: UpdateService (Release Check)...");
Console.ResetColor();
try
@@ -147,7 +291,6 @@ class Program
string responseBody = await response.Content.ReadAsStringAsync();
Console.WriteLine($" HTTP Status: {(int)response.StatusCode} {response.StatusCode}");
Console.WriteLine($" Response Payload:\n {responseBody.Replace("\n", "\n ")}");
if (response.IsSuccessStatusCode)
{