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>
438 lines
19 KiB
C#
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('/');
|
|
}
|