Files

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";
}