feat: prepare Pro licensing and Android API 36

This commit is contained in:
livio committed 2026-09-28 09:50:58 +02:00
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>
+393
View File
@@ -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;
}
+80
View File
@@ -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);
}
}
}
}
}
+16
View File
@@ -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>