Desfase de reloj en la verificación de licencias: por qué una clave recién emitida falla en un portátil atrasado, y la tolerancia que tu verificación necesita
Un cliente activa una licencia a las 9:00 y tu aplicación la rechaza como «aún no válida». Nada está falsificado, nada ha caducado: el reloj de su portátil va cuatro minutos por detrás de la máquina que emitió la clave, así que la claim NotBefore queda en su futuro. Esta entrada trata el problema del reloj honesto (no el del atacante que retrasa el reloj, que es otra entrada distinta): los pocos minutos de desacuerdo entre el reloj del emisor y el del verificador que convierten una licencia correcta en un fallo espurio en el peor momento posible, justo después de la compra. Lee las tres claims de tiempo de una licencia Keyright (IssuedAt, NotBefore, Expiry), muestra la comparación ingenua que se rompe y construye la verificación con tolerancia: un pequeño margen simétrico en NotBefore y Expiry, por qué la tolerancia es asimétrica en su intención, y por qué el tiempo transcurrido dentro de una sesión debe medirse con un reloj monótono y no con el reloj de pared del que acabas de decidir que no puedes fiarte del todo.
Un cliente compra tu software, activa la licencia a las 9:00 en punto y tu aplicación le dice que la licencia «aún no es válida». No hay nada mal en la clave. Está firmada, es suya, no caduca hasta dentro de un año. Simplemente, el reloj de su portátil va cuatro minutos por detrás de la máquina que emitió la clave, así que el instante NotBefore de la licencia queda cuatro minutos en su futuro. Una comparación de tiempo estricta la rechaza, y has convertido una venta exitosa en un ticket de soporte en los primeros diez segundos.
Este es el problema del reloj honesto, y es distinto del atacante que retrasa el reloj para burlar una caducidad: esa es otra defensa. Aquí nadie hace trampas; dos relojes simplemente no coinciden. Esta entrada construye la verificación de Keyright que absorbe ese desacuerdo sin abrir un agujero.
Tres claims de tiempo, y las dos comparaciones que importan
Una licencia Keyright lleva tres instantes UTC, y responden a tres preguntas distintas:
public sealed class LicenseInfo
{
public DateTime IssuedAtUtc { get; } // when the issuer signed it
public DateTime? NotBeforeUtc { get; } // not valid before this (usually == IssuedAt)
public DateTime? ExpiryUtc { get; } // not valid after this (null = perpetual)
// ...
}
IssuedAtUtc es informativo: te dice cuán antigua es la clave. Las dos claims que condicionan la validez son NotBeforeUtc y ExpiryUtc, y la verificación es, en el fondo, dos comparaciones contra ahora:
// The naive gate. Correct on paper, fragile in the field.
var now = DateTime.UtcNow;
if (info.NotBeforeUtc is { } nbf && now < nbf) return Invalid("not yet valid");
if (info.ExpiryUtc is { } exp && now >= exp) return Invalid("expired");
El defecto es que now es la idea de «ahora» de esta máquina, y esta máquina no es la que estampó la licencia. Ambas comparaciones son exactas al milisegundo, así que cualquier desacuerdo entre los dos relojes cae justo en el límite y da la vuelta al resultado.
Por qué el reloj atrasado pierde, justo después de la compra
Las máquinas de consumo se atrasan con mucha más frecuencia de la que se adelantan: un portátil que ha estado suspendido, un escritorio cuya sincronización NTP lleva retraso, una VM restaurada desde una instantánea. Imagina al emisor a las 9:00:00 y el reloj del cliente a las 8:56:00, cuatro minutos atrás. La licencia se estampa con NotBefore = 09:00:00. El DateTime.UtcNow del cliente marca 08:56:00, que es menor que 09:00:00, así que now < nbf es verdadero y el gate dice aún no válida. La licencia ni siquiera empezará a funcionar hasta que el reloj del cliente pase arrastrándose de las 9:00: cuatro minutos de un cliente que paga mirando un rechazo.
La solución: un único mando de tolerancia, aplicado en ambos extremos
La cura es un pequeño margen —la tolerancia— que se resta en la comparación de NotBefore y se suma en la comparación de Expiry. Keyright lo expone como una única opción para que lo fijes una vez en lugar de esparcir números mágicos por tu gate:
var result = KeyrightClient.Validate(key, new ValidationOptions
{
PublicKey = AppKeys.LicensePublicKey,
ClockSkew = TimeSpan.FromMinutes(5), // default; tune per deployment
});
Internamente, las dos comparaciones pasan a ser:
var now = DateTime.UtcNow;
var skew = options.ClockSkew; // e.g. 5 minutes
// Accept a license that looks slightly early because our clock may be slow.
if (info.NotBeforeUtc is { } nbf && now < nbf - skew)
return Invalid("not yet valid");
// Allow a hair of grace so a license does not blink off exactly on the tick.
if (info.ExpiryUtc is { } exp && now >= exp + skew)
return Invalid("expired");
Con un ClockSkew de cinco minutos, el cliente cuyo reloj va cuatro minutos atrasado ahora evalúa now < nbf - skew → 08:56 < 08:55 → falso, así que la licencia se acepta. El atacante que necesitaría retrasar el reloj más de cinco minutos para mantener viva una licencia caducada solo gana esos cinco minutos, que son ruido frente a una suscripción anual.
La tolerancia es simétrica en valor, asimétrica en intención
El mismo valor skew cae en ambos extremos, pero está haciendo dos trabajos distintos, y conviene nombrarlos. En el extremo de NotBefore, la tolerancia perdona a un verificador cuyo reloj va por detrás del emisor: el caso habitual del «portátil atrasado» de arriba. En el extremo de Expiry, la tolerancia es una ventana de gracia de cortesía para que una licencia no se apague en el milisegundo exacto de la caducidad en un verificador cuyo reloj va ligeramente adelantado; sin ella, un cliente con un reloj adelantado pierde el acceso unos minutos antes de la caducidad que realmente le vendiste.
Mantén ambas pequeñas. Una tolerancia de Expiry grande es simplemente una extensión gratuita que no pretendías conceder, y una tolerancia de NotBefore grande amplía la ventana dentro de la cual podría situarse un reloj retrasado. Minutos, no horas. El valor por defecto de cinco minutos es el punto óptimo: cómodamente mayor que la deriva ordinaria, mucho menor que cualquier cosa por la que merezca la pena hacer trampas.
Medir duraciones: recurre al reloj monótono
Hay una segunda trampa, más sutil, una vez que aceptas que el reloj de pared no es del todo fiable. En cualquier sitio donde midas tú mismo el tiempo transcurrido —un periodo de gracia tras no poder contactar con el servidor de licencias, un TTL de caché sobre un resultado verificado, «cuánto hace desde la última validación exitosa»— no restes dos lecturas de DateTime.UtcNow. El reloj de pared puede saltar: NTP lo corrige, la máquina sale de la suspensión, un usuario lo edita. Restar dos lecturas a través de uno de esos saltos puede entregarte un intervalo negativo o uno inflado de forma absurda.
// Wrong: wall-clock subtraction can go backwards or jump.
var started = DateTime.UtcNow;
// ... later ...
var elapsed = DateTime.UtcNow - started; // may be negative after an NTP step
// Right: a monotonic clock only moves forward, immune to clock edits.
var sw = Stopwatch.StartNew();
// ... later ...
var elapsed = sw.Elapsed; // always sane
Stopwatch está respaldado por el temporizador monótono del sistema operativo; solo avanza, a ritmo constante, haga lo que haga el reloj de pared. Así que la división del trabajo queda limpia: usa el reloj de pared únicamente para comparar contra los instantes absolutos NotBefore y Expiry de la licencia —con tolerancia— y usa Stopwatch para toda duración que calcules dentro de una sesión en ejecución. El reloj del que no puedes fiarte del todo para «qué hora es» es exactamente el reloj que no debes usar para «cuánto tiempo ha pasado».
La conclusión
El desfase de reloj no es un ataque, es el estado normal del mundo: dos máquinas que discrepan en unos minutos sin ninguna mala intención. Una verificación que compara instantes al milisegundo rechazará licencias honestas y recién compradas en cuanto el reloj de un cliente vaya atrasado: el falso negativo más costoso posible. Dale a la comparación unos minutos de tolerancia en los extremos NotBefore y Expiry mediante un único mando ClockSkew, mantén esa tolerancia en minutos para que nunca se convierta en una extensión gratuita ni en un agujero de manipulación, y mide toda duración transcurrida con un Stopwatch monótono en lugar del reloj de pared del que has decidido desconfiar. Esa es la diferencia entre un gate de licencia que funciona en un laboratorio y uno que funciona en un portátil que acaba de despertar.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.