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; } = StateStore.CurrentSchemaVersion; 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; } /// Ablauf der Lizenz selbst (Unix-Zeit), 0 wenn unbefristet. public long ExpiresAt { get; set; } /// /// Ende der Offline-Gnadenfrist (Unix-Zeit). Getrennt von /// , weil eine Lizenz bis 2040 laufen kann, die /// Frist ohne Serverkontakt aber nur ueber die vom Server gemeldeten /// cache_ttl_hours. Schema 2 kannte das Feld nicht; dort wird die Frist /// aus IssuedAt abgeleitet. /// public long CacheExpiresAt { get; set; } /// Vom Server gemeldete Gnadenfrist in Stunden. public int CacheTtlHours { 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 /// Schema, das dieser Client schreibt. public const int CurrentSchemaVersion = 3; /// /// Schemata, die noch gelesen werden. Schema 2 hat keine getrennte /// Cache-Frist; ein Aufsteigen darf keinen Zwang zur Online-Pruefung /// ausloesen, nur weil das SDK aktualisiert wurde. /// private static readonly int[] SupportedSchemaVersions = { 2, 3 }; 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(jsonStr); if (data == null || Array.IndexOf(SupportedSchemaVersions, data.SchemaVersion) < 0) return null; // Incompatible schema -> Treat as Cache Miss if (!BelongsHere(data, productSlug, hardwareId)) return null; return data; } // Legacy Migration Check (non-LLS2 file) // // Vorher wurde hier beliebiges JSON nach LocalCacheData // deserialisiert und sofort im LLS2-Format zurueckgeschrieben. // Passte kein einziges Feld, entstand ein Standardobjekt, das die // urspruengliche Datei ueberschrieb. Da LicenseLabrador denselben // Pfad und Dateinamen verwendet - GetStorageDirectory beruecksichtigt // dafuer eigens LICENSELABRADOR_STORAGE_DIR - zerstoerte das den // fremden Cache still. Uebernommen wird jetzt nur, was sich als // Cache genau dieses Produkts auf genau dieser Maschine ausweist. try { string legacyJson = Encoding.UTF8.GetString(payloadBytes); // Ein Ueberbleibsel im Binaerformat ist kein JSON-Objekt. if (legacyJson.TrimStart().StartsWith("{", StringComparison.Ordinal)) { var legacyData = JsonSerializer.Deserialize(legacyJson); if (legacyData != null && BelongsHere(legacyData, productSlug, hardwareId)) { legacyData.SchemaVersion = CurrentSchemaVersion; Save(productSlug, hardwareId, legacyData); return legacyData; } } } catch (JsonException) { } return null; } catch { return null; } } /// /// Prueft, ob ein gelesener Cache tatsaechlich zu diesem Produkt gehoert. /// /// Fuer LLS2-Dateien ist die Hardware-Bindung bereits durch die /// Schluesselableitung gegeben - dort faellt die Entschluesselung sonst aus. /// Entscheidend ist der Produktbezug: ohne ihn wuerde eine fremde /// state.dat im selben Verzeichnis uebernommen und ueberschrieben. /// private static bool BelongsHere(LocalCacheData data, string productSlug, string hardwareId) { if (string.IsNullOrWhiteSpace(data.ProductSlug)) return false; if (!string.Equals(data.ProductSlug, productSlug, StringComparison.OrdinalIgnoreCase)) return false; // Ein Cache ohne Schluessel taugt zu nichts und ist meist ein // Standardobjekt aus einer Datei, die gar keine unsrige war. if (string.IsNullOrWhiteSpace(data.LicenseKey)) return false; return true; } public static bool Save(string productSlug, string hardwareId, LocalCacheData cacheData) { try { cacheData.SchemaVersion = CurrentSchemaVersion; 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 } }