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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user