Skip to content
← Tous les articles
· Delta1 Labs Licensing.NETGuide

Versionner la charge utile de licence : ajouter des claims sans casser les clients déjà déployés

Une licence signée est un ensemble de claims figés en octets, et dès que vous livrez un client qui les lit, cette disposition d'octets devient un contrat que vous ne pouvez pas changer unilatéralement. Puis le produit réclame un nouveau claim — un plafond de sièges, un verrou régional, une liste de fonctionnalités — et vous découvrez que la moitié de votre parc installé fait tourner un parseur qui ne l'a jamais vu. Cet article détaille comment les charges utiles de licence façon Keyright évoluent sans danger : une version de schéma couverte par la signature, un parsing en monde ouvert qui garde vérifiables les claims inconnus, une liste de claims critiques pour qu'un vieux client refuse ce qu'il ne peut pas appliquer au lieu de l'ignorer en silence, une négociation de schéma à l'activation pour que le côté émetteur n'émette jamais ce qu'un client ne sait pas lire, et les tests à fichiers de référence qui empêchent qu'un changement de format atteigne la production sans que personne ne le remarque.

Une clé de licence est un ensemble de claims que le service émetteur signe et que le client vérifie : pour qui, quel produit, quelle édition, quand elle expire, à quelle machine elle est liée. La première version est facile. Vous définissez un record, le sérialisez, signez les octets, livrez un client qui vérifie la signature et lit les champs, et ça marche.

Les ennuis commencent le jour où vous avez besoin d”un nouveau claim. Le produit veut un plafond de sièges. Les ventes veulent une restriction régionale. L”ingénierie veut une liste de fonctionnalités pour qu”une seule clé déverrouille un sous-ensemble de modules. Chacun de ces éléments est un changement de la charge utile, et la charge utile est déjà parsée par chaque copie de votre logiciel sur le terrain, y compris celles qui ne se mettront pas à jour avant un an. Certaines de ces installations liront la nouvelle charge utile et ignoreront en silence le champ qu”elles ne comprennent pas ; que ce soit correct dépend entièrement de ce que le champ signifie. Ignorer un nom affiché et rien ne se passe. Ignorer un plafond de sièges et vous venez de donner des sièges illimités à tous ceux qui n”ont pas mis à jour.

Cet article traite de la façon de mener cette évolution délibérément : une version couverte par la signature, un parsing qui tolère ce qu”il ne comprend pas sans affaiblir la vérification, un moyen de marquer les claims qu”un vieux client n”a pas le droit d”ignorer, une négociation pour que le serveur n”émette jamais une charge utile qu”un client ne sait pas lire, et des tests qui font tenir l”ensemble à travers les versions.

charge utile signée (schéma 2)schema: 2sub: cust_8831product: acme-appedition: proexp: 2027-10-11displayName: "Acme Corp"seatCap: 25critical: ["seatCap"]sig: RSA-PSS sur octets canoniquesclient de schéma 2vérifie la signature sur les octets brutslit displayName, applique seatCap = 25tous les claims critiques compris → licence valideclient de schéma 1 (ancienne build)vérifie la signature sur les octets bruts — passe toujoursdisplayName inconnu → conservé, ignoré (inoffensif)seatCap inconnu mais dans critical → refus, « mise à jour requise »

La charge utile est un contrat que la signature fige

Partez de la forme que vous avez déjà. Une charge utile de première génération façon Keyright pourrait être ce record, sérialisé en JSON canonique et signé en signature détachée sur ces octets :

public sealed record LicensePayload
{
    public required string Sub { get; init; }        // customer id
    public required string Product { get; init; }    // product slug the key is issued for
    public required string Edition { get; init; }    // "pro", "team", ...
    public required DateTimeOffset Exp { get; init; }
    public string? MachineId { get; init; }          // node-lock, optional
}

L”important dans ce record, ce ne sont pas les champs ; c”est que l”encodage de ces champs est désormais figé. La signature couvre les octets exacts que le service émetteur a produits, donc la vérification côté client dépend de la reproduction — ou, mieux, du fait de ne jamais altérer — ces octets. Nous avons expliqué pourquoi l”ordre des champs et l”encodage comptent dans l”article sur la signature canonique ; ici la conséquence est que le format est un contrat entre deux parties qui se mettent à jour selon des calendriers totalement différents. Le service émetteur se met à jour quand vous le déployez. Le client se met à jour quand le client en a envie.

Chaque changement de la charge utile doit donc être jugé par une seule question : que fait le plus vieux client que je supporte encore quand il voit ceci ? Il n”y a que trois réponses honnêtes — il le lit correctement, il l”ignore sans danger, ou il l”ignore dangereusement — et toute la conception qui suit existe pour s”assurer que la troisième n”arrive jamais en silence.

Règle un : la version de schéma est un claim, et elle est signée

Mettez un entier schema dans la charge utile dès le premier jour, même s”il vaut 1 et que rien ne le lit. Parce qu”il est à l”intérieur des octets signés, personne ne peut rétrograder une charge utile vers une version à sémantique plus faible sans casser la signature. Parce que c”est un entier simple, le client peut prendre des décisions dessus avant de comprendre quoi que ce soit d”autre.

public sealed record LicensePayload
{
    public required int Schema { get; init; }          // 1, 2, 3 ... — signed like every other claim
    public required string Sub { get; init; }
    public required string Product { get; init; }
    public required string Edition { get; init; }
    public required DateTimeOffset Exp { get; init; }
    public string? MachineId { get; init; }

    // Schema 2 additions:
    public string? DisplayName { get; init; }          // informational — safe to ignore
    public int? SeatCap { get; init; }                 // restriction — NOT safe to ignore
    public IReadOnlyList<string> Critical { get; init; } = [];   // names of claims that must be understood

    // Anything we do not have a property for lands here and is preserved.
    [JsonExtensionData]
    public Dictionary<string, JsonElement>? Unknown { get; init; }
}

Le numéro Schema n”est pas « la version du client qui peut lire ceci ». C”est « la version du vocabulaire dans lequel cette charge utile a été écrite ». Un client de schéma 1 peut parfaitement lire une charge utile de schéma 2 tant que les ajouts du schéma 2 sont des choses qu”il a le droit d”ignorer — et c”est ce que décident les deux règles suivantes.

Règle deux : vérifiez les octets, puis parsez en monde ouvert

Le parseur doit tolérer les claims qu”il ne connaît pas. Dans System.Text.Json, c”est le dictionnaire [JsonExtensionData] ci-dessus : chaque propriété que le record ne déclare pas est collectée au lieu d”être rejetée. C”est la lecture en monde ouvert, et c”est ce qui permet à un vieux client d”accepter une charge utile portant de nouveaux claims informatifs.

Le piège est de combiner la lecture en monde ouvert avec la vérification en monde fermé. Si le client désérialise la charge utile dans son record, puis re-sérialise le record et vérifie la signature sur cela, un vieux client qui a jeté (ou réordonné) un champ inconnu produit des octets différents et la signature échoue sur une licence parfaitement valide. L”ordre des opérations doit être : vérifier la signature sur les octets bruts exactement tels que reçus, et seulement ensuite passer ces octets au parseur.

public static LicensePayload VerifyAndRead(ReadOnlySpan<byte> payloadBytes,
                                           ReadOnlySpan<byte> signature,
                                           RSA issuerPublicKey)
{
    // 1. The signature covers the bytes on the wire. Verify before touching them.
    if (!issuerPublicKey.VerifyData(payloadBytes, signature,
                                    HashAlgorithmName.SHA256, RSASignaturePadding.Pss))
        throw new LicenseException(LicenseFailure.BadSignature);

    // 2. Only now parse — open-world, unknown claims retained in Unknown.
    var payload = JsonSerializer.Deserialize<LicensePayload>(payloadBytes, PayloadJson.Options)
                  ?? throw new LicenseException(LicenseFailure.Malformed);

    // 3. Schema gate: refuse only what we cannot possibly interpret.
    if (payload.Schema < 1 || payload.Schema > PayloadJson.MaxSchemaUnderstood + 1)
        throw new LicenseException(LicenseFailure.UnsupportedSchema, payload.Schema);

    return payload;
}

Remarquez la porte de l”étape 3. Elle ne dit pas « refuse tout ce qui est plus récent que moi ». Elle dit « refuse ce que je ne peux même pas commencer à interpréter » — plus d”un schéma en avance, ou un schéma qui n”a jamais existé. Un schéma en avance est explicitement laissé passer, car la règle des claims critiques est sur le point de le gérer correctement.

Règle trois : les claims critiques — le vieux client doit refuser ce qu”il ne peut pas appliquer

Le format de certificats X.509 a résolu exactement ce problème il y a des décennies avec l”indicateur critical sur les extensions : une extension qu”un vérificateur ne reconnaît pas peut être ignorée sauf si elle est marquée critique, auquel cas le certificat doit être rejeté. Le licensing a les deux mêmes types de claim, et la même réponse fonctionne.

Un claim informatif — un nom affiché, un contact de support, une référence de bon de commande — peut être ignoré par un client qui n”en a jamais entendu parler. Rien ne change dans l”application de la licence.

Un claim restrictif — un plafond de sièges, un verrou régional, un plafond de débit d”API, une borne de couverture « cette clé ne peut exécuter que les versions sorties avant cette date » — change ce que le client a le droit de faire. Un client qui l”ignore ne se contente pas d”afficher moins ; il accorde trop. Le vieux client doit savoir que quelque chose qu”il ne comprend pas le restreint et échouer en mode fermé.

La liste Critical rend cela connaissable sans que le vieux client comprenne le claim lui-même. Le service émetteur énumère les noms de tous les claims restrictifs qu”il a mis dans la charge utile ; le client vérifie qu”il reconnaît chaque nom de cette liste, et refuse la licence — avec une raison claire « mise à jour requise », pas un échec générique — si l”un lui est inconnu.

static readonly HashSet<string> KnownClaims = new(StringComparer.Ordinal)
{
    // Every claim this build can *enforce*. Informational claims need not be listed —
    // they are never critical — but listing them is harmless.
    "sub", "product", "edition", "exp", "machineId",
    "seatCap",        // added in the schema-2 client
};

public static LicenseInfo Evaluate(LicensePayload p)
{
    foreach (var name in p.Critical)
    {
        if (!KnownClaims.Contains(name))
            return LicenseInfo.Free(LicenseFailure.UpdateRequired,
                                    $"License requires a claim this version cannot enforce: {name}");
    }

    // From here on, enforcement is normal: every claim we know, we apply.
    if (p.Exp < Clock.UtcNow) return LicenseInfo.Free(LicenseFailure.Expired);
    if (p.SeatCap is int cap && Seats.InUse() > cap) return LicenseInfo.Free(LicenseFailure.SeatCapExceeded);
    ...
    return LicenseInfo.Paid(p.Edition, p);
}

C”est la même posture d”échec en mode fermé que Validate() de Keyright adopte partout — il ne lève jamais, il renvoie une édition Free avec une raison — appliquée au format lui-même. Le vieux client ne fait pas semblant. Il dit « il y a une restriction sur cette licence que je suis trop ancien pour honorer », ce qui est exactement le message que le support veut voir dans le journal.

Un détail à bien soigner : Critical doit elle-même faire partie du vocabulaire du schéma 1, sinon un client de schéma 1 ne peut pas la voir. C”est pourquoi la liste entre dès le premier jour comme tableau vide. Si vous l”ajoutez après coup à un format déjà livré, vous avez une génération de schéma où les seuls ajouts sûrs sont informatifs — et c”est un argument de poids pour ajouter la liste maintenant, avant d”en avoir besoin.

Règle quatre : ne réaffectez jamais, ne rétrécissez jamais

Deux changements ressemblent à du versionnage et sont en réalité des ruptures.

Réaffecter un claim — garder le nom edition mais changer l”ensemble des chaînes qu”il peut contenir, ou garder exp mais passer des secondes aux millisecondes — casse tous les vieux clients d”une manière qu”aucune vérification de version n”attrape, car le champ se parse bien et signifie autre chose. Si le sens d”un claim change, c”est un nouveau claim avec un nouveau nom, et l”ancien est soit maintenu renseigné pendant une période de dépréciation, soit supprimé dans un saut de schéma majeur que les vieux clients refuseront à la porte.

Rétrécir une valeur par défaut — un claim qui était optionnel et permissif au schéma 1 devenant obligatoire ou restrictif au schéma 2 — est une restriction déguisée. Si SeatCap absent signifiait « illimité » au schéma 1 et que vous voulez qu”absent signifie « un siège » au schéma 2, les vieux clients accorderont des sièges illimités sur chaque clé de schéma 2 qui omet le claim. Gardez la valeur par défaut permissive et rendez la restriction explicite et critique.

La règle qui couvre les deux : à l”intérieur d”une génération de schéma, les ajouts sont le seul changement ; le sens d”un claim existant et le comportement en son absence sont figés. Un saut majeur (schéma 2 → 3) est l”endroit où vous pouvez les rompre, et la porte de VerifyAndRead plus la liste des critiques garantit que les vieux clients refusent plutôt que de mal lire.

Négociation : le serveur ne devrait pas émettre ce que le client ne sait pas lire

Tout ce qui précède est défensif : le client se protégeant d”une charge utile écrite dans un vocabulaire plus récent. Le complément est que le côté émetteur sache ce que chaque client sait lire, ce qui coûte un entier sur la requête d”activation que vous envoyez déjà.

public sealed record ActivationRequest
{
    public required string Product { get; init; }
    public required string LicenseKey { get; init; }
    public required string MachineId { get; init; }
    public required int MaxSchema { get; init; }      // the highest schema this client parses
    public required string ClientVersion { get; init; }
}

Avec cela, le service a un vrai choix quand une licence a besoin d”un claim pour lequel le client est trop ancien :

  • L”exprimer dans le vocabulaire ancien quand c”est possible. Un plafond de 1 siège pour un client de schéma 1 peut être émis comme clé node-locked de schéma 1, que ce client applique nativement.
  • Émettre le nouveau schéma et laisser la liste des critiques faire son travail. Le client échoue en mode fermé avec « mise à jour requise » — correct, mais cela laisse l”utilisateur avec une installation non fonctionnelle jusqu”à ce qu”il mette à jour.
  • Refuser l”activation avec une raison précise — « cette licence requiert le client 3.2 ou ultérieur » — pour que le message atteigne l”utilisateur au moment où il peut agir, pas comme un échec cryptique plus tard.

Lequel vous choisissez est une décision produit ; ce qui compte, c”est que le serveur décide avec de l”information plutôt qu”en devinant. Le service enregistre aussi MaxSchema par activation, ce qui répond à la question qui autrement paralyse chaque changement de format : combien de mes clients sont encore au schéma 1 ? Quand ce nombre atteint zéro, vous pouvez retirer l”ancien émetteur ; jusque-là, vous savez exactement ce que vous cassez.

LicensePayload BuildPayload(LicenseRecord lic, ActivationRequest req)
{
    int target = Math.Min(req.MaxSchema, CurrentSchema);

    var p = new LicensePayload
    {
        Schema = target, Sub = lic.CustomerId, Product = lic.Product,
        Edition = lic.Edition, Exp = lic.ExpiresAt, MachineId = req.MachineId,
    };

    if (target >= 2)
    {
        p = p with { DisplayName = lic.CustomerDisplayName, SeatCap = lic.SeatCap,
                     Critical = lic.SeatCap is null ? [] : ["seatCap"] };
    }
    else if (lic.SeatCap is int cap && cap != 1)
    {
        // Cannot express a multi-seat cap in schema 1: refuse with a reason the UI can show.
        throw new ActivationRefused(RefusalReason.ClientTooOld, minimumClient: "3.2.0");
    }

    return p;
}

Fichiers de référence : rendre le contrat testable

Un contrat de format qui n”est pas sous test dérivera. Le remède est peu spectaculaire et très efficace : versionnez des charges utiles signées réelles et exécutez-les dans les deux sens en CI.

En avant : pour chaque schéma que vous avez jamais émis, gardez un répertoire de références signées — une clé valide, une expirée, une avec chaque claim optionnel présent, une avec un claim informatif inconnu injecté, une avec un claim critique inconnu injecté — et vérifiez le verdict du parseur actuel sur chacune. La référence au critique inconnu doit s”évaluer en UpdateRequired ; celle à l”informatif inconnu doit s”évaluer en Paid. La première fois que quelqu”un « nettoie » le parseur pour qu”il rejette les champs inconnus, ceci échoue.

En arrière : référencez le plus vieux paquet client que vous supportez encore comme version NuGet épinglée dans le projet de test, pas comme code source. Construisez des charges utiles avec l”émetteur du serveur actuel pour chaque schéma qu”il peut produire, et passez chacune par VerifyAndRead et Evaluate du vieux client. Le vieux client doit vérifier chacune (les octets sont canoniques ; la signature tient quel que soit le vocabulaire), doit accepter les ajouts informatifs, et doit refuser exactement les charges utiles qui portent un claim critique qu”il ne connaît pas.

[Theory]
[MemberData(nameof(Goldens), "schema-1")]
[MemberData(nameof(Goldens), "schema-2")]
public void Current_parser_accepts_every_shipped_schema(GoldenCase g)
{
    var payload = Licensing.VerifyAndRead(g.PayloadBytes, g.Signature, TestKeys.Issuer);
    var verdict = Licensing.Evaluate(payload);
    Assert.Equal(g.ExpectedFailure, verdict.Failure);   // None, Expired, UpdateRequired, ...
}

[Fact]
public void Oldest_supported_client_fails_closed_on_critical_claims()
{
    var bytes = Server.Emit(schema: 2, seatCap: 25);           // current emitter
    var v = LegacyClient.Validate(bytes.Payload, bytes.Signature); // pinned 3.1.x package
    Assert.Equal(LegacyFailure.UpdateRequired, v.Failure);
}

Deux assertions de cette suite portent l”essentiel du poids : une signature qui a vérifié hier vérifie aujourd”hui, et un vieux client n”accorde jamais trop. Tout le reste sur l”évolution du format est une conséquence du maintien de ces deux-là au vert.

Ce que vous livrez vraiment

La mécanique tient en quelques lignes : un claim schema à l”intérieur des octets signés ; la vérification de la signature sur les octets bruts avant le parsing ; un parseur qui conserve les claims inconnus au lieu de les rejeter ; une liste critical pour qu”un vieux client refuse les restrictions qu”il ne peut pas appliquer et ignore l”information qu”il peut ignorer ; un rapport de capacités d”un entier à l”activation pour que le serveur émette ce que le client sait lire ou refuse avec une raison ; et des fichiers de référence signés exécutés en avant et en arrière en CI. À l”intérieur d”une génération de schéma, vous ne faites qu”ajouter, jamais réaffecter, jamais rétrécir une valeur par défaut. Avec cela en place, « il nous faut un nouveau claim » cesse d”être un événement de mise à jour de tout le parc et devient un changement ordinaire : les clients qui peuvent le lire le lisent, ceux qui ne peuvent pas échouent en mode fermé avec un message qui dit exactement pourquoi, et la signature ne cesse jamais de signifier ce qu”elle signifiait.

Essayez Nebula.NET

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