Skip to content
← Todas las publicaciones
· Delta1 Labs Licenciamiento.NETGuía

Diseñar una compuerta de licencia: dónde comprobar, qué cachear y cuándo fallar cerrado

Una sola llamada a Validate() es la parte fácil del licenciamiento. La parte difícil es la arquitectura: en qué lugar de tu app vive la comprobación, con qué frecuencia se ejecuta, qué cacheas entre ejecuciones y —la pregunta que decide si un bug deja fuera a un cliente que paga o regala tu software— qué fallos fallan cerrado y cuáles fallan abierto. Aquí tienes una compuerta de licencia a prueba de fallos para .NET construida sobre Keyright, con una tabla de decisión estado por estado y una instantánea cacheada que sobrevive sin conexión.

Añadir Keyright a una app .NET parece una sola línea: client.Validate() devuelve un LicenseInfo, compruebas IsValid, listo. Esa línea es la parte fácil, y no es donde el licenciamiento se rompe. El licenciamiento se rompe en la arquitectura alrededor de esa llamada: dónde vive, con qué frecuencia se ejecuta, qué conservas entre ejecuciones y, sobre todo, qué ocurre cuando no puede darte una respuesta limpia. Equivócate en una dirección y un tropiezo transitorio deja fuera a un cliente que te pagó. Equivócate en la otra y un binario manipulado ejecuta tus funciones Pro gratis.

Este artículo construye una compuerta de licencia: un único objeto que posee la única llamada de validación, cachea una instantánea inmutable del resultado y toma una decisión deliberada por cada motivo de fallo sobre si fallar cerrado o fallar abierto. Es la pieza que la mayoría de los tutoriales de licenciamiento se saltan, y es la pieza que decide si tu licenciamiento es un activo o una cola de soporte.

Una llamada, un propietario, una instantánea

La primera regla es que exactamente un objeto llama al SDK. Dispersa Validate() por tus view models y obtendrás re-verificación en cada clic de botón, respuestas inconsistentes cuando el reloj pase la caducidad a mitad de sesión y ningún lugar único donde cambiar la política. En vez de eso, valida una vez, congela la respuesta en una instantánea y deja que el resto de la app lea la instantánea.

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);
}

La instantánea es un record por una razón: es inmutable, así que una vez que la compuerta la entrega, ninguna función puede mutar accidentalmente el estado de licenciamiento, y un refresco en segundo plano intercambia la referencia completa de forma atómica en lugar de editar campos que otros hilos están leyendo.

La compuerta en sí contiene el KeyrightClient, la instantánea actual y la política que convierte un LicenseInfo crudo en una instantánea:

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();               // sin conexión: verifica firma + lease localmente
        }
        catch (Exception ex)
        {
            // Una excepción lanzada es un estado *desconocido*, no uno licenciado.
            _current = LicenseSnapshot.Free with { Status = LicenseStatus.Malformed };
            Log.Warning(ex, "license validation threw; treating as unlicensed");
            return;
        }
        _current = Decide(info);
    }
}

Fíjate en el campo volatile y en el intercambio del objeto completo: un lector en el hilo de UI siempre ve o la instantánea antigua o la nueva, nunca un struct a medio actualizar. Y fíjate en que una excepción lanzada se resuelve a la edición gratuita, nunca a «licenciado». Un estado de licenciamiento desconocido nunca es uno de pago: ese principio es todo el artículo en una línea.

LicenseGateposee Validate() + instantáneaSDK de KeyrightValidate() — localverifica, sin redinstantánea inmutableAllowsPaid · EditionEntitlements · Statuscódigo de funcionesgate.Allows("export")solo lee la instantáneallama una vezcongela el resultadoconsulta

La tabla de decisión es la compuerta

LicenseInfo.Status no es un booleano. Keyright informa de ocho resultados distintos, y reducirlos a IsValid desecha exactamente la información que necesitas para elegir una reacción:

public enum LicenseStatus
{
    Valid = 0,            // firma + lease verificados, no caducada, la máquina coincide
    NoLicense = 1,        // nada que verificar
    Malformed = 2,        // se encontró un artefacto de licencia pero no se pudo parsear
    SignatureInvalid = 3, // parseada, pero la firma no coincide con la clave embebida
    Expired = 4,          // firma válida, pasada su caducidad
    MachineMismatch = 5,  // válida, pero ligada a una máquina distinta
    Revoked = 6,          // revocada explícitamente en el servidor y vista en un refresco de lease
    ClockTampered = 7,    // reloj local retrasado respecto a una marca de tiempo de confianza
}

La política de la compuerta es un único switch sobre estos, y escribirla te obliga a tomar cada decisión de fallar abierto / fallar cerrado de forma explícita en lugar de por accidente:

private static LicenseSnapshot Decide(LicenseInfo info)
{
    switch (info.Status)
    {
        // Pase limpio: el único estado que concede funciones de pago.
        case LicenseStatus.Valid:
            return new(true, info.Edition, info.Entitlements(),
                       info.Status, DateTime.UtcNow);

        // Ausencia sin culpa: es un usuario gratuito, no un atacante. Falla ABIERTO a gratuito.
        case LicenseStatus.NoLicense:
            return LicenseSnapshot.Free;

        // Una suscripción que caducó. ExpiryUtc es real y está firmado; honra una ventana
        // de gracia para que una renovación en curso o un lease que aún no se ha refrescado
        // no deje fuera a un cliente que paga a mitad de sesión. Falla ABIERTO, breve y acotado.
        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;

        // Todo lo de abajo es evidencia de manipulación o de una licencia que nunca
        // fue tuya para ejecutar. No hay lectura benigna. Falla CERRADO, sin más.
        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 forma de la política es lo importante. Tres de los ocho estados son sin culpa: Valid concede, NoLicense es simplemente un usuario gratuito, y una suscripción recién Expired sigue funcionando durante una ventana de gracia acotada para que una renovación que todavía se está propagando nunca produzca un bloqueo. Los otros cinco —Malformed, SignatureInvalid, MachineMismatch, Revoked, ClockTampered— no tienen interpretación benigna, así que caen directamente a gratuito sin gracia. No estás siendo hostil con los clientes al fallar cerrado en esos casos; te estás negando a extender confianza a un artefacto que no logró demostrar que merece alguna.

estadoreaccióninclinaciónValidconcede funciones de pago (entitlements)pasaNoLicensecae a la edición gratuitaabierto — sin culpaExpired (dentro de la gracia de 14 días)mantiene el pago, acotadoabierto — acotadoExpired (gracia vencida)cae a la edición gratuitaabierto — sin culpaMalformed · SignatureInvalidMachineMismatch · RevokedClockTampereddeniega el pago — no confiableCERRADO — sin lectura benigna

Por qué ClockTampered debe fallar cerrado aunque parezca inofensivo

El único estado que tienta a los desarrolladores a fallar abierto es ClockTampered. Parece un error honesto: un portátil con la pila de la CMOS agotada, una VM restaurada desde una instantánea, un usuario en la zona horaria equivocada. ¿Por qué castigar eso?

Porque el reloj es lo único que se interpone entre una licencia limitada en el tiempo y una infinita. Keyright registra una marca de tiempo máxima de confianza cada vez que ve un reloj que avanza de forma monótona (en la activación, en cada refresco de lease). ClockTampered significa que el reloj local está ahora por detrás de esa marca máxima: el reloj de pared fue hacia atrás. Las explicaciones benignas son reales, pero también lo es el ataque: retrasa el reloj al mes pasado y una prueba que caducó ayer vuelve a ser joven. Una compuerta que falla abierto ante ClockTampered no tiene caducidad en absoluto, porque cualquier caducidad se puede deshacer retrasando la fecha. Así que la compuerta falla cerrado y la vía de recuperación es que el usuario arregle su reloj y vuelva a lanzar la app —una acción verificable— en lugar de que tu código adivine qué retrocesos son honestos.

Refrescar sin dependencia de la red

La instantánea se toma una vez al arrancar y luego con un temporizador lento. Aquí importan dos cosas. Primera, el refresco es sin conexión: Validate() re-verifica la licencia firmada y el lease cacheado contra la clave pública embebida sin ninguna llamada de red, así que un refresco funciona en un avión. Segunda, el lease es lo que hace seguro un proceso de larga vida: la activación obtuvo un lease firmado y ligado a la máquina válido durante 14 días, y mientras el lease no haya caducado la compuerta sigue devolviendo Valid sin llegar jamás a tu servicio emisor.

// En una app WPF/WinForms: un temporizador de baja frecuencia, no un hook en la ruta caliente.
var timer = new System.Threading.Timer(_ => gate.Refresh(),
    null, dueTime: TimeSpan.Zero, period: TimeSpan.FromHours(6));

// Refresca también cuando la máquina despierte, donde el reloj y el lease pueden haberse movido.
SystemEvents.PowerModeChanged += (_, e) =>
{
    if (e.Mode == PowerModes.Resume) gate.Refresh();
};

El único momento en que la red entra en escena es un refresco de lease: cuando el lease está cerca de su caducidad, el SDK lo renueva contra tu servicio emisor en su siguiente momento en línea. Si el servicio no está disponible, el lease simplemente agota la gracia que le queda; la compuerta sigue diciendo Valid todo el tiempo y solo cae cuando el lease vence de verdad sin renovación. Una caída de tu lado se degrada, por tanto, con elegancia a operación sin conexión, no a una interrupción para el cliente, lo cual solo es cierto porque la compuerta lee un lease firmado y cacheado en lugar de llamar a casa en cada comprobación.

Restringir funciones, no solo la app

Con la compuerta en su sitio, la restricción por función es una sola línea, y como los entitlements van firmados dentro de la licencia, distribuyes un único binario para todos los niveles:

// Capacidad booleana.
exportButton.IsEnabled = gate.Allows("export-to-pdf");

// Cuota numérica: "unlimited" en el payload firmado se lee de vuelta como long.MaxValue.
int maxSeats = (int)Math.Min(gate.Limit("seats", fallback: 1), int.MaxValue);

// Un panel entero solo-Pro, enlazado una vez a la instantánea.
proPanel.Visibility = gate.Current.AllowsPaid ? Visibility.Visible : Visibility.Collapsed;

Todo pasa por la compuerta, así que hay exactamente un lugar que conoce la política, un lugar que cachea y un lugar que auditar cuando alguien pregunte «¿qué pasa si la licencia se revoca mientras la app está en ejecución?». La respuesta es: en el siguiente refresco el estado pasa a Revoked, Decide lo asigna a gratuito, la instantánea se intercambia de forma atómica y el panel Pro se colapsa, sin que ninguna comprobación en línea en otra parte del código tuviera que enterarse.

La única regla

Si recuerdas una sola cosa, que sea el invariante que la compuerta hace cumplir en todas partes: un estado de licenciamiento desconocido nunca es uno licenciado. Una excepción lanzada, un artefacto que no se puede parsear, una firma mala, un reloj que fue hacia atrás: ninguno de esos es «probablemente está bien». Son la ausencia de prueba, y la ausencia de prueba falla cerrado. El puñado de estados que sí fallan abierto —una licencia ausente, una suscripción dentro de su ventana de gracia— son aquellos para los que puedes nombrar en voz alta una razón sin culpa. Todo lo demás cae a gratuito y espera a que el usuario presente una licencia que verifique. Esa única regla, aplicada en una sola compuerta en lugar de en un centenar de comprobaciones dispersas, es la diferencia entre un licenciamiento en el que puedes confiar y uno que tienes que vigilar.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.