447 lines
17 KiB
C#
447 lines
17 KiB
C#
|
|
using Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Models;
|
|||
|
|
using Microsoft.Win32;
|
|||
|
|
using Serilog;
|
|||
|
|
using System;
|
|||
|
|
using System.Diagnostics;
|
|||
|
|
using System.IO;
|
|||
|
|
using System.Linq;
|
|||
|
|
using System.Security.Cryptography;
|
|||
|
|
using System.Security.Principal;
|
|||
|
|
using System.Text.Json;
|
|||
|
|
|
|||
|
|
#pragma warning disable CA1416
|
|||
|
|
|
|||
|
|
namespace Footwear_Test_methodsfor_wholeshoe_Slipresistanceperformance.Services
|
|||
|
|
{
|
|||
|
|
public sealed class MachineLicenseService
|
|||
|
|
{
|
|||
|
|
public const string InstallArgument = "--install-license";
|
|||
|
|
private const string RegistryPath = @"SOFTWARE\FootwearSlipResistance\License";
|
|||
|
|
private const string RegistryInstallIdName = "InstallId";
|
|||
|
|
private static readonly TimeSpan ClockRollbackTolerance = TimeSpan.FromMinutes(5);
|
|||
|
|
private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromMinutes(10);
|
|||
|
|
private static readonly byte[] AdditionalEntropy = "FootwearSlipResistance-License-v1"u8.ToArray();
|
|||
|
|
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
|||
|
|
|
|||
|
|
private LicenseData? current;
|
|||
|
|
|
|||
|
|
public LicenseData? Current => current;
|
|||
|
|
|
|||
|
|
public static string LicenseFilePath =>
|
|||
|
|
Path.Combine(
|
|||
|
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
|||
|
|
"FootwearSlipResistance",
|
|||
|
|
"license.dat");
|
|||
|
|
|
|||
|
|
public LicenseCheckResult Check(bool updateHeartbeat = false)
|
|||
|
|
{
|
|||
|
|
var loadResult = TryLoad(out var data);
|
|||
|
|
if (loadResult is not null)
|
|||
|
|
{
|
|||
|
|
current = null;
|
|||
|
|
return loadResult;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
current = data;
|
|||
|
|
var now = DateTime.UtcNow;
|
|||
|
|
if (now + ClockRollbackTolerance < data!.LastTrustedUtc)
|
|||
|
|
{
|
|||
|
|
return new LicenseCheckResult(
|
|||
|
|
LicenseCheckState.Tampered,
|
|||
|
|
data.Stage,
|
|||
|
|
GetExpiry(data),
|
|||
|
|
"检测到系统时间回退,软件已锁定,请联系管理员处理。");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (updateHeartbeat && now - data.LastTrustedUtc >= HeartbeatInterval)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
current = data with { LastTrustedUtc = now };
|
|||
|
|
SaveCurrent();
|
|||
|
|
data = current;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Log.Warning(ex, "更新授权可信时间失败");
|
|||
|
|
return new LicenseCheckResult(
|
|||
|
|
LicenseCheckState.Tampered,
|
|||
|
|
data.Stage,
|
|||
|
|
GetExpiry(data),
|
|||
|
|
"授权状态无法安全更新,软件已锁定,请联系管理员处理。");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var expiry = GetExpiry(data);
|
|||
|
|
if (expiry.HasValue && now >= expiry.Value)
|
|||
|
|
{
|
|||
|
|
return new LicenseCheckResult(
|
|||
|
|
LicenseCheckState.Expired,
|
|||
|
|
data.Stage,
|
|||
|
|
expiry,
|
|||
|
|
data.Stage == LicenseStage.FirstPeriod
|
|||
|
|
? "第一阶段使用时效已到,请输入第一次时效密码。"
|
|||
|
|
: "第二阶段使用时效已到,请输入第二次时效密码。");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return new LicenseCheckResult(
|
|||
|
|
LicenseCheckState.Valid,
|
|||
|
|
data.Stage,
|
|||
|
|
expiry,
|
|||
|
|
data.Stage == LicenseStage.Permanent ? "软件已永久授权。" : "软件授权有效。");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public void Initialize(
|
|||
|
|
string adminPassword,
|
|||
|
|
string firstUnlockPassword,
|
|||
|
|
string secondUnlockPassword,
|
|||
|
|
int firstPeriodMonths,
|
|||
|
|
int secondPeriodMonths)
|
|||
|
|
{
|
|||
|
|
ValidateSettings(adminPassword, firstUnlockPassword, secondUnlockPassword, firstPeriodMonths, secondPeriodMonths);
|
|||
|
|
var now = DateTime.UtcNow;
|
|||
|
|
var data = new LicenseData(
|
|||
|
|
Guid.NewGuid().ToString("N"),
|
|||
|
|
LicenseStage.FirstPeriod,
|
|||
|
|
now,
|
|||
|
|
now,
|
|||
|
|
firstPeriodMonths,
|
|||
|
|
secondPeriodMonths,
|
|||
|
|
CreateSecret(adminPassword),
|
|||
|
|
CreateSecret(firstUnlockPassword),
|
|||
|
|
CreateSecret(secondUnlockPassword));
|
|||
|
|
|
|||
|
|
if (IsAdministrator())
|
|||
|
|
{
|
|||
|
|
InstallInitialLicense(data);
|
|||
|
|
current = data;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var tempPath = Path.Combine(Path.GetTempPath(), $"footwear-license-{Guid.NewGuid():N}.dat");
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
File.WriteAllBytes(tempPath, Protect(data));
|
|||
|
|
var executable = Environment.ProcessPath ?? throw new InvalidOperationException("无法确定当前程序路径。");
|
|||
|
|
using var process = Process.Start(new ProcessStartInfo
|
|||
|
|
{
|
|||
|
|
FileName = executable,
|
|||
|
|
Arguments = $"{InstallArgument} \"{tempPath}\"",
|
|||
|
|
UseShellExecute = true,
|
|||
|
|
Verb = "runas"
|
|||
|
|
}) ?? throw new InvalidOperationException("无法启动授权初始化程序。");
|
|||
|
|
process.WaitForExit();
|
|||
|
|
if (process.ExitCode != 0)
|
|||
|
|
{
|
|||
|
|
throw new InvalidOperationException("授权初始化未完成或管理员权限确认被取消。");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var result = Check();
|
|||
|
|
if (!result.CanUseSoftware)
|
|||
|
|
{
|
|||
|
|
throw new InvalidOperationException(result.Message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
finally
|
|||
|
|
{
|
|||
|
|
TryDelete(tempPath);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public static int RunElevatedInstall(string packagePath)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (!IsAdministrator())
|
|||
|
|
{
|
|||
|
|
return 2;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var bytes = File.ReadAllBytes(packagePath);
|
|||
|
|
var data = Unprotect(bytes);
|
|||
|
|
InstallInitialLicense(data);
|
|||
|
|
return 0;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Log.Error(ex, "整机授权初始化失败");
|
|||
|
|
return 1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public bool VerifyAdminPassword(string password) =>
|
|||
|
|
EnsureCurrent() && VerifySecret(password, current!.AdminPassword);
|
|||
|
|
|
|||
|
|
public bool UnlockCurrentStage(string password)
|
|||
|
|
{
|
|||
|
|
if (!EnsureCurrent() || current!.Stage == LicenseStage.Permanent)
|
|||
|
|
{
|
|||
|
|
return current?.Stage == LicenseStage.Permanent;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var secret = current.Stage == LicenseStage.FirstPeriod
|
|||
|
|
? current.FirstUnlockPassword
|
|||
|
|
: current.SecondUnlockPassword;
|
|||
|
|
if (!VerifySecret(password, secret))
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var now = DateTime.UtcNow;
|
|||
|
|
current = current.Stage == LicenseStage.FirstPeriod
|
|||
|
|
? current with { Stage = LicenseStage.SecondPeriod, StageStartedUtc = now, LastTrustedUtc = now }
|
|||
|
|
: current with { Stage = LicenseStage.Permanent, StageStartedUtc = now, LastTrustedUtc = now };
|
|||
|
|
SaveCurrent();
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public void UpdateSettings(
|
|||
|
|
string? newAdminPassword,
|
|||
|
|
string? newFirstUnlockPassword,
|
|||
|
|
string? newSecondUnlockPassword,
|
|||
|
|
int firstPeriodMonths,
|
|||
|
|
int secondPeriodMonths)
|
|||
|
|
{
|
|||
|
|
if (!EnsureCurrent())
|
|||
|
|
{
|
|||
|
|
throw new InvalidOperationException("授权状态未加载。");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ValidateMonths(firstPeriodMonths, secondPeriodMonths);
|
|||
|
|
current = current! with
|
|||
|
|
{
|
|||
|
|
FirstPeriodMonths = firstPeriodMonths,
|
|||
|
|
SecondPeriodMonths = secondPeriodMonths,
|
|||
|
|
AdminPassword = string.IsNullOrWhiteSpace(newAdminPassword) ? current.AdminPassword : CreateSecret(newAdminPassword),
|
|||
|
|
FirstUnlockPassword = string.IsNullOrWhiteSpace(newFirstUnlockPassword) ? current.FirstUnlockPassword : CreateSecret(newFirstUnlockPassword),
|
|||
|
|
SecondUnlockPassword = string.IsNullOrWhiteSpace(newSecondUnlockPassword) ? current.SecondUnlockPassword : CreateSecret(newSecondUnlockPassword),
|
|||
|
|
LastTrustedUtc = DateTime.UtcNow
|
|||
|
|
};
|
|||
|
|
SaveCurrent();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public void RestartTiming()
|
|||
|
|
{
|
|||
|
|
if (!EnsureCurrent())
|
|||
|
|
{
|
|||
|
|
throw new InvalidOperationException("授权状态未加载。");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var now = DateTime.UtcNow;
|
|||
|
|
current = current! with
|
|||
|
|
{
|
|||
|
|
Stage = LicenseStage.FirstPeriod,
|
|||
|
|
StageStartedUtc = now,
|
|||
|
|
LastTrustedUtc = now
|
|||
|
|
};
|
|||
|
|
SaveCurrent();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public string DescribeCurrent()
|
|||
|
|
{
|
|||
|
|
if (!EnsureCurrent())
|
|||
|
|
{
|
|||
|
|
return "授权状态不可用";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var expiry = GetExpiry(current!);
|
|||
|
|
var stageText = current!.Stage switch
|
|||
|
|
{
|
|||
|
|
LicenseStage.FirstPeriod => "第一阶段",
|
|||
|
|
LicenseStage.SecondPeriod => "第二阶段",
|
|||
|
|
_ => "永久授权"
|
|||
|
|
};
|
|||
|
|
return expiry.HasValue
|
|||
|
|
? $"{stageText};开始:{current.StageStartedUtc.ToLocalTime():yyyy-MM-dd HH:mm};到期:{expiry.Value.ToLocalTime():yyyy-MM-dd HH:mm}"
|
|||
|
|
: stageText;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private LicenseCheckResult? TryLoad(out LicenseData? data)
|
|||
|
|
{
|
|||
|
|
data = null;
|
|||
|
|
var registryInstallId = ReadRegistryInstallId();
|
|||
|
|
var fileExists = File.Exists(LicenseFilePath);
|
|||
|
|
if (!fileExists && string.IsNullOrWhiteSpace(registryInstallId))
|
|||
|
|
{
|
|||
|
|
return new LicenseCheckResult(LicenseCheckState.NotInitialized, LicenseStage.FirstPeriod, null, "首次使用,请先完成时效授权初始化。");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!fileExists || string.IsNullOrWhiteSpace(registryInstallId))
|
|||
|
|
{
|
|||
|
|
return new LicenseCheckResult(LicenseCheckState.Tampered, LicenseStage.FirstPeriod, null, "授权文件或整机安装标记缺失,软件已锁定。");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
data = Unprotect(File.ReadAllBytes(LicenseFilePath));
|
|||
|
|
if (!string.Equals(data.InstallId, registryInstallId, StringComparison.Ordinal))
|
|||
|
|
{
|
|||
|
|
data = null;
|
|||
|
|
return new LicenseCheckResult(LicenseCheckState.Tampered, LicenseStage.FirstPeriod, null, "授权文件与整机安装标记不匹配,软件已锁定。");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Log.Warning(ex, "读取整机授权失败:Path={Path}", LicenseFilePath);
|
|||
|
|
return new LicenseCheckResult(LicenseCheckState.Tampered, LicenseStage.FirstPeriod, null, "授权文件损坏或无法读取,软件已锁定。");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private bool EnsureCurrent()
|
|||
|
|
{
|
|||
|
|
if (current is not null)
|
|||
|
|
{
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var result = Check();
|
|||
|
|
return result.State is LicenseCheckState.Valid or LicenseCheckState.Expired;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void SaveCurrent()
|
|||
|
|
{
|
|||
|
|
if (current is null)
|
|||
|
|
{
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var directory = Path.GetDirectoryName(LicenseFilePath)!;
|
|||
|
|
Directory.CreateDirectory(directory);
|
|||
|
|
var tempPath = Path.Combine(directory, $"license-{Guid.NewGuid():N}.tmp");
|
|||
|
|
File.WriteAllBytes(tempPath, Protect(current));
|
|||
|
|
File.Move(tempPath, LicenseFilePath, true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static void InstallInitialLicense(LicenseData data)
|
|||
|
|
{
|
|||
|
|
var directory = Path.GetDirectoryName(LicenseFilePath)!;
|
|||
|
|
Directory.CreateDirectory(directory);
|
|||
|
|
File.WriteAllBytes(LicenseFilePath, Protect(data));
|
|||
|
|
using var key = Registry.LocalMachine.CreateSubKey(RegistryPath, true)
|
|||
|
|
?? throw new InvalidOperationException("无法创建整机授权注册表标记。");
|
|||
|
|
key.SetValue(RegistryInstallIdName, data.InstallId, RegistryValueKind.String);
|
|||
|
|
|
|||
|
|
if (OperatingSystem.IsWindows())
|
|||
|
|
{
|
|||
|
|
using var permissions = Process.Start(new ProcessStartInfo
|
|||
|
|
{
|
|||
|
|
FileName = "icacls.exe",
|
|||
|
|
Arguments = $"\"{directory}\" /grant *S-1-5-32-545:(OI)(CI)M /T /C",
|
|||
|
|
UseShellExecute = false,
|
|||
|
|
CreateNoWindow = true
|
|||
|
|
});
|
|||
|
|
permissions?.WaitForExit();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static byte[] Protect(LicenseData data) =>
|
|||
|
|
ProtectedData.Protect(
|
|||
|
|
JsonSerializer.SerializeToUtf8Bytes(data, JsonOptions),
|
|||
|
|
AdditionalEntropy,
|
|||
|
|
DataProtectionScope.LocalMachine);
|
|||
|
|
|
|||
|
|
private static LicenseData Unprotect(byte[] protectedBytes) =>
|
|||
|
|
JsonSerializer.Deserialize<LicenseData>(
|
|||
|
|
ProtectedData.Unprotect(protectedBytes, AdditionalEntropy, DataProtectionScope.LocalMachine))
|
|||
|
|
?? throw new InvalidDataException("授权数据为空。");
|
|||
|
|
|
|||
|
|
private static string? ReadRegistryInstallId()
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
using var key = Registry.LocalMachine.OpenSubKey(RegistryPath, false);
|
|||
|
|
return key?.GetValue(RegistryInstallIdName) as string;
|
|||
|
|
}
|
|||
|
|
catch (Exception ex)
|
|||
|
|
{
|
|||
|
|
Log.Warning(ex, "读取整机授权注册表标记失败");
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static PasswordSecret CreateSecret(string password)
|
|||
|
|
{
|
|||
|
|
ValidatePassword(password, "密码");
|
|||
|
|
var salt = RandomNumberGenerator.GetBytes(16);
|
|||
|
|
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, 100_000, HashAlgorithmName.SHA256, 32);
|
|||
|
|
return new PasswordSecret(Convert.ToBase64String(salt), Convert.ToBase64String(hash));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static bool VerifySecret(string password, PasswordSecret secret)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
var salt = Convert.FromBase64String(secret.Salt);
|
|||
|
|
var expected = Convert.FromBase64String(secret.Hash);
|
|||
|
|
var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, 100_000, HashAlgorithmName.SHA256, expected.Length);
|
|||
|
|
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
|||
|
|
}
|
|||
|
|
catch
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static DateTime? GetExpiry(LicenseData data) =>
|
|||
|
|
data.Stage switch
|
|||
|
|
{
|
|||
|
|
LicenseStage.FirstPeriod => data.StageStartedUtc.AddMonths(data.FirstPeriodMonths),
|
|||
|
|
LicenseStage.SecondPeriod => data.StageStartedUtc.AddMonths(data.SecondPeriodMonths),
|
|||
|
|
_ => null
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
private static void ValidateSettings(string admin, string first, string second, int firstMonths, int secondMonths)
|
|||
|
|
{
|
|||
|
|
ValidatePassword(admin, "管理密码");
|
|||
|
|
ValidatePassword(first, "第一次时效密码");
|
|||
|
|
ValidatePassword(second, "第二次时效密码");
|
|||
|
|
ValidateMonths(firstMonths, secondMonths);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static void ValidatePassword(string password, string label)
|
|||
|
|
{
|
|||
|
|
if (string.IsNullOrWhiteSpace(password) || password.Length is < 4 or > 64)
|
|||
|
|
{
|
|||
|
|
throw new ArgumentException($"{label}长度必须为 4-64 个字符。");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static void ValidateMonths(int firstMonths, int secondMonths)
|
|||
|
|
{
|
|||
|
|
if (firstMonths is < 1 or > 120 || secondMonths is < 1 or > 120)
|
|||
|
|
{
|
|||
|
|
throw new ArgumentException("两段时效必须为 1-120 个自然月。");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static bool IsAdministrator()
|
|||
|
|
{
|
|||
|
|
if (!OperatingSystem.IsWindows())
|
|||
|
|
{
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
using var identity = WindowsIdentity.GetCurrent();
|
|||
|
|
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static void TryDelete(string path)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
if (File.Exists(path))
|
|||
|
|
{
|
|||
|
|
File.Delete(path);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch
|
|||
|
|
{
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#pragma warning restore CA1416
|