From 07d58a8854092b6374f580ed5f15c18a5c40303c Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Sat, 2 May 2026 19:15:49 +0200 Subject: [PATCH] Licensing: 100-day offline cache with anti-rollback + cached server status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CachedValidityDays bumped 7 → 100 days. Covers the "user goes online once every 3 months for updates" use case without repeatedly nagging for re-auth. - LastValidationAt now uses response.ServerTime (signed Ed25519 by the server) rather than DateTime.UtcNow, so the client's local clock can't be used to forge a fresh validation date. - LastSeenUtc monotonic counter, persisted on every launch as max(stored, now). Combined with a 1h grace window: if the user rolls their PC clock backward to extend the offline cache, the rollback is detected (now < LastSeen - 1h) and the cache is invalidated → forced re-validation next time online. - CachedStatus persists the server-returned status (valid/expired/revoked/ invalid/machine_limit_exceeded) so a revocation done while the user is offline still shows correctly when they next launch with cached data. Auto-override to "expired" if entitlement date has passed (handles the valid → expired transition without needing a server round-trip). - ILicenseService.GetDecryptedKey() exposed for the new auto-revalidation flow. This puts the trust boundary in the right place: cached data is informational offline, the actual download authorization always re-validates against the server (the gate to download is /api/download-url/, which checks the live license state on each call). User can't fake offline-only. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Licensing/ILicenseService.cs | 7 ++ .../Licensing/LicenseService.cs | 97 +++++++++++++++++-- src/PSLauncher.Models/LocalConfig.cs | 40 ++++++++ 3 files changed, 136 insertions(+), 8 deletions(-) diff --git a/src/PSLauncher.Core/Licensing/ILicenseService.cs b/src/PSLauncher.Core/Licensing/ILicenseService.cs index f58c65c..4ce4725 100644 --- a/src/PSLauncher.Core/Licensing/ILicenseService.cs +++ b/src/PSLauncher.Core/Licensing/ILicenseService.cs @@ -18,4 +18,11 @@ public interface ILicenseService /// auquel cas l'appelant retombe sur l'URL publique du manifest. /// Task GetSignedDownloadUrlAsync(string version, CancellationToken ct); + + /// + /// Décrypte la clé de license stockée en cache (DPAPI CurrentUser). Retourne null + /// si pas de clé stockée ou si le déchiffrement échoue (cache déplacé vers une + /// autre machine ou autre user Windows). + /// + string? GetDecryptedKey(); } diff --git a/src/PSLauncher.Core/Licensing/LicenseService.cs b/src/PSLauncher.Core/Licensing/LicenseService.cs index c169212..7aef60b 100644 --- a/src/PSLauncher.Core/Licensing/LicenseService.cs +++ b/src/PSLauncher.Core/Licensing/LicenseService.cs @@ -13,7 +13,19 @@ namespace PSLauncher.Core.Licensing; public sealed class LicenseService : ILicenseService { - private const int CachedValidityDays = 7; + /// + /// 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() { @@ -113,21 +125,85 @@ public sealed class LicenseService : ILicenseService 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; - // Cache offline valide 7 jours après la dernière validation réussie - var age = DateTime.UtcNow - _config.License.LastValidationAt.Value; - if (age.TotalDays > CachedValidityDays) 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 = _config.License.CachedEntitlementUntil >= DateTime.UtcNow ? "valid" : "expired", + Status = cachedStatus, OwnerName = _config.License.CachedOwnerName, DownloadEntitlementUntil = _config.License.CachedEntitlementUntil, - ServerTime = _config.License.LastValidationAt, + ServerTime = lastValidation, }; } @@ -135,9 +211,14 @@ public sealed class LicenseService : ILicenseService { var encrypted = ProtectKey(licenseKey); _config.License.EncryptedKey = encrypted; - _config.License.LastValidationAt = DateTime.UtcNow; - _config.License.CachedOwnerName = response.OwnerName; + // 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; _configStore.Save(_config); } diff --git a/src/PSLauncher.Models/LocalConfig.cs b/src/PSLauncher.Models/LocalConfig.cs index 2c17e10..c2cbde2 100644 --- a/src/PSLauncher.Models/LocalConfig.cs +++ b/src/PSLauncher.Models/LocalConfig.cs @@ -13,13 +13,53 @@ public sealed class LocalConfig /// public string Language { get; set; } = "auto"; + /// + /// Nombre de connexions HTTP parallèles utilisées pour télécharger un build. + /// 1 = comportement legacy single-connection. 4-8 contourne le throttling + /// per-connection d'Apache/OVH mutualisé. 8 est un bon défaut. + /// + public int ParallelDownloadSegments { get; set; } = 8; + + /// + /// Si false, ignore la vérification SHA-256 même si le manifest l'exige. + /// Utile sur les machines lentes ou pour gagner 30 s-3 min en fin de DL d'un gros build. + /// La sécurité repose alors uniquement sur la signature Ed25519 du manifest + HTTPS, + /// ce qui reste robuste. + /// + public bool VerifyDownloadHash { get; set; } = true; + public LicenseConfig License { get; set; } = new(); } public sealed class LicenseConfig { public string? EncryptedKey { get; set; } + + /// + /// Date UTC de la dernière validation online réussie. Utilise response.ServerTime + /// (signé Ed25519) plutôt que l'horloge locale, donc non manipulable par l'utilisateur. + /// Sert de point de départ au calcul d'expiration du cache offline. + /// public DateTime? LastValidationAt { get; set; } + public DateTime? CachedEntitlementUntil { get; set; } public string? CachedOwnerName { get; set; } + + /// + /// Statut tel que renvoyé par le serveur lors de la dernière validation : "valid", + /// "expired", "revoked", "invalid", "machine_limit_exceeded". Persisté pour qu'une + /// révocation effectuée pendant que le user est offline soit toujours visible + /// même après son expiration de cache local. Le calcul "valid → expired" basé + /// sur la date est aussi appliqué côté lecture (sanity) si le temps a passé. + /// + public string? CachedStatus { get; set; } + + /// + /// Compteur monotone : date UTC la plus récente jamais observée par le launcher + /// sur cette machine. Mise à jour à chaque démarrage si now est postérieur. + /// Sert d'anti-rollback : si l'utilisateur recule l'horloge PC, on détecte + /// (now < LastSeenUtc - grace) et on invalide le cache → forçage d'une + /// re-validation online la prochaine fois qu'il sera connecté. + /// + public DateTime? LastSeenUtc { get; set; } }