Desactivar una licencia y moverla a otro equipo: recibos, pruebas sin conexión y límites de transferencia
Liberar un puesto parece la mitad fácil del bloqueo por equipo: el cliente pulsa "desactivar este dispositivo" y el puesto vuelve a estar disponible. Pero una desactivación es una afirmación que el cliente hace sobre su propio estado, y el cliente es la única parte en la que no puedes confiar. En línea, el problema es el orden y los reintentos: liberar en el servidor antes o después de borrar localmente, y qué pasa cuando una de las dos cosas falla. Sin conexión, el problema es la prueba: un equipo sin red tiene que entregar al cliente algo que convenza al servidor de que un puesto se ha cedido de verdad. Y para los equipos muertos —el portátil que cayó al lago— el problema es el abuso: cada liberación forzada es una oportunidad para multiplicar puestos. Esta entrada lee el SDK y la política del servidor de Keyright para los tres casos: la liberación en línea idempotente, el recibo de desactivación con HMAC ligado al secreto de instancia de la activación, por qué el TTL del arrendamiento acota lo que puede ganar un cliente mentiroso, y el enfriamiento de transferencias y los límites móviles que impiden que "he perdido mi equipo" se convierta en un generador de puestos.
La activación se lleva toda la atención: huellas, puestos, arrendamientos firmados. La desactivación —devolver un puesto para que pueda usarse en otro sitio— se trata como la inversa trivial. No lo es, por una razón: una activación es algo que el servidor concede, pero una desactivación es algo que el cliente afirma. “He dejado de usar esta licencia en este equipo” es una declaración de la única parte del sistema en la que no puedes confiar, y el diseño de una desactivación segura va enteramente de cuánto de esa declaración puedes verificar y cuánto puedes acotar.
Hay tres casos, y necesitan tres respuestas distintas. El equipo está en línea y coopera: el problema es el orden y los reintentos. El equipo está sin conexión —aislado de la red, o un portátil de campo sin cobertura—: el problema es la prueba. El equipo ya no existe —robado, muerto, reinstalado sin desactivar—: el problema es el abuso, porque cada liberación forzada es una oportunidad de convertir una licencia en dos. Esta entrada recorre el SDK y la política del servidor de Keyright para cada uno.
En línea: libera en el servidor, luego borra, y hazlo repetible
El caso cooperativo sigue teniendo un error que la mayoría de las primeras implementaciones publican: el orden de los dos efectos secundarios. Una desactivación tiene que hacer dos cosas —liberar el puesto en el servidor y destruir la licencia en el cliente— y exactamente una de ellas puede fallar después de que la otra haya tenido éxito.
Borra primero, llama después al servidor, y una llamada fallida deja al cliente sin licencia en este equipo y con un puesto todavía consumido. Es el peor resultado: un puesto atascado, una aplicación sin licencia y un ticket. Llama primero al servidor, borra después, y un borrado fallido deja a la aplicación con un arrendamiento firmado en caché — pero ese arrendamiento tiene un TTL, el servidor ya ha liberado el puesto, y el siguiente latido devuelve un resultado de activación perdida. El equipo pasa a no licenciado por sí solo dentro de un periodo de arrendamiento. El fallo está acotado y se cura solo. Así que: primero el servidor.
public async Task<DeactivationOutcome> DeactivateAsync(CancellationToken ct)
{
var current = await _store.ReadAsync(ct);
if (current is null)
return DeactivationOutcome.NothingToRelease;
// The nonce is generated ONCE and persisted before the call, so a retry after
// a crash or timeout replays the same idempotency key instead of minting a new one.
var nonce = current.PendingDeactivationNonce ??= Guid.NewGuid().ToString("N");
await _store.WriteAsync(current, ct);
var result = await _client.FreeSeatAsync(new FreeSeatRequest(
LicenseId: current.LicenseId,
ActivationId: current.ActivationId,
InstanceSecret: current.InstanceSecret,
Nonce: nonce), ct); // POST /v1/free-seat
if (!result.Released && !result.AlreadyReleased)
return DeactivationOutcome.ServerRefused(result.Reason); // store is intact; nothing lost
await _store.WipeAsync(ct); // only after the server has freed the seat
_state.Clear(); // drop the in-memory lease; app is now unlicensed
return DeactivationOutcome.Released;
}
Dos detalles llevan el peso. El nonce se escribe en el almacén antes de que salga la petición, así que si el proceso muere a mitad de la llamada, el siguiente intento envía la misma clave (ActivationId, Nonce). Y el servidor trata esa clave como idempotente: una segunda petición con la misma clave devuelve el resultado de la primera.
public async Task<FreeSeatResult> FreeSeatAsync(FreeSeatRequest req, CancellationToken ct)
{
await using var _ = await _store.LockActivationAsync(req.ActivationId, ct);
// Replay: same activation + same nonce → return the recorded outcome, do not error.
if (await _store.FindReleaseAsync(req.ActivationId, req.Nonce, ct) is { } prior)
return FreeSeatResult.AlreadyReleased(prior.ReleasedAt);
var activation = await _store.FindActivationAsync(req.ActivationId, ct);
if (activation is null || !activation.SecretMatches(req.InstanceSecret))
return FreeSeatResult.Refused("unknown activation or bad instance secret");
activation.Release(ReleaseReason.SelfOnline, _clock.GetUtcNow());
await _store.RecordReleaseAsync(req.ActivationId, req.Nonce, activation.ReleasedAt!.Value, ct);
await _store.UpsertActivationAsync(activation, ct);
return FreeSeatResult.Released(activation.ReleasedAt.Value);
}
Fíjate en qué se verifica: el InstanceSecret. Una petición de desactivación no se autentica con la clave de licencia —cualquiera que tuviera la clave podría, si no, liberar un puesto que no posee—, sino con el secreto opaco que el servidor entregó a esta activación cuando se creó. Ese mismo secreto es lo que hace posible el caso sin conexión.
Sin conexión: un recibo que el servidor puede comprobar sin hablar con el cliente
Un equipo aislado no puede llamar a /v1/free-seat. Lo que sí puede hacer es producir algo que el cliente lleve al portal —una cadena corta— que convenza al servidor de que el puesto se ha cedido. La pregunta es qué puede demostrar honestamente esa cadena.
No puede demostrar que el borrado ocurrió. El cliente es código no confiable en un equipo que no controlas; un cliente modificado podría emitir el recibo y conservar los archivos de licencia. Así que no diseñes alrededor de una “prueba de borrado”. Diseña alrededor de una prueba de rendición: el recibo demuestra que la instancia activada —la única poseedora del secreto de instancia— eligió liberar. Todo lo demás se deriva de lo que el servidor haga con eso.
public sealed record DeactivationReceipt(
string ActivationId,
string LicenseId,
string Fingerprint,
DateTimeOffset IssuedAt,
string Nonce,
string Mac); // HMAC-SHA256 over the canonical fields, keyed by InstanceSecret
public async Task<string> DeactivateOfflineAsync(CancellationToken ct)
{
var current = await _store.ReadAsync(ct) ?? throw new InvalidOperationException("not activated");
var body = new DeactivationReceipt(
current.ActivationId, current.LicenseId, ComputeFingerprint(),
_clock.GetUtcNow(), Guid.NewGuid().ToString("N"), Mac: "");
var canonical = $"{body.ActivationId}|{body.LicenseId}|{body.Fingerprint}|{body.IssuedAt:O}|{body.Nonce}";
using var hmac = new HMACSHA256(Convert.FromBase64String(current.InstanceSecret));
var mac = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical)));
// Wipe BEFORE the receipt is ever displayed. If the wipe throws, no receipt exists,
// the machine is still licensed, and the customer can try again.
await _store.WipeAsync(ct);
_state.Clear();
return Base64Url.Encode(JsonSerializer.SerializeToUtf8Bytes(body with { Mac = mac }));
}
El orden está invertido respecto a la vía en línea, deliberadamente. En línea, el servidor actúa primero porque el servidor es la fuente de verdad y el borrado local es limpieza. Sin conexión, nada puede actuar sobre el servidor hasta que el cliente llegue a un navegador, así que el borrado local va primero y el recibo es el producto de un borrado con éxito: el usuario nunca ve un recibo de una licencia que sigue en disco. Si el borrado falla, no hay recibo y no hay daño.
El servidor verifica el recibo con el secreto de instancia que almacenó en la activación:
public async Task<ReceiptResult> RedeemReceiptAsync(string encoded, CancellationToken ct)
{
if (JsonSerializer.Deserialize<DeactivationReceipt>(Base64Url.Decode(encoded)) is not { } r)
return ReceiptResult.Invalid("malformed");
await using var _ = await _store.LockActivationAsync(r.ActivationId, ct);
var activation = await _store.FindActivationAsync(r.ActivationId, ct);
if (activation is null || activation.LicenseId != r.LicenseId)
return ReceiptResult.Invalid("unknown activation");
var canonical = $"{r.ActivationId}|{r.LicenseId}|{r.Fingerprint}|{r.IssuedAt:O}|{r.Nonce}";
using var hmac = new HMACSHA256(activation.InstanceSecretBytes);
var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical));
if (!CryptographicOperations.FixedTimeEquals(expected, Convert.FromBase64String(r.Mac)))
return ReceiptResult.Invalid("bad mac");
if (await _store.NonceSeenAsync(r.ActivationId, r.Nonce, ct))
return ReceiptResult.AlreadyRedeemed(activation.ReleasedAt);
if (_clock.GetUtcNow() - r.IssuedAt > _policy.ReceiptMaxAge) // e.g. 30 days
return ReceiptResult.Invalid("expired");
activation.Release(ReleaseReason.OfflineReceipt, _clock.GetUtcNow());
await _store.RememberNonceAsync(r.ActivationId, r.Nonce, ct);
await _store.UpsertActivationAsync(activation, ct);
return ReceiptResult.Released;
}
Ahora, la contabilidad honesta. Supón que el cliente mintió: emitió un recibo válido y conservó sus archivos de licencia. ¿Qué ganó? El servidor ha marcado la activación como liberada. El arrendamiento firmado en caché del equipo sigue siendo criptográficamente válido, pero lleva una caducidad, y el equipo no tiene forma de renovarlo: está sin conexión, y aunque volviera a estarlo, un latido contra una activación liberada devuelve activación perdida. Así que el tiempo robado está acotado por arriba por un TTL de arrendamiento: la vida restante del arrendamiento en el momento del recibo. Para un despliegue aislado en el que emites arrendamientos sin conexión de 30 días, ese es el precio de la desactivación sin conexión, y lo fijas cuando fijas el TTL. No hay diseño que lo reduzca a cero sin red, y cualquier producto que afirme lo contrario está confiando en el cliente.
El recibo contiene deliberadamente la huella. No hace falta para la verificación —el HMAC ya liga la activación—, pero permite al portal mostrar al cliente qué equipo está liberando, y permite a tu personal de soporte detectar un recibo canjeado para un equipo que no coincide con el registro de activación.
Equipos muertos: la vía forzada, y por qué tiene una ventana de enfriamiento
El tercer caso no tiene cliente en absoluto. El portátil fue robado; la VM se borró; el disco lo reinstaló alguien que no sabía que la licencia estaba ahí. El cliente necesita recuperar el puesto y no hay nada a lo que llamar, nada que firmar. La única opción es una liberación forzada: un administrador en tu panel, o el cliente en el portal, declara el equipo perdido.
Esta es la vía que convierte el licenciamiento en un generador de puestos si no tienes cuidado. “Mi equipo murió” es gratis de decir, y un cliente que lo dice una vez por semana tiene, en la práctica, una licencia ilimitada. Dos mecanismos lo mantienen honesto, y ninguno depende de creerse la afirmación.
El primero es la ventana de enfriamiento. El equipo muerto puede no estar muerto. Puede ser un equipo que simplemente está sin conexión, funcionando con un arrendamiento en caché que el servidor ya emitió — un arrendamiento válido hasta su caducidad independientemente de lo que el servidor piense ahora. Si la liberación forzada liberara el puesto de inmediato, el cliente podría activar un equipo nuevo mientras el “muerto” sigue funcionando con su caché, y el puesto estaría vivo en dos sitios. Así que una liberación forzada no libera el puesto; lo pasa a enfriamiento, y el puesto solo queda disponible cuando ha transcurrido la vida máxima restante del arrendamiento.
public ForcedReleaseResult ForceRelease(Activation activation, ReleaseReason reason, DateTimeOffset now)
{
// The last lease we issued to this activation is the longest anything on the
// "dead" machine can still be running on. The seat is not free until it is gone.
var coolingUntil = activation.LastIssuedLease.ExpiresAt;
activation.Release(reason, now);
activation.SeatAvailableAt = coolingUntil > now ? coolingUntil : now;
return new ForcedReleaseResult(activation.SeatAvailableAt);
}
Para una aplicación de escritorio que late cada hora con un arrendamiento de 24 horas, el enfriamiento es como mucho un día. Para un arrendamiento aislado de 30 días es de hasta 30 días, que es la respuesta correcta: esa licencia puede estar realmente todavía en uso. Si el equipo está muerto de verdad, el cliente espera; si no lo está, la ventana de enfriamiento es lo que mantiene fiel tu recuento de puestos. Tu portal debería mostrar la cuenta atrás para que el cliente no se quede adivinando.
El segundo mecanismo es el límite móvil de transferencias. Las liberaciones forzadas se cuentan; las de autoservicio y las de recibo no — porque esas dos vías detuvieron de verdad la instancia antigua, mientras que la vía forzada solo lo afirmó.
public sealed record TransferPolicy(int MaxForcedReleases, TimeSpan Window); // e.g. 3 per 30 days
public bool CanForceRelease(License license, TransferPolicy policy, DateTimeOffset now)
{
var recent = license.Releases.Count(r =>
r.Reason is ReleaseReason.AdminForced or ReleaseReason.CustomerForced &&
r.ReleasedAt > now - policy.Window);
return recent < policy.MaxForcedReleases;
}
Tres cada treinta días es generoso para los clientes honestos —el hardware falla, pero no semanalmente— y ruinoso para cualquiera que intente montar una granja de puestos a través del botón de “equipo perdido”. Cuando se alcanza el límite el portal lo dice, y el cliente habla contigo, que es exactamente la conversación que quieres tener en ese punto. Keyright te deja fijar la ventana y el recuento por producto, porque una flota de dispositivos de campo robustos muere legítimamente más a menudo que una mesa llena de estaciones de trabajo.
Cada liberación es un asiento en el libro
Las tres vías acaban en el mismo sitio: activation.Release(reason, now). El motivo no es decoración. Es lo que hace legible la historia de una licencia seis meses después, cuando un cliente con una licencia de 5 puestos ha activado de algún modo cuarenta equipos y necesitas saber si fueron cuarenta sustituciones honestas u otra cosa.
public enum ReleaseReason
{
SelfOnline, // client called /v1/free-seat with its instance secret — seat really stopped
OfflineReceipt, // HMAC receipt redeemed — activated instance surrendered; bounded by TTL
CustomerForced, // customer declared the machine lost in the portal — counts toward limit
AdminForced, // you did it from the dashboard — counts toward limit, with a note
Reaped // lease expired with no heartbeat; the seat aged out on its own
}
La vista de activaciones por licencia del panel muestra estos motivos junto a los metadatos del dispositivo, así que la historia honesta —un equipo sustituido, un recibo canjeado desde el campo, una liberación forzada en marzo— se lee de un vistazo, y la deshonesta —nada más que liberaciones forzadas, cada nueve días— se lee igual de claro.
Qué no hacer
No liberes el puesto desde un desinstalador. Los desinstaladores se ejecutan sin red, sin avisos de consentimiento que controles, y a menudo como parte de una reinstalación que el cliente no pretendía que fuera una desactivación. Un puesto liberado por un gancho de desinstalación es un puesto liberado por accidente, y una reinstalación consume entonces un segundo puesto al reactivar. Haz de la desactivación una acción explícita con un resultado explícito.
No aceptes “lo he borrado” sin el secreto de instancia. Una petición de desactivación autenticada solo con la clave de licencia permite a cualquiera que haya visto la clave —una clave compartida, una clave filtrada, un antiguo empleado— liberar puestos que no posee. El secreto de instancia es lo único que solo tiene el cliente activado, y es la única credencial que una liberación debería aceptar.
Y no dejes que la vía forzada sea gratis. La ventana de enfriamiento y el límite móvil no están ahí porque desconfíes de tus clientes; están ahí porque sin ellos el recuento de puestos es una opinión. Con ellos, un puesto está libre cuando el servidor puede demostrar que nada se ejecuta sobre él — y esa es la diferencia entre un bloqueo por equipo que cuenta y uno que meramente espera.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.