using System.Net.Mail; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.Data.Sqlite; namespace LmeLicenseManager; internal sealed class LicenseRepository { private const string ProductName = "lme-pro-manual"; private const string EncryptionKeyBase64 = "GiTTRSc1y4DseejPaA0NiXgy95DlgEsIe3JKdJ70sVQ="; private const string ExpectedPublicKeyPem = """ -----BEGIN PUBLIC KEY----- MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAmjh4E1fpaaonATNDipsR 1iy5cNSStD7fgB7i5xJTSZV36Td1B7hQXfZB8Z7vx9iFJpbCQMePuFiDKNbTUQFf EKEp8lBq7SHo0LvbKNOSK9RMkqpb3ova9Er14gSCgshivf1c774VXTlt9J7uAWoE QeQMNKJUSogoMkxEKc4Xmye65lGjJVeiuqdDCJh5o42j8DfGwqOyGX4808vSeixe 3lArRmoq9SGNO05VVunF8NKJyhiRLfropUvC+eFsiJMp3VMEYVn5FUcd8ljO67jv pnngWh6zCZxs+h0mT0YRU9F3tWHqqsG1PE6NZnspmBdNfUfb9LxKt1RshEhUWiBx 0vvdUtg20nESOvFnieIX64pcx0LqFlDMmEWDMQjZ7KEtwXVphN1mhsryUiS6Cz3b oKTryH4FQY9iIWNNzdzXujrV5ycE49orFEtrXRPnXBlB97Hl3Dlxi19lvPwxWXQ5 fKXrTJ2rL/WvlMpZ5YOggAmv7P7tA4B+Oi5uMiHzXEDlAgMBAAE= -----END PUBLIC KEY----- """; private static readonly byte[] KeyEntropy = Encoding.UTF8.GetBytes("LME-Pro-Manual-License-Signing-v1"); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly string _directory; private readonly bool _allowKeyGeneration; private string DatabasePath => Path.Combine(_directory, "licenses.db"); private string PrivateKeyPath => Path.Combine(_directory, "manual-license-private.dpapi"); public string PublicKeyPath => Path.Combine(_directory, "manual-license-public.pem"); public string StoragePath => _directory; public LicenseRepository(string? directory = null, bool allowKeyGeneration = false) { _directory = directory ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LME", "LicenseManager"); _allowKeyGeneration = allowKeyGeneration; } public void Initialize() { Directory.CreateDirectory(_directory); EnsureSigningKey(); using var connection = OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = """ CREATE TABLE IF NOT EXISTS licenses ( license_id TEXT PRIMARY KEY, first_name TEXT NOT NULL, last_name TEXT NOT NULL, email TEXT NOT NULL, issued_at_utc TEXT NOT NULL, expires_at_utc TEXT NOT NULL, token TEXT NOT NULL, token_sha256 TEXT NOT NULL, notes TEXT NOT NULL DEFAULT '', revoked_at_utc TEXT NULL ); CREATE INDEX IF NOT EXISTS ix_licenses_email ON licenses(email); CREATE INDEX IF NOT EXISTS ix_licenses_expires ON licenses(expires_at_utc); """; command.ExecuteNonQuery(); } public IssuedLicense Issue(string firstName, string lastName, string email, DateTimeOffset expiresAt, string notes) { firstName = Required(firstName, "Nome"); lastName = Required(lastName, "Cognome"); email = ValidateEmail(email); var now = DateTimeOffset.UtcNow; var expiryUtc = expiresAt.ToUniversalTime(); if (expiryUtc <= now.AddMinutes(1)) { throw new InvalidOperationException("La scadenza deve essere futura."); } var payload = new ManualLicensePayload( 1, ProductName, Guid.NewGuid().ToString("D"), firstName, lastName, email, now, expiryUtc); var plaintext = JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions); var envelope = EncryptPayload(plaintext); byte[] signature; using (var rsa = LoadPrivateKey()) { signature = rsa.SignData(envelope, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); } var token = $"LME1.{Base64UrlEncode(envelope)}.{Base64UrlEncode(signature)}"; var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); using var connection = OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = """ INSERT INTO licenses ( license_id, first_name, last_name, email, issued_at_utc, expires_at_utc, token, token_sha256, notes ) VALUES ( $id, $firstName, $lastName, $email, $issuedAt, $expiresAt, $token, $tokenHash, $notes ); """; command.Parameters.AddWithValue("$id", payload.LicenseId); command.Parameters.AddWithValue("$firstName", payload.FirstName); command.Parameters.AddWithValue("$lastName", payload.LastName); command.Parameters.AddWithValue("$email", payload.Email); command.Parameters.AddWithValue("$issuedAt", payload.IssuedAtUtc.ToString("O")); command.Parameters.AddWithValue("$expiresAt", payload.ExpiresAtUtc.ToString("O")); command.Parameters.AddWithValue("$token", token); command.Parameters.AddWithValue("$tokenHash", tokenHash); command.Parameters.AddWithValue("$notes", notes.Trim()); command.ExecuteNonQuery(); return new IssuedLicense(payload.LicenseId, firstName, lastName, email, now, expiryUtc, token, notes.Trim(), null); } public IReadOnlyList GetAll() { var result = new List(); using var connection = OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = """ SELECT license_id, first_name, last_name, email, issued_at_utc, expires_at_utc, token, notes, revoked_at_utc FROM licenses ORDER BY issued_at_utc DESC; """; using var reader = command.ExecuteReader(); while (reader.Read()) { result.Add(new IssuedLicense( reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.GetString(3), DateTimeOffset.Parse(reader.GetString(4)), DateTimeOffset.Parse(reader.GetString(5)), reader.GetString(6), reader.GetString(7), reader.IsDBNull(8) ? null : DateTimeOffset.Parse(reader.GetString(8)))); } return result; } public bool VerifyToken(string token) { try { var parts = token.Split('.'); if (parts.Length != 3 || parts[0] != "LME1") { return false; } var envelope = Base64UrlDecode(parts[1]); var signature = Base64UrlDecode(parts[2]); using var rsa = RSA.Create(); rsa.ImportFromPem(File.ReadAllText(PublicKeyPath)); if (!rsa.VerifyData(envelope, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)) { return false; } var payload = JsonSerializer.Deserialize(DecryptPayload(envelope), JsonOptions); return payload is { Version: 1, Product: ProductName } && payload.ExpiresAtUtc > payload.IssuedAtUtc; } catch (Exception error) when (error is FormatException or CryptographicException or JsonException) { return false; } } public void MarkRevoked(string licenseId) { using var connection = OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = "UPDATE licenses SET revoked_at_utc = $revokedAt WHERE license_id = $id;"; command.Parameters.AddWithValue("$revokedAt", DateTimeOffset.UtcNow.ToString("O")); command.Parameters.AddWithValue("$id", licenseId); command.ExecuteNonQuery(); } public void ExportSigningKeyBackup(string path, string password) { if (string.IsNullOrWhiteSpace(password) || password.Length < 12) { throw new InvalidOperationException("La password del backup deve contenere almeno 12 caratteri."); } using var rsa = LoadPrivateKey(); var encrypted = rsa.ExportEncryptedPkcs8PrivateKey( password, new PbeParameters(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 250_000)); File.WriteAllText(path, PemEncoding.WriteString("ENCRYPTED PRIVATE KEY", encrypted), Encoding.ASCII); CryptographicOperations.ZeroMemory(encrypted); } public void ImportSigningKeyBackup(string path, string password) { using var rsa = RSA.Create(); rsa.ImportFromEncryptedPem(File.ReadAllText(path), password); if (!_allowKeyGeneration && !IsExpectedSigningKey(rsa)) { throw new CryptographicException("Il backup appartiene a una diversa versione o chiave di LME Pro."); } var privateBytes = rsa.ExportPkcs8PrivateKey(); try { var protectedBytes = ProtectedData.Protect(privateBytes, KeyEntropy, DataProtectionScope.CurrentUser); if (File.Exists(PrivateKeyPath)) { File.SetAttributes(PrivateKeyPath, FileAttributes.Normal); } File.WriteAllBytes(PrivateKeyPath, protectedBytes); File.WriteAllText(PublicKeyPath, rsa.ExportSubjectPublicKeyInfoPem(), Encoding.ASCII); File.SetAttributes(PrivateKeyPath, FileAttributes.Hidden); } finally { CryptographicOperations.ZeroMemory(privateBytes); } } private void EnsureSigningKey() { if (File.Exists(PrivateKeyPath)) { try { using var existing = LoadPrivateKey(); if (!_allowKeyGeneration && !IsExpectedSigningKey(existing)) { PreserveIncompatiblePrivateKey(); File.WriteAllText(PublicKeyPath, ExpectedPublicKeyPem, Encoding.ASCII); return; } File.WriteAllText(PublicKeyPath, existing.ExportSubjectPublicKeyInfoPem(), Encoding.ASCII); return; } catch (CryptographicException) when (!_allowKeyGeneration) { PreserveIncompatiblePrivateKey(); File.WriteAllText(PublicKeyPath, ExpectedPublicKeyPem, Encoding.ASCII); return; } } if (!_allowKeyGeneration) { File.WriteAllText(PublicKeyPath, ExpectedPublicKeyPem, Encoding.ASCII); return; } using var rsa = RSA.Create(3072); var privateBytes = rsa.ExportPkcs8PrivateKey(); var protectedBytes = ProtectedData.Protect(privateBytes, KeyEntropy, DataProtectionScope.CurrentUser); CryptographicOperations.ZeroMemory(privateBytes); File.WriteAllBytes(PrivateKeyPath, protectedBytes); File.WriteAllText(PublicKeyPath, rsa.ExportSubjectPublicKeyInfoPem(), Encoding.ASCII); File.SetAttributes(PrivateKeyPath, FileAttributes.Hidden); } private void PreserveIncompatiblePrivateKey() { File.SetAttributes(PrivateKeyPath, FileAttributes.Normal); var preservedPath = Path.Combine( _directory, $"manual-license-private-incompatible-{DateTime.UtcNow:yyyyMMddHHmmss}.dpapi"); File.Move(PrivateKeyPath, preservedPath); } private static bool IsExpectedSigningKey(RSA rsa) { using var expected = RSA.Create(); expected.ImportFromPem(ExpectedPublicKeyPem); return CryptographicOperations.FixedTimeEquals( rsa.ExportSubjectPublicKeyInfo(), expected.ExportSubjectPublicKeyInfo()); } private RSA LoadPrivateKey() { if (!File.Exists(PrivateKeyPath)) { throw new InvalidOperationException("Chiave di emissione non disponibile. Usa 'Ripristina chiave' e importa il backup .lmekey."); } var protectedBytes = File.ReadAllBytes(PrivateKeyPath); var privateBytes = ProtectedData.Unprotect(protectedBytes, KeyEntropy, DataProtectionScope.CurrentUser); try { var rsa = RSA.Create(); rsa.ImportPkcs8PrivateKey(privateBytes, out _); return rsa; } finally { CryptographicOperations.ZeroMemory(privateBytes); } } private static byte[] EncryptPayload(byte[] plaintext) { const int nonceLength = 12; const int tagLength = 16; var nonce = RandomNumberGenerator.GetBytes(nonceLength); var tag = new byte[tagLength]; var ciphertext = new byte[plaintext.Length]; using var aes = new AesGcm(Convert.FromBase64String(EncryptionKeyBase64), tagLength); aes.Encrypt(nonce, plaintext, ciphertext, tag); return [.. nonce, .. tag, .. ciphertext]; } private static byte[] DecryptPayload(byte[] envelope) { const int nonceLength = 12; const int tagLength = 16; if (envelope.Length <= nonceLength + tagLength) { throw new CryptographicException("Invalid license envelope."); } var plaintext = new byte[envelope.Length - nonceLength - tagLength]; using var aes = new AesGcm(Convert.FromBase64String(EncryptionKeyBase64), tagLength); aes.Decrypt( envelope.AsSpan(0, nonceLength), envelope.AsSpan(nonceLength + tagLength), envelope.AsSpan(nonceLength, tagLength), plaintext); return plaintext; } private SqliteConnection OpenConnection() { var connection = new SqliteConnection($"Data Source={DatabasePath};Pooling=False"); connection.Open(); return connection; } private static string Base64UrlEncode(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); private static byte[] Base64UrlDecode(string value) { var padded = value.Replace('-', '+').Replace('_', '/'); padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '='); return Convert.FromBase64String(padded); } private static string Required(string value, string fieldName) { var normalized = value.Trim(); return normalized.Length > 0 ? normalized : throw new InvalidOperationException($"{fieldName} obbligatorio."); } private static string ValidateEmail(string value) { try { return new MailAddress(Required(value, "Email")).Address; } catch (FormatException) { throw new InvalidOperationException("Indirizzo email non valido."); } } private sealed record ManualLicensePayload( int Version, string Product, string LicenseId, string FirstName, string LastName, string Email, DateTimeOffset IssuedAtUtc, DateTimeOffset ExpiresAtUtc); } internal sealed record IssuedLicense( string LicenseId, string FirstName, string LastName, string Email, DateTimeOffset IssuedAtUtc, DateTimeOffset ExpiresAtUtc, string Token, string Notes, DateTimeOffset? RevokedAtUtc) { public string Name => $"{FirstName} {LastName}"; public string Status => RevokedAtUtc is not null ? "Revocata (registro)" : ExpiresAtUtc <= DateTimeOffset.UtcNow ? "Scaduta" : "Attiva"; }