Eine Keyright-Lizenz unter Native AOT verifizieren: keine Reflection, ein trimmbarer Verify-Pfad und der öffentliche Schlüssel als ReadOnlySpan<byte>
Native AOT und vollständiges Trimming belohnen genau das, was eine Lizenzprüfung ohnehin sein sollte: eine reine Funktion über Bytes. Doch die Bequemlichkeiten, die ein SDK auf dem JIT angenehm machen — ein reflectionbasierter JSON-Serialisierer, das Laden eines Schlüssels per Name, das Ermitteln des Signaturalgorithmus aus einem String — sind genau das, was der Trimmer entfernt und wofür der AOT-Compiler keinen Code erzeugen will. Dieser Beitrag nimmt eine Offline-Verifikation von Keyright und macht sie durchgängig AOT-sauber: den öffentlichen Schlüssel als ReadOnlySpan<byte> einbetten, der ohne Allokation zu einem Zeiger in die Datensektion kompiliert, ihn mit ImportSubjectPublicKeyInfo importieren und mit ECDsa.VerifyData verifizieren — beides trim-sicher — und die signierte Payload mit einem Utf8JsonReader statt eines Reflection-Serialisierers lesen, sodass der gesamte Pfad weder RequiresDynamicCode noch RequiresUnreferencedCode trägt und null Trim-Warnungen erzeugt. Er liest das IL der Span-über-statische-Daten-Optimierung, zeigt den einen JsonSerializer-Aufruf, der das Publish vergiftet hätte, und erklärt, warum ein Lizenzverifizierer der Teil Ihrer Anwendung ist, der sich am leichtesten nach AOT bringen lässt.
Native AOT stellt Ihrem Code eine unverblümte Frage: Lässt sich jeder Typ, den Sie anfassen, und jede Methode, die Sie aufrufen, zur Build-Zeit entscheiden? Für den Großteil einer Anwendung lautet die ehrliche Antwort „nicht ohne Arbeit“ — irgendwo steckt Reflection, ein Serialisierer läuft über einen offenen Objektgraphen, ein Provider wird aus einem String aufgelöst. Ein Lizenzverifizierer ist die erfreuliche Ausnahme. Richtig gemacht ist er eine reine Funktion über Bytes, und Bytes sind das Eine, womit AOT und Trimming perfekt umgehen.
Falsch gemacht — mit den Bequemlichkeiten, die sich auf dem JIT kostenlos anfühlten — ist er zugleich das, was ein sauberes dotnet publish in eine Wand aus IL2026/IL3050-Warnungen und eine NotSupportedException zur Laufzeit verwandelt, sobald ein Kunde den getrimmten Build zum ersten Mal startet. Dieser Beitrag nimmt eine Offline-Verifikation von Keyright und macht sie AOT-sauber — und liest dabei das IL, wo es darauf ankommt.
Die Gestalt einer Verifikation und die Gestalt des Problems
Eine Offline-Lizenz von Keyright ist ein signierter String: eine Payload aus Claims — Produkt, Tier, Ablaufdatum, Berechtigungen — und eine Signatur über die kanonischen Bytes dieser Payload, geprüft gegen einen öffentlichen Schlüssel, den die Anwendung mitführt. (Die Anatomie dieses Schlüssels und warum er eingebettet statt abgerufen wird, steht in Den öffentlichen Verifikationsschlüssel pinnen.) Die Verifikation besteht aus drei Schritten: Schlüssel holen, Signatur prüfen, Claims lesen. Jeder Schritt hat einen Standard aus der JIT-Ära, den AOT ablehnt.
Der Schlüssel als ReadOnlySpan<byte> über der Datensektion
Der erste Instinkt auf dem JIT ist, den öffentlichen Schlüssel per Name aus einer eingebetteten Ressource zu laden. Das ist ein Reflection-Aufruf, dem der Trimmer nicht folgen kann — der Name ist ein String, nichts verankert die Ressource, und in einem getrimmten Build kommt der Stream leer zurück. Der Schlüssel ist konstante Daten; betten Sie ihn als konstante Daten ein.
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…
};
}
Ein new byte[] { … } würde normalerweise bei jedem Ausführen der Eigenschaft ein Array allokieren. Wird das Ergebnis jedoch als ReadOnlySpan<byte> zurückgegeben und besteht der Initialisierer ausschließlich aus Konstanten, wendet Roslyn die Span-über-statische-Daten-Optimierung an: Es schreibt die Bytes als RVA-Feld in die Datensektion der Assembly und liefert einen Span, der direkt über diesem Speicher aufgebaut wird. So sieht der Rumpf der Eigenschaft tatsächlich kompiliert aus:
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
Kein newarr, kein elementweises stelem, nichts auf dem GC-Heap. ldsflda nimmt die Adresse eines compilergenerierten Feldes, dessen Anfangswert der Schlüssel ist, ldc.i4.s 91 ist die Länge, und der ReadOnlySpan<byte>(void*, int)-Konstruktor verpackt beides. Jeder Aufruf von KeyringPublicKey.P256 ist ein Zeiger und eine Länge. (Ein "…"u8-UTF-8-Literal kompiliert zur selben Form und ist die sauberere Schreibweise, wenn die Bytes Text sind; für rohes DER ist die Byte-Array-Form klarer.) Für den Trimmer ist das der ideale Träger — ein Span über ein <PrivateImplementationDetails>-Feld hat keinen öffentlichen Namen, der nicht verankert werden könnte, und die Daten werden von der einen Methode referenziert, die sie zurückgibt.
Verifizieren ohne Reflection-Serialisierer
Die Signaturprüfung selbst war nie das Problem. ECDsa.Create(), ImportSubjectPublicKeyInfo und VerifyData tragen weder [RequiresUnreferencedCode] noch [RequiresDynamicCode], und unter Native AOT binden sie direkt an die Krypto-Bibliotheken der Plattform:
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);
}
Zwei bewusste Entscheidungen. Das Signaturformat ist IeeeP1363FixedFieldConcatenation — die r || s-Kodierung fester Größe, die einen Lizenzschlüssel kompakt hält — und es muss dem entsprechen, womit Keyright signiert hat; Rfc3279DerSequence an dieser Stelle ließe jede Verifikation stillschweigend fehlschlagen und false zurückgeben, statt zu werfen — der mit Abstand häufigste selbstverschuldete Fehler bei der AOT-Migration eines Verifizierers. Und die Claims werden erst gelesen, nachdem die Signatur geprüft ist, sodass der Parser immer nur Bytes sieht, die Sie signiert haben.
Dieser Parser ist die andere Stelle, an der der JIT-Standard das Publish vergiftet hätte. Die verlockende Zeile lautet JsonSerializer.Deserialize<LicenseClaims>(payload). Ihre Reflection-Überladung ist mit [RequiresUnreferencedCode] und [RequiresDynamicCode] annotiert: Beim Trimming warnt sie mit IL2026, unter AOT warnt sie mit IL3050 und wirft zur Laufzeit eine NotSupportedException, weil es keinen Laufzeit-Codegenerator gibt, der den Konverter bauen könnte. Es gibt zwei AOT-saubere Auswege. Der erste ist der Source-Generator von System.Text.Json — einen JsonSerializerContext deklarieren und gegen dessen JsonTypeInfo deserialisieren, was die gesamte Reflection in die Build-Zeit verlagert. Der zweite, für eine so kleine und so feste Payload, ist das direkte Lesen:
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 ist ein ref struct, das die Bytes ohne Allokation und ohne Reflection durchläuft — ValueTextEquals("product"u8) vergleicht gegen einen UTF-8-Literal-Span, ohne je einen Schlüsselstring zu materialisieren. Es ist mehr Code als ein Einzeiler, aber es ist Code, den der AOT-Compiler vollständig erzeugen kann und aus dem der Trimmer nichts zu entfernen hat. Für eine Lizenz-Payload — eine Handvoll bekannter Claims — ist das manuelle Lesen unabhängig von AOT oft die richtige Wahl, weil es zugleich exakt festlegt, welchen Feldern Sie vertrauen.
Die Annotationen ehrlich halten
Der Lohn der Umstellung ist, dass die öffentliche Oberfläche des Verifizierers als trim- und AOT-sicher deklariert werden kann und der Analyzer Sie daran misst. Nichts auf dem Verify-Pfad trägt [RequiresUnreferencedCode] oder [RequiresDynamicCode], also propagiert nichts diese Annotationen zu Ihren Aufrufern hinauf.
Um das zu belegen, schalten Sie die Analyzer ein und publizieren Sie tatsächlich — die Warnungen entstehen zur Build-Zeit, die NotSupportedException zur Laufzeit, und beides soll ans Licht kommen, bevor ein Kunde es entdeckt:
<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)
Null Warnungen ist der Maßstab. Ein einziges IL2026 bedeutet, dass etwas auf Ihrem Verify-Pfad noch nach Reflection greift, und die eine Stelle, an der sich das in einem Lizenzierungs-SDK versteckt, ist fast immer ein verirrter JsonSerializer oder ein per Name geladener Schlüssel.
Warum das der leichte Teil ist
Es verdient, klar gesagt zu werden, weil Teams AOT aufschieben in der Annahme, die Lizenzierungsschicht werde sich sträuben: Der Verifizierer ist der leichteste Teil Ihrer Anwendung, den man nach AOT bringt, nicht der schwerste. Er hat keine Plugins zu entdecken, keine View-Models zu serialisieren, keinen DI-Container zur Laufzeit aufzulösen. Er braucht einen Algorithmus, einen Schlüssel und eine Payload-Form — alles zur Build-Zeit bekannt, alles zu konstanten Daten und direkten Aufrufen kompilierbar. Die benötigte Kryptografie ist der trim-sichere Teil von System.Security.Cryptography; die einzige nötige Disziplin ist der Verzicht auf die beiden Bequemlichkeiten — den Reflection-Serialisierer und den Schlüssel per Name —, die Ihnen nie etwas eingebracht haben, was ein Utf8JsonReader und ein ReadOnlySpan<byte> nicht auch leisten.
Machen Sie den Verify-Pfad zu einer reinen Funktion über Bytes, und AOT hört auf, eine Migration zu sein, und wird zu einer Eigenschaft, die Sie zusichern können: keine Reflection, kein dynamischer Code, keine Allokation für den Schlüssel und ein dotnet publish, das mit 0 Warning(s) endet. Ausgerechnet die Lizenzprüfung darf vorangehen.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.