389 lines
15 KiB
C#
389 lines
15 KiB
C#
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";
|
|
}
|