Stocker une licence activée côté client : DPAPI, cache infalsifiable et ce qu'il ne faut pas croire
L'activation remet au client un bail signé ; puis arrive la question que personne ne prévoit : où le ranger ? Déposez-le dans un fichier en clair et un utilisateur modifie la date d'expiration ; chiffrez-le maladroitement et vous avez lié l'application à une seule machine par accident. Le cache sert à la disponibilité, pas à l'authenticité : c'est toujours la signature qui décide si un bail est réel, donc le stockage n'a que trois tâches : survivre à un redémarrage, résister à une modification occasionnelle et refuser un bail copié depuis une autre machine. Voici comment sceller un bail en cache avec DPAPI là où il existe, le lier à l'appareil avec un HMAC pour détecter un cache modifié ou transplanté, et pourquoi il ne faut jamais faire confiance à l'expiration en cache plutôt qu'à celle signée.
L’activation est la partie que tout le monde conçoit avec soin : le client prouve son identité, le serveur émet un bail signé, l’application le vérifie et s’exécute. Puis le bail doit vivre quelque part, et c’est là que la conception soignée se défait discrètement. Écrivez-le dans un fichier en clair et un utilisateur repousse l’expiration d’une décennie. Protégez-le maladroitement contre la copie et vous avez lié l’application à une seule machine par accident, si bien qu’une réinstallation bloque un client payant. La couche de stockage ressemble à de la plomberie et se comporte comme une politique.
L’idée qui clarifie tout est celle-ci : le cache sert à la disponibilité, pas à l’authenticité. Le bail signé est déjà infalsifiable — changez une revendication et la signature cesse de vérifier. Le stockage local n’a donc que trois tâches : survivre à un redémarrage pour que l’application démarre hors ligne, résister à une modification occasionnelle pour qu’un utilisateur ne puisse pas le bricoler trivialement, et refuser un bail transplanté depuis une autre machine. Il ne décide pas si le bail est valide ; c’est la signature qui le fait. Cet article explique comment Keyright persiste un bail activé : en le scellant avec DPAPI là où il existe, en le liant à l’appareil avec un HMAC, et avec la seule règle qui garde l’ensemble honnête — ne jamais faire confiance à l’expiration en cache plutôt qu’à celle signée.
La forme de ce que vous stockez
Après l’activation, vous détenez un bail signé — des revendications plus une signature — et quelques métadonnées sur le moment où le rafraîchir. Stockez le bail tel quel ; tout le reste n’est qu’une indication :
public sealed record CachedLease(
string SignedLease, // the lease exactly as issued: claims + signature, opaque to the client
DateTimeOffset CachedAt, // when we wrote it — a REFRESH hint, never an authority on validity
string DeviceTag); // HMAC over SignedLease, keyed by the device fingerprint
Remarquez ce qui n’est pas là : pas de expiresOn en clair, pas de tier, pas de booléen isValid. Dès que vous stockez une copie non signée et pratique d’une revendication, vous avez créé quelque chose qu’un utilisateur peut modifier et que votre code pourrait être tenté de croire. Le seul champ porteur de revendications est SignedLease, et vous n’en lisez les revendications qu’après avoir vérifié la signature.
Scellez-le au repos avec DPAPI (là où il existe)
Sous Windows, ProtectedData chiffre le blob avec une clé dérivée des identifiants de l’utilisateur courant. Cela procure gratuitement la confidentialité au repos et une liaison faible à l’appareil et à l’utilisateur — les mêmes octets ne se déchiffreront pas sous un autre utilisateur ni sur une autre machine :
static byte[] Protect(byte[] plaintext) =>
OperatingSystem.IsWindows()
? ProtectedData.Protect(plaintext, optionalEntropy: null, DataProtectionScope.CurrentUser)
: KeychainOrFingerprintEncrypt(plaintext); // Linux/macOS fallback
static byte[] Unprotect(byte[] sealed_) =>
OperatingSystem.IsWindows()
? ProtectedData.Unprotect(sealed_, optionalEntropy: null, DataProtectionScope.CurrentUser)
: KeychainOrFingerprintDecrypt(sealed_);
ProtectedData est réservé à Windows — sous Linux et macOS il lève PlatformNotSupportedException, donc le repli utilise le trousseau du système ou une clé dérivée de l’empreinte de l’appareil. Soyez honnête sur ce qu’est cette couche : c’est de la confidentialité au repos, pas de l’authenticité. Un utilisateur avec sa propre session peut toujours déchiffrer son propre cache. C’est acceptable, car le chiffrement n’a jamais été ce qui l’empêchait de modifier un bail — c’est la signature.
Liez la copie à l’appareil
La liaison à l’appareil de DPAPI est accessoire et réservée à Windows ; rendez-la explicite et multiplateforme avec un HMAC sur le bail stocké, avec une clé dérivée de l’empreinte de l’appareil. Au chargement, recalculez-le à partir de l’empreinte de cette machine et comparez :
static string DeviceTag(string signedLease, DeviceFingerprint fp)
{
using var h = new HMACSHA256(fp.DeriveKey()); // key bound to this device
var mac = h.ComputeHash(Encoding.UTF8.GetBytes(signedLease));
return Convert.ToBase64String(mac);
}
static bool BelongsHere(CachedLease c, DeviceFingerprint fp) =>
CryptographicOperations.FixedTimeEquals(
Convert.FromBase64String(c.DeviceTag),
Convert.FromBase64String(DeviceTag(c.SignedLease, fp)));
Copiez l’intégralité du fichier de cache sur une autre machine et l’empreinte diffère, donc l’étiquette recalculée ne correspondra pas à c.DeviceTag ; BelongsHere renvoie false, vous rejetez le cache, et l’application se réactive — ce que le serveur peut refuser si la nouvelle machine dépasse son nombre de sièges. Utilisez FixedTimeEquals, pas ==, pour que la comparaison ne fuie pas d’information temporelle. Ce n’est pas incassable : un attaquant qui réplique aussi les entrées de l’empreinte le contourne. L’objectif est de transformer « copier un fichier » en « cloner aussi l’identité de l’appareil », exactement le surcoût que le verrouillage par nœud existe pour imposer.
Le chemin de chargement, dans le seul ordre qui soit sûr
Mettez les trois tâches en séquence et la règle unique — l’authenticité vient de la signature, jamais du cache — en découle naturellement :
public LicenseClaims LoadOrActivate(DeviceFingerprint fp)
{
if (TryReadCache(out var raw) &&
TryUnprotect(raw, out var cached) && // gate 1: at-rest seal intact
BelongsHere(cached, fp)) // gate 2: this machine
{
if (_keySet.TryVerify(cached.SignedLease, out var claims)) // gate 3: authenticity
return claims; // gate 4: trust CLAIMS, not the file
}
return Activate(fp); // any failure: re-activate; the server enforces seats + revocation
}
La règle qui garde tout honnête
Lisez l’expiration que vous appliquez dans claims — le résultat d’une vérification de signature réussie — et nulle part ailleurs. Le CachedAt du cache ne sert qu’à décider quand rafraîchir, jamais si la licence est valide. À l’instant où vous comparez « maintenant » à un horodatage non signé stocké à côté du blob, vous avez donné à l’utilisateur un champ à modifier. Le retour en arrière de la date est une attaque à part avec sa propre défense (faire confiance à l’horloge) ; la contribution de la couche de stockage est simplement de ne jamais introduire d’expiration non signée que quiconque pourrait falsifier.
La même discipline s’applique à l’échec. Quand une barrière échoue, vous ne rafistolez pas le cache — vous le rejetez et vous réactivez. La réactivation est le moment où le serveur peut appliquer les limites de sièges et la révocation, donc un cache transplanté ou modifié n’échoue pas seulement en local ; il renvoie le client à travers la seule vérification capable de dire « non ».
Ce qu’il faut retenir
Une licence activée doit persister, et le stockage est l’endroit où un licensing soigné fuit discrètement si vous le laissez faire. Cantonnez le cache à son vrai rôle — la disponibilité, pas l’authenticité. Stockez le bail signé tel quel, sans copies non signées et pratiques de ses revendications. Scellez-le au repos avec DPAPI sous Windows (et un trousseau ou une clé dérivée de l’empreinte ailleurs), liez la copie à l’appareil avec un HMAC pour détecter un fichier transplanté, et vérifiez la signature avant de lire une seule revendication. Appliquez l’expiration depuis les revendications vérifiées, traitez les horodatages du fichier comme de simples indications de rafraîchissement, et en cas d’échec rejetez et réactivez pour que le serveur reste l’autorité sur les sièges et la révocation. Faites cela et la couche de stockage cesse d’être le point faible sous une conception d’activation par ailleurs solide.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.