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.
|
/// auquel cas l'appelant retombe sur l'URL publique du manifest.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct);
|
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
|
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()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -113,21 +125,85 @@ public sealed class LicenseService : ILicenseService
|
|||||||
return parsed;
|
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()
|
public LicenseValidationResponse? GetCached()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_config.License.EncryptedKey)) return null;
|
if (string.IsNullOrEmpty(_config.License.EncryptedKey)) return null;
|
||||||
if (_config.License.LastValidationAt is null) return null;
|
if (_config.License.LastValidationAt is null) return null;
|
||||||
|
|
||||||
// Cache offline valide 7 jours après la dernière validation réussie
|
var now = DateTime.UtcNow;
|
||||||
var age = DateTime.UtcNow - _config.License.LastValidationAt.Value;
|
var lastValidation = _config.License.LastValidationAt.Value;
|
||||||
if (age.TotalDays > CachedValidityDays) return null;
|
// 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
|
return new LicenseValidationResponse
|
||||||
{
|
{
|
||||||
Status = _config.License.CachedEntitlementUntil >= DateTime.UtcNow ? "valid" : "expired",
|
Status = cachedStatus,
|
||||||
OwnerName = _config.License.CachedOwnerName,
|
OwnerName = _config.License.CachedOwnerName,
|
||||||
DownloadEntitlementUntil = _config.License.CachedEntitlementUntil,
|
DownloadEntitlementUntil = _config.License.CachedEntitlementUntil,
|
||||||
ServerTime = _config.License.LastValidationAt,
|
ServerTime = lastValidation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,9 +211,14 @@ public sealed class LicenseService : ILicenseService
|
|||||||
{
|
{
|
||||||
var encrypted = ProtectKey(licenseKey);
|
var encrypted = ProtectKey(licenseKey);
|
||||||
_config.License.EncryptedKey = encrypted;
|
_config.License.EncryptedKey = encrypted;
|
||||||
_config.License.LastValidationAt = DateTime.UtcNow;
|
// ServerTime fait partie du payload signé Ed25519 par le serveur, donc non
|
||||||
_config.License.CachedOwnerName = response.OwnerName;
|
// 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.CachedEntitlementUntil = response.DownloadEntitlementUntil;
|
||||||
|
_config.License.CachedStatus = response.Status;
|
||||||
_configStore.Save(_config);
|
_configStore.Save(_config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,53 @@ public sealed class LocalConfig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string Language { get; set; } = "auto";
|
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 LicenseConfig License { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class LicenseConfig
|
public sealed class LicenseConfig
|
||||||
{
|
{
|
||||||
public string? EncryptedKey { get; set; }
|
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? LastValidationAt { get; set; }
|
||||||
|
|
||||||
public DateTime? CachedEntitlementUntil { get; set; }
|
public DateTime? CachedEntitlementUntil { get; set; }
|
||||||
public string? CachedOwnerName { 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 < 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; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user