Skip to content
← Tous les articles
· Delta1 Labs KeyrightLicensingCryptographie

Signature canonique des payloads de licence : pourquoi l'ordre des champs et l'encodage décident si votre signature se vérifie

Une signature est calculée sur des octets, pas sur des champs — donc si votre service émetteur et votre client sérialisent le payload de la licence ne serait-ce que légèrement différemment, la vérification échoue sur une licence parfaitement authentique. Voici comment canoniser un payload une seule fois (ordre des champs stable, formatage invariant des nombres et des dates, UTF-8, base64url) pour qu'une même signature se vérifie à l'identique en .NET, Node, Python et Java.

Les signatures sont le fondement du licensing hors ligne — un client fait confiance à une licence parce qu”il peut vérifier, avec une clé publique embarquée, que votre service émetteur l”a signée et que rien n”a changé depuis. Mais cette confiance repose sur un détail facile à rater et brutal à déboguer : une signature n”est pas calculée sur vos champs, elle est calculée sur des octets. Le vérificateur re-dérive des octets à partir des claims qu”il a reçus et demande si la signature correspond à ces octets exacts. Si les octets de l”émetteur et ceux du vérificateur diffèrent d”un seul espace, d”une clé réordonnée ou d”un nombre formaté autrement, la vérification échoue — sur une licence parfaitement authentique. Mettez deux langages dans l”affaire, chacun avec son sérialiseur et sa locale, et « la signature ne se vérifie pas » devient un mystère entre équipes.

Le remède est la canonisation : définir une représentation en octets déterministe unique du payload que chaque participant — votre service émetteur en .NET et vos clients en .NET, Node, Python et Java — reconstruit à l”identique. Cet article porte sur la façon de le faire avec précision, et sur les manières concrètes et silencieuses dont cela se casse entre langages.

Une signature signe des octets, pas du sens

Partez de ce que les primitives font réellement. ECDSA.SignData(bytes) ou RSA.SignData(bytes) prennent un tableau d”octets, le hachent et signent le hachage. VerifyData(bytes, signature) hache les octets qu”on lui donne et les confronte à la signature. Aucune de ces fonctions ne sait quoi que ce soit du JSON, des champs ou de l”intention. Deux payloads qu”un humain appellerait « la même licence » sont le même uniquement s”ils se sérialisent vers les mêmes octets.

Le vrai contrat entre émetteur et client n”est donc pas « ces champs » — c”est « ces octets, produits de cette façon exacte ». La canonisation est la spécification écrite de cette production. Comme le souligne l”anatomie d”une clé de licence signée, vous vérifiez la signature d”abord et faites confiance aux claims ensuite ; la canonisation est ce qui rend cette vérification reproductible en premier lieu.

La forme canonique, champ par champ

Un payload canonique est une fonction qui va des claims à une chaîne d”octets unique, avec chaque degré de liberté figé. Voici une forme qui voyage bien entre langages :

  • L”ordre des champs est fixe. Soit triez les clés lexicographiquement par leurs octets UTF-8, soit définissez une liste de champs ordonnée et explicite dans la spécification. Les deux marchent ; la seule mauvaise réponse est « l”ordre que le sérialiseur a émis par hasard ». La plupart des bibliothèques JSON ne garantissent pas l”ordre, et certaines réordonnent lors d”un aller-retour.
  • Les nombres utilisent la culture invariante, sans séparateurs de milliers, sans + initial. 5, pas 5.0, pas 5,00. Les entiers restent des entiers. Si vous devez porter des décimales, fixez la précision et le point décimal (.) dans la spécification — ne laissez jamais une locale allemande émettre 1,50 là où une anglaise émet 1.50.
  • Les booléens sont true / false en minuscules. Pas True (le str(bool) par défaut de Python), pas 1/0.
  • Les dates sont en ISO-8601 UTC avec un Z final, à une précision fixe. Décidez une fois pour toutes si vous incluez les millisecondes, et incluez toujours le décalage. 2027-01-15T00:00:00Z — jamais une heure locale, jamais un fuseau implicite.
  • Les chaînes sont en UTF-8, et vous choisissez une forme de normalisation Unicode (NFC est le choix habituel) pour que les caractères accentués se comparent octet par octet entre plateformes.
  • Aucun espace accidentel entre les tokens, et aucun saut de ligne final.
  • La signature est en base64url sans remplissage, avec l”alphabet -/_, pour qu”elle soit sûre à embarquer dans des fichiers et des URL et se décode de la même façon partout.

L”enjeu n”est pas que ces choix précis soient les seuls corrects — c”est que un choix doit être couché par écrit et respecté par chaque SDK. La forme canonique est un protocole, pas un détail d”implémentation.

Construire et signer les octets canoniques en C#

Voici un exemple côté émetteur qui construit une chaîne canonique avec une liste de champs fixe et la signe avec ECDSA sur la courbe NIST P-256. Les claims sont écrits dans un ordre déterministe, les nombres et les dates sont formatés avec CultureInfo.InvariantCulture, et le résultat est encodé en UTF-8 avant la signature.

using System.Globalization;
using System.Security.Cryptography;
using System.Text;

static string Canonicalize(License lic)
{
    // Ordre des champs fixe, formatage invariant, séparateurs '\n'. Cette chaîne
    // exacte est le contrat — chaque SDK doit la reconstruire octet par octet.
    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 : triées, jointes par des virgules, pour que l'ordre de la liste source n'importe jamais.
    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('/', '_');

// --- côté émetteur (la clé privée reste ici) ---
byte[] canonical = Encoding.UTF8.GetBytes(Canonicalize(license));
byte[] signature = ecdsaPrivate.SignData(canonical, HashAlgorithmName.SHA256);
string sigField = Base64Url(signature);

Deux détails portent tout le poids. ToString(CultureInfo.InvariantCulture) est ce qui empêche une machine configurée pour une locale à virgule décimale de sérialiser seats ou tout nombre différemment d”une machine configurée pour un point décimal. Et trier features avant de joindre signifie que le client peut envoyer sa liste de features dans n”importe quel ordre et reconstruire malgré tout la ligne canonique identique. Les séparateurs (\n entre les lignes, , entre les features, = entre clé et valeur) sont fixés par la spécification — le client doit utiliser les mêmes.

Vérifier avec la clé publique seule

Le client n”a que la clé publique. Il parse les claims reçus, reconstruit les octets canoniques avec la même fonction, décode la signature depuis le base64url et vérifie. Il ne re-signe jamais rien, et ne fait confiance à aucun champ tant que la signature n”est pas validée.

static byte[] FromBase64Url(string s)
{
    s = s.Replace('-', '+').Replace('_', '/');
    s = (s.Length % 4) switch { 2 => s + "==", 3 => s + "=", _ => s };
    return Convert.FromBase64String(s);
}

// --- côté client (seule la clé publique est embarquée) ---
byte[] canonical = Encoding.UTF8.GetBytes(Canonicalize(received)); // la MÊME fonction
byte[] signature = FromBase64Url(received.Signature);

bool ok = ecdsaPublic.VerifyData(canonical, signature, HashAlgorithmName.SHA256);
if (!ok)
    return LicenseInfo.Invalid("la signature n'a pas été vérifiée"); // échec fermé

// ce n'est que maintenant que product, tier, seats, features sont fiables

Remarquez que le helper FromBase64Url rajoute le remplissage avant de décoder, car le Convert.FromBase64String de .NET l”exige. Un décodeur Node ou Python pourrait accepter la forme sans remplissage directement — ce qui est exactement le genre de différence entre langages qu”il faut trancher dans la spécification plutôt que découvrir en production. (Pour voir où ce pas de vérification se situe dans un flux hors ligne complet — node-locking, révocation, échec fermé — lisez la validation de licence hors ligne expliquée.)

Un décalage concret entre langages

Voici la défaillance en chair et en os. Supposons que le service émetteur signe la chaîne canonique ci-dessus, et qu”un client Node réimplémente Canonicalize. Un développeur, voulant que l”interface affiche un badge d”essai, ajoute une ligne à la version de la fonction du client :

// Client Node — FAUX : ajoute un champ que l'émetteur n'a jamais signé
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;   // <-- ligne en trop, absente de la forme signée
  return s;
}

L”émetteur a signé six champs. Le client en hache sept. Les chaînes d”octets diffèrent du \nisTrial=false final, si bien que verify renvoie false sur toutes les licences que voit ce client — les authentiques incluses. Rien ne semble anormal : les claims sont tous corrects, la clé est correcte, l”algorithme correspond. Le seul défaut est qu”un côté a canonisé une chaîne d”octets différente.

Le correctif n”est pas de rendre le client « tolérant ». C”est de convenir que la forme canonique est exactement les six champs que l”émetteur signe, et que isTrial — s”il fait seulement partie de la licence — est l”un de ces champs signés des deux côtés, formaté à l”identique, à sa position définie. Un drapeau qui influence le comportement mais n”est pas dans le payload signé est soit une donnée non signée à laquelle vous ne devez pas faire confiance, soit un champ que vous avez oublié de canoniser. Il n”y a pas de troisième option. La même discipline tue les variantes plus subtiles de ce bug : un client qui joint les features avec , au lieu de ,, un qui émet la date sans le Z, un qui laisse JSON.stringify réordonner les clés, un qui laisse un saut de ligne final que l”émetteur a rogné.

Côté émetteur (clé privée)claimsproduct, tier, seats…octets canoniquesordre fixe · UTF-8signer(octets, priv)→ signature base64urllicence = claims + signatureCôté client (clé publique seule)claims reçusparsés depuis la licenceoctets canoniques+ ligne isTrial en tropvérifier(octets, sig, pub)hache 7 champsÉCHOUE(authentique)mêmes claims, mais les deux chaînesd'octets canoniques diffèrent → signature non concordante

Les pièges, nommés

Chacun d”eux change les octets sans changer le sens apparent, ce qui les rend si difficiles à repérer :

  • Ajouter des drapeaux non signés. L”exemple travaillé ci-dessus. Un marqueur, un interrupteur, un indice d”interface — si un côté le plie dans la chaîne canonique et l”autre non, rien ne se vérifie.
  • Espaces et sauts de ligne finaux. Un sérialiseur qui met en forme, un éditeur qui ajoute un saut de ligne final, un WriteLine au lieu d”un Write. Rognez délibérément et spécifiez le résultat.
  • Normalisation Unicode. Un nom de titulaire avec un caractère accentué peut être stocké composé (NFC) sur un OS et décomposé (NFD) sur un autre ; les deux sont visuellement identiques et différents en octets. Normalisez vers une forme avant d”encoder.
  • Locale des nombres et des dates. Virgule vs point décimal, milliers groupés, dates avec ou sans millisecondes ou décalage. Fixez la culture invariante et un format de date unique.
  • base64 vs base64url, rempli vs non rempli. Les alphabets +// vs -/_ et la règle de remplissage = doivent correspondre aux deux bouts, sinon les octets de la signature se décodent mal.
  • Boutisme dans les encodages binaires. Si vous canonisez un jour vers une disposition binaire au lieu du texte — en empaquetant des entiers ou des longueurs en octets bruts — le big-endian sur une plateforme et le little-endian sur une autre divergeront en silence. Fixez l”ordre des octets (big-endian/réseau est le choix conventionnel) dans la spécification. Les formes canoniques textuelles contournent cela entièrement, ce qui est une raison de les préférer.
  • Réordonnancement des clés par le sérialiseur. Beaucoup de bibliothèques JSON ne préservent pas l”ordre d”insertion et certaines réordonnent au parse/ré-émission. Ne dépendez jamais de l”ordre émis ; triez ou utilisez une liste de champs explicite.

Une forme canonique, couchée par écrit

Le fil conducteur est simple : la signature est une promesse sur des octets, donc l”émetteur et chaque client doivent fabriquer ces octets selon la même recette. La canonisation est cette recette — ordre des champs fixe, nombres invariants, dates ISO-8601 UTC, UTF-8, une forme Unicode choisie, base64url pour la signature — inscrite dans le protocole et implémentée à l”identique en .NET, Node, Python et Java. Les SDK de Keyright partagent exactement une de ces définitions canoniques pour qu”une licence signée par le service émetteur se vérifie octet par octet dans chaque langage client, et le test qui compte n”est pas « se vérifie-t-elle en .NET ? » mais « la même licence, parsée et reconstruite dans chaque SDK, produit-elle les octets canoniques identiques ? ». Quand c”est le cas, la vérification entre langages est ennuyeuse — ce qui est exactement ce que vous voulez de la cryptographie. Quand ce ne l”est pas, le bug n”est presque jamais dans les maths ; il est dans les octets, et la recette est l”endroit où vous le trouvez.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.