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,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