using System.Net.Http.Json; using System.Reflection; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using NSec.Cryptography; using PSLauncher.Core.Configuration; using PSLauncher.Models; namespace PSLauncher.Core.Licensing; public sealed class LicenseService : ILicenseService { /// /// Durée de validité du cache offline. 100 jours = ~3 mois + marge pour un user /// qui se connecte en moyenne tous les 3 mois. Au-delà, le launcher exige une /// nouvelle validation online (la license elle-même peut être valide sur le serveur, /// c'est juste qu'on ne peut plus le vérifier sans réseau). /// private const int CachedValidityDays = 100; /// /// Tolérance pour les corrections d'horloge légitimes (NTP sync, daylight saving, /// reboot après débranchement long, etc.). Au-delà, on suspecte une manipulation. /// private static readonly TimeSpan ClockRollbackGrace = TimeSpan.FromHours(1); private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, }; private readonly HttpClient _http; private readonly Func _serverBaseUrlProvider; private readonly IConfigStore _configStore; private readonly LocalConfig _config; private readonly ILogger _logger; private readonly string? _serverPublicKeyHex; public LicenseService( HttpClient http, Func serverBaseUrlProvider, IConfigStore configStore, LocalConfig config, ILogger logger) { _http = http; _serverBaseUrlProvider = serverBaseUrlProvider; _configStore = configStore; _config = config; _logger = logger; _serverPublicKeyHex = TryReadEmbeddedPublicKey(); } public bool HasLicense() => !string.IsNullOrEmpty(_config.License.EncryptedKey); public string GetMachineId() { // Combine MachineGuid + nom utilisateur, hash SHA-256 → ID stable, sans leak du GUID brut string raw; try { using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Cryptography"); raw = (key?.GetValue("MachineGuid") as string) ?? Environment.MachineName; } catch { raw = Environment.MachineName; } var input = $"{raw}|{Environment.UserName}"; var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); return Convert.ToHexString(hash).ToLowerInvariant(); } public async Task ValidateAsync(string licenseKey, CancellationToken ct) { var url = TrimSlash(_serverBaseUrlProvider()) + "/license/validate"; var req = new LicenseValidationRequest { LicenseKey = licenseKey, MachineId = GetMachineId(), MachineLabel = $"{Environment.MachineName} / {Environment.UserName}", LauncherVersion = GetLauncherVersion(), }; _logger.LogInformation("Validating license at {Url} (key {KeyHint}…)", url, licenseKey.Length >= 8 ? licenseKey[..8] : licenseKey); // Timeout court (3 s) pour fail-fast en offline. Le HttpClient global a // Timeout=Infinite, donc sans ce CTS on hangerait jusqu'à ce que l'OS // abandonne le SYN TCP (~15-21 s sur Windows). Le call site (Refresh // License pendant Check Updates) catch l'exception et garde le cache // local, donc fail-fast est sans danger. using var validateCts = CancellationTokenSource.CreateLinkedTokenSource(ct); validateCts.CancelAfter(3_000); using var httpReq = new HttpRequestMessage(HttpMethod.Post, url); httpReq.Content = JsonContent.Create(req); using var resp = await _http.SendAsync(httpReq, validateCts.Token).ConfigureAwait(false); var bodyText = await resp.Content.ReadAsStringAsync(validateCts.Token).ConfigureAwait(false); LicenseValidationResponse? parsed; try { parsed = JsonSerializer.Deserialize(bodyText, JsonOptions); } catch (Exception ex) { throw new InvalidOperationException($"Réponse serveur invalide : {ex.Message}\n{bodyText}", ex); } if (parsed is null) throw new InvalidOperationException("Réponse serveur vide"); // Statuts d'erreur applicatifs renvoyés en 4xx avec un body décrivant l'erreur if (!resp.IsSuccessStatusCode && string.IsNullOrEmpty(parsed.Status)) { parsed.Status = "invalid"; parsed.Message ??= $"HTTP {(int)resp.StatusCode}"; } // Vérification de la signature serveur (si on a la clé publique embarquée et une signature) if (!string.IsNullOrEmpty(_serverPublicKeyHex) && !string.IsNullOrEmpty(parsed.Signature)) { if (!VerifySignature(parsed, parsed.Signature, _serverPublicKeyHex!)) { _logger.LogError("License response signature INVALID — possible MITM"); throw new InvalidOperationException("Réponse serveur non authentifiée (signature invalide)"); } _logger.LogDebug("License response signature OK"); } return parsed; } /// /// Retourne la license cachée avec deux protections : /// /// 1. ANTI-ROLLBACK : si l'horloge système est en retard de plus d'1h par /// rapport au plus récent LastSeenUtc jamais vu par le launcher, /// on suspecte une manipulation (recul de date PC pour gagner du crédit /// offline) → cache invalidé, forçage d'une re-validation online. /// /// 2. COMPTEUR MONOTONE : LastSeenUtc ne peut que progresser. Mis à /// jour à max(stored, now) à chaque démarrage et persisté immédiatement. /// Donc même si l'utilisateur recule son horloge APRÈS un démarrage, la /// valeur stockée reste la plus récente jamais vue. /// /// Retourne null si : /// - pas de license stockée /// - rollback détecté (horloge manipulée) /// - cache trop ancien (> CachedValidityDays jours depuis LastValidationAt) /// public LicenseValidationResponse? GetCached() { if (string.IsNullOrEmpty(_config.License.EncryptedKey)) return null; if (_config.License.LastValidationAt is null) return null; var now = DateTime.UtcNow; var lastValidation = _config.License.LastValidationAt.Value; // Si LastSeenUtc absent (1ère exécution avec cette nouvelle version), on initialise // sur LastValidationAt — référence sûre car signée par le serveur. var lastSeen = _config.License.LastSeenUtc ?? lastValidation; // 1. Détection rollback : horloge actuelle significativement en retard if (now < lastSeen - ClockRollbackGrace) { _logger.LogWarning( "Clock rollback detected (now={Now}, lastSeen={LastSeen}, delta={Delta}). " + "Cache invalidated — re-validation requise dès que online.", now, lastSeen, lastSeen - now); return null; } // 2. Mise à jour monotone : on ne descend jamais var newLastSeen = lastSeen > now ? lastSeen : now; if (_config.License.LastSeenUtc != newLastSeen) { _config.License.LastSeenUtc = newLastSeen; try { _configStore.Save(_config); } catch (Exception ex) { _logger.LogDebug(ex, "Failed to persist LastSeenUtc update"); } } // 3. Âge du cache calculé via le compteur monotone (résistant au rollback). // Si l'utilisateur a fait avancer l'horloge artificiellement, ça consomme // son crédit offline plus vite — pas grave, il ré-valide quand vraiment online. var ageDays = (newLastSeen - lastValidation).TotalDays; if (ageDays > CachedValidityDays) { _logger.LogInformation( "Offline license cache expired (age {Age:N1} days > max {Max} days). " + "Re-validation requise dès que online.", ageDays, CachedValidityDays); return null; } // Statut : on prend la valeur cachée par le serveur (révoquée, valide, etc.) // mais avec auto-override "expired" si la date d'entitlement est dépassée. // Ça gère le passage automatique valide → expirée même si le user reste // offline pendant des semaines. var cachedStatus = _config.License.CachedStatus ?? "valid"; if (cachedStatus == "valid" && _config.License.CachedEntitlementUntil is { } entUntil && entUntil < newLastSeen) { cachedStatus = "expired"; } return new LicenseValidationResponse { Status = cachedStatus, OwnerName = _config.License.CachedOwnerName, DownloadEntitlementUntil = _config.License.CachedEntitlementUntil, ServerTime = lastValidation, // Restauration des champs channel + canSeeBetas (ajoutés persistants // depuis v0.27.2). Sans ça, le channelProvider du ManifestService // retombait sur null à chaque relance, le client fetch sans // ?channel=, et l'utilisateur ne voyait que les versions default — // jamais les builds spécifiques à son channel (firefighter, police…). Channel = _config.License.CachedChannel, CanSeeBetas = _config.License.CachedCanSeeBetas, }; } public void SaveCached(string licenseKey, LicenseValidationResponse response) { var encrypted = ProtectKey(licenseKey); _config.License.EncryptedKey = encrypted; // ServerTime fait partie du payload signé Ed25519 par le serveur, donc non // manipulable par l'utilisateur. Si absent (cas dégradé), on retombe sur // l'horloge locale. _config.License.LastValidationAt = response.ServerTime ?? DateTime.UtcNow; _config.License.LastSeenUtc = DateTime.UtcNow; _config.License.CachedOwnerName = response.OwnerName; _config.License.CachedEntitlementUntil = response.DownloadEntitlementUntil; _config.License.CachedStatus = response.Status; _config.License.CachedChannel = response.Channel; _config.License.CachedCanSeeBetas = response.CanSeeBetas; _configStore.Save(_config); } public void Clear() { _config.License = new LicenseConfig(); _configStore.Save(_config); } public bool CanDownloadVersion(LicenseValidationResponse? license, VersionManifest version) { if (license is null) return false; return license.CanDownload(version); } public async Task GetSignedDownloadUrlAsync(string version, CancellationToken ct) { var key = GetDecryptedKey(); if (string.IsNullOrEmpty(key)) return null; // On utilise ?key=… plutôt que le header Authorization parce que Apache OVH // (et beaucoup de mutualisés FastCGI) strippe ce header avant qu'il atteigne // PHP. La requête reste en HTTPS donc la clé n'est pas exposée sur le câble. var encodedKey = Uri.EscapeDataString(key); var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version + "?key=" + encodedKey; try { // Timeout court : on est dans le hot path d'Install. Si OVH ne répond pas // en 5s, on fallback sur l'URL publique du manifest plutôt que de bloquer // la UI 100s. Le DL via peer LAN a déjà été tenté avant ce point — si on // est ici c'est qu'aucun peer n'avait la version. using var sigCts = CancellationTokenSource.CreateLinkedTokenSource(ct); sigCts.CancelAfter(5_000); using var req = new HttpRequestMessage(HttpMethod.Get, url); using var resp = await _http.SendAsync(req, sigCts.Token).ConfigureAwait(false); if (!resp.IsSuccessStatusCode) { _logger.LogInformation("Signed URL endpoint returned {Status} — falling back to public URL", (int)resp.StatusCode); return null; } var body = await resp.Content.ReadAsStringAsync(sigCts.Token).ConfigureAwait(false); using var doc = JsonDocument.Parse(body); return doc.RootElement.TryGetProperty("url", out var u) ? u.GetString() : null; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception ex) { _logger.LogWarning("Signed URL fetch failed/timeout ({Reason}), falling back to public URL", ex.Message); return null; } } public string? GetDecryptedKey() { if (string.IsNullOrEmpty(_config.License.EncryptedKey)) return null; try { return UnprotectKey(_config.License.EncryptedKey); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to decrypt cached license key"); return null; } } // ----- DPAPI ----- private static string ProtectKey(string clearKey) { var data = Encoding.UTF8.GetBytes(clearKey); var protectedBytes = ProtectedData.Protect(data, optionalEntropy: null, scope: DataProtectionScope.CurrentUser); return Convert.ToBase64String(protectedBytes); } private static string UnprotectKey(string base64) { var protectedBytes = Convert.FromBase64String(base64); var data = ProtectedData.Unprotect(protectedBytes, optionalEntropy: null, scope: DataProtectionScope.CurrentUser); return Encoding.UTF8.GetString(data); } // ----- Signature ----- /// /// Encode canonical du payload en JSON sans le champ 'signature', exactement comme côté PHP /// (Crypto::canonicalJson : JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE). /// public static byte[] CanonicalBytesFor(LicenseValidationResponse response) { // On reconstruit un dictionnaire ordonné comme côté PHP, sans signature. // ATTENTION : ordre CRITIQUE — Crypto::canonicalJson côté PHP ne trie pas // les clés, il prend l'ordre du tableau associatif. Toute modif ici doit // être miroir exact du dictionnaire PHP dans ValidateLicense.php. var dict = new Dictionary { ["status"] = response.Status, ["licenseId"] = response.LicenseId, ["ownerName"] = response.OwnerName, ["issuedAt"] = FormatDateAtom(response.IssuedAt), ["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil), ["maxMachines"] = response.MaxMachines, ["channel"] = response.Channel, // null si default ["canSeeBetas"] = response.CanSeeBetas, ["serverTime"] = FormatDateAtom(response.ServerTime), }; var opts = new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition = JsonIgnoreCondition.Never, }; return JsonSerializer.SerializeToUtf8Bytes(dict, opts); } /// /// Format figé "yyyy-MM-ddTHH:mm:ssZ" en UTC, identique à ce que produit côté serveur /// PHP `(new DateTimeImmutable($s, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z')`. /// private static string? FormatDateAtom(DateTime? d) { if (d is null) return null; return d.Value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss") + "Z"; } public static bool VerifySignature(LicenseValidationResponse response, string base64Signature, string publicKeyHex) { try { var alg = SignatureAlgorithm.Ed25519; var pkBytes = Convert.FromHexString(publicKeyHex); var pk = PublicKey.Import(alg, pkBytes, KeyBlobFormat.RawPublicKey); var sig = Convert.FromBase64String(base64Signature); // Tente d'abord le canonical actuel (avec channel + canSeeBetas). // Si ça échoue, tente le canonical legacy (sans ces champs) — c'est // le format des réponses signées par les serveurs OU lancées par // les clients d'avant v0.26. Sans ce fallback, toutes les licenses // cachées localement avant l'update à v0.26 deviendraient invalides // et l'utilisateur serait forcé de revalider online — bloqué hors-ligne. var payloadCurrent = CanonicalBytesFor(response); if (alg.Verify(pk, payloadCurrent, sig)) return true; var payloadLegacy = CanonicalBytesForLegacy(response); return alg.Verify(pk, payloadLegacy, sig); } catch { return false; } } /// /// Canonical historique (avant v0.26.0) : pas de channel ni /// canSeeBetas. Conservé pour valider les réponses cachées par /// les anciennes versions du client. Une fois que toutes les licenses /// auront été re-validées online avec le nouveau serveur, on pourra /// retirer cette méthode (mais on prévient pas de coût à la garder). /// private static byte[] CanonicalBytesForLegacy(LicenseValidationResponse response) { var dict = new Dictionary { ["status"] = response.Status, ["licenseId"] = response.LicenseId, ["ownerName"] = response.OwnerName, ["issuedAt"] = FormatDateAtom(response.IssuedAt), ["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil), ["maxMachines"] = response.MaxMachines, ["serverTime"] = FormatDateAtom(response.ServerTime), }; var opts = new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition = JsonIgnoreCondition.Never, }; return JsonSerializer.SerializeToUtf8Bytes(dict, opts); } // ----- Public key embarquée ----- private static string? TryReadEmbeddedPublicKey() { var asm = Assembly.GetExecutingAssembly(); foreach (var name in asm.GetManifestResourceNames()) { if (name.EndsWith("server-pubkey.txt", StringComparison.OrdinalIgnoreCase)) { using var s = asm.GetManifestResourceStream(name); if (s is null) continue; using var sr = new StreamReader(s); var hex = sr.ReadToEnd().Trim(); if (hex.StartsWith("#") || string.IsNullOrEmpty(hex)) return null; // placeholder/ commenté return hex; } } return null; } private static string GetLauncherVersion() { var asm = Assembly.GetExecutingAssembly(); return asm.GetName().Version?.ToString() ?? "0.0.0"; } private static string TrimSlash(string s) => s.TrimEnd('/'); }