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

Verificar una licencia de Keyright en Native AOT: sin reflexión, una ruta de verificación recortable y la clave pública como ReadOnlySpan<byte>

Native AOT y el recorte (trimming) completo premian lo único que una comprobación de licencia ya debería ser: una función pura sobre bytes. Pero las comodidades que hacen agradable un SDK en el JIT (un serializador JSON basado en reflexión, cargar una clave por nombre, obtener el algoritmo de firma a partir de una cadena) son exactamente lo que el recortador elimina y aquello para lo que el compilador AOT se niega a generar código. Este artículo toma una verificación sin conexión de Keyright y la deja limpia para AOT de principio a fin: incrusta la clave pública como un ReadOnlySpan<byte> que compila a un puntero a la sección de datos sin asignación alguna, la importa con ImportSubjectPublicKeyInfo y verifica con ECDsa.VerifyData (ambos seguros frente al recorte) y lee la carga firmada con un Utf8JsonReader en lugar de un serializador por reflexión, de modo que toda la ruta no lleva ningún RequiresDynamicCode ni RequiresUnreferencedCode y produce cero advertencias de recorte. Lee el IL de la optimización de span sobre datos estáticos, muestra la única llamada a JsonSerializer que habría envenenado la publicación y explica por qué un verificador de licencias es la parte más fácil de su aplicación para llevar a AOT.

Native AOT le hace a su código una pregunta directa: ¿puede decidirse en tiempo de compilación cada tipo que toca y cada método que llama? Para la mayor parte de una aplicación, la respuesta honesta es «no sin trabajo»: hay reflexión en alguna parte, un serializador recorriendo un grafo abierto, un proveedor resuelto a partir de una cadena. Un verificador de licencias es la feliz excepción. Bien hecho, es una función pura sobre bytes, y los bytes son lo único que AOT y el recorte (trimming) manejan a la perfección.

Hecho mal (con las comodidades que parecían gratuitas en el JIT) es también lo que convierte un dotnet publish limpio en un muro de advertencias IL2026/IL3050 y en una NotSupportedException en tiempo de ejecución la primera vez que un cliente ejecuta la compilación recortada. Este artículo toma una verificación sin conexión de Keyright y la deja limpia para AOT, leyendo el IL donde importa.

La forma de una verificación, y la forma del problema

Una licencia sin conexión de Keyright es una cadena firmada: una carga (payload) de claims (producto, nivel, caducidad, derechos) y una firma sobre los bytes canónicos de esa carga, comprobada contra una clave pública que la aplicación lleva consigo. (La anatomía de esa clave y por qué se incrusta en lugar de descargarse está en fijar la clave pública de verificación.) La verificación tiene tres pasos: obtener la clave, comprobar la firma, leer los claims. Cada paso tiene un valor predeterminado de la era JIT que AOT rechaza.

Tres pasos; el del medio siempre estuvo bien1 · obtener la clave2 · comprobar la firma3 · leer los claimsGetManifestResourceStream("keys/pub.der")no se enraíza nada → vacíoECDsa.VerifyData(msg, sig,SHA256, IeeeP1363)sin anotaciones — seguro en AOTJsonSerializer.Deserialize<Claims>(json)IL2026 + IL3050reescribir los pasos 1 y 3; conservar el 2static ReadOnlySpan<byte>PublicKey => new byte[]{…};span sobre datos, sin asignaciónImportSubjectPublicKeyInfo(PublicKey)misma llamada en JIT y AOTUtf8JsonReadersobre el span de la cargacero reflexiónEl verificador nunca asigna memoria para la clave, nunca deserializa por tipo y no lleva ninguna anotación insegura para el recorte en su superficie pública.

La clave como ReadOnlySpan<byte> sobre la sección de datos

El primer instinto en el JIT es cargar la clave pública desde un recurso incrustado por nombre. Esa es una llamada por reflexión que el recortador no puede seguir: el nombre es una cadena, nada enraíza el recurso y, en una compilación recortada, el flujo vuelve vacío. La clave son datos constantes; incrústela como datos constantes.

internal static class KeyringPublicKey
{
    // SubjectPublicKeyInfo DER for the P-256 verification key.
    internal static ReadOnlySpan<byte> P256 => new byte[]
    {
        0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01,
        0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00,
        // …remaining 65 bytes of the uncompressed point…
    };
}

Un new byte[] { … } normalmente asignaría un array cada vez que se ejecuta la propiedad. Pero cuando el resultado se devuelve como ReadOnlySpan<byte> y el inicializador consta solo de constantes, Roslyn aplica la optimización de span sobre datos estáticos: escribe los bytes en la sección de datos del ensamblado como un campo RVA y devuelve un span construido directamente sobre esa memoria. Esto es a lo que realmente compila el cuerpo de la propiedad:

IL_0000: ldsflda    valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=91'
                     '<PrivateImplementationDetails>'::'2C9A…'    // &(data-section bytes)
IL_0005: ldc.i4.s   91
IL_0007: newobj     instance void valuetype [System.Runtime]System.ReadOnlySpan`1<uint8>::.ctor(void*, int32)
IL_000c: ret

Sin newarr, sin stelem elemento a elemento, nada en el montón del GC. ldsflda toma la dirección de un campo generado por el compilador cuyo valor inicial es la clave, ldc.i4.s 91 es la longitud, y el constructor ReadOnlySpan<byte>(void*, int) envuelve ambos. Cada llamada a KeyringPublicKey.P256 es un puntero y una longitud. (Un literal UTF-8 "…"u8 compila a la misma forma y es la escritura más pulcra cuando los bytes son texto; para DER en bruto, la forma de array de bytes es más clara.) Para el recortador, este es el portador ideal: un span sobre un campo de <PrivateImplementationDetails> no tiene ningún nombre público que pueda dejar de enraizarse, y los datos los referencia el único método que los devuelve.

Verificar sin un serializador por reflexión

La verificación de la firma en sí nunca fue el problema. ECDsa.Create(), ImportSubjectPublicKeyInfo y VerifyData no llevan [RequiresUnreferencedCode] ni [RequiresDynamicCode], y bajo Native AOT se enlazan directamente con las bibliotecas criptográficas de la plataforma:

public static bool TryVerify(ReadOnlySpan<byte> licenseBytes, out LicenseClaims claims)
{
    claims = default;

    // 1. split "payload.signature" on the single '.' — both base64url
    int dot = licenseBytes.IndexOf((byte)'.');
    if (dot < 0) return false;
    Span<byte> payload = new byte[Base64Url.DecodedLength(licenseBytes[..dot])];
    Span<byte> sig = new byte[Base64Url.DecodedLength(licenseBytes[(dot + 1)..])];
    if (!Base64Url.TryDecode(licenseBytes[..dot], payload, out int pLen)) return false;
    if (!Base64Url.TryDecode(licenseBytes[(dot + 1)..], sig, out int sLen)) return false;

    // 2. verify the signature over the canonical payload bytes
    using var ecdsa = ECDsa.Create();
    ecdsa.ImportSubjectPublicKeyInfo(KeyringPublicKey.P256, out _);
    bool ok = ecdsa.VerifyData(
        payload[..pLen], sig[..sLen],
        HashAlgorithmName.SHA256,
        DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
    if (!ok) return false;

    // 3. read the claims from the verified payload — by hand
    return LicenseClaims.TryParse(payload[..pLen], out claims);
}

Dos decisiones deliberadas. El formato de firma es IeeeP1363FixedFieldConcatenation (la codificación r || s de tamaño fijo que mantiene compacta una clave de licencia) y debe coincidir con cómo la firmó Keyright; pasar aquí Rfc3279DerSequence haría fallar cada verificación en silencio, devolviendo false en lugar de lanzar una excepción, que es el error autoinfligido más común en la migración a AOT de un verificador. Y los claims se leen solo después de que la firma haya pasado la comprobación, de modo que el analizador solo ve bytes que usted firmó.

Ese analizador es el otro lugar donde el valor predeterminado del JIT habría envenenado la publicación. La línea tentadora es JsonSerializer.Deserialize<LicenseClaims>(payload). Su sobrecarga por reflexión está anotada con [RequiresUnreferencedCode] y [RequiresDynamicCode]: bajo recorte advierte IL2026, y bajo AOT advierte IL3050 y lanza NotSupportedException en tiempo de ejecución porque no existe ningún generador de código en tiempo de ejecución que construya el convertidor. Hay dos salidas limpias para AOT. La primera es el generador de código fuente de System.Text.Json: declarar un JsonSerializerContext y deserializar contra su JsonTypeInfo, lo que traslada toda la reflexión al tiempo de compilación. La segunda, para una carga tan pequeña y tan fija como esta, es leerla directamente:

public static bool TryParse(ReadOnlySpan<byte> utf8Json, out LicenseClaims claims)
{
    claims = default;
    string? product = null, tier = null; long exp = 0;
    var reader = new Utf8JsonReader(utf8Json);
    while (reader.Read())
    {
        if (reader.TokenType != JsonTokenType.PropertyName) continue;
        if (reader.ValueTextEquals("product"u8)) { reader.Read(); product = reader.GetString(); }
        else if (reader.ValueTextEquals("tier"u8)) { reader.Read(); tier = reader.GetString(); }
        else if (reader.ValueTextEquals("exp"u8)) { reader.Read(); exp = reader.GetInt64(); }
    }
    if (product is null || tier is null) return false;
    claims = new LicenseClaims(product, tier, DateTimeOffset.FromUnixTimeSeconds(exp));
    return true;
}

Utf8JsonReader es un ref struct que recorre los bytes sin asignaciones y sin reflexión: ValueTextEquals("product"u8) compara contra un span literal UTF-8 sin materializar nunca una cadena para la clave. Es más código que una sola línea, pero es código que el compilador AOT puede emitir íntegramente y del que el recortador no tiene nada que quitar. Para una carga de licencia (un puñado de claims conocidos), leerla a mano suele ser la decisión correcta con independencia de AOT, porque además fija exactamente en qué campos se confía.

Mantener honestas las anotaciones

La recompensa de la reescritura es que la superficie pública del verificador puede declararse segura frente al recorte y a AOT, y el analizador le obligará a cumplirlo. Nada en la ruta de verificación lleva [RequiresUnreferencedCode] ni [RequiresDynamicCode], así que nada propaga esas anotaciones hacia sus llamadores.

OperaciónEstadoSoluciónJsonSerializer.Deserialize<T>IL2026+IL3050JsonTypeInfo (source-gen) o Utf8JsonReaderGetManifestResourceStream(name)sin enraizarReadOnlySpan<byte> sobre la sección de datosImportSubjectPublicKeyInfo + VerifyDatasegurosin cambios — soportado en AOTX509Certificate2 chain buildpesadoimportar el SPKI en bruto, omitir el certificado

Para demostrarlo, active los analizadores y publique de verdad: las advertencias son de tiempo de compilación y la NotSupportedException es de tiempo de ejecución, así que conviene que ambas afloren antes de que lo haga un cliente:

<PropertyGroup>
  <PublishAot>true</PublishAot>
  <IsTrimmable>true</IsTrimmable>
  <TrimmerSingleWarn>false</TrimmerSingleWarn>
  <EnableTrimAnalyzer>true</EnableTrimAnalyzer>
  <EnableAotAnalyzer>true</EnableAotAnalyzer>
</PropertyGroup>
$ dotnet publish -c Release -r linux-x64
...
Determining projects to restore...
  MyApp -> /app/bin/Release/net8.0/linux-x64/publish/
Build succeeded.
    0 Warning(s)
    0 Error(s)

Cero advertencias es el listón. Un solo IL2026 significa que algo en su ruta de verificación sigue recurriendo a la reflexión, y el único lugar donde se esconde en un SDK de licenciamiento es casi siempre un JsonSerializer perdido o una clave cargada por nombre.

Por qué esta es la parte fácil

Merece la pena decirlo sin rodeos, porque los equipos posponen AOT pensando que la capa de licenciamiento les plantará batalla: el verificador es la parte más fácil de su aplicación para llevar a AOT, no la más difícil. No tiene complementos que descubrir, ni modelos de vista que serializar, ni contenedor de DI que resolver en tiempo de ejecución. Necesita un algoritmo, una clave y una forma de carga, todo lo cual se conoce en tiempo de compilación y todo lo cual compila a datos constantes y llamadas directas. La criptografía que necesita es la parte segura frente al recorte de System.Security.Cryptography; la única disciplina requerida es renunciar a las dos comodidades (el serializador por reflexión y la clave por nombre) que nunca aportaron nada que un Utf8JsonReader y un ReadOnlySpan<byte> no aporten.

Convierta la ruta de verificación en una función pura sobre bytes y AOT deja de ser una migración para convertirse en una propiedad que puede afirmar: sin reflexión, sin código dinámico, sin asignación para la clave y un dotnet publish que termina en 0 Warning(s). La comprobación de licencia, precisamente ella, es la que puede ir primero.

Prueba Nebula.NET

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