Files
PS_Launcher/src/PSLauncher.Core/Licensing/LicenseService.cs
j.foucher d9c3f09e9a v0.27.2 — Persiste channel + canSeeBetas dans le cache license
Bug : le LicenseValidationResponse réhydraté par GetCached() ne contenait
plus les champs Channel et CanSeeBetas (introduits en v0.26.0). Cause :
LocalConfig.LicenseConfig ne persistait pas ces deux champs, donc à chaque
relance du launcher (ou simple GetCached), Channel retombait à null et
CanSeeBetas à false.

Conséquence visible : un client avec license channel='firefighter'
côté serveur fetchait toujours /api/manifest (sans ?channel=), recevait
le manifest default-only, et ne voyait jamais le build firefighter même
après re-validation de license.

Fix :
- Ajout des champs CachedChannel + CachedCanSeeBetas sur LicenseConfig
  (persistés dans %LocalAppData%\PSLauncher\config.json)
- SaveCached() écrit ces champs depuis la réponse signée du serveur
- GetCached() les restaure dans la LicenseValidationResponse renvoyée

Migration silencieuse : un launcher pre-v0.27.2 a CachedChannel=null
et CachedCanSeeBetas=false dans son config.json. Au prochain Vérifier
les MAJ, RefreshLicenseFromServerAsync re-valide la license, le serveur
renvoie les valeurs courantes, SaveCached les persiste. La fois d'après
le launcher fetch correctement avec ?channel=X.

À tester chez l'utilisateur :
1. Recompiler + réinstaller le launcher en v0.27.2
2. Settings → Vérifier les MAJ (force la revalidation license)
3. Vérifier %LocalAppData%\PSLauncher\config.json :
   "CachedChannel": "firefighter" doit apparaître
4. Le manifest-cache.json devrait alors contenir le bon ZIP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:37:19 +02:00

438 lines
19 KiB
C#

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
{
/// <summary>
/// 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).
/// </summary>
private const int CachedValidityDays = 100;
/// <summary>
/// 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.
/// </summary>
private static readonly TimeSpan ClockRollbackGrace = TimeSpan.FromHours(1);
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
private readonly HttpClient _http;
private readonly Func<string> _serverBaseUrlProvider;
private readonly IConfigStore _configStore;
private readonly LocalConfig _config;
private readonly ILogger<LicenseService> _logger;
private readonly string? _serverPublicKeyHex;
public LicenseService(
HttpClient http,
Func<string> serverBaseUrlProvider,
IConfigStore configStore,
LocalConfig config,
ILogger<LicenseService> 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<LicenseValidationResponse> 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<LicenseValidationResponse>(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;
}
/// <summary>
/// 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 <c>LastSeenUtc</c> 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 : <c>LastSeenUtc</c> ne peut que progresser. Mis à
/// jour à <c>max(stored, now)</c> à 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)
/// </summary>
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<string?> 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 -----
/// <summary>
/// Encode canonical du payload en JSON sans le champ 'signature', exactement comme côté PHP
/// (Crypto::canonicalJson : JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE).
/// </summary>
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<string, object?>
{
["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);
}
/// <summary>
/// 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')`.
/// </summary>
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;
}
}
/// <summary>
/// Canonical historique (avant v0.26.0) : pas de <c>channel</c> ni
/// <c>canSeeBetas</c>. 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).
/// </summary>
private static byte[] CanonicalBytesForLegacy(LicenseValidationResponse response)
{
var dict = new Dictionary<string, object?>
{
["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('/');
}