Licensing: 100-day offline cache with anti-rollback + cached server status

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 19:15:49 +02:00
parent 9cea07d6be
commit 07d58a8854
3 changed files with 136 additions and 8 deletions

View File

@@ -18,4 +18,11 @@ public interface ILicenseService
/// auquel cas l'appelant retombe sur l'URL publique du manifest.
/// </summary>
Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct);
/// <summary>
/// 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).
/// </summary>
string? GetDecryptedKey();
}

View File

@@ -13,7 +13,19 @@ namespace PSLauncher.Core.Licensing;
public sealed class LicenseService : ILicenseService
{
private const int CachedValidityDays = 7;
/// <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()
{
@@ -113,21 +125,85 @@ public sealed class LicenseService : ILicenseService
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;
// 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);
}

View File

@@ -13,13 +13,53 @@ public sealed class LocalConfig
/// </summary>
public string Language { get; set; } = "auto";
/// <summary>
/// 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.
/// </summary>
public int ParallelDownloadSegments { get; set; } = 8;
/// <summary>
/// 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.
/// </summary>
public bool VerifyDownloadHash { get; set; } = true;
public LicenseConfig License { get; set; } = new();
}
public sealed class LicenseConfig
{
public string? EncryptedKey { get; set; }
/// <summary>
/// Date UTC de la dernière validation online réussie. Utilise <c>response.ServerTime</c>
/// (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.
/// </summary>
public DateTime? LastValidationAt { get; set; }
public DateTime? CachedEntitlementUntil { get; set; }
public string? CachedOwnerName { get; set; }
/// <summary>
/// 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é.
/// </summary>
public string? CachedStatus { get; set; }
/// <summary>
/// Compteur monotone : date UTC la plus récente jamais observée par le launcher
/// sur cette machine. Mise à jour à chaque démarrage si <c>now</c> est postérieur.
/// Sert d'anti-rollback : si l'utilisateur recule l'horloge PC, on détecte
/// (now &lt; LastSeenUtc - grace) et on invalide le cache → forçage d'une
/// re-validation online la prochaine fois qu'il sera connecté.
/// </summary>
public DateTime? LastSeenUtc { get; set; }
}