feat: prepare Pro licensing and Android API 36
This commit is contained in:
1 parent
b600412285
commit
11a91863a3
25 files changed
+2447
-62
No files matched your search
@@ -0,0 +1,388 @@
|
||||
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<IssuedLicense> GetAll()
|
||||
{
|
||||
var result = new List<IssuedLicense>();
|
||||
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<ManualLicensePayload>(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))
|
||||
{
|
||||
using var existing = LoadPrivateKey();
|
||||
if (!_allowKeyGeneration && !IsExpectedSigningKey(existing))
|
||||
{
|
||||
throw new CryptographicException("La chiave locale non corrisponde alla chiave pubblica incorporata in LME Pro.");
|
||||
}
|
||||
|
||||
File.WriteAllText(PublicKeyPath, existing.ExportSubjectPublicKeyInfoPem(), 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 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";
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AssemblyName>LME.Pro.LicenseManager</AssemblyName>
|
||||
<RootNamespace>LmeLicenseManager</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.12" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,393 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace LmeLicenseManager;
|
||||
|
||||
internal sealed class MainForm : Form
|
||||
{
|
||||
private readonly LicenseRepository _repository;
|
||||
private readonly TextBox _firstName = new();
|
||||
private readonly TextBox _lastName = new();
|
||||
private readonly TextBox _email = new();
|
||||
private readonly DateTimePicker _expiresAt = new();
|
||||
private readonly TextBox _notes = new();
|
||||
private readonly TextBox _licenseCode = new();
|
||||
private readonly DataGridView _grid = new();
|
||||
private readonly Label _status = new();
|
||||
private IReadOnlyList<IssuedLicense> _licenses = [];
|
||||
|
||||
public MainForm(LicenseRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
Text = "LME Pro - Gestore licenze manuali";
|
||||
MinimumSize = new Size(980, 720);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
Font = new Font("Segoe UI", 9F);
|
||||
|
||||
var root = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ColumnCount = 1,
|
||||
RowCount = 5,
|
||||
Padding = new Padding(18)
|
||||
};
|
||||
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
|
||||
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
Controls.Add(root);
|
||||
|
||||
root.Controls.Add(BuildHeader(), 0, 0);
|
||||
root.Controls.Add(BuildIssueForm(), 0, 1);
|
||||
root.Controls.Add(BuildCodePanel(), 0, 2);
|
||||
root.Controls.Add(BuildGrid(), 0, 3);
|
||||
|
||||
_status.AutoSize = true;
|
||||
_status.Padding = new Padding(0, 8, 0, 0);
|
||||
_status.ForeColor = Color.FromArgb(60, 77, 88);
|
||||
root.Controls.Add(_status, 0, 4);
|
||||
|
||||
RefreshLicenses();
|
||||
}
|
||||
|
||||
private Control BuildHeader()
|
||||
{
|
||||
var panel = new TableLayoutPanel { Dock = DockStyle.Top, AutoSize = true, ColumnCount = 2 };
|
||||
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
|
||||
var text = new Label
|
||||
{
|
||||
AutoSize = true,
|
||||
Text = "Licenze manuali LME Pro\r\nEmetti codici cifrati e conserva titolari e scadenze nel registro locale.",
|
||||
Font = new Font("Segoe UI", 12F, FontStyle.Bold),
|
||||
Padding = new Padding(0, 0, 0, 14)
|
||||
};
|
||||
panel.Controls.Add(text, 0, 0);
|
||||
|
||||
var commands = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.LeftToRight, Margin = new Padding(8, 0, 0, 12) };
|
||||
var openFolder = new Button { Text = "Apri archivio", AutoSize = true };
|
||||
openFolder.Click += (_, _) => Process.Start(new ProcessStartInfo("explorer.exe", _repository.StoragePath) { UseShellExecute = true });
|
||||
var exportKey = new Button { Text = "Backup chiave", AutoSize = true };
|
||||
exportKey.Click += (_, _) => ExportSigningKey();
|
||||
var importKey = new Button { Text = "Ripristina chiave", AutoSize = true };
|
||||
importKey.Click += (_, _) => ImportSigningKey();
|
||||
commands.Controls.Add(openFolder);
|
||||
commands.Controls.Add(exportKey);
|
||||
commands.Controls.Add(importKey);
|
||||
panel.Controls.Add(commands, 1, 0);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private Control BuildIssueForm()
|
||||
{
|
||||
var group = new GroupBox { Text = "Nuova licenza", Dock = DockStyle.Top, AutoSize = true, Padding = new Padding(12) };
|
||||
var form = new TableLayoutPanel { Dock = DockStyle.Top, AutoSize = true, ColumnCount = 4, RowCount = 3 };
|
||||
form.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25));
|
||||
form.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25));
|
||||
form.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 30));
|
||||
form.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 20));
|
||||
|
||||
AddLabeledControl(form, "Nome", _firstName, 0, 0);
|
||||
AddLabeledControl(form, "Cognome", _lastName, 1, 0);
|
||||
AddLabeledControl(form, "Email", _email, 2, 0);
|
||||
_expiresAt.Format = DateTimePickerFormat.Custom;
|
||||
_expiresAt.CustomFormat = "dd/MM/yyyy HH:mm";
|
||||
_expiresAt.Value = DateTime.Now.AddYears(1);
|
||||
AddLabeledControl(form, "Scadenza", _expiresAt, 3, 0);
|
||||
|
||||
_notes.PlaceholderText = "Riferimento fattura, cliente, note interne...";
|
||||
AddLabeledControl(form, "Note", _notes, 0, 1, 3);
|
||||
|
||||
var issue = new Button
|
||||
{
|
||||
Text = "Emetti licenza",
|
||||
Dock = DockStyle.Fill,
|
||||
Height = 36,
|
||||
Margin = new Padding(6, 23, 3, 4),
|
||||
BackColor = Color.FromArgb(7, 94, 84),
|
||||
ForeColor = Color.White,
|
||||
FlatStyle = FlatStyle.Flat
|
||||
};
|
||||
issue.Click += (_, _) => IssueLicense();
|
||||
form.Controls.Add(issue, 3, 1);
|
||||
|
||||
var note = new Label
|
||||
{
|
||||
AutoSize = true,
|
||||
Text = "La revoca registrata qui non disattiva un codice già consegnato e offline: la disattivazione effettiva avviene alla scadenza.",
|
||||
ForeColor = Color.FromArgb(90, 103, 111),
|
||||
Padding = new Padding(3, 8, 3, 2)
|
||||
};
|
||||
form.Controls.Add(note, 0, 2);
|
||||
form.SetColumnSpan(note, 4);
|
||||
group.Controls.Add(form);
|
||||
return group;
|
||||
}
|
||||
|
||||
private Control BuildCodePanel()
|
||||
{
|
||||
var group = new GroupBox { Text = "Codice da consegnare", Dock = DockStyle.Top, Height = 122, Padding = new Padding(12) };
|
||||
var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2 };
|
||||
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
layout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
|
||||
_licenseCode.Multiline = true;
|
||||
_licenseCode.ReadOnly = true;
|
||||
_licenseCode.ScrollBars = ScrollBars.Vertical;
|
||||
_licenseCode.Dock = DockStyle.Fill;
|
||||
_licenseCode.Font = new Font("Consolas", 8.5F);
|
||||
layout.Controls.Add(_licenseCode, 0, 0);
|
||||
|
||||
var copy = new Button { Text = "Copia codice", AutoSize = true, Dock = DockStyle.Top, Margin = new Padding(8, 0, 0, 0) };
|
||||
copy.Click += (_, _) => CopyCurrentCode();
|
||||
layout.Controls.Add(copy, 1, 0);
|
||||
group.Controls.Add(layout);
|
||||
return group;
|
||||
}
|
||||
|
||||
private Control BuildGrid()
|
||||
{
|
||||
var panel = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 2, Padding = new Padding(0, 12, 0, 0) };
|
||||
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
|
||||
|
||||
var commands = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Top, FlowDirection = FlowDirection.LeftToRight };
|
||||
var refresh = new Button { Text = "Aggiorna registro", AutoSize = true };
|
||||
refresh.Click += (_, _) => RefreshLicenses();
|
||||
var revoke = new Button { Text = "Segna revocata", AutoSize = true };
|
||||
revoke.Click += (_, _) => RevokeSelected();
|
||||
commands.Controls.Add(refresh);
|
||||
commands.Controls.Add(revoke);
|
||||
panel.Controls.Add(commands, 0, 0);
|
||||
|
||||
_grid.Dock = DockStyle.Fill;
|
||||
_grid.ReadOnly = true;
|
||||
_grid.AllowUserToAddRows = false;
|
||||
_grid.AllowUserToDeleteRows = false;
|
||||
_grid.AutoGenerateColumns = true;
|
||||
_grid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill;
|
||||
_grid.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
|
||||
_grid.MultiSelect = false;
|
||||
_grid.SelectionChanged += (_, _) => ShowSelectedCode();
|
||||
panel.Controls.Add(_grid, 0, 1);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private void IssueLicense()
|
||||
{
|
||||
try
|
||||
{
|
||||
var expiry = new DateTimeOffset(_expiresAt.Value);
|
||||
var license = _repository.Issue(_firstName.Text, _lastName.Text, _email.Text, expiry, _notes.Text);
|
||||
_licenseCode.Text = license.Token;
|
||||
Clipboard.SetText(license.Token);
|
||||
_status.Text = $"Licenza {ShortId(license.LicenseId)} emessa e copiata negli appunti. Scadenza: {license.ExpiresAtUtc.ToLocalTime():dd/MM/yyyy HH:mm}.";
|
||||
RefreshLicenses(license.LicenseId);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
MessageBox.Show(this, error.GetBaseException().Message, "Licenza non emessa", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshLicenses(string? selectLicenseId = null)
|
||||
{
|
||||
_licenses = _repository.GetAll();
|
||||
_grid.DataSource = _licenses.Select(item => new
|
||||
{
|
||||
Id = ShortId(item.LicenseId),
|
||||
item.Name,
|
||||
item.Email,
|
||||
Emessa = item.IssuedAtUtc.ToLocalTime().ToString("dd/MM/yyyy HH:mm"),
|
||||
Scadenza = item.ExpiresAtUtc.ToLocalTime().ToString("dd/MM/yyyy HH:mm"),
|
||||
item.Status,
|
||||
item.Notes,
|
||||
LicenseId = item.LicenseId
|
||||
}).ToList();
|
||||
|
||||
if (selectLicenseId is not null)
|
||||
{
|
||||
foreach (DataGridViewRow row in _grid.Rows)
|
||||
{
|
||||
if (Equals(row.Cells["LicenseId"].Value, selectLicenseId))
|
||||
{
|
||||
row.Selected = true;
|
||||
_grid.CurrentCell = row.Cells[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_grid.Columns.Contains("LicenseId"))
|
||||
{
|
||||
_grid.Columns["LicenseId"]!.Visible = false;
|
||||
}
|
||||
|
||||
_status.Text = $"{_licenses.Count} licenze registrate. Archivio: {_repository.StoragePath}";
|
||||
}
|
||||
|
||||
private void ShowSelectedCode()
|
||||
{
|
||||
var selected = GetSelectedLicense();
|
||||
if (selected is not null)
|
||||
{
|
||||
_licenseCode.Text = selected.Token;
|
||||
}
|
||||
}
|
||||
|
||||
private void CopyCurrentCode()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_licenseCode.Text))
|
||||
{
|
||||
Clipboard.SetText(_licenseCode.Text);
|
||||
_status.Text = "Codice licenza copiato negli appunti.";
|
||||
}
|
||||
}
|
||||
|
||||
private void RevokeSelected()
|
||||
{
|
||||
var selected = GetSelectedLicense();
|
||||
if (selected is null || selected.RevokedAtUtc is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var answer = MessageBox.Show(
|
||||
this,
|
||||
"La revoca verrà annotata nel registro, ma un'app offline continuerà a riconoscere il codice fino alla scadenza. Continuare?",
|
||||
"Segna licenza revocata",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Warning);
|
||||
if (answer != DialogResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_repository.MarkRevoked(selected.LicenseId);
|
||||
RefreshLicenses(selected.LicenseId);
|
||||
}
|
||||
|
||||
private void ExportSigningKey()
|
||||
{
|
||||
var password = PromptPassword("Password backup", "Scegli una password di almeno 12 caratteri per cifrare la chiave.", true);
|
||||
if (password is null) return;
|
||||
using var dialog = new SaveFileDialog
|
||||
{
|
||||
Title = "Salva backup della chiave licenze",
|
||||
Filter = "Backup chiave LME (*.lmekey)|*.lmekey",
|
||||
FileName = $"LME-Pro-licenze-{DateTime.Now:yyyyMMdd}.lmekey",
|
||||
AddExtension = true
|
||||
};
|
||||
if (dialog.ShowDialog(this) != DialogResult.OK) return;
|
||||
try
|
||||
{
|
||||
_repository.ExportSigningKeyBackup(dialog.FileName, password);
|
||||
_status.Text = "Backup cifrato della chiave creato. Conserva file e password separatamente.";
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
MessageBox.Show(this, error.GetBaseException().Message, "Backup non creato", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void ImportSigningKey()
|
||||
{
|
||||
using var dialog = new OpenFileDialog
|
||||
{
|
||||
Title = "Apri backup della chiave licenze",
|
||||
Filter = "Backup chiave LME (*.lmekey)|*.lmekey"
|
||||
};
|
||||
if (dialog.ShowDialog(this) != DialogResult.OK) return;
|
||||
var password = PromptPassword("Password backup", "Inserisci la password usata per cifrare questa chiave.", false);
|
||||
if (password is null) return;
|
||||
var answer = MessageBox.Show(
|
||||
this,
|
||||
"La chiave di emissione corrente verrà sostituita. Importa soltanto il backup associato a questa versione dell'app.",
|
||||
"Ripristina chiave licenze",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Warning);
|
||||
if (answer != DialogResult.Yes) return;
|
||||
try
|
||||
{
|
||||
_repository.ImportSigningKeyBackup(dialog.FileName, password);
|
||||
_status.Text = "Chiave di emissione ripristinata dal backup.";
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
MessageBox.Show(this, error.GetBaseException().Message, "Chiave non importata", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private string? PromptPassword(string title, string message, bool confirm)
|
||||
{
|
||||
using var dialog = new Form
|
||||
{
|
||||
Text = title,
|
||||
Width = 470,
|
||||
Height = confirm ? 245 : 205,
|
||||
StartPosition = FormStartPosition.CenterParent,
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog,
|
||||
MaximizeBox = false,
|
||||
MinimizeBox = false
|
||||
};
|
||||
var layout = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(14), RowCount = confirm ? 5 : 4, ColumnCount = 1 };
|
||||
var explanation = new Label { Text = message, AutoSize = true, MaximumSize = new Size(420, 0) };
|
||||
var password = new TextBox { UseSystemPasswordChar = true, Dock = DockStyle.Top };
|
||||
var confirmation = new TextBox { UseSystemPasswordChar = true, Dock = DockStyle.Top, Visible = confirm };
|
||||
var buttons = new FlowLayoutPanel { FlowDirection = FlowDirection.RightToLeft, Dock = DockStyle.Fill, AutoSize = true };
|
||||
var ok = new Button { Text = "OK", DialogResult = DialogResult.OK, AutoSize = true };
|
||||
var cancel = new Button { Text = "Annulla", DialogResult = DialogResult.Cancel, AutoSize = true };
|
||||
buttons.Controls.Add(ok);
|
||||
buttons.Controls.Add(cancel);
|
||||
layout.Controls.Add(explanation);
|
||||
layout.Controls.Add(new Label { Text = "Password", AutoSize = true });
|
||||
layout.Controls.Add(password);
|
||||
if (confirm)
|
||||
{
|
||||
var confirmationPanel = new TableLayoutPanel { Dock = DockStyle.Top, AutoSize = true, RowCount = 2 };
|
||||
confirmationPanel.Controls.Add(new Label { Text = "Conferma password", AutoSize = true });
|
||||
confirmationPanel.Controls.Add(confirmation);
|
||||
layout.Controls.Add(confirmationPanel);
|
||||
}
|
||||
layout.Controls.Add(buttons);
|
||||
dialog.Controls.Add(layout);
|
||||
dialog.AcceptButton = ok;
|
||||
dialog.CancelButton = cancel;
|
||||
if (dialog.ShowDialog(this) != DialogResult.OK) return null;
|
||||
if (confirm && password.Text != confirmation.Text)
|
||||
{
|
||||
MessageBox.Show(this, "Le password non coincidono.", title, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return null;
|
||||
}
|
||||
return password.Text;
|
||||
}
|
||||
|
||||
private IssuedLicense? GetSelectedLicense()
|
||||
{
|
||||
if (_grid.CurrentRow?.Cells["LicenseId"].Value is not string id)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return _licenses.FirstOrDefault(item => item.LicenseId == id);
|
||||
}
|
||||
|
||||
private static void AddLabeledControl(TableLayoutPanel form, string labelText, Control control, int column, int row, int columnSpan = 1)
|
||||
{
|
||||
var panel = new TableLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, RowCount = 2, Margin = new Padding(3) };
|
||||
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
panel.Controls.Add(new Label { Text = labelText, AutoSize = true }, 0, 0);
|
||||
control.Dock = DockStyle.Top;
|
||||
control.Margin = new Padding(0, 4, 0, 0);
|
||||
panel.Controls.Add(control, 0, 1);
|
||||
form.Controls.Add(panel, column, row);
|
||||
if (columnSpan > 1)
|
||||
{
|
||||
form.SetColumnSpan(panel, columnSpan);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ShortId(string id) => id.Length > 8 ? id[..8].ToUpperInvariant() : id;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
namespace LmeLicenseManager;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static void Main(string[] args)
|
||||
{
|
||||
if (args.Contains("--self-test", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
RunSelfTest();
|
||||
return;
|
||||
}
|
||||
|
||||
var repository = new LicenseRepository();
|
||||
repository.Initialize();
|
||||
if (args.Contains("--init", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplicationConfiguration.Initialize();
|
||||
Application.Run(new MainForm(repository));
|
||||
}
|
||||
|
||||
private static void RunSelfTest()
|
||||
{
|
||||
var testDirectory = Path.Combine(Path.GetTempPath(), $"LME-License-SelfTest-{Guid.NewGuid():N}");
|
||||
var importedDirectory = Path.Combine(Path.GetTempPath(), $"LME-License-SelfTest-{Guid.NewGuid():N}");
|
||||
const string backupPassword = "LME-Test-Backup-Password-2026";
|
||||
try
|
||||
{
|
||||
var repository = new LicenseRepository(testDirectory, allowKeyGeneration: true);
|
||||
repository.Initialize();
|
||||
var license = repository.Issue(
|
||||
"Mario",
|
||||
"Rossi",
|
||||
"mario.rossi@example.test",
|
||||
DateTimeOffset.Now.AddDays(30),
|
||||
"Test automatico");
|
||||
if (!repository.VerifyToken(license.Token))
|
||||
{
|
||||
throw new InvalidOperationException("Il codice emesso non supera la verifica crittografica.");
|
||||
}
|
||||
|
||||
var backupPath = Path.Combine(testDirectory, "signing-key.lmekey");
|
||||
repository.ExportSigningKeyBackup(backupPath, backupPassword);
|
||||
|
||||
var importedRepository = new LicenseRepository(importedDirectory, allowKeyGeneration: true);
|
||||
importedRepository.Initialize();
|
||||
importedRepository.ImportSigningKeyBackup(backupPath, backupPassword);
|
||||
var portableLicense = importedRepository.Issue(
|
||||
"Giulia",
|
||||
"Bianchi",
|
||||
"giulia.bianchi@example.test",
|
||||
DateTimeOffset.Now.AddYears(1),
|
||||
"Test portabilita chiave");
|
||||
if (!repository.VerifyToken(portableLicense.Token)
|
||||
|| !importedRepository.VerifyToken(license.Token))
|
||||
{
|
||||
throw new InvalidOperationException("La chiave importata non produce licenze compatibili.");
|
||||
}
|
||||
|
||||
Console.WriteLine("Self-test licenze e portabilita chiave completato.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var directory in new[] { testDirectory, importedDirectory })
|
||||
{
|
||||
var fullPath = Path.GetFullPath(directory);
|
||||
var tempRoot = Path.GetFullPath(Path.GetTempPath());
|
||||
if (Directory.Exists(fullPath)
|
||||
&& fullPath.StartsWith(tempRoot, StringComparison.OrdinalIgnoreCase)
|
||||
&& Path.GetFileName(fullPath).StartsWith("LME-License-SelfTest-", StringComparison.Ordinal))
|
||||
{
|
||||
Directory.Delete(fullPath, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="LME.Pro.LicenseManager" />
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
Reference in new issue
Block a user