Skip to content
← All posts
· Delta1 Labs KeyrightLicensing.NETDeep Dive

Verifying a Keyright license on Native AOT: no reflection, a trimmed verify path, and the public key as a ReadOnlySpan<byte>

Native AOT and full trimming reward the one thing a license check should already be: a pure function over bytes. But the conveniences that make an SDK pleasant on the JIT — a reflection-based JSON serializer, loading a key by name, pulling the signing algorithm from a string — are exactly what the trimmer removes and the AOT compiler refuses to generate code for. This post takes a Keyright offline verify and makes it AOT-clean end to end: embedding the public key as a ReadOnlySpan<byte> that compiles to a pointer into the data section with no allocation, importing it with ImportSubjectPublicKeyInfo and verifying with ECDsa.VerifyData — both trim-safe — and reading the signed payload with a Utf8JsonReader instead of a reflection serializer, so the whole path carries no RequiresDynamicCode or RequiresUnreferencedCode and produces zero trim warnings. It reads the IL of the span-over-static-data optimization, shows the one JsonSerializer call that would have poisoned the publish, and explains why a license verifier is the easiest part of your app to get to AOT.

Native AOT asks your code a blunt question: can every type you touch, and every method you call, be decided at build time? For most of an application the honest answer is “not without work” — there is reflection somewhere, a serializer walking an open graph, a provider resolved from a string. A license verifier is the happy exception. Done right it is a pure function over bytes, and bytes are the one thing AOT and trimming handle perfectly.

Done wrong — with the conveniences that felt free on the JIT — it is also the thing that turns a clean dotnet publish into a wall of IL2026/IL3050 warnings and a runtime NotSupportedException the first time a customer runs the trimmed build. This post takes a Keyright offline verify and makes it AOT-clean, reading the IL where it matters.

The shape of a verify, and the shape of the trouble

An offline Keyright license is a signed string: a payload of claims — product, tier, expiry, entitlements — and a signature over the canonical bytes of that payload, checked against a public key the app carries. (The anatomy of that key and why it is embedded rather than fetched is in pinning your verification public key.) The verify is three steps: get the key, check the signature, read the claims. Each step has a JIT-era default that AOT rejects.

Three steps; the middle one was always fine1 · get the key2 · check the signature3 · read the claimsGetManifestResourceStream("keys/pub.der")trimmer roots nothing → emptyECDsa.VerifyData(msg, sig,SHA256, IeeeP1363)no annotations — AOT-safeJsonSerializer.Deserialize<Claims>(json)IL2026 + IL3050rewrite step 1 and step 3; keep step 2static ReadOnlySpan<byte>PublicKey => new byte[]{…};data-section span, no allocImportSubjectPublicKeyInfo(PublicKey)same call on JIT and AOTUtf8JsonReaderover the payload spanzero reflectionThe verifier never allocates for the key, never deserializes by type, and carries no trim-unsafe annotation on its public surface.

The key as a ReadOnlySpan<byte> over the data section

The first instinct on the JIT is to load the public key from an embedded resource by name. That is a reflection call the trimmer cannot follow — the name is a string, nothing roots the resource, and in a trimmed build the stream comes back empty. The key is constant data; embed it as constant data.

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…
    };
}

A new byte[] { … } would normally allocate an array every time the property runs. But when the result is returned as a ReadOnlySpan<byte> and the initializer is all constants, Roslyn applies the span-over-static-data optimization: it writes the bytes into the assembly’s data section as an RVA field and hands back a span built directly over that memory. Here is what the property body actually compiles to:

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

No newarr, no element-by-element stelem, nothing on the GC heap. ldsflda takes the address of a compiler-generated field whose initial value is the key, ldc.i4.s 91 is the length, and the ReadOnlySpan<byte>(void*, int) constructor wraps the two. Every call to KeyringPublicKey.P256 is a pointer and a length. (A "…"u8 UTF-8 literal compiles to the same shape and is the neater spelling when the bytes are text; for raw DER the byte-array form is clearer.) For the trimmer this is the ideal carrier — a span over a <PrivateImplementationDetails> field has no public name to fail to root, and the data is referenced by the one method that returns it.

Verifying without a reflection serializer

Signature verification itself was never the problem. ECDsa.Create(), ImportSubjectPublicKeyInfo and VerifyData carry no [RequiresUnreferencedCode] or [RequiresDynamicCode], and under Native AOT they bind to the platform crypto libraries directly:

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);
}

Two deliberate choices. The signature format is IeeeP1363FixedFieldConcatenation — the fixed-size r || s encoding that keeps a license key compact — and it must match how Keyright signed it; passing Rfc3279DerSequence here would fail every verify silently, returning false rather than throwing, which is the single most common self-inflicted AOT-migration bug in a verifier. And the claims are read only after the signature checks out, so the parser only ever sees bytes you signed.

That parser is the other place the JIT default would have poisoned the publish. The tempting line is JsonSerializer.Deserialize<LicenseClaims>(payload). Its reflection overload is annotated [RequiresUnreferencedCode] and [RequiresDynamicCode]: under trimming it warns IL2026, and under AOT it warns IL3050 and throws NotSupportedException at runtime because there is no runtime code generator to build the converter. You have two AOT-clean ways out. The first is the System.Text.Json source generator — declare a JsonSerializerContext and deserialize against its JsonTypeInfo, which moves all the reflection to build time. The second, for a payload this small and this fixed, is to read it directly:

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 is a ref struct that walks the bytes with no allocation and no reflection — ValueTextEquals("product"u8) compares against a UTF-8 literal span without ever materializing a key string. It is more code than a one-liner, but it is code the AOT compiler can emit in full and the trimmer has nothing to remove from. For a license payload — a handful of known claims — hand-reading is often the right call regardless of AOT, because it also pins down exactly which fields you trust.

Keeping the annotations honest

The payoff of the rewrite is that the verifier’s public surface can be stated to be trim- and AOT-safe, and the analyzer will hold you to it. Nothing on the verify path carries [RequiresUnreferencedCode] or [RequiresDynamicCode], so nothing propagates those up to your callers.

OperationStatusFixJsonSerializer.Deserialize<T>IL2026+IL3050JsonTypeInfo (source-gen) or Utf8JsonReaderGetManifestResourceStream(name)unrootedReadOnlySpan<byte> over data sectionImportSubjectPublicKeyInfo + VerifyDatasafeno change — supported on AOTX509Certificate2 chain buildheavyimport raw SPKI, skip the certificate

To prove it, turn the analyzers on and publish for real — the warnings are build-time and the NotSupportedException is run-time, so you want both surfaced before a customer does:

<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)

Zero warnings is the bar. A single IL2026 means something on your verify path is still reaching for reflection, and the one place it hides in a licensing SDK is almost always a stray JsonSerializer or a key loaded by name.

Why this is the easy part

It is worth saying plainly, because teams put off AOT thinking the licensing layer will fight them: the verifier is the easiest part of your app to get to AOT, not the hardest. It has no plugins to discover, no view models to serialize, no DI container to resolve at runtime. It needs one algorithm, one key, and one payload shape — all of which you know at build time, all of which compile to constant data and direct calls. The crypto you need is the trim-safe part of System.Security.Cryptography; the only discipline required is refusing the two conveniences — the reflection serializer and the key-by-name — that were never buying you anything a Utf8JsonReader and a ReadOnlySpan<byte> do not.

Make the verify path a pure function over bytes and AOT stops being a migration and becomes a property you can assert: no reflection, no dynamic code, no allocation for the key, and a dotnet publish that ends in 0 Warning(s). The license check, of all things, gets to go first.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.