Versionar el payload de la licencia: añadir claims sin romper los clientes que ya están en producción
Una licencia firmada es un conjunto de claims congelados en bytes, y en el momento en que publicas un cliente que los lee, esa disposición de bytes se convierte en un contrato que no puedes cambiar unilateralmente. Entonces producto pide un claim nuevo —un tope de asientos, un bloqueo regional, una lista de funcionalidades— y descubres que la mitad de tu base instalada ejecuta un parser que nunca lo ha visto. Esta entrada recorre cómo evolucionan de forma segura los payloads de licencia al estilo Keyright: una versión de esquema cubierta por la firma, un parseo de mundo abierto que mantiene verificables los claims desconocidos, una lista de claims críticos para que un cliente antiguo rechace lo que no puede aplicar en lugar de ignorarlo en silencio, negociación de esquema en la activación para que el lado emisor nunca emita lo que un cliente no puede leer, y las pruebas con ficheros dorados que impiden que un cambio de formato llegue a producción sin que nadie lo note.
Una clave de licencia es un conjunto de claims que el servicio emisor firma y el cliente verifica: para quién es, qué producto, qué edición, cuándo caduca, a qué máquina está vinculada. La primera versión es fácil. Defines un record, lo serializas, firmas los bytes, publicas un cliente que comprueba la firma y lee los campos, y funciona.
El problema empieza el día en que necesitas un claim nuevo. Producto quiere un tope de asientos. Ventas quiere una restricción regional. Ingeniería quiere una lista de funcionalidades para que una sola clave desbloquee un subconjunto de módulos. Cada uno de ellos es un cambio en el payload, y el payload ya está siendo parseado por cada copia de tu software en producción, incluidas las que no se actualizarán en un año. Algunas de esas instalaciones leerán el payload nuevo e ignorarán en silencio el campo que no entienden; que eso sea correcto depende por completo de lo que signifique el campo. Ignora un nombre visible y no pasa nada. Ignora un tope de asientos y acabas de regalar asientos ilimitados a todo el que no haya actualizado.
Esta entrada trata de hacer esa evolución de forma deliberada: una versión cubierta por la firma, un parseo que tolera lo que no entiende sin debilitar la verificación, una manera de marcar los claims que un cliente antiguo no puede ignorar, negociación para que el servidor nunca emita un payload que un cliente no pueda leer, y pruebas que hacen que todo eso se mantenga a lo largo de las versiones.
El payload es un contrato que la firma congela
Empieza por la forma que ya tienes. Un payload de primera generación al estilo Keyright podría ser este record, serializado a JSON canónico y firmado como firma separada sobre esos bytes:
public sealed record LicensePayload
{
public required string Sub { get; init; } // customer id
public required string Product { get; init; } // product slug the key is issued for
public required string Edition { get; init; } // "pro", "team", ...
public required DateTimeOffset Exp { get; init; }
public string? MachineId { get; init; } // node-lock, optional
}
Lo importante de este record no son los campos; es que la codificación de esos campos ya está fijada. La firma cubre los bytes exactos que produjo el servicio emisor, así que la verificación en el cliente depende de reproducir —o, mejor, de no alterar nunca— esos bytes. Ya explicamos por qué importan el orden de campos y la codificación en la entrada sobre firma canónica; aquí la consecuencia es que el formato es un contrato entre dos partes que actualizan con calendarios completamente distintos. El servicio emisor se actualiza cuando lo despliegas tú. El cliente se actualiza cuando al cliente le apetece.
Por tanto, cada cambio al payload tiene que juzgarse con una sola pregunta: ¿qué hace el cliente más antiguo que aún soporto cuando ve esto? Solo hay tres respuestas honestas —lo lee correctamente, lo ignora inofensivamente, o lo ignora peligrosamente— y todo el diseño que sigue existe para asegurar que la tercera nunca ocurra en silencio.
Regla uno: la versión de esquema es un claim, y va firmada
Pon un entero schema en el payload desde el primer día, aunque sea 1 y nada lo lea. Como está dentro de los bytes firmados, nadie puede degradar un payload a una versión con semántica más débil sin romper la firma. Como es un entero simple, el cliente puede tomar decisiones sobre él antes de entender nada más.
public sealed record LicensePayload
{
public required int Schema { get; init; } // 1, 2, 3 ... — signed like every other claim
public required string Sub { get; init; }
public required string Product { get; init; }
public required string Edition { get; init; }
public required DateTimeOffset Exp { get; init; }
public string? MachineId { get; init; }
// Schema 2 additions:
public string? DisplayName { get; init; } // informational — safe to ignore
public int? SeatCap { get; init; } // restriction — NOT safe to ignore
public IReadOnlyList<string> Critical { get; init; } = []; // names of claims that must be understood
// Anything we do not have a property for lands here and is preserved.
[JsonExtensionData]
public Dictionary<string, JsonElement>? Unknown { get; init; }
}
El número Schema no es “la versión del cliente que puede leer esto”. Es “la versión del vocabulario en que se escribió este payload”. Un cliente de esquema 1 puede leer perfectamente un payload de esquema 2 siempre que los añadidos del esquema 2 sean cosas que tiene permitido ignorar, y eso es lo que deciden las dos reglas siguientes.
Regla dos: verifica los bytes, luego parsea en mundo abierto
El parser debe tolerar claims que no conoce. En System.Text.Json eso es el diccionario [JsonExtensionData] de arriba: cada propiedad que el record no declara se recoge en lugar de rechazarse. Esto es lectura de mundo abierto, y es lo que permite que un cliente antiguo acepte un payload que lleva claims informativos nuevos.
La trampa es combinar lectura de mundo abierto con verificación de mundo cerrado. Si el cliente deserializa el payload en su record, luego vuelve a serializar el record y verifica la firma sobre eso, un cliente antiguo que descartó (o reordenó) un campo desconocido produce bytes distintos y la firma falla en una licencia perfectamente válida. El orden de operaciones tiene que ser: verificar la firma sobre los bytes en bruto exactamente como se recibieron, y solo entonces entregar esos bytes al parser.
public static LicensePayload VerifyAndRead(ReadOnlySpan<byte> payloadBytes,
ReadOnlySpan<byte> signature,
RSA issuerPublicKey)
{
// 1. The signature covers the bytes on the wire. Verify before touching them.
if (!issuerPublicKey.VerifyData(payloadBytes, signature,
HashAlgorithmName.SHA256, RSASignaturePadding.Pss))
throw new LicenseException(LicenseFailure.BadSignature);
// 2. Only now parse — open-world, unknown claims retained in Unknown.
var payload = JsonSerializer.Deserialize<LicensePayload>(payloadBytes, PayloadJson.Options)
?? throw new LicenseException(LicenseFailure.Malformed);
// 3. Schema gate: refuse only what we cannot possibly interpret.
if (payload.Schema < 1 || payload.Schema > PayloadJson.MaxSchemaUnderstood + 1)
throw new LicenseException(LicenseFailure.UnsupportedSchema, payload.Schema);
return payload;
}
Fíjate en la puerta del paso 3. No dice “rechaza todo lo más nuevo que yo”. Dice “rechaza lo que ni siquiera puedo empezar a interpretar”: más de un esquema por delante, o un esquema que nunca existió. Un esquema por delante se deja pasar explícitamente, porque la regla de claims críticos está a punto de gestionarlo como es debido.
Regla tres: claims críticos — el cliente antiguo debe rechazar lo que no puede aplicar
El formato de certificados X.509 resolvió exactamente este problema hace décadas con la marca critical en las extensiones: una extensión que el verificador no reconoce puede ignorarse salvo que esté marcada como crítica, en cuyo caso el certificado debe rechazarse. El licenciamiento tiene los mismos dos tipos de claim, y la misma respuesta funciona.
Un claim informativo —un nombre visible, un contacto de soporte, una referencia de pedido— puede ser ignorado por un cliente que nunca ha oído hablar de él. Nada cambia en la aplicación de la licencia.
Un claim restrictivo —un tope de asientos, un bloqueo regional, un techo de tasa de API, un límite de cobertura “esta clave solo puede ejecutar versiones publicadas antes de esta fecha”— cambia lo que el cliente tiene permitido hacer. Un cliente que lo ignora no se limita a mostrar menos; concede de más. El cliente antiguo debe saber que algo que no entiende lo está restringiendo y fallar cerrado.
La lista Critical hace que eso sea cognoscible sin que el cliente antiguo entienda el claim en sí. El servicio emisor enumera los nombres de todos los claims restrictivos que puso en el payload; el cliente comprueba que reconoce cada nombre de esa lista, y rechaza la licencia —con una razón clara de “actualización necesaria”, no un fallo genérico— si alguno le es desconocido.
static readonly HashSet<string> KnownClaims = new(StringComparer.Ordinal)
{
// Every claim this build can *enforce*. Informational claims need not be listed —
// they are never critical — but listing them is harmless.
"sub", "product", "edition", "exp", "machineId",
"seatCap", // added in the schema-2 client
};
public static LicenseInfo Evaluate(LicensePayload p)
{
foreach (var name in p.Critical)
{
if (!KnownClaims.Contains(name))
return LicenseInfo.Free(LicenseFailure.UpdateRequired,
$"License requires a claim this version cannot enforce: {name}");
}
// From here on, enforcement is normal: every claim we know, we apply.
if (p.Exp < Clock.UtcNow) return LicenseInfo.Free(LicenseFailure.Expired);
if (p.SeatCap is int cap && Seats.InUse() > cap) return LicenseInfo.Free(LicenseFailure.SeatCapExceeded);
...
return LicenseInfo.Paid(p.Edition, p);
}
Es la misma postura de fallo cerrado que Validate() de Keyright adopta en todas partes —nunca lanza, devuelve una edición Free con una razón— aplicada al propio formato. El cliente antiguo no finge. Dice “hay una restricción en esta licencia que soy demasiado antiguo para respetar”, que es exactamente el mensaje que soporte quiere ver en el log.
Un detalle que conviene hacer bien: Critical tiene que estar a su vez en el vocabulario del esquema 1, o un cliente de esquema 1 no podrá verlo. Por eso la lista entra el primer día como array vacío. Si la estás añadiendo a posteriori a un formato que ya se publicó, tienes una generación de esquema en la que los únicos añadidos seguros son los informativos, y ese es un argumento de peso para añadir la lista ahora, antes de necesitarla.
Regla cuatro: nunca reutilices, nunca estreches
Dos cambios parecen versionado y en realidad son rotura.
Reutilizar un claim —mantener el nombre edition pero cambiar el conjunto de cadenas que puede contener, o mantener exp pero pasar de segundos a milisegundos— rompe todos los clientes antiguos de una forma que ninguna comprobación de versión detecta, porque el campo se parsea bien y significa otra cosa. Si cambia el significado de un claim, es un claim nuevo con un nombre nuevo, y el antiguo o bien se mantiene poblado durante un periodo de retirada o bien se elimina en un salto de esquema mayor que los clientes antiguos rechazarán en la puerta.
Estrechar un valor por defecto —un claim que en el esquema 1 era opcional y permisivo pasando a ser obligatorio o restrictivo en el esquema 2— es una restricción disfrazada. Si SeatCap ausente significaba “ilimitado” en el esquema 1 y quieres que ausente signifique “un asiento” en el esquema 2, los clientes antiguos concederán asientos ilimitados en cada clave de esquema 2 que omita el claim. Mantén el valor por defecto permisivo y haz la restricción explícita y crítica.
La regla que cubre ambos casos: dentro de una generación de esquema, los añadidos son el único cambio; el significado de un claim existente y el comportamiento ante su ausencia están congelados. Un salto mayor (esquema 2 → 3) es donde puedes romperlos, y la puerta de VerifyAndRead más la lista de críticos garantizan que los clientes antiguos rechacen en lugar de malinterpretar.
Negociación: el servidor no debería emitir lo que el cliente no puede leer
Todo lo anterior es defensivo: el cliente protegiéndose de un payload escrito en un vocabulario más nuevo. El complemento es que el lado emisor sepa qué puede leer cada cliente, lo que cuesta un entero en la solicitud de activación que ya envías.
public sealed record ActivationRequest
{
public required string Product { get; init; }
public required string LicenseKey { get; init; }
public required string MachineId { get; init; }
public required int MaxSchema { get; init; } // the highest schema this client parses
public required string ClientVersion { get; init; }
}
Con eso, el servicio tiene una elección real cuando una licencia necesita un claim para el que el cliente es demasiado antiguo:
- Expresarlo en el vocabulario antiguo cuando sea posible. Un tope de 1 asiento para un cliente de esquema 1 puede emitirse como clave node-locked de esquema 1, que ese cliente aplica de forma nativa.
- Emitir el esquema nuevo y dejar que la lista de críticos haga su trabajo. El cliente falla cerrado con “actualización necesaria”: correcto, pero deja al usuario con una instalación que no funciona hasta que actualice.
- Rechazar la activación con una razón precisa —“esta licencia requiere el cliente 3.2 o posterior”— para que el mensaje llegue al usuario en el momento en que puede actuar, no como un fallo críptico más tarde.
Cuál eliges es una decisión de producto; lo que importa es que el servidor decide con información en lugar de adivinar. El servicio también registra MaxSchema por activación, lo que responde a la pregunta que de otro modo paraliza cada cambio de formato: ¿cuántos de mis clientes siguen en el esquema 1? Cuando ese número llega a cero puedes retirar el emisor antiguo; hasta entonces sabes exactamente qué estás rompiendo.
LicensePayload BuildPayload(LicenseRecord lic, ActivationRequest req)
{
int target = Math.Min(req.MaxSchema, CurrentSchema);
var p = new LicensePayload
{
Schema = target, Sub = lic.CustomerId, Product = lic.Product,
Edition = lic.Edition, Exp = lic.ExpiresAt, MachineId = req.MachineId,
};
if (target >= 2)
{
p = p with { DisplayName = lic.CustomerDisplayName, SeatCap = lic.SeatCap,
Critical = lic.SeatCap is null ? [] : ["seatCap"] };
}
else if (lic.SeatCap is int cap && cap != 1)
{
// Cannot express a multi-seat cap in schema 1: refuse with a reason the UI can show.
throw new ActivationRefused(RefusalReason.ClientTooOld, minimumClient: "3.2.0");
}
return p;
}
Ficheros dorados: hacer que el contrato sea comprobable
Un contrato de formato que no está bajo prueba derivará. La solución es poco vistosa y muy eficaz: confirma payloads firmados reales en el repositorio y ejecútalos en ambos sentidos en CI.
Hacia delante: para cada esquema que hayas emitido alguna vez, mantén un directorio de dorados firmados —una clave válida, una caducada, una con cada claim opcional presente, una con un claim informativo desconocido inyectado, una con un claim crítico desconocido inyectado— y comprueba el veredicto del parser actual sobre cada uno. El dorado con crítico desconocido debe evaluarse como UpdateRequired; el de informativo desconocido debe evaluarse como Paid. La primera vez que alguien “limpie” el parser para que rechace campos desconocidos, esto falla.
Hacia atrás: referencia el paquete de cliente más antiguo que aún soportes como versión NuGet fijada en el proyecto de pruebas, no como código fuente. Construye payloads con el emisor del servidor actual en cada esquema que pueda producir, y pasa cada uno por VerifyAndRead y Evaluate del cliente antiguo. El cliente antiguo debe verificar todos (los bytes son canónicos; la firma se mantiene sea cual sea el vocabulario), debe aceptar los añadidos informativos, y debe rechazar exactamente los payloads que llevan un claim crítico que no conoce.
[Theory]
[MemberData(nameof(Goldens), "schema-1")]
[MemberData(nameof(Goldens), "schema-2")]
public void Current_parser_accepts_every_shipped_schema(GoldenCase g)
{
var payload = Licensing.VerifyAndRead(g.PayloadBytes, g.Signature, TestKeys.Issuer);
var verdict = Licensing.Evaluate(payload);
Assert.Equal(g.ExpectedFailure, verdict.Failure); // None, Expired, UpdateRequired, ...
}
[Fact]
public void Oldest_supported_client_fails_closed_on_critical_claims()
{
var bytes = Server.Emit(schema: 2, seatCap: 25); // current emitter
var v = LegacyClient.Validate(bytes.Payload, bytes.Signature); // pinned 3.1.x package
Assert.Equal(LegacyFailure.UpdateRequired, v.Failure);
}
Dos aserciones de esa suite soportan la mayor parte del peso: una firma que verificó ayer verifica hoy, y un cliente antiguo nunca concede de más. Todo lo demás sobre la evolución del formato es consecuencia de mantener esas dos en verde.
Lo que realmente publicas
La mecánica cabe en unas pocas líneas: un claim schema dentro de los bytes firmados; verificación de la firma sobre los bytes en bruto antes de parsear; un parser que conserva los claims desconocidos en lugar de rechazarlos; una lista critical para que un cliente antiguo rechace las restricciones que no puede aplicar e ignore la información que sí puede; un informe de capacidades de un entero en la activación para que el servidor emita lo que el cliente puede leer o rechace con una razón; y ficheros dorados firmados ejecutados hacia delante y hacia atrás en CI. Dentro de una generación de esquema solo añades, nunca reutilizas, nunca estrechas un valor por defecto. Con eso en su sitio, “necesitamos un claim nuevo” deja de ser un evento de actualización de toda la flota y se convierte en un cambio ordinario: los clientes que pueden leerlo lo hacen, los que no fallan cerrados con un mensaje que dice exactamente por qué, y la firma nunca deja de significar lo que significaba.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.