Vérifier une licence Keyright sur Native AOT : pas de réflexion, un chemin de vérification élagué, et la clé publique sous forme de ReadOnlySpan<byte>
Native AOT et l'élagage (trimming) complet récompensent la seule chose qu'une vérification de licence devrait déjà être : une fonction pure sur des octets. Mais les commodités qui rendent un SDK agréable sur le JIT — un sérialiseur JSON par réflexion, le chargement d'une clé par son nom, l'extraction de l'algorithme de signature depuis une chaîne — sont exactement ce que l'élagueur supprime et ce pour quoi le compilateur AOT refuse de générer du code. Cet article prend une vérification hors ligne de Keyright et la rend AOT-propre de bout en bout : en intégrant la clé publique sous forme de ReadOnlySpan<byte> qui se compile en un pointeur vers la section de données sans aucune allocation, en l'important avec ImportSubjectPublicKeyInfo et en la vérifiant avec ECDsa.VerifyData — tous deux sûrs pour l'élagage — et en lisant la charge utile signée avec un Utf8JsonReader plutôt qu'un sérialiseur par réflexion, de sorte que l'ensemble du chemin ne porte aucun RequiresDynamicCode ni RequiresUnreferencedCode et ne produit aucun avertissement d'élagage. Il lit l'IL de l'optimisation span-sur-données-statiques, montre le seul appel à JsonSerializer qui aurait empoisonné la publication, et explique pourquoi un vérificateur de licence est la partie de votre application la plus facile à rendre AOT.
Native AOT pose à votre code une question brutale : chaque type que vous touchez, et chaque méthode que vous appelez, peuvent-ils être déterminés à la compilation ? Pour la plus grande partie d’une application, la réponse honnête est « pas sans travail » — il y a de la réflexion quelque part, un sérialiseur qui parcourt un graphe ouvert, un fournisseur résolu depuis une chaîne. Un vérificateur de licence est l’heureuse exception. Bien fait, c’est une fonction pure sur des octets, et les octets sont la seule chose que l’AOT et l’élagage gèrent parfaitement.
Mal fait — avec les commodités qui semblaient gratuites sur le JIT — c’est aussi ce qui transforme un dotnet publish propre en un mur d’avertissements IL2026/IL3050 et en une NotSupportedException à l’exécution la première fois qu’un client lance la version élaguée. Cet article prend une vérification hors ligne de Keyright et la rend AOT-propre, en lisant l’IL là où cela compte.
La forme d’une vérification, et la forme des ennuis
Une licence Keyright hors ligne est une chaîne signée : une charge utile de revendications — produit, niveau, expiration, droits — et une signature sur les octets canoniques de cette charge utile, vérifiée contre une clé publique que l’application transporte. (L’anatomie de cette clé et la raison pour laquelle elle est intégrée plutôt que récupérée se trouvent dans épingler votre clé publique de vérification.) La vérification comporte trois étapes : obtenir la clé, vérifier la signature, lire les revendications. Chaque étape a un réglage par défaut hérité du JIT que l’AOT rejette.
La clé sous forme de ReadOnlySpan<byte> sur la section de données
Le premier réflexe sur le JIT est de charger la clé publique depuis une ressource intégrée par son nom. C’est un appel par réflexion que l’élagueur ne peut pas suivre — le nom est une chaîne, rien n’enracine la ressource, et dans une version élaguée le flux revient vide. La clé est une donnée constante ; intégrez-la comme une donnée constante.
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[] { … } allouerait normalement un tableau à chaque exécution de la propriété. Mais lorsque le résultat est renvoyé sous forme de ReadOnlySpan<byte> et que l’initialiseur n’est fait que de constantes, Roslyn applique l’optimisation span-sur-données-statiques : il écrit les octets dans la section de données de l’assembly comme un champ RVA et renvoie un span construit directement sur cette mémoire. Voici ce en quoi le corps de la propriété se compile réellement :
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
Pas de newarr, pas de stelem élément par élément, rien sur le tas du GC. ldsflda prend l’adresse d’un champ généré par le compilateur dont la valeur initiale est la clé, ldc.i4.s 91 est la longueur, et le constructeur ReadOnlySpan<byte>(void*, int) enveloppe les deux. Chaque appel à KeyringPublicKey.P256 est un pointeur et une longueur. (Un littéral UTF-8 "…"u8 se compile en la même forme et est l’écriture la plus nette lorsque les octets sont du texte ; pour du DER brut, la forme en tableau d’octets est plus claire.) Pour l’élagueur, c’est le support idéal — un span sur un champ <PrivateImplementationDetails> n’a aucun nom public qu’on risquerait de ne pas enraciner, et la donnée est référencée par la seule méthode qui la renvoie.
Vérifier sans sérialiseur par réflexion
La vérification de la signature elle-même n’a jamais été le problème. ECDsa.Create(), ImportSubjectPublicKeyInfo et VerifyData ne portent aucun [RequiresUnreferencedCode] ni [RequiresDynamicCode], et sous Native AOT ils se lient directement aux bibliothèques cryptographiques de la plateforme :
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);
}
Deux choix délibérés. Le format de signature est IeeeP1363FixedFieldConcatenation — l’encodage r || s de taille fixe qui garde une clé de licence compacte — et il doit correspondre à la façon dont Keyright l’a signée ; passer Rfc3279DerSequence ici ferait échouer silencieusement chaque vérification, en renvoyant false au lieu de lever une exception, ce qui est le bug de migration AOT auto-infligé le plus courant dans un vérificateur. Et les revendications ne sont lues qu’après que la signature a été validée, de sorte que l’analyseur ne voit jamais que des octets que vous avez signés.
Cet analyseur est l’autre endroit où le réglage par défaut du JIT aurait empoisonné la publication. La ligne tentante est JsonSerializer.Deserialize<LicenseClaims>(payload). Sa surcharge par réflexion est annotée [RequiresUnreferencedCode] et [RequiresDynamicCode] : sous élagage elle avertit IL2026, et sous AOT elle avertit IL3050 et lève NotSupportedException à l’exécution parce qu’il n’y a aucun générateur de code à l’exécution pour construire le convertisseur. Vous avez deux issues AOT-propres. La première est le générateur de source System.Text.Json — déclarez un JsonSerializerContext et désérialisez contre son JsonTypeInfo, ce qui déplace toute la réflexion vers la compilation. La seconde, pour une charge utile aussi petite et aussi fixe, est de la lire directement :
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 est un ref struct qui parcourt les octets sans aucune allocation ni réflexion — ValueTextEquals("product"u8) compare contre un span de littéral UTF-8 sans jamais matérialiser une chaîne de clé. C’est plus de code qu’une ligne unique, mais c’est du code que le compilateur AOT peut émettre intégralement et dont l’élagueur n’a rien à retirer. Pour une charge utile de licence — une poignée de revendications connues — la lecture à la main est souvent le bon choix indépendamment de l’AOT, car elle fixe aussi précisément les champs auxquels vous faites confiance.
Garder les annotations honnêtes
La récompense de la réécriture est que la surface publique du vérificateur peut être déclarée sûre pour l’élagage et pour l’AOT, et l’analyseur vous tiendra à cet engagement. Rien sur le chemin de vérification ne porte [RequiresUnreferencedCode] ni [RequiresDynamicCode], donc rien ne propage ces annotations jusqu’à vos appelants.
Pour le prouver, activez les analyseurs et publiez pour de vrai — les avertissements sont à la compilation et la NotSupportedException est à l’exécution, donc vous voulez faire émerger les deux avant qu’un client ne le fasse :
<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)
Zéro avertissement est la barre. Un seul IL2026 signifie que quelque chose sur votre chemin de vérification tend encore vers la réflexion, et le seul endroit où cela se cache dans un SDK de licences est presque toujours un JsonSerializer égaré ou une clé chargée par son nom.
Pourquoi c’est la partie facile
Il vaut la peine de le dire clairement, car les équipes repoussent l’AOT en pensant que la couche de licences leur résistera : le vérificateur est la partie la plus facile de votre application à rendre AOT, pas la plus difficile. Il n’a aucun plugin à découvrir, aucun modèle de vue à sérialiser, aucun conteneur d’injection de dépendances à résoudre à l’exécution. Il a besoin d’un algorithme, d’une clé et d’une forme de charge utile — que vous connaissez tous à la compilation, qui se compilent tous en données constantes et en appels directs. La cryptographie dont vous avez besoin est la partie sûre pour l’élagage de System.Security.Cryptography ; la seule discipline requise est de refuser les deux commodités — le sérialiseur par réflexion et la clé par son nom — qui ne vous apportaient jamais rien qu’un Utf8JsonReader et un ReadOnlySpan<byte> ne fassent déjà.
Faites du chemin de vérification une fonction pure sur des octets et l’AOT cesse d’être une migration pour devenir une propriété que vous pouvez affirmer : pas de réflexion, pas de code dynamique, pas d’allocation pour la clé, et un dotnet publish qui se termine par 0 Warning(s). La vérification de licence, de toutes choses, a le droit de passer en premier.
Essayez Nebula.NET
Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.