Skip to content
← Tous les articles
· Delta1 Labs Licensing.NETGuide

Concevoir une barrière de licence : où vérifier, quoi mettre en cache et quand échouer fermé

Un simple appel à Validate() est la partie facile du licensing. La partie difficile, c'est l'architecture : où vit le contrôle dans votre app, à quelle fréquence il s'exécute, ce que vous mettez en cache entre deux exécutions et — la question qui décide si un bug bloque un client payant ou distribue votre logiciel gratuitement — quels échecs ferment et quels échecs ouvrent. Voici une barrière de licence à sûreté intégrée pour .NET bâtie sur Keyright, avec une table de décision statut par statut et un instantané en cache qui survit hors ligne.

Ajouter Keyright à une app .NET ressemble à une seule ligne : client.Validate() renvoie un LicenseInfo, vous vérifiez IsValid, terminé. Cette ligne est la partie facile, et ce n’est pas là que le licensing déraille. Le licensing déraille dans l’architecture autour de cet appel — où il vit, à quelle fréquence il s’exécute, ce que vous conservez entre deux exécutions et, surtout, ce qui se passe quand il ne peut pas vous donner une réponse nette. Trompez-vous dans un sens et un hoquet transitoire bloque un client qui vous a payé. Trompez-vous dans l’autre et un binaire altéré exécute vos fonctionnalités Pro gratuitement.

Cet article construit une barrière de licence : un objet unique qui possède le seul appel de validation, met en cache un instantané immuable du résultat et prend, pour chaque raison d’échec, une décision délibérée : échouer fermé ou échouer ouvert. C’est la pièce que la plupart des tutoriels de licensing sautent, et c’est la pièce qui décide si votre licensing est un atout ou une file d’attente au support.

Un appel, un propriétaire, un instantané

La première règle est qu’exactement un objet appelle le SDK. Dispersez Validate() dans vos view models et vous obtenez une re-vérification à chaque clic de bouton, des réponses incohérentes quand l’horloge franchit l’expiration en pleine session, et aucun endroit unique où changer la politique. À la place, validez une fois, figez la réponse dans un instantané et laissez le reste de l’app lire l’instantané.

public sealed record LicenseSnapshot(
    bool AllowsPaid,
    Edition Edition,
    EntitlementSet Entitlements,
    LicenseStatus Status,
    DateTime TakenUtc)
{
    public static readonly LicenseSnapshot Free =
        new(false, Edition.Free, EntitlementSet.Empty, LicenseStatus.NoLicense, DateTime.UtcNow);
}

L’instantané est un record pour une raison : il est immuable, donc une fois que la barrière l’a distribué, aucune fonctionnalité ne peut muter accidentellement l’état de licensing, et un rafraîchissement en arrière-plan échange la référence entière de façon atomique au lieu de modifier des champs que d’autres threads sont en train de lire.

La barrière elle-même détient le KeyrightClient, l’instantané courant et la politique qui transforme un LicenseInfo brut en instantané :

public sealed class LicenseGate
{
    private readonly KeyrightClient _client;
    private volatile LicenseSnapshot _current = LicenseSnapshot.Free;

    public LicenseGate(KeyrightClient client) => _client = client;

    public LicenseSnapshot Current => _current;

    public bool Allows(string feature) =>
        _current.AllowsPaid && _current.Entitlements.IsEnabled(feature);

    public long Limit(string feature, long fallback) =>
        _current.AllowsPaid ? _current.Entitlements.GetLimit(feature, fallback) : fallback;

    public void Refresh()
    {
        LicenseInfo info;
        try
        {
            info = _client.Validate();               // hors ligne : vérifie la signature + le lease localement
        }
        catch (Exception ex)
        {
            // Une exception levée est un état *inconnu*, pas un état sous licence.
            _current = LicenseSnapshot.Free with { Status = LicenseStatus.Malformed };
            Log.Warning(ex, "license validation threw; treating as unlicensed");
            return;
        }
        _current = Decide(info);
    }
}

Notez le champ volatile et l’échange de l’objet entier : un lecteur sur le thread UI voit toujours soit l’ancien instantané, soit le nouveau, jamais une struct à moitié mise à jour. Et notez qu’une exception levée se résout en édition gratuite, jamais en « sous licence ». Un état de licensing inconnu n’est jamais un état payant — ce principe, c’est tout l’article en une ligne.

LicenseGatepossède Validate() + instantanéSDK KeyrightValidate() — vérifieen local, sans réseauinstantané immuableAllowsPaid · EditionEntitlements · Statuscode fonctionnelgate.Allows("export")ne lit que l'instantanéappelle une foisfige le résultatinterroge

La table de décision, c’est la barrière

LicenseInfo.Status n’est pas un booléen. Keyright rapporte huit issues distinctes, et les réduire à IsValid jette exactement l’information dont vous avez besoin pour choisir une réaction :

public enum LicenseStatus
{
    Valid = 0,            // signature + lease vérifiés, non expirée, machine correspondante
    NoLicense = 1,        // rien à vérifier
    Malformed = 2,        // artefact de licence trouvé mais impossible à analyser
    SignatureInvalid = 3, // analysée, mais la signature ne correspond pas à la clé embarquée
    Expired = 4,          // signature valide, expiration dépassée
    MachineMismatch = 5,  // valide, mais verrouillée sur une autre machine
    Revoked = 6,          // révoquée explicitement côté serveur et vue lors d'un rafraîchissement de lease
    ClockTampered = 7,    // horloge locale reculée par rapport à un horodatage de confiance
}

La politique de la barrière est un unique switch sur ces valeurs, et l’écrire vous force à trancher chaque choix échouer-ouvert / échouer-fermé explicitement plutôt que par accident :

private static LicenseSnapshot Decide(LicenseInfo info)
{
    switch (info.Status)
    {
        // Passage net — le seul état qui accorde les fonctionnalités payantes.
        case LicenseStatus.Valid:
            return new(true, info.Edition, info.Entitlements(),
                       info.Status, DateTime.UtcNow);

        // Absence sans faute — c'est un utilisateur gratuit, pas un attaquant. Échouer OUVERT vers le gratuit.
        case LicenseStatus.NoLicense:
            return LicenseSnapshot.Free;

        // Un abonnement arrivé à échéance. ExpiryUtc est réel et signé ; honorez une fenêtre
        // de grâce pour qu'un renouvellement en cours ou un lease pas encore rafraîchi
        // ne bloque pas un client payant en pleine session. Échouer OUVERT, brièvement et borné.
        case LicenseStatus.Expired when WithinGrace(info, days: 14):
            return new(true, info.Edition, info.Entitlements(),
                       info.Status, DateTime.UtcNow);
        case LicenseStatus.Expired:
            return LicenseSnapshot.Free;

        // Tout ce qui suit est la preuve d'une altération ou d'une licence que vous n'avez
        // jamais été en droit d'exécuter. Il n'y a aucune lecture bénigne. Échouer FERMÉ, sans appel.
        case LicenseStatus.Malformed:
        case LicenseStatus.SignatureInvalid:
        case LicenseStatus.MachineMismatch:
        case LicenseStatus.Revoked:
        case LicenseStatus.ClockTampered:
        default:
            return LicenseSnapshot.Free with { Status = info.Status };
    }
}

private static bool WithinGrace(LicenseInfo info, int days) =>
    info.ExpiryUtc is { } exp && DateTime.UtcNow <= exp.AddDays(days);

La forme de la politique est tout l’enjeu. Trois des huit états sont sans faute : Valid accorde, NoLicense n’est qu’un utilisateur gratuit, et un abonnement fraîchement Expired continue de fonctionner pendant une fenêtre de grâce bornée pour qu’un renouvellement encore en cours de propagation ne produise jamais un blocage. Les cinq autres — Malformed, SignatureInvalid, MachineMismatch, Revoked, ClockTampered — n’ont aucune interprétation bénigne, donc ils retombent directement au gratuit sans grâce. Vous n’êtes pas hostile à vos clients en les faisant échouer fermé ; vous refusez d’accorder votre confiance à un artefact qui n’a pas réussi à prouver qu’il en méritait la moindre.

statutréactiontendanceValidaccorder le payant (selon les entitlements)passeNoLicenseretomber à l'édition gratuiteouvert — sans fauteExpired (dans la grâce de 14 jours)garder le payant, bornéouvert — bornéExpired (grâce dépassée)retomber à l'édition gratuiteouvert — sans fauteMalformed · SignatureInvalidMachineMismatch · RevokedClockTamperedrefuser le payant — non fiableFERMÉ — rien de bénin

Pourquoi ClockTampered doit échouer fermé même s’il paraît anodin

Le seul statut qui tente les développeurs d’échouer ouvert est ClockTampered. Il ressemble à une erreur honnête — un portable avec une pile CMOS morte, une VM restaurée depuis un snapshot, un utilisateur dans le mauvais fuseau horaire. Pourquoi punir cela ?

Parce que l’horloge est la seule chose qui sépare une licence limitée dans le temps d’une licence infinie. Keyright enregistre un horodatage plafond de confiance chaque fois qu’il voit une horloge avancer de façon monotone (à l’activation, à chaque rafraîchissement de lease). ClockTampered signifie que l’horloge locale est maintenant en retard sur ce plafond — l’heure murale a reculé. Les explications bénignes sont réelles, mais l’attaque aussi : reculez l’horloge au mois dernier et un essai expiré hier redevient jeune. Une barrière qui échoue ouvert sur ClockTampered n’a aucune expiration du tout, parce que n’importe quelle expiration peut être annulée en reculant la date. La barrière échoue donc fermé, et le chemin de récupération consiste pour l’utilisateur à corriger son horloge et relancer — une action vérifiable — plutôt que pour votre code à deviner quels reculs sont honnêtes.

Rafraîchir sans dépendance au réseau

L’instantané est pris une fois au démarrage, puis sur un minuteur lent. Deux choses comptent ici. D’abord, le rafraîchissement est hors ligne — Validate() re-vérifie la licence signée et le lease en cache par rapport à la clé publique embarquée sans appel réseau, donc un rafraîchissement fonctionne dans un avion. Ensuite, le lease est ce qui rend un processus de longue durée sûr : l’activation a obtenu un lease signé, lié à la machine, valable 14 jours, et tant que le lease n’est pas expiré la barrière continue de renvoyer Valid sans jamais joindre votre service émetteur.

// Dans une app WPF/WinForms : un minuteur basse fréquence, pas un crochet sur un chemin chaud.
var timer = new System.Threading.Timer(_ => gate.Refresh(),
    null, dueTime: TimeSpan.Zero, period: TimeSpan.FromHours(6));

// Rafraîchissez aussi au réveil de la machine, où l'horloge et le lease ont pu bouger.
SystemEvents.PowerModeChanged += (_, e) =>
{
    if (e.Mode == PowerModes.Resume) gate.Refresh();
};

Le seul moment où le réseau entre en jeu est un rafraîchissement de lease — quand le lease approche de son expiration, le SDK le renouvelle auprès de votre service émetteur à son prochain moment en ligne. Si le service est injoignable, le lease épuise simplement sa grâce restante ; la barrière continue de dire Valid tout ce temps et ne retombe que lorsque le lease expire vraiment sans renouvellement. Une indisponibilité de votre côté se dégrade donc gracieusement en fonctionnement hors ligne, pas en panne pour le client — ce qui n’est vrai que parce que la barrière lit un lease signé en cache au lieu de téléphoner à la maison à chaque contrôle.

Contrôler des fonctionnalités, pas seulement l’app

Avec la barrière en place, le contrôle par fonctionnalité tient en une ligne, et comme les entitlements sont signés dans la licence vous distribuez un seul binaire pour tous les niveaux :

// Capacité booléenne.
exportButton.IsEnabled = gate.Allows("export-to-pdf");

// Quota numérique — "unlimited" dans la charge utile signée se relit comme long.MaxValue.
int maxSeats = (int)Math.Min(gate.Limit("seats", fallback: 1), int.MaxValue);

// Un panneau entier réservé à Pro, lié une fois à l'instantané.
proPanel.Visibility = gate.Current.AllowsPaid ? Visibility.Visible : Visibility.Collapsed;

Tout passe par la barrière, donc il y a exactement un endroit qui connaît la politique, un endroit qui met en cache et un endroit à auditer quand quelqu’un demande « que se passe-t-il si la licence est révoquée pendant que l’app tourne ? » La réponse : au prochain rafraîchissement le statut devient Revoked, Decide le mappe au gratuit, l’instantané est échangé atomiquement et le panneau Pro se replie — aucun contrôle en ligne ailleurs dans la base de code n’a eu besoin de le savoir.

La règle unique

Si vous ne retenez qu’une chose, que ce soit l’invariant que la barrière fait respecter partout : un état de licensing inconnu n’est jamais un état sous licence. Une exception levée, un artefact impossible à analyser, une mauvaise signature, une horloge qui a reculé — rien de tout cela n’est « probablement correct ». C’est l’absence de preuve, et l’absence de preuve échoue fermé. La poignée d’états qui échouent bel et bien ouvert — une licence absente, un abonnement dans sa fenêtre de grâce — sont ceux pour lesquels vous pouvez nommer à voix haute une raison sans faute. Tout le reste retombe au gratuit et attend que l’utilisateur présente une licence qui se vérifie. Cette règle unique, appliquée dans une seule barrière plutôt que dans cent contrôles dispersés, est la différence entre un licensing auquel vous pouvez faire confiance et un licensing qu’il faut surveiller en permanence.

Essayez Nebula.NET

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