Firma canónica de payloads de licencia: por qué el orden de los campos y la codificación deciden si tu firma verifica
Una firma se calcula sobre bytes, no sobre campos — así que si tu servicio emisor y tu cliente serializan el payload de la licencia aunque sea de forma ligeramente distinta, la verificación falla en una licencia perfectamente genuina. Así se canonicaliza un payload una sola vez (orden de campos estable, formato invariante de números y fechas, UTF-8, base64url) para que una misma firma verifique de forma idéntica en .NET, Node, Python y Java.
Las firmas son la base del licenciamiento sin conexión — un cliente confía en una licencia porque puede verificar, con una clave pública embebida, que tu servicio emisor la firmó y que nada ha cambiado desde entonces. Pero esa confianza descansa en un detalle fácil de equivocar y brutal de depurar: una firma no se calcula sobre tus campos, se calcula sobre bytes. El verificador vuelve a derivar bytes a partir de los claims que recibió y pregunta si la firma coincide con esos bytes exactos. Si los bytes del emisor y los del verificador difieren en un solo espacio, una clave reordenada o un número con formato distinto, la verificación falla — en una licencia completamente genuina. Mete dos lenguajes de por medio, cada uno con su serializador y su locale, y «la firma no verifica» se convierte en un misterio entre equipos.
La cura es la canonicalización: definir una única representación en bytes determinista del payload que cada participante — tu servicio emisor en .NET y tus clientes en .NET, Node, Python y Java — reconstruya de forma idéntica. Este artículo trata de cómo hacerlo con precisión, y de las formas concretas y silenciosas en que se rompe entre lenguajes.
Una firma firma bytes, no significado
Empieza por lo que las primitivas hacen en realidad. ECDSA.SignData(bytes) o RSA.SignData(bytes) toman un array de bytes, calculan su hash y firman el hash. VerifyData(bytes, signature) calcula el hash de los bytes que recibe y los coteja con la firma. Ninguna función sabe nada de JSON, campos ni intención. Dos payloads que un humano llamaría «la misma licencia» son el mismo solo si se serializan a los mismos bytes.
Así que el verdadero contrato entre emisor y cliente no es «estos campos» — es «estos bytes, producidos de esta forma exacta». La canonicalización es la especificación escrita de esa producción. Como señala la anatomía de una clave de licencia firmada, verificas la firma primero y confías en los claims después; la canonicalización es lo que hace que esa verificación sea reproducible en primer lugar.
La forma canónica, campo a campo
Un payload canónico es una función que va de los claims a una única cadena de bytes, con cada grado de libertad fijado. Esta es una forma que viaja bien entre lenguajes:
- El orden de los campos es fijo. O bien ordena las claves lexicográficamente por sus bytes UTF-8, o define una lista ordenada y explícita de campos en la especificación. Ambas valen; la única respuesta incorrecta es «el orden que el serializador emitió por casualidad». La mayoría de las librerías JSON no garantizan el orden, y algunas reordenan al ida y vuelta.
- Los números usan cultura invariante, sin separadores de miles, sin
+inicial.5, no5.0, no5,00. Los enteros siguen siendo enteros. Si debes llevar decimales, fija la precisión y el punto decimal (.) en la especificación — nunca dejes que un locale alemán emita1,50donde uno inglés emite1.50. - Los booleanos son
true/falseen minúscula. NoTrue(elstr(bool)por defecto de Python), no1/0. - Las fechas son ISO-8601 en UTC con una
Zfinal, a una precisión fija. Decide de una vez si incluyes milisegundos, e incluye siempre el offset.2027-01-15T00:00:00Z— nunca hora local, nunca zona implícita. - Las cadenas son UTF-8, y eliges una forma de normalización Unicode (NFC es la elección habitual) para que los caracteres acentuados se comparen byte a byte entre plataformas.
- Sin espacios en blanco incidentales entre tokens, y sin salto de línea final.
- La firma es base64url sin relleno, usando el alfabeto
-/_, para que sea seguro embeberla en ficheros y URLs y se decodifique igual en todas partes.
La cuestión no es que estas elecciones concretas sean las únicas correctas — es que alguna elección debe quedar por escrito y ser obedecida por cada SDK. La forma canónica es un protocolo, no un detalle de implementación.
Construir y firmar los bytes canónicos en C#
Este es un ejemplo del lado emisor que construye una cadena canónica con una lista fija de campos y la firma con ECDSA sobre la curva NIST P-256. Los claims se escriben en un orden determinista, los números y fechas se formatean con CultureInfo.InvariantCulture, y el resultado se codifica como UTF-8 antes de firmar.
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
static string Canonicalize(License lic)
{
// Orden de campos fijo, formato invariante, separadores '\n'. Esta cadena
// exacta es el contrato — cada SDK debe reconstruirla byte a byte.
var sb = new StringBuilder();
sb.Append("product=").Append(lic.Product).Append('\n');
sb.Append("licensee=").Append(lic.Licensee).Append('\n');
sb.Append("tier=").Append(lic.Tier).Append('\n');
sb.Append("seats=").Append(lic.Seats.ToString(CultureInfo.InvariantCulture)).Append('\n');
sb.Append("issuedUtc=").Append(lic.IssuedUtc.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)).Append('\n');
// Features: ordenadas y unidas por comas, para que el orden de la lista origen nunca importe.
sb.Append("features=").Append(string.Join(",", lic.Features.OrderBy(f => f, StringComparer.Ordinal)));
return sb.ToString();
}
static string Base64Url(byte[] data) =>
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
// --- lado emisor (la clave privada se queda aquí) ---
byte[] canonical = Encoding.UTF8.GetBytes(Canonicalize(license));
byte[] signature = ecdsaPrivate.SignData(canonical, HashAlgorithmName.SHA256);
string sigField = Base64Url(signature);
Dos detalles cargan con el peso. ToString(CultureInfo.InvariantCulture) es lo que impide que una máquina configurada con locale de coma decimal serialice seats o cualquier número de forma distinta a una máquina configurada con punto decimal. Y ordenar features antes de unir significa que el cliente puede enviar su lista de features en cualquier orden y aun así reconstruir la línea canónica idéntica. Los separadores (\n entre líneas, , entre features, = entre clave y valor) están fijados por la especificación — el cliente debe usar los mismos.
Verificar solo con la clave pública
El cliente solo tiene la clave pública. Parsea los claims recibidos, reconstruye los bytes canónicos con la misma función, decodifica la firma desde base64url y verifica. Nunca vuelve a firmar nada, y no confía en ningún campo hasta que la firma cuadra.
static byte[] FromBase64Url(string s)
{
s = s.Replace('-', '+').Replace('_', '/');
s = (s.Length % 4) switch { 2 => s + "==", 3 => s + "=", _ => s };
return Convert.FromBase64String(s);
}
// --- lado cliente (solo la clave pública está embebida) ---
byte[] canonical = Encoding.UTF8.GetBytes(Canonicalize(received)); // la MISMA función
byte[] signature = FromBase64Url(received.Signature);
bool ok = ecdsaPublic.VerifyData(canonical, signature, HashAlgorithmName.SHA256);
if (!ok)
return LicenseInfo.Invalid("la firma no verificó"); // fallo cerrado
// solo ahora product, tier, seats, features son fiables
Fíjate en que el helper FromBase64Url vuelve a añadir el relleno antes de decodificar, porque el Convert.FromBase64String de .NET lo exige. Un decodificador de Node o Python podría aceptar la forma sin relleno directamente — que es exactamente el tipo de diferencia entre lenguajes que hay que zanjar en la especificación en lugar de descubrir en producción. (Para ver dónde encaja este paso de verificación en un flujo completo sin conexión — node-locking, revocación, fallo cerrado — lee la validación de licencias sin conexión explicada.)
Un desajuste concreto entre lenguajes
Aquí está el fallo en carne y hueso. Supón que el servicio emisor firma la cadena canónica de arriba, y un cliente Node reimplementa Canonicalize. Un desarrollador, queriendo que la interfaz muestre una insignia de prueba, añade una línea a la versión de la función del cliente:
// Cliente Node — MAL: añade un campo que el emisor nunca firmó
function canonicalize(lic) {
let s = "";
s += "product=" + lic.product + "\n";
s += "licensee=" + lic.licensee + "\n";
s += "tier=" + lic.tier + "\n";
s += "seats=" + lic.seats + "\n";
s += "issuedUtc=" + lic.issuedUtc + "\n";
s += "features=" + lic.features.slice().sort().join(",");
s += "\nisTrial=" + lic.isTrial; // <-- línea extra, no está en la forma firmada
return s;
}
El emisor firmó seis campos. El cliente calcula el hash de siete. Las cadenas de bytes difieren en el \nisTrial=false final, así que verify devuelve false en todas las licencias que ve este cliente — incluidas las genuinas. Nada parece mal: los claims son todos correctos, la clave es correcta, el algoritmo coincide. El único defecto es que un lado canonicalizó una cadena de bytes distinta.
La solución no es hacer el cliente «tolerante». Es acordar que la forma canónica son exactamente los seis campos que el emisor firma, y que isTrial — si acaso es parte de la licencia — es uno de esos campos firmados en ambos lados, formateado de forma idéntica, en su posición definida. Una bandera que influye en el comportamiento pero no está en el payload firmado es o bien datos sin firmar en los que no debes confiar, o un campo que olvidaste canonicalizar. No hay tercera opción. La misma disciplina acaba con las variantes más sutiles de este fallo: un cliente que une features con , en lugar de ,, uno que emite la fecha sin la Z, uno que deja que JSON.stringify reordene las claves, uno que deja un salto de línea final que el emisor recortó.
Las trampas, con nombre
Cada una de estas cambia los bytes sin cambiar el significado aparente, que es por lo que son tan difíciles de detectar:
- Añadir banderas sin firmar. El ejemplo trabajado de arriba. Un marcador, un interruptor, una pista de interfaz — si un lado lo mete en la cadena canónica y el otro no, nada verifica.
- Espacios y saltos de línea al final. Un serializador que embellece, un editor que añade un salto de línea final, un
WriteLineen lugar de unWrite. Recorta deliberadamente y especifica el resultado. - Normalización Unicode. Un nombre de licenciatario con un carácter acentuado puede almacenarse compuesto (NFC) en un sistema y descompuesto (NFD) en otro; los dos son visualmente idénticos y distintos en bytes. Normaliza a una forma antes de codificar.
- Locale de números y fechas. Coma vs punto decimal, miles agrupados, fechas con o sin milisegundos u offset. Fija la cultura invariante y un único formato de fecha.
- base64 vs base64url, con o sin relleno. Los alfabetos
+//vs-/_y la regla de relleno=deben coincidir en ambos extremos, o los bytes de la firma se decodifican mal. - Endianness en codificaciones binarias. Si alguna vez canonicalizas a una disposición binaria en lugar de texto — empaquetando enteros o longitudes como bytes crudos — big-endian en una plataforma y little-endian en otra discreparán en silencio. Fija el orden de bytes (big-endian/de red es la elección convencional) en la especificación. Las formas canónicas de texto evitan esto por completo, que es una razón para preferirlas.
- Reordenamiento de claves del serializador. Muchas librerías JSON no preservan el orden de inserción y algunas reordenan al parsear/reemitir. Nunca dependas del orden emitido; ordena o usa una lista explícita de campos.
Una forma canónica, por escrito
El hilo conductor es simple: la firma es una promesa sobre bytes, así que el emisor y cada cliente deben fabricar esos bytes con la misma receta. La canonicalización es esa receta — orden de campos fijo, números invariantes, fechas ISO-8601 en UTC, UTF-8, una forma Unicode elegida, base64url para la firma — escrita en el protocolo e implementada de forma idéntica en .NET, Node, Python y Java. Los SDK de Keyright comparten exactamente una de estas definiciones canónicas para que una licencia firmada por el servicio emisor verifique byte a byte en cada lenguaje cliente, y la prueba que importa no es «¿verifica en .NET?» sino «¿la misma licencia, parseada y reconstruida en cada SDK, produce los bytes canónicos idénticos?». Cuando lo hace, la verificación entre lenguajes es aburrida — que es exactamente lo que quieres de la criptografía. Cuando no, el fallo casi nunca está en las matemáticas; está en los bytes, y la receta es donde lo encuentras.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.