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";
|
||||
}
|
||||
Reference in new issue
Block a user