Licencier une bibliothèque de composants .NET : la licence développeur, la clé qui se compile dans l'application de votre client, et ce que l'utilisateur final ne voit jamais
Une bibliothèque de composants a deux publics et un seul d'entre eux est votre client. Le développeur qui achète votre moteur PDF ou votre contrôle de graphiques détient la licence ; les utilisateurs finaux qui exécutent les applications construites avec n'ont jamais entendu parler de vous, ne doivent jamais se voir demander une clé, et travaillent sur des machines que vous ne pouvez pas activer. Cela change où une licence est vérifiée et ce qui voyage à l'intérieur du binaire livré. Cet article construit la conception vers laquelle convergent la plupart des éditeurs de contrôles avec Keyright : une licence développeur perpétuelle, non liée à une machine, vérifiée au moment du build par une cible MSBuild dans votre package NuGet, incorporée dans l'assembly du client sous forme d'attribut signé, et vérifiée à nouveau à l'exécution, hors ligne, avec la clé publique de votre bibliothèque — avec un entitlement updates-until au lieu d'une expiration pour que l'application du client ne s'arrête jamais, une revendication d'audience pour qu'une licence extraite d'une application n'en déverrouille pas une autre, et un nom de titulaire signé dans chaque binaire qui l'utilise, à la manière d'un filigrane.
La plupart des conseils en matière de licensing supposent que la personne qui exécute votre code est celle qui l’a payé. Une bibliothèque de composants brise cette hypothèse en deux. Vous vendez un moteur PDF, un contrôle de graphiques, un SDK de codes-barres, à un développeur. Le développeur le compile dans une application et livre cette application à des utilisateurs finaux — des gens qui n’ont jamais entendu parler de vous, à qui l’on ne peut pas demander de clé, et qui travaillent sur des machines que vous n’activerez jamais. Votre client est le développeur. Votre code s’exécute partout ailleurs.
Cette séparation change deux choses : où la licence est vérifiée, et ce qui doit voyager à l’intérieur du binaire que votre client livre. Cet article construit la conception vers laquelle convergent la plupart des éditeurs de contrôles — une licence développeur vérifiée au moment du build, incorporée dans l’assembly du client, et vérifiée à nouveau hors ligne à l’exécution — avec Keyright, et passe en revue les trois endroits où la version évidente de cette conception se trompe.
Deux parties, deux vérifications
Le côté éditeur est familier : vous émettez une licence, le développeur l’active, vous comptez les sièges. La partie inhabituelle est le second saut. Tout ce dont votre bibliothèque a besoin à l’exécution pour décider « sous licence ou évaluation » doit se trouver à l’intérieur de l’application du client, placé là au moment du build, et vérifiable sur une machine sans fichier de licence, sans variable d’environnement, sans réseau, et sans la moindre idée de qui vous êtes. Le document de licence lui-même est la bonne chose à y mettre — c’est une charge utile signée, pas un secret — mais seulement s’il est émis dans une forme qui survit au voyage. Cette forme est la première chose à faire correctement.
Émettre la licence développeur dans une forme qui peut voyager
Une licence Keyright est un petit document JSON signé : titulaire, tier, expiration, produit, entitlements, signature. Pour une application de bureau, vous l’émettriez typiquement node-locked et, pour un abonnement, avec expiration. Pour une bibliothèque de composants, ces deux choix sont mauvais, parce que le document va être copié dans une application qui s’exécute sur des machines dont la licence ne sait rien.
Émettez-la plutôt ainsi :
{
"licensee": "Contoso Ltd",
"expiryUtc": null,
"tier": "Pro",
"trial": false,
"product": "acme-pdf",
"licenseId": "lic_8f3a2c71",
"entitlements": {
"redistribute": "true",
"developers": "5",
"updates-until": "20271015",
"audience": "Contoso.Reports;Contoso.Reports.Desktop"
},
"signature": "…"
}
Quatre décisions y sont encodées :
expiryUtcest null. La licence est perpétuelle. Si vous avez vendu « un an de mises à jour », ce n’est pas une expiration — une expiration éteindrait toutes les applications que Contoso a livrées le jour où sa maintenance expire. La fenêtre de maintenance est l’entitlementupdates-until, et la bibliothèque le compare à sa propre date de publication, pas à l’horloge murale. Un build 2027 de votre bibliothèque vérifie queupdates-until≥ sa date de build ; un build 2028 refuse la même licence au moment du build, sous les yeux du développeur, qui est l’endroit où ce message a sa place. (La couverture des versions de Keyright formalise exactement cela pour les applications ; pour une bibliothèque, un entitlement que la bibliothèque lit elle-même est la chose la plus simple qui fonctionne partout.)- Pas de
machineId. Le document n’est pas node-locked ; il doit se valider sur la machine de chaque utilisateur final. Les sièges développeur sont appliqués du côté du développeur par l’activation — chaque développeur active la clé, le service d’émission compte les siègesdevelopers, et le bail signé que le SDK met en cache est ce qui est lié à sa machine. Le bail n’est jamais livré. redistributeest un drapeau explicite. Le même produit peut aussi vendre une licence à usage interne à une équipe qui construit des applications métier pour son propre personnel ; celle-là reçoitredistribute: falseet la cible de build refuse de l’incorporer. Rendre le drapeau explicite vaut mieux que de le déduire du tier.audienceliste les assemblies pour lesquels la licence a été vendue. C’est la mesure anti-copie, traitée plus bas.
La vérifier au moment du build, depuis votre package NuGet
Le build du développeur est l’endroit où vous pouvez encore parler à votre client. Votre package NuGet livre un build/Acme.Pdf.targets que MSBuild importe automatiquement dans tout projet qui le référence ; la cible s’exécute avant CoreCompile, valide la licence du développeur et génère l’attribut que le runtime cherchera.
<!-- build/Acme.Pdf.targets — imported automatically by the PackageReference -->
<Project>
<PropertyGroup>
<!-- Optional explicit path; otherwise ACME_PDF_LICENSE env var, then the default app-data path. -->
<AcmePdfLicense Condition="'$(AcmePdfLicense)' == ''"></AcmePdfLicense>
<!-- Set true on CI to turn 'no valid license' into a build error instead of an evaluation build. -->
<AcmePdfRequireLicense Condition="'$(AcmePdfRequireLicense)' == ''">false</AcmePdfRequireLicense>
<_AcmePdfLicenseFile>$(IntermediateOutputPath)AcmePdfLicense.g.cs</_AcmePdfLicenseFile>
</PropertyGroup>
<UsingTask TaskName="Acme.Pdf.Build.EmbedLicense"
AssemblyFile="$(MSBuildThisFileDirectory)..\tools\Acme.Pdf.Build.dll" />
<Target Name="AcmePdfEmbedLicense" BeforeTargets="CoreCompile"
Inputs="$(MSBuildProjectFullPath)" Outputs="$(_AcmePdfLicenseFile)">
<EmbedLicense LicensePath="$(AcmePdfLicense)"
AssemblyName="$(AssemblyName)"
RequireLicense="$(AcmePdfRequireLicense)"
OutputFile="$(_AcmePdfLicenseFile)" />
<ItemGroup>
<Compile Include="$(_AcmePdfLicenseFile)" />
<FileWrites Include="$(_AcmePdfLicenseFile)" />
</ItemGroup>
</Target>
</Project>
La tâche elle-même est une mince enveloppe autour du SDK Keyright. Elle résout la licence selon la précédence normale du SDK — un chemin explicite l’emporte, puis une variable d’environnement, puis la valeur par défaut par utilisateur — la valide hors ligne, applique les règles propres à la bibliothèque et écrit la source générée :
public sealed class EmbedLicense : Microsoft.Build.Utilities.Task
{
public string? LicensePath { get; set; }
[Required] public string AssemblyName { get; set; } = "";
public bool RequireLicense { get; set; }
[Required] public string OutputFile { get; set; } = "";
// The release date of THIS SDK build, stamped at pack time. Compared against updates-until.
const long SdkReleaseDate = 20261015;
public override bool Execute()
{
var client = KeyrightClient.Initialize(new KeyrightOptions
{
Product = "acme-pdf",
PublicKeyBase64 = AcmePublicKey.Base64, // public key only; safe in a NuGet
LicenseFilePath = string.IsNullOrEmpty(LicensePath) ? null : LicensePath,
EnvVarName = "ACME_PDF_LICENSE", // not the shared KEYRIGHT_LICENSE
});
LicenseInfo info = client.Validate(out LicenseSourceKind source); // offline; never throws
string? problem = Diagnose(info);
if (problem is not null)
{
if (RequireLicense) { Log.LogError("ACMEPDF001: {0}", problem); return false; }
Log.LogWarning("ACMEPDF002: {0} Building in evaluation mode (output is watermarked).", problem);
File.WriteAllText(OutputFile, "// Acme.Pdf: no license embedded (evaluation build)\n");
return true;
}
// Embed the signed document verbatim. It is public data; the signature is the protection.
string json = File.ReadAllText(ResolvedPath(client, source)); // the file `source` resolved to
string b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
File.WriteAllText(OutputFile,
$"[assembly: Acme.Pdf.Licensing.AcmePdfLicense(\"{b64}\")]\n");
Log.LogMessage(MessageImportance.High,
"Acme.Pdf: licensed to {0} ({1}); embedded into {2}.", info.Licensee, info.LicenseId, AssemblyName);
return true;
}
string? Diagnose(LicenseInfo info)
{
if (!info.IsValid) return $"No valid Acme.Pdf developer license ({info.Status}: {info.Message}).";
if (info.IsTrial) return "Trial licenses cannot be embedded in a redistributable build.";
if (info.ExpiryUtc.HasValue) return "Only perpetual developer licenses can be embedded; this one expires.";
if (!info.Entitlements.IsEnabled("redistribute"))
return "This license does not include redistribution rights.";
if (info.Entitlements.GetLimit("updates-until") < SdkReleaseDate)
return $"This license covers updates until {info.Entitlements.GetRaw("updates-until")}; this SDK build is newer.";
var audience = (info.Entitlements.GetRaw("audience") ?? "").Split(';', StringSplitOptions.RemoveEmptyEntries);
if (audience.Length > 0 && !audience.Contains(AssemblyName, StringComparer.OrdinalIgnoreCase))
return $"This license is issued for {string.Join(", ", audience)}, not '{AssemblyName}'.";
return null;
}
}
Trois détails ont du poids. EnvVarName est fixé à une variable propre au produit plutôt qu’à la valeur par défaut du SDK KEYRIGHT_LICENSE, parce qu’un agent de build peut détenir des licences pour plusieurs produits sous licence Keyright et que vous ne voulez pas que la vôtre soit résolue depuis le fichier de quelqu’un d’autre. Validate(out source) dit à la tâche quelle source l’a emporté, ce qui fait de « ça marche sur ma machine mais pas dans le pipeline » un diagnostic d’une ligne — c’est le même motif qu’utilise une CLI sans interface. Et Diagnose refuse, au moment du build, chaque forme de licence qui échouerait plus tard sur la machine d’un utilisateur final : les versions d’essai, tout ce qui a une expiration, tout ce qui n’a pas de droits de redistribution, tout ce pour quoi le build du SDK est trop récent. Chacun de ces messages est un message sur lequel le développeur peut agir. Aucun d’eux n’est un message que vous pourrez jamais délivrer à un utilisateur final.
Ce qui est incorporé, et pourquoi c’est sûr
Le fichier généré tient en une ligne :
[assembly: Acme.Pdf.Licensing.AcmePdfLicense("eyJsaWNlbnNlZSI6ICJDb250b3NvIEx0ZCIsIC…")]
Cette chaîne est le document de licence signé du développeur, encodé en base64, logé dans les métadonnées de l’assembly du client, où quiconque dispose d’un décompilateur peut le lire. C’est très bien, et cela vaut la peine d’être précis sur le pourquoi. Une licence Keyright est protégée par sa signature, pas par le secret — le SDK revérifie la signature avec la clé publique compilée dans votre bibliothèque à chaque validation. Rien dans le document ne permet à quiconque d’émettre une autre licence, et en modifier un octet invalide la signature. Que le nom du titulaire et l’identifiant de licence soient lisibles dans le binaire n’est pas une faiblesse ; c’est un filigrane que vous obtenez gratuitement, et c’est ce qui vous permet de répondre à « quelle licence se trouve dans cette application piratée ? » à partir du seul binaire.
Le type d’attribut vit dans votre bibliothèque, est trivial, et doit survivre à l’obfuscation avec son nom — le runtime le recherche par réflexion :
namespace Acme.Pdf.Licensing;
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)]
public sealed class AcmePdfLicenseAttribute : Attribute
{
public AcmePdfLicenseAttribute(string licenseBase64) => LicenseBase64 = licenseBase64;
public string LicenseBase64 { get; }
}
La vérification à l’exécution : trouver l’attribut, vérifier hors ligne, n’écrire rien
À l’intérieur de la bibliothèque, la porte s’exécute une fois, paresseusement, la première fois que quelque chose de soumis à licence est utilisé. Elle doit trouver l’attribut sur l’assembly qui a été compilé contre vous, qui n’est pas toujours l’assembly d’entrée — votre contrôle peut se trouver dans un plugin, une bibliothèque de classes ou un hôte de test — elle parcourt donc les assemblies chargés plutôt que de supposer :
internal static class LicenseGate
{
static readonly Lazy<LicenseInfo> Current = new(Resolve, LazyThreadSafetyMode.ExecutionAndPublication);
public static bool IsLicensed => Current.Value.IsValid;
public static string Licensee => Current.Value.Licensee;
static LicenseInfo Resolve()
{
// Find the first assembly carrying our attribute: usually the entry assembly, but a
// plugin host or test runner may put the licensed consumer further down the list.
var carrier = AppDomain.CurrentDomain.GetAssemblies()
.Select(a => (asm: a, attr: a.GetCustomAttribute<AcmePdfLicenseAttribute>()))
.FirstOrDefault(t => t.attr is not null);
if (carrier.attr is null)
return LicenseInfo.Free(LicenseStatus.NoLicense, "No Acme.Pdf license embedded in the host application.");
string json = Encoding.UTF8.GetString(Convert.FromBase64String(carrier.attr.LicenseBase64));
var client = KeyrightClient.Initialize(new KeyrightOptions
{
Product = "acme-pdf",
PublicKeyBase64 = AcmePublicKey.Base64, // same public key the build task used
LicenseString = json, // authoritative: no env var, no file, no lease lookup
EnvVarName = "", // belt and braces: never consult the end user's environment
});
LicenseInfo info = client.Validate(); // signature + product + entitlements, offline, never throws
if (!info.IsValid) return info;
// The same rules the build enforced, re-checked where the code actually runs.
if (!info.Entitlements.IsEnabled("redistribute"))
return LicenseInfo.Free(LicenseStatus.NoLicense, "License does not include redistribution.");
if (info.Entitlements.GetLimit("updates-until") < BuildStamp.SdkReleaseDate)
return LicenseInfo.Free(LicenseStatus.Expired, "License does not cover this Acme.Pdf release.");
var audience = (info.Entitlements.GetRaw("audience") ?? "").Split(';', StringSplitOptions.RemoveEmptyEntries);
if (audience.Length > 0 && !audience.Contains(carrier.asm.GetName().Name, StringComparer.OrdinalIgnoreCase))
return LicenseInfo.Free(LicenseStatus.NoLicense, "License audience does not match the host assembly.");
return info;
}
}
LicenseString est le détail qui rend tout cela correct. Dans le SDK Keyright, une chaîne de licence explicite fait autorité : quand elle est définie, le client valide ce document et rien d’autre — il ne regarde ni les variables d’environnement, ni les chemins de configuration, ni l’emplacement par défaut dans les données d’application, ni un bail en cache. Sur la machine d’un utilisateur final, consulter l’un quelconque de ceux-là serait une erreur ; il n’y a pas de fichier là-bas, et si par coïncidence il y en avait un (l’utilisateur final est aussi un développeur disposant d’un autre produit sous licence Keyright), il ne doit pas être pris en compte. La chaîne explicite court-circuite tout cela.
Et parce que la licence incorporée est perpétuelle, le SDK n’écrit rien sur le disque de l’utilisateur final. Keyright ne conserve un état local — horodatages de première et de dernière utilisation pour la durée d’essai et la détection de recul d’horloge — que pour les licences limitées dans le temps. Un document perpétuel est validé uniquement à partir de sa signature et de son contenu, il n’y a donc pas de state.json, pas de cache de bail, pas de dossier portant le nom de votre produit dans les données d’application de l’utilisateur. C’est la bonne empreinte pour du code qui s’exécute à l’intérieur de l’application de quelqu’un d’autre.
Ce que la bibliothèque fait avec IsLicensed == false est une décision produit, et pour un composant la réponse est presque toujours le mode évaluation plutôt que le refus : rendre le graphique avec un filigrane « Evaluation » en diagonale, estampiller le pied de page du PDF, plafonner le nombre de codes-barres. Un développeur qui vous évalue doit voir votre bibliothèque fonctionner. Un utilisateur final dont l’éditeur a livré un build sans licence doit voir quelque chose qui pousse l’éditeur à corriger — ce qu’un filigrane fait, et ce qu’une exception au démarrage fait aussi, mais auprès de ses clients plutôt que des vôtres.
Les trois façons dont la conception évidente échoue
La licence qui expire. L’erreur la plus courante est d’émettre les licences développeur exactement comme les licences d’application — un abonnement de douze mois avec expiryUtc renseigné — et de les incorporer. Cela fonctionne parfaitement pendant un an. Puis le renouvellement du développeur expire, ou arrive simplement en retard, et toutes les applications qu’il a jamais livrées basculent en mode évaluation sur les machines de ses clients, simultanément. La tâche de build ci-dessus refuse d’incorporer quoi que ce soit avec une expiration exactement pour cette raison ; la fenêtre de maintenance vit dans updates-until et est appliquée par rapport à la date de publication de la bibliothèque, qui ne bouge plus une fois livrée.
La licence node-locked. Tout aussi naturel, tout aussi faux : lier la licence développeur à la machine du développeur pour qu’elle ne puisse pas être partagée. Incorporée, elle échoue avec MachineMismatch sur le PC de chaque utilisateur final. Le problème des sièges est réel, mais il se résout de l’autre côté de la frontière — le développeur active, le service d’émission compte les sièges par rapport à developers, et l’artefact lié à la machine est le bail que le SDK met en cache localement, jamais le document de licence. Si vous avez besoin de l’assurance supplémentaire, la tâche de build peut exiger un bail valide sur la machine du développeur (source == LicenseSourceKind.Lease) tout en incorporant le document non lié.
La licence extraite. Le document est lisible dans le binaire livré, donc un développeur qui n’a pas payé peut l’extraire de l’application d’un concurrent et le coller dans la sienne. L’entitlement audience est la réponse : au moment de l’émission, vous enregistrez les noms d’assembly pour lesquels la licence a été vendue, la tâche de build refuse de l’incorporer dans tout autre, et le runtime vérifie le nom de l’assembly porteur par rapport à la liste. Soyez honnête sur ce que cela apporte. Un attaquant peut renommer son assembly Contoso.Reports et passer. Mais il doit maintenant savoir qu’il faut le faire, son produit est livré sous le nom d’assembly d’un inconnu, et son binaire porte le nom de titulaire et l’identifiant de licence de Contoso, signés, pour que vous les trouviez — c’est le genre de preuve avec lequel on gagne un litige de licence. Pour l’attaque consistant à patcher la vérification, la porte ci-dessus est du C# ordinaire que Nebula peut protéger : autoProtectLicensing trouve les méthodes qui appellent le SDK Keyright et leur applique les transformations les plus fortes de l’édition, de sorte que la branche IsLicensed n’est pas un simple brfalse à inverser. Protéger la vérification de licence couvre ce versant.
Quand c’est le déploiement que vous licenciez
Tout ce qui précède est le modèle par développeur, runtime sans redevance — celui qui convient aux contrôles, SDK et moteurs vendus aux développeurs qui construisent des applications de bureau et mobiles. Il existe un second modèle pour les bibliothèques qui s’exécutent sur des serveurs : vous licenciez le déploiement, pas le développeur, parce qu’un seul développeur peut monter un millier d’instances et que la valeur croît avec les instances.
Ce modèle abandonne entièrement l’incorporation. L’application hôte s’active avec sa propre clé au démarrage — ActivateAsync avec l’espace de noms produit de l’hôte — et l’entitlement de votre SDK voyage à l’intérieur de la licence de l’hôte sous forme de drapeau (acme-pdf: true) ou de limite (acme-pdf-pages-per-day: 50000). Votre bibliothèque interroge alors le LicenseInfo résolu de l’hôte plutôt que de porter le sien ; licencier des conteneurs et des services autoscalés est le motif. Les deux modèles ne s’excluent pas. Une bibliothèque vendue des deux façons émet deux espaces de noms produit, incorpore pour la référence desktop et lit l’entitlement de l’hôte pour la référence serveur ; la porte d’exécution vérifie simplement l’attribut incorporé d’abord et la licence de l’hôte ensuite.
La forme, en un paragraphe
Une bibliothèque de composants est licenciée à quelqu’un qui n’est pas là quand elle s’exécute. Vérifiez donc la licence là où se trouve le client — dans son build, depuis votre package NuGet, avec des messages sur lesquels un développeur peut agir — et placez le document signé dans le binaire qu’il livre, dans une forme qui survit au voyage : perpétuelle, non liée, avec la fenêtre de maintenance comme entitlement que la bibliothèque compare à sa propre date de publication et une audience pour laquelle la licence a été vendue. À l’exécution, vérifiez ce document hors ligne avec la clé publique que vous avez compilée, n’écrivez rien sur le disque de l’utilisateur final, et dégradez vers un mode évaluation qui embarrasse l’éditeur plutôt que de punir son client. Le bail reste à la maison. La licence voyage. L’utilisateur final ne sait jamais que vous existez, ce qui est exactement ce qu’il faut.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.