Skip to content
← Todas las publicaciones
· Delta1 Labs Licenciamiento.NETAnálisis a fondo

Verificar una clave de licencia no es gratis: cachear la comprobación de firma sin cachear una respuesta obsoleta

Una licencia firmada de Keyright se verifica con una comprobación de firma RSA, y esa comprobación cuesta microsegundos reales. Llama a Validate() en cada petición y habrás puesto criptografía de clave pública en tu ruta caliente sin ningún motivo: la firma no ha cambiado desde el arranque. Pero tampoco puedes cachear el resultado para siempre, porque la respuesta depende del tiempo: un entitlement puede expirar a mediodía, un lease puede ser recolectado, una renovación puede ampliar el plan. Esta entrada separa las dos mitades de una comprobación de licencia —la verificación de firma, cara y cacheable, y la evaluación de la licencia en el instante actual, barata y obligatoriamente por llamada—, mide lo que cuesta cada una y muestra una memoización de dos niveles que mantiene la criptografía fuera de la ruta caliente sin devolver jamás una respuesta con un minuto de retraso.

Una licencia de Keyright es un documento firmado. El servicio emisor guarda una clave privada RSA, firma un payload canónico —la edición, los entitlements, el bloqueo a nodo, la expiración— y cada copia de tu aplicación incrusta la clave pública correspondiente y verifica esa firma offline. Esa verificación es el cimiento de todo el esquema: es lo que hace que una licencia legible en el cliente sea imposible de falsificar. Tampoco es gratis, y el error de rendimiento más común con cualquier SDK de licencias firmadas es pagarla muchas más veces de lo que los hechos exigen.

Aquí está el error en su forma más pura: una comprobación de licencia conectada directamente a un handler de autorización de ASP.NET Core:

public async Task HandleRequirementAsync(
    AuthorizationHandlerContext context, EntitlementRequirement requirement)
{
    // Called on every single request to a gated endpoint.
    LicenseInfo info = _client.Validate();          // ← RSA verify + parse, every time
    if (info.IsEnabled(requirement.Entitlement))
        context.Succeed(requirement);
}

Validate() es computación pura, offline y local: aquí no hay ninguna llamada de red, el lease se cacheó en la activación. Pero “local” no es lo mismo que “suficientemente barato para hacerlo miles de veces por segundo”. Cada llamada vuelve a ejecutar la verificación de firma RSA sobre los bytes de la licencia, vuelve a decodificar el payload base64, lo vuelve a parsear y reconstruye el conjunto de entitlements, solo para responder a una pregunta cuyas entradas no han cambiado desde que arrancó el proceso.

Lo que cuesta realmente la llamada

Conviene ser concreto sobre adónde va el tiempo. Una llamada a Validate() hace, en orden: decodificar en base64 la cadena de licencia, verificar la firma RSA-2048 sobre el payload canónico, parsear el payload (de UTF-8 a campos), construir el diccionario de entitlements y, por último, evaluar la licencia contra la hora actual. Solo el último paso depende de cuándo lo llamas. Todo lo anterior depende únicamente de los bytes de la licencia y de tu clave pública.

decodificar base64de la licenciaRSA-2048 verifydecenas de µsparsear payload +construir entitlementsevaluar en el ahoracomparar expiraciones · nsconstante para una licencia dada — cachear estodepende del tiempo — nunca cachear

Ninguna de estas cifras es alarmante por sí sola: decenas de microsegundos para la verificación, unas pocas asignaciones de corta vida para el parseo. El daño está en la repetición. Pon esto en una ruta por petición a mil peticiones por segundo y estarás quemando el equivalente a un núcleo, y generando un flujo constante de basura, para volver a demostrar una firma que ya era válida. La solución no es una biblioteca criptográfica más rápida. Es dejar de hacer la pregunta cara cuando ya conoces la respuesta.

Verificar una vez, evaluar cada vez

La idea clave es que las dos mitades tienen vidas de caché completamente distintas. La verificación de firma y el parseo son inmutables mientras el proceso mantenga los mismos bytes de licencia: verificar los mismos bytes contra la misma clave pública una segunda vez no puede producir un resultado diferente. La evaluación en el instante actual es lo contrario: es precisamente la parte que debe cambiar, porque un entitlement puede llevar su propia expiración y el reloj validado avanza.

Así que sepáralas. Verifica y parsea una vez, conserva la instantánea LicenseInfo resultante y, en cada llamada, haz solo la evaluación barata y dependiente del tiempo contra esa instantánea. LicenseInfo es un valor inmutable —el SDK lo produce y nunca lo muta—, así que es seguro compartirlo entre hilos sin un bloqueo.

public sealed class LicenseSnapshot
{
    private readonly KeyrightClient _client;
    private volatile LicenseInfo _verified;   // signature-verified, parsed once
    private long _verifiedAtTicks;
    private readonly long _refreshTicks = TimeSpan.FromSeconds(2).Ticks;

    public LicenseSnapshot(KeyrightClient client)
    {
        _client = client;
        _verified = client.Validate();                 // pay the RSA cost once
        _verifiedAtTicks = DateTime.UtcNow.Ticks;
    }

    private LicenseInfo Current()
    {
        long now = DateTime.UtcNow.Ticks;
        if (now - Volatile.Read(ref _verifiedAtTicks) > _refreshTicks)
        {
            _verified = _client.Validate();            // re-verify on a short interval
            Volatile.Write(ref _verifiedAtTicks, now);
        }
        return _verified;
    }

    // Hot path: a field read plus a time comparison. No crypto, no allocation.
    public bool IsEnabled(string entitlement) => Current().IsEnabled(entitlement);
}

Dos cosas hacen que esto sea correcto y no meramente rápido. Primero, IsEnabled sobre la instantánea es la mitad barata: para un entitlement de Keyright que lleva una expiración por entitlement, IsEnabled compara esa expiración contra el reloj validado en el momento de la llamada, así que una funcionalidad que caduca a mediodía se lee como desactivada a las 12:00:01 aunque la instantánea se verificara a la hora del desayuno. La comprobación de firma, cara, se cachea; la decisión sensible al tiempo, no. Segundo, el intervalo de refresco es corto —segundos, no minutos—. Existe solo para detectar un lease cambiado (una renovación que amplió el plan, una revocación que lo redujo a Free), no para racionar CPU, porque el coste de CPU ya se eliminó al cachear la instantánea entre refrescos.

La trampa: cachear la respuesta en lugar de la verificación

El modo de fallo que merece la pena nombrar en voz alta es el que parece una optimización y en realidad es un bug de corrección. Es tentador cachear el booleano —“¿está activado export?”— porque ese es el valor que tu punto de llamada quiere:

// WRONG: caches the time-dependent answer.
private readonly ConcurrentDictionary<string, bool> _answers = new();
public bool IsEnabled(string ent) =>
    _answers.GetOrAdd(ent, e => _client.Validate().IsEnabled(e));

Esto es rápido e incorrecto. En el momento en que export expira, este diccionario sigue devolviendo true durante toda la vida del proceso, porque nada reevalúa la expiración. Has cacheado lo único que debía cambiar y has dejado lo único que nunca cambia —la verificación de firma— ejecutándose en cada fallo de caché. La separación correcta cachea la instantánea (el invariante) y vuelve a ejecutar la evaluación (la variable). Conserva la licencia parseada; nunca conserves el veredicto.

Nivel lentoreverificar firmacada ~2 s / al activarLicenseInfoinstantánea inmutableNivel rápido — ruta calienteIsEnabled(e) at now()lectura de campo + comparar expiraciónnanosegundos, sin asignaciónescribelee

Cuando aun así quieres atajar

Hay una microoptimización legítima encima de esto, y no es cachear el veredicto: es saltarse la comprobación de refresco cuando el reloj obviamente no se ha movido lo suficiente como para importar. El método Current() de arriba ya hace la puerta más barata posible: una lectura de DateTime.UtcNow y una comparación de enteros. Si el perfilado dice que incluso eso es demasiado en una ruta caliente extrema, pasa la propia instantánea al llamador y deja que un bucle interno cerrado lea los entitlements directamente de ella, refrescando solo en los límites de un lote:

// Batch boundary — refresh once, then evaluate N times with zero overhead.
LicenseInfo info = _snapshot.Current();
foreach (var job in batch)
    if (info.IsEnabled("bulk-process"))
        Process(job);

Esto es seguro precisamente porque el lote es corto en relación con el intervalo de refresco, así que el reloj no puede avanzar de forma significativa durante él. Para cualquier cosa de vida más larga —un handler de petición, un tick de un servicio en segundo plano— vuelve a pasar por Current() para que un entitlement recién caducado se detecte.

La regla

Una comprobación de licencia firmada son dos trabajos con un solo nombre de método. Verificar la firma demuestra que la licencia es auténtica, cuesta microsegundos reales y tiene una respuesta que no cambia mientras el proceso mantenga la licencia: así que verifica una vez y conserva la instantánea. Evaluar la licencia te dice qué es cierto ahora mismo, cuesta nanosegundos y es exactamente la parte que debe cambiar: así que nunca la cachees. Haz bien la separación y sacarás la criptografía de clave pública de tu ruta caliente sin devolver jamás una respuesta obsoleta, que es de lo que se trata: rápido y honesto, no lo uno a costa de lo otro.

El SDK de Keyright te da la materia prima para esto: un Validate() offline que devuelve un LicenseInfo inmutable, y un lease cacheado para que nunca vuelvas a la red. Consulta entitlements y feature flags para el modelo de capacidades que transporta la instantánea, expiración por entitlement para entender por qué la evaluación debe seguir siendo por llamada, y puertas fail-closed para saber a qué se resuelve la instantánea cuando una licencia falta o está revocada.

Prueba Nebula.NET

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