Licenciar una biblioteca de componentes .NET: la licencia de desarrollador, la clave que se compila en la aplicación de tu cliente y lo que el usuario final nunca ve
Una biblioteca de componentes tiene dos audiencias y solo una de ellas es tu cliente. El desarrollador que compra tu motor de PDF o tu control de gráficos es quien tiene la licencia; los usuarios finales que ejecutan las aplicaciones construidas con él nunca han oído hablar de ti, nunca se les debe pedir una clave y trabajan en máquinas que tú no puedes activar. Eso cambia dónde se comprueba una licencia y qué viaja dentro del binario distribuido. Este artículo construye el diseño al que convergen la mayoría de los proveedores de controles con Keyright: una licencia de desarrollador perpetua, sin bloqueo por equipo, verificada en tiempo de compilación por un target de MSBuild dentro de tu paquete NuGet, incrustada en el ensamblado del cliente como un atributo firmado y verificada de nuevo en tiempo de ejecución, sin conexión, contra la clave pública de tu biblioteca — con un derecho updates-until en lugar de una caducidad para que la aplicación del cliente nunca se detenga, una reclamación de audiencia para que una licencia extraída de una aplicación no active otra, y un nombre de licenciatario con valor de marca de agua firmado en cada binario que la usa.
La mayoría de los consejos sobre licenciamiento dan por hecho que la persona que ejecuta tu código es la persona que pagó por él. Una biblioteca de componentes rompe esa suposición por la mitad. Vendes un motor de PDF, un control de gráficos, un SDK de códigos de barras, a un desarrollador. El desarrollador lo compila dentro de una aplicación y distribuye esa aplicación a usuarios finales — personas que nunca han oído hablar de ti, a las que no se les puede pedir una clave y que trabajan en máquinas que tú nunca activarás. Tu cliente es el desarrollador. Tu código se ejecuta en todos los demás sitios.
Esa división cambia dos cosas: dónde se comprueba la licencia y qué tiene que viajar dentro del binario que distribuye tu cliente. Este artículo construye el diseño al que convergen la mayoría de los proveedores de controles — una licencia de desarrollador verificada en tiempo de compilación, incrustada en el ensamblado del cliente y verificada de nuevo sin conexión en tiempo de ejecución — con Keyright, y repasa los tres puntos en los que la versión obvia de ese diseño sale mal.
Dos partes, dos comprobaciones
El lado del proveedor te resulta familiar: emites una licencia, el desarrollador la activa, tú cuentas puestos. La parte desconocida es el segundo salto. Lo que sea que tu biblioteca necesite en tiempo de ejecución para decidir «con licencia o evaluación» tiene que estar dentro de la aplicación del cliente, colocado ahí en tiempo de compilación, y ser verificable en una máquina sin archivo de licencia, sin variable de entorno, sin red y sin idea de quién eres. El propio documento de licencia es lo correcto para poner ahí — es una carga firmada, no un secreto — pero solo si se emite con una forma que sobreviva al viaje. Esa forma es lo primero que hay que hacer bien.
Emite la licencia de desarrollador con una forma que pueda viajar
Una licencia de Keyright es un pequeño documento JSON firmado: licenciatario, nivel, caducidad, producto, derechos, firma. Para una aplicación de escritorio normalmente la emitirías bloqueada por equipo y, para una suscripción, con caducidad. Para una biblioteca de componentes ambas cosas son un error, porque el documento va a copiarse en una aplicación que se ejecuta en máquinas de las que la licencia no sabe nada.
Emítela así en su lugar:
{
"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": "…"
}
Ahí van codificadas cuatro decisiones:
expiryUtces null. La licencia es perpetua. Si vendiste «un año de actualizaciones», eso no es una caducidad — una caducidad apagaría todas las aplicaciones que Contoso haya distribuido el día en que venza su mantenimiento. La ventana de mantenimiento es el derechoupdates-until, y la biblioteca lo compara con su propia fecha de publicación, no con el reloj de pared. Una compilación de 2027 de tu biblioteca comprueba queupdates-until≥ su fecha de compilación; una compilación de 2028 rechaza la misma licencia en tiempo de compilación, delante del desarrollador, que es donde corresponde ese mensaje. (La cobertura de versiones de Keyright formaliza exactamente esto para aplicaciones; para una biblioteca, un derecho que la propia biblioteca lee es lo más sencillo que funciona en todas partes.)- Sin
machineId. El documento no está bloqueado por equipo; tiene que validarse en la máquina de cada usuario final. Los puestos de desarrollador se hacen cumplir en el lado del desarrollador mediante la activación — cada desarrollador activa la clave, el servicio emisor cuenta los puestos dedevelopers, y la lease firmada que el SDK cachea es lo que queda vinculado a su máquina. La lease nunca se distribuye. redistributees un flag explícito. El mismo producto puede vender también una licencia de uso interno a un equipo que construye aplicaciones de línea de negocio para su propio personal; esa reciberedistribute: falsey el target de build se niega a incrustarla. Hacer el flag explícito es mejor que inferirlo del nivel.audienceenumera los ensamblados para los que se vendió la licencia. Esta es la medida anticopia, que se trata más abajo.
Compruébala en tiempo de compilación, desde dentro de tu paquete NuGet
La compilación del desarrollador es donde todavía puedes hablar con tu cliente. Tu paquete NuGet incluye un build/Acme.Pdf.targets que MSBuild importa automáticamente en cualquier proyecto que lo referencie; el target se ejecuta antes de CoreCompile, valida la licencia del desarrollador y genera el atributo que el tiempo de ejecución buscará.
<!-- 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 tarea en sí es una fina envoltura sobre el SDK de Keyright. Resuelve la licencia mediante la precedencia normal del SDK — una ruta explícita gana, luego una variable de entorno, luego el valor por defecto por usuario — la valida sin conexión, aplica las reglas específicas de la biblioteca y escribe el código fuente generado:
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;
}
}
Tres detalles tienen peso. EnvVarName se establece a una variable específica del producto en lugar de la KEYRIGHT_LICENSE por defecto del SDK, porque un agente de compilación puede tener licencias de varios productos licenciados con Keyright y no quieres que la tuya se resuelva desde el archivo de otro. Validate(out source) le dice a la tarea qué origen ganó, que es lo que convierte «en mi máquina funciona pero en el pipeline no» en un diagnóstico de una línea — el patrón es el mismo que usa una CLI sin interfaz. Y Diagnose rechaza, en tiempo de compilación, toda forma de licencia que fallaría después en la máquina de un usuario final: pruebas, cualquier cosa con caducidad, cualquier cosa sin derechos de redistribución, cualquier cosa para la que la compilación del SDK sea demasiado nueva. Cada uno de esos es un mensaje sobre el que el desarrollador puede actuar. Ninguno de ellos es un mensaje que puedas entregar jamás a un usuario final.
Qué se incrusta, y por qué es seguro
El archivo generado es una línea:
[assembly: Acme.Pdf.Licensing.AcmePdfLicense("eyJsaWNlbnNlZSI6ICJDb250b3NvIEx0ZCIsIC…")]
Esa cadena es el documento de licencia firmado del desarrollador, codificado en base64, situado en los metadatos del ensamblado del cliente, donde cualquiera con un decompilador puede leerlo. Eso está bien, y merece la pena ser preciso sobre el porqué. Una licencia de Keyright está protegida por su firma, no por el secreto — el SDK vuelve a verificar la firma con la clave pública compilada en tu biblioteca en cada validación. Nada en el documento permite a nadie emitir otra licencia, y editar un byte de él invalida la firma. Que el nombre del licenciatario y el id de licencia sean legibles en el binario no es una debilidad; es una marca de agua que obtienes gratis, y es lo que te permite responder a «¿de quién es la licencia que hay en esta aplicación crackeada?» solo a partir del binario.
El tipo del atributo vive en tu biblioteca, es trivial y debe sobrevivir a la ofuscación por nombre — el tiempo de ejecución lo busca mediante reflexión:
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 comprobación en tiempo de ejecución: encontrar el atributo, verificar sin conexión, no escribir nada
Dentro de la biblioteca, la puerta se ejecuta una vez, de forma perezosa, la primera vez que se usa algo con licencia. Tiene que encontrar el atributo en el ensamblado que se compiló contra ti, que no siempre es el ensamblado de entrada — tu control puede estar dentro de un plugin, una biblioteca de clases o un host de pruebas — así que recorre los ensamblados cargados en lugar de dar nada por supuesto:
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 es el detalle que hace que esto sea correcto. En el SDK de Keyright una cadena de licencia explícita es autoritativa: cuando está establecida, el cliente valida ese documento y nada más — no mira variables de entorno, rutas de configuración, la ubicación por defecto en app-data ni una lease cacheada. En la máquina de un usuario final sería un error consultar cualquiera de ellas; allí no hay ningún archivo, y si por casualidad lo hubiera (el usuario final es también un desarrollador con algún otro producto licenciado con Keyright) no debe recogerse. La cadena explícita cortocircuita todo eso.
Y como la licencia incrustada es perpetua, el SDK no escribe nada en el disco del usuario final. Keyright mantiene estado local — marcas de tiempo de primera y última vez visto para la duración de las pruebas y la detección de retroceso del reloj — solo para licencias limitadas en el tiempo. Un documento perpetuo se valida puramente a partir de su firma y su contenido, así que no hay state.json, ni caché de lease, ni carpeta bajo los datos de aplicación del usuario con el nombre de tu producto. Esa es la huella correcta para código que se ejecuta dentro de la aplicación de otro.
Lo que la biblioteca hace con IsLicensed == false es una decisión de producto, y para un componente la respuesta es casi siempre modo de evaluación en lugar de rechazo: renderizar el gráfico con una marca de agua diagonal de «Evaluación», estampar el pie de página del PDF, limitar el número de códigos de barras. Un desarrollador que te está evaluando debería ver tu biblioteca funcionar. Un usuario final cuyo proveedor distribuyó una compilación sin licencia debería ver algo que haga que el proveedor lo arregle — cosa que una marca de agua consigue, y una excepción al arrancar también, pero con los clientes de ellos en lugar de los tuyos.
Las tres formas en que falla el diseño obvio
La licencia con caducidad. El error más común es emitir licencias de desarrollador exactamente igual que licencias de aplicación — una suscripción de doce meses con expiryUtc establecido — e incrustarlas. Funciona perfectamente durante un año. Entonces la renovación del desarrollador vence, o simplemente llega tarde, y todas las aplicaciones que haya distribuido alguna vez caen en modo de evaluación en las máquinas de sus clientes simultáneamente. La tarea de build de arriba se niega a incrustar cualquier cosa con caducidad exactamente por esta razón; la ventana de mantenimiento vive en updates-until y se hace cumplir contra la fecha de publicación de la biblioteca, que no se mueve una vez distribuida.
La licencia bloqueada por equipo. Igual de natural, igual de equivocada: vincular la licencia de desarrollador a la máquina del desarrollador para que no pueda compartirse. Incrustada, falla con MachineMismatch en el PC de cada usuario final. El problema de los puestos es real, pero se resuelve al otro lado de la frontera — el desarrollador activa, el servicio emisor cuenta puestos contra developers, y el artefacto vinculado a la máquina es la lease que el SDK cachea localmente, nunca el documento de licencia. Si necesitas la garantía adicional, la tarea de build puede exigir una lease válida en la máquina del desarrollador (source == LicenseSourceKind.Lease) sin dejar de incrustar el documento sin vinculación.
La licencia extraída. El documento es legible en el binario distribuido, así que un desarrollador que no pagó puede sacarlo de la aplicación de un competidor y pegarlo en la suya. El derecho audience es la respuesta: en el momento de la emisión registras los nombres de ensamblado para los que se vendió la licencia, la tarea de build se niega a incrustarla en cualquier otro, y el tiempo de ejecución comprueba el nombre del ensamblado portador contra la lista. Sé honesto sobre lo que esto compra. Un atacante puede renombrar su ensamblado a Contoso.Reports y pasar. Pero ahora tiene que saber que debe hacerlo, su producto se distribuye bajo el nombre de ensamblado de un extraño, y su binario lleva el nombre de licenciatario y el id de licencia de Contoso, firmados, para que tú los encuentres — este es el tipo de evidencia con el que se gana una disputa de licenciamiento. Para el ataque de parchear la comprobación, la puerta de arriba es C# corriente que Nebula puede proteger: autoProtectLicensing encuentra los métodos que llaman al SDK de Keyright y les aplica las transformaciones más fuertes que aplica la edición, de modo que la rama de IsLicensed no sea un único brfalse que invertir. Proteger la comprobación de licencia cubre ese lado.
Cuando lo que licencias es el despliegue
Todo lo anterior es el modelo de por desarrollador, con runtime libre de regalías — el que encaja con controles, SDK y motores vendidos a desarrolladores que construyen aplicaciones de escritorio y móviles. Hay un segundo modelo para bibliotecas que se ejecutan en servidores: licencias el despliegue, no al desarrollador, porque un solo desarrollador puede levantar mil instancias y el valor escala con las instancias.
Ese modelo prescinde por completo de la incrustación. La aplicación host activa con su propia clave al arrancar — ActivateAsync con el espacio de nombres de producto del host — y el derecho de tu SDK viaja dentro de la licencia del host como un flag (acme-pdf: true) o un límite (acme-pdf-pages-per-day: 50000). Tu biblioteca entonces consulta el LicenseInfo resuelto del host en lugar de llevar el suyo propio; licenciar contenedores y servicios con autoescalado es el patrón. Los dos modelos no son excluyentes. Una biblioteca que se vende de ambas formas emite dos espacios de nombres de producto, incrusta para el SKU de escritorio y lee el derecho del host para el SKU de servidor; la puerta en tiempo de ejecución simplemente comprueba primero el atributo incrustado y después la licencia del host.
La forma, en un párrafo
Una biblioteca de componentes se licencia a alguien que no está presente cuando se ejecuta. Así que comprueba la licencia donde está el cliente — en su compilación, desde tu paquete NuGet, con mensajes sobre los que un desarrollador puede actuar — y pon el documento firmado en el binario que distribuye, con una forma que sobreviva al viaje: perpetua, sin vinculación, con la ventana de mantenimiento como un derecho que la biblioteca compara con su propia fecha de publicación y una audiencia para la que se vendió la licencia. En tiempo de ejecución, verifica ese documento sin conexión con la clave pública que compilaste dentro, no escribas nada en el disco del usuario final y degrada a un modo de evaluación que avergüence al proveedor en lugar de castigar a su cliente. La lease se queda en casa. La licencia viaja. El usuario final nunca sabe que existes, que es exactamente lo correcto.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.