Skip to content
← Tous les articles
· Delta1 Labs KeyrightLicensingAnalyse approfondie

Désactiver une licence et la déplacer vers une autre machine : reçus, preuves hors ligne et limites de transfert

Libérer un siège ressemble à la moitié facile du verrouillage par machine — le client clique sur « désactiver cet appareil » et le siège est de nouveau disponible. Mais une désactivation est une affirmation que le client fait sur son propre état, et le client est la seule partie à laquelle vous ne pouvez pas faire confiance. En ligne, le problème est l'ordre et les nouvelles tentatives : libérer sur le serveur avant ou après l'effacement local, et ce qui se passe quand l'un des deux échoue. Hors ligne, le problème est la preuve : une machine sans réseau doit remettre au client quelque chose qui convainc le serveur qu'un siège a vraiment été rendu. Et pour les machines mortes — le portable tombé dans le lac — le problème est l'abus : chaque libération forcée est une occasion de multiplier les sièges. Ce billet lit le SDK Keyright et la politique serveur pour les trois cas : la libération en ligne idempotente, le reçu de désactivation HMAC lié au secret d'instance de l'activation, pourquoi le TTL du bail borne ce qu'un client menteur peut gagner, et le délai de refroidissement des transferts et les limites glissantes qui empêchent « j'ai perdu ma machine » de devenir un générateur de sièges.

L’activation attire toute l’attention : empreintes, sièges, baux signés. La désactivation — rendre un siège pour qu’il puisse servir ailleurs — est traitée comme l’inverse trivial. Ce n’est pas le cas, pour une raison : une activation est quelque chose que le serveur accorde, mais une désactivation est quelque chose que le client affirme. « J’ai cessé d’utiliser cette licence sur cette machine » est une déclaration de la seule partie du système à laquelle vous ne pouvez pas faire confiance, et la conception d’une désactivation sûre porte entièrement sur la part de cette déclaration que vous pouvez vérifier et la part que vous pouvez borner.

Il y a trois cas, et ils appellent trois réponses différentes. La machine est en ligne et coopérative : le problème est l’ordre et les nouvelles tentatives. La machine est hors ligne — isolée du réseau, ou un portable de terrain sans signal : le problème est la preuve. La machine a disparu — volée, morte, réinstallée sans désactivation : le problème est l’abus, parce que chaque libération forcée est une occasion de transformer une licence en deux. Ce billet parcourt le SDK Keyright et la politique serveur pour chacun.

en ligne — coopérative1. POST /v1/free-seat key = activation + nonce2. le serveur libère3. le client effaceserveur d'abord, puis local ;réessai sûr (idempotent) ;un cache bloqué meurt au TTLsiège libre : immédiatementcompte dans la limite : nonhors ligne — reçu1. le client efface2. reçu = charge + HMAC(instanceSecret)3. collé dans le portail4. le serveur vérifie, libèreprouve que l'instance activées'est rendue ; le bail en cachene survit pas à son TTLcompte dans la limite : nonmachine morte — forcée1. admin / client : « perdue »2. siège → refroidissement (TTL)3. puis librel'ancienne machine peut encoretourner sur un bail en cache,donc jamais active deux foissiège libre : après le TTLcompte dans la limite : OUI(3 par 30 jours, glissant)

En ligne : libérer sur le serveur, puis effacer, et rendre l’opération répétable

Le cas coopératif embarque encore un bogue que la plupart des premières implémentations livrent : l’ordre des deux effets de bord. Une désactivation doit faire deux choses — libérer le siège sur le serveur et détruire la licence sur le client — et exactement l’une des deux peut échouer après que l’autre a réussi.

Effacez d’abord, puis appelez le serveur, et un appel raté laisse le client sans licence sur cette machine et avec un siège toujours consommé. C’est le pire résultat : un siège bloqué, une application non licenciée et un ticket. Appelez d’abord le serveur, puis effacez, et un effacement raté laisse l’application avec un bail signé en cache — mais ce bail a un TTL, le serveur a déjà libéré le siège, et le battement de cœur suivant renvoie un résultat d’activation perdue. La machine retombe d’elle-même en état non licencié en l’espace d’une période de bail. La défaillance est bornée et se répare seule. Donc : le serveur d’abord.

public async Task<DeactivationOutcome> DeactivateAsync(CancellationToken ct)
{
    var current = await _store.ReadAsync(ct);
    if (current is null)
        return DeactivationOutcome.NothingToRelease;

    // The nonce is generated ONCE and persisted before the call, so a retry after
    // a crash or timeout replays the same idempotency key instead of minting a new one.
    var nonce = current.PendingDeactivationNonce ??= Guid.NewGuid().ToString("N");
    await _store.WriteAsync(current, ct);

    var result = await _client.FreeSeatAsync(new FreeSeatRequest(
        LicenseId:      current.LicenseId,
        ActivationId:   current.ActivationId,
        InstanceSecret: current.InstanceSecret,
        Nonce:          nonce), ct);                          // POST /v1/free-seat

    if (!result.Released && !result.AlreadyReleased)
        return DeactivationOutcome.ServerRefused(result.Reason); // store is intact; nothing lost

    await _store.WipeAsync(ct);          // only after the server has freed the seat
    _state.Clear();                      // drop the in-memory lease; app is now unlicensed
    return DeactivationOutcome.Released;
}

Deux détails portent le poids. Le nonce est écrit dans le stockage avant que la requête ne parte, de sorte que si le processus meurt en plein appel, la tentative suivante envoie la même clé (ActivationId, Nonce). Et le serveur traite cette clé comme idempotente : une seconde requête avec la même clé renvoie le résultat de la première.

public async Task<FreeSeatResult> FreeSeatAsync(FreeSeatRequest req, CancellationToken ct)
{
    await using var _ = await _store.LockActivationAsync(req.ActivationId, ct);

    // Replay: same activation + same nonce → return the recorded outcome, do not error.
    if (await _store.FindReleaseAsync(req.ActivationId, req.Nonce, ct) is { } prior)
        return FreeSeatResult.AlreadyReleased(prior.ReleasedAt);

    var activation = await _store.FindActivationAsync(req.ActivationId, ct);
    if (activation is null || !activation.SecretMatches(req.InstanceSecret))
        return FreeSeatResult.Refused("unknown activation or bad instance secret");

    activation.Release(ReleaseReason.SelfOnline, _clock.GetUtcNow());
    await _store.RecordReleaseAsync(req.ActivationId, req.Nonce, activation.ReleasedAt!.Value, ct);
    await _store.UpsertActivationAsync(activation, ct);
    return FreeSeatResult.Released(activation.ReleasedAt.Value);
}

Notez ce qui est vérifié : l’InstanceSecret. Une requête de désactivation n’est pas authentifiée par la clé de licence — quiconque détient la clé pourrait sinon libérer un siège qu’il ne possède pas — mais par le secret opaque que le serveur a remis à cette activation lors de sa création. Ce même secret est ce qui rend possible le cas hors ligne.

Hors ligne : un reçu que le serveur peut vérifier sans parler au client

Une machine isolée ne peut pas appeler /v1/free-seat. Ce qu’elle peut faire, c’est produire quelque chose que le client porte au portail — une courte chaîne — qui convainc le serveur que le siège a été rendu. La question est ce que cette chaîne peut honnêtement prouver.

Elle ne peut pas prouver que l’effacement a eu lieu. Le client est du code non fiable sur une machine que vous ne contrôlez pas ; un client modifié pourrait émettre le reçu et conserver les fichiers de licence. Ne concevez donc pas autour d’une « preuve de suppression ». Concevez autour d’une preuve de reddition : le reçu prouve que l’instance activée — seule détentrice du secret d’instance — a choisi de libérer. Tout le reste découle de ce que le serveur en fait.

public sealed record DeactivationReceipt(
    string ActivationId,
    string LicenseId,
    string Fingerprint,
    DateTimeOffset IssuedAt,
    string Nonce,
    string Mac);                 // HMAC-SHA256 over the canonical fields, keyed by InstanceSecret

public async Task<string> DeactivateOfflineAsync(CancellationToken ct)
{
    var current = await _store.ReadAsync(ct) ?? throw new InvalidOperationException("not activated");

    var body = new DeactivationReceipt(
        current.ActivationId, current.LicenseId, ComputeFingerprint(),
        _clock.GetUtcNow(), Guid.NewGuid().ToString("N"), Mac: "");

    var canonical = $"{body.ActivationId}|{body.LicenseId}|{body.Fingerprint}|{body.IssuedAt:O}|{body.Nonce}";
    using var hmac = new HMACSHA256(Convert.FromBase64String(current.InstanceSecret));
    var mac = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical)));

    // Wipe BEFORE the receipt is ever displayed. If the wipe throws, no receipt exists,
    // the machine is still licensed, and the customer can try again.
    await _store.WipeAsync(ct);
    _state.Clear();

    return Base64Url.Encode(JsonSerializer.SerializeToUtf8Bytes(body with { Mac = mac }));
}

L’ordre est inversé par rapport à la voie en ligne, délibérément. En ligne, le serveur agit en premier parce qu’il est la source de vérité et que l’effacement local n’est que du nettoyage. Hors ligne, rien ne peut agir sur le serveur avant que le client n’atteigne un navigateur, donc l’effacement local vient en premier et le reçu est le produit d’un effacement réussi : l’utilisateur ne voit jamais un reçu pour une licence encore sur le disque. Si l’effacement échoue, pas de reçu, pas de dégât.

Le serveur vérifie le reçu avec le secret d’instance qu’il a stocké à l’activation :

public async Task<ReceiptResult> RedeemReceiptAsync(string encoded, CancellationToken ct)
{
    if (JsonSerializer.Deserialize<DeactivationReceipt>(Base64Url.Decode(encoded)) is not { } r)
        return ReceiptResult.Invalid("malformed");

    await using var _ = await _store.LockActivationAsync(r.ActivationId, ct);
    var activation = await _store.FindActivationAsync(r.ActivationId, ct);
    if (activation is null || activation.LicenseId != r.LicenseId)
        return ReceiptResult.Invalid("unknown activation");

    var canonical = $"{r.ActivationId}|{r.LicenseId}|{r.Fingerprint}|{r.IssuedAt:O}|{r.Nonce}";
    using var hmac = new HMACSHA256(activation.InstanceSecretBytes);
    var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical));
    if (!CryptographicOperations.FixedTimeEquals(expected, Convert.FromBase64String(r.Mac)))
        return ReceiptResult.Invalid("bad mac");

    if (await _store.NonceSeenAsync(r.ActivationId, r.Nonce, ct))
        return ReceiptResult.AlreadyRedeemed(activation.ReleasedAt);
    if (_clock.GetUtcNow() - r.IssuedAt > _policy.ReceiptMaxAge)      // e.g. 30 days
        return ReceiptResult.Invalid("expired");

    activation.Release(ReleaseReason.OfflineReceipt, _clock.GetUtcNow());
    await _store.RememberNonceAsync(r.ActivationId, r.Nonce, ct);
    await _store.UpsertActivationAsync(activation, ct);
    return ReceiptResult.Released;
}

Maintenant, la comptabilité honnête. Supposons que le client ait menti — il a émis un reçu valide et conservé ses fichiers de licence. Qu’a-t-il gagné ? Le serveur a marqué l’activation comme libérée. Le bail signé en cache sur la machine est toujours cryptographiquement valide, mais il porte une expiration, et la machine n’a aucun moyen de le renouveler : elle est hors ligne, et même si elle revenait en ligne, un battement de cœur contre une activation libérée renvoie activation perdue. Le temps volé est donc borné par un TTL de bail — la durée de vie restante du bail au moment du reçu. Pour un déploiement isolé où vous émettez des baux hors ligne de 30 jours, c’est le prix de la désactivation hors ligne, et vous le fixez quand vous fixez le TTL. Aucune conception ne le ramène à zéro sans réseau, et tout produit qui prétend le contraire fait confiance au client.

Le reçu contient délibérément l’empreinte. Elle n’est pas nécessaire à la vérification — le HMAC lie déjà l’activation — mais elle permet au portail de montrer au client quelle machine il libère, et permet à votre support de repérer un reçu échangé pour une machine qui ne correspond pas à la fiche d’activation.

Machines mortes : la voie forcée, et pourquoi elle a une fenêtre de refroidissement

Le troisième cas n’a aucun client. Le portable a été volé ; la VM a été supprimée ; le disque a été réinstallé par quelqu’un qui ignorait que la licence s’y trouvait. Le client a besoin de récupérer le siège et il n’y a rien à appeler, rien à signer. La seule option est une libération forcée : un administrateur dans votre tableau de bord, ou le client dans le portail, déclare la machine disparue.

C’est la voie qui transforme la gestion de licences en générateur de sièges si vous n’y prenez garde. « Ma machine est morte » ne coûte rien à dire, et un client qui le dit une fois par semaine dispose, de fait, d’une licence illimitée. Deux mécanismes maintiennent l’honnêteté, et aucun ne repose sur le fait de croire l’affirmation.

Le premier est la fenêtre de refroidissement. La machine morte n’est peut-être pas morte. C’est peut-être une machine simplement hors ligne, qui tourne sur un bail en cache que le serveur a déjà émis — un bail valide jusqu’à son expiration quoi que le serveur pense désormais. Si la libération forcée libérait le siège immédiatement, le client pourrait activer une nouvelle machine pendant que la « morte » continue de tourner sur son cache, et le siège serait actif à deux endroits. Une libération forcée ne libère donc pas le siège ; elle le place en refroidissement, et le siège ne devient disponible qu’une fois écoulée la durée de vie maximale restante du bail.

public ForcedReleaseResult ForceRelease(Activation activation, ReleaseReason reason, DateTimeOffset now)
{
    // The last lease we issued to this activation is the longest anything on the
    // "dead" machine can still be running on. The seat is not free until it is gone.
    var coolingUntil = activation.LastIssuedLease.ExpiresAt;

    activation.Release(reason, now);
    activation.SeatAvailableAt = coolingUntil > now ? coolingUntil : now;

    return new ForcedReleaseResult(activation.SeatAvailableAt);
}

Pour une application de bureau qui envoie un battement de cœur toutes les heures sur un bail de 24 heures, le refroidissement est d’au plus un jour. Pour un bail isolé de 30 jours, il va jusqu’à 30 jours, ce qui est la bonne réponse : cette licence est peut-être encore réellement en usage. Si la machine est vraiment morte, le client attend ; si elle ne l’est pas, la fenêtre de refroidissement est ce qui garde votre décompte de sièges exact. Votre portail devrait afficher le compte à rebours pour que le client ne reste pas dans le flou.

Le second mécanisme est la limite glissante de transferts. Les libérations forcées sont comptées, les libérations en libre-service et par reçu ne le sont pas — parce que ces deux voies ont réellement arrêté l’ancienne instance, tandis que la voie forcée n’a fait que l’affirmer.

public sealed record TransferPolicy(int MaxForcedReleases, TimeSpan Window);   // e.g. 3 per 30 days

public bool CanForceRelease(License license, TransferPolicy policy, DateTimeOffset now)
{
    var recent = license.Releases.Count(r =>
        r.Reason is ReleaseReason.AdminForced or ReleaseReason.CustomerForced &&
        r.ReleasedAt > now - policy.Window);

    return recent < policy.MaxForcedReleases;
}

Trois par trente jours est généreux pour les clients honnêtes — le matériel tombe en panne, mais pas chaque semaine — et ruineux pour quiconque tente de monter une ferme de sièges via le bouton « machine perdue ». Quand la limite est atteinte, le portail le dit, et le client vous parle, ce qui est exactement la conversation que vous voulez avoir à ce stade. Keyright vous laisse définir la fenêtre et le nombre par produit, parce qu’une flotte d’appareils de terrain durcis meurt légitimement plus souvent qu’un bureau rempli de postes de travail.

Chaque libération est une écriture dans le registre

Les trois voies aboutissent au même endroit : activation.Release(reason, now). La raison n’est pas décorative. C’est ce qui rend lisible l’historique d’une licence six mois plus tard, quand un client avec une licence 5 sièges a d’une manière ou d’une autre activé quarante machines et que vous devez savoir s’il s’agissait de quarante remplacements honnêtes ou d’autre chose.

public enum ReleaseReason
{
    SelfOnline,       // client called /v1/free-seat with its instance secret — seat really stopped
    OfflineReceipt,   // HMAC receipt redeemed — activated instance surrendered; bounded by TTL
    CustomerForced,   // customer declared the machine lost in the portal — counts toward limit
    AdminForced,      // you did it from the dashboard — counts toward limit, with a note
    Reaped            // lease expired with no heartbeat; the seat aged out on its own
}

La vue des activations par licence du tableau de bord affiche ces raisons à côté des métadonnées de l’appareil, de sorte que l’histoire honnête — une machine remplacée, un reçu échangé depuis le terrain, une libération forcée en mars — se lit d’un coup d’œil, et la malhonnête — rien que des libérations forcées, tous les neuf jours — se lit tout aussi clairement.

Ce qu’il ne faut pas faire

Ne libérez pas le siège depuis un désinstallateur. Les désinstallateurs s’exécutent sans réseau, sans invites de consentement que vous contrôlez, et souvent dans le cadre d’une réinstallation que le client n’entendait pas comme une désactivation. Un siège libéré par un crochet de désinstallation est un siège libéré par accident, et une réinstallation consomme alors un second siège à la réactivation. Faites de la désactivation une action explicite avec un résultat explicite.

N’acceptez pas « je l’ai supprimé » sans le secret d’instance. Une requête de désactivation authentifiée uniquement par la clé de licence permet à quiconque a vu la clé — une clé partagée, une clé divulguée, un ancien employé — de libérer des sièges qu’il ne détient pas. Le secret d’instance est la seule chose que seul le client activé possède, et c’est la seule pièce d’identité qu’une libération devrait accepter.

Et ne laissez pas la voie forcée être gratuite. La fenêtre de refroidissement et la limite glissante ne sont pas là parce que vous vous méfiez de vos clients ; elles sont là parce que sans elles, le décompte des sièges est une opinion. Avec elles, un siège est libre quand le serveur peut prouver que rien ne tourne dessus — et c’est la différence entre un verrouillage par machine qui compte et un qui se contente d’espérer.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.