Skip to content
← Tous les articles
· Delta1 Labs KeyrightLicences.NETAnalyse approfondie

Journaliser un contrôle de licence : des événements structurés qu'un opérateur peut lire, et jamais la clé elle-même

Le contrôle de licence est l'unique événement qui décide si votre logiciel a le droit de s'exécuter, et il est presque toujours journalisé soit comme rien du tout, soit comme un désastre — un log.LogInformation("license: {License}", info) qui sérialise l'objet entier et imprime la clé signée, la signature et l'e-mail du licencié dans un fichier qui sera expédié vers un agrégateur de logs et partagé dans un ticket de support. Cet article construit la voie médiane : un seul événement structuré par vérification, émis via un LoggerMessage généré à la compilation pour que la règle de caviardage vive à un seul endroit, avec la clé réduite à une courte empreinte qui permet à un opérateur de corréler sans jamais transporter le secret. Il mappe le LicenseStatus de Keyright sur des niveaux de log de sorte qu'une licence invalide soit une Error sur laquelle alerter et une licence saine une Information discrète, attribue une plage d'EventId stable pour qu'un tableau de bord compte les échecs de licence sans comparaison de chaînes, enveloppe la vérification dans un scope de journalisation pour que chaque ligne en aval porte l'empreinte, garde l'événement hors du chemin chaud par requête, et se termine par le test — une assertion sur FakeLogger — qui prouve que la clé brute n'atteint jamais le sink.

Un contrôle de licence est l’unique événement qui décide si votre logiciel a le droit de s’exécuter ce soir, et il est presque toujours journalisé de l’une de deux mauvaises façons. Soit il est journalisé comme rien — la vérification a lieu, réussit ou échoue en silence, et la première fois que quelqu’un entend parler d’une clé échue, c’est par une plainte d’utilisateur — soit il est journalisé comme un désastre : log.LogInformation("license: {@License}", info), qui sérialise l’objet entier et écrit la clé signée, la signature brute et l’e-mail du licencié dans un fichier qui sera expédié vers un agrégateur et collé dans un ticket de support avant midi.

Cet article construit la version intermédiaire : un événement structuré et interrogeable par vérification, émis de sorte qu’un opérateur puisse le lire, alerter dessus et le corréler — et de sorte que la clé elle-même ne quitte jamais le processus. Il s’agit de journaliser une vérification Keyright comme vous journaliseriez un événement d’authentification, parce que c’est ce qu’elle est.

La chose que vous vérifiez

Le Validate() de Keyright renvoie un LicenseInfo avec un Status, un Licensee, une date d’expiration et les valeurs dérivées DaysRemaining/IsExpiringSoon sur lesquelles les travaux de renouvellement et de health check s’appuient déjà :

LicenseInfo info = client.Validate(licenseKey);
// info.Status        -> LicenseStatus (Valid, Expired, Revoked, SignatureInvalid, ...)
// info.Licensee      -> "Acme GmbH"
// info.ExpiryUtc     -> DateTime?
// info.DaysRemaining -> int
// info.IsExpiringSoon-> bool

Le licenseKey de cet appel est la chose qui ne doit jamais atteindre le log. Tout le reste sur info peut légitimement être enregistré ; la clé et la signature, non. La première décision de conception consiste donc à calculer une empreinte de la clé une seule fois, et à passer cela au logger — jamais la clé.

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

static string Fingerprint(string licenseKey)
{
    // A stable, non-reversible handle: first 8 hex of SHA-256 over the key.
    // Enough to say "same key as the one in the ticket?" — not enough to be the key.
    Span<byte> hash = stackalloc byte[32];
    SHA256.HashData(Encoding.UTF8.GetBytes(licenseKey), hash);
    return Convert.ToHexString(hash)[..8].ToLowerInvariant();   // e.g. "9f3a1c20"
}

Si votre payload porte un claim key-id, préférez-le — c’est déjà un identifiant non secret. L’empreinte est la solution de repli pour les clés qui n’en ont pas.

Un événement, déclaré une seule fois

La règle de caviardage ne vaut que par le nombre d’endroits où elle doit tenir. Disséminez la journalisation sur une douzaine de sites d’appel log.LogInformation("...") et celui qui passe licenseKey au lieu de fingerprint est la fuite. Déclarez donc l’événement une seule fois, sous forme de LoggerMessage généré à la compilation, dont la signature rend la clé brute impossible à passer :

using Microsoft.Extensions.Logging;

public static partial class LicenseLog
{
    // Note the parameters: fingerprint, status, licensee, days — never the key.
    [LoggerMessage(
        EventId = 4000, Level = LogLevel.Information,
        Message = "License verified: {Status} for {Licensee} (key {Fingerprint}), {DaysRemaining} day(s) remaining")]
    public static partial void Verified(
        this ILogger logger,
        string status, string licensee, string fingerprint, int daysRemaining);

    [LoggerMessage(
        EventId = 4001, Level = LogLevel.Warning,
        Message = "License expiring: {Status} for {Licensee} (key {Fingerprint}) expires in {DaysRemaining} day(s)")]
    public static partial void Expiring(
        this ILogger logger,
        string status, string licensee, string fingerprint, int daysRemaining);

    [LoggerMessage(
        EventId = 4002, Level = LogLevel.Error,
        Message = "License invalid: {Status} for {Licensee} (key {Fingerprint})")]
    public static partial void Invalid(
        this ILogger logger,
        string status, string licensee, string fingerprint);
}

Le générateur de source transforme chacun de ces éléments en une méthode sans allocation qui vérifie le niveau, écrit les champs nommés et ne formate le message que si un sink le demande. Aucun d’eux n’a de paramètre licenseKey, si bien qu’un appelant ne peut pas journaliser la clé via ce type, même par accident — le compilateur l’en empêche. C’est toute la raison de préférer le générateur à un template libre : la règle est désormais une signature de méthode, pas une convention de revue de code.

Mapper le statut sur le niveau

Le dispatch de LicenseStatus vers l’un de ces trois événements est la table de traduction, et elle s’aligne sur la façon dont l’alerting fonctionne déjà — Information est discret, Warning est remarqué, Error est compté et déclenche le pager :

public static void LogLicense(this ILogger logger, LicenseInfo info, string fingerprint)
{
    string status   = info.Status.ToString();
    string licensee  = info.Licensee ?? "(none)";

    switch (info.Status)
    {
        case LicenseStatus.Valid when info.IsExpiringSoon:
            logger.Expiring(status, licensee, fingerprint, info.DaysRemaining);
            break;
        case LicenseStatus.Valid:
            logger.Verified(status, licensee, fingerprint, info.DaysRemaining);
            break;
        default:   // Expired, Revoked, SignatureInvalid, MachineMismatch, ClockTampered, NoLicense
            logger.Invalid(status, licensee, fingerprint);
            break;
    }
}
LicenseInfo → caviardage → événement structuréLicenseInfoStatusLicenseeDaysRemainingclé (secret bearer)octets de signaturefrontière de caviardageSHA-256→ 9f3a1c204000 · Information · verifiedStatus Valid → ligne de base discrète4001 · Warning · expiringValid + IsExpiringSoon4002 · Error · invalidExpired · Revoked · SignatureInvalidMachineMismatch · ClockTamperedLa clé brute ne franchit jamais la frontière — seule l'empreinte de 8 caractères est émise.

Un sink console JSON rend alors le cas sain ainsi — notez que la clé n’y figure nulle part, seulement son empreinte :

{
  "Timestamp": "2026-10-16T09:00:01.42Z",
  "LogLevel": "Information",
  "EventId": 4000,
  "Message": "License verified: Valid for Acme GmbH (key 9f3a1c20), 48 day(s) remaining",
  "State": { "Status": "Valid", "Licensee": "Acme GmbH", "Fingerprint": "9f3a1c20", "DaysRemaining": 48 }
}

Parce que Status, Fingerprint et DaysRemaining sont des champs structurés nommés et pas seulement du texte figé dans le message, un backend de logs peut tracer « empreintes distinctes vues cette semaine » ou alerter sur « nombre d’EventId 4002 > 0 » sans analyser la chaîne du message. C’est le dividende de la forme structurée : l’événement est interrogeable, pas seulement lisible.

Un scope pour que le reste des logs se corrèle

La vérification tient en une ligne, mais le travail qui la suit — un renouvellement de bail en arrière-plan, un contrôle d’entitlement, une feature gate — produit ses propres logs, et un relecteur d’incident veut les rattacher à la licence qui était en vigueur. Ouvrez un scope de journalisation indexé par l’empreinte autour de ce travail, et chaque ligne à l’intérieur porte automatiquement le champ :

using (logger.BeginScope(new Dictionary<string, object> { ["LicenseKey"] = fingerprint }))
{
    logger.LogLicense(info, fingerprint);
    // ... anything logged in here also carries LicenseKey=9f3a1c20 ...
}

Désormais un SignatureInvalid à 03:00 et un contrôle d’entitlement échoué à 03:01 partagent un champ LicenseKey, et le relecteur peut extraire toute l’histoire avec un seul filtre. Le scope porte l’empreinte, jamais la clé — la même règle, appliquée à l’identifiant de corrélation.

Prouver que la clé ne s’échappe jamais

La seule assertion qui vaille la peine d’être écrite est la négative : que la clé brute n’apparaît dans rien de ce que le logger émet. Microsoft.Extensions.Diagnostics.Testing fournit un FakeLogger qui capture les enregistrements en mémoire, si bien que le test est direct :

[Fact]
public void Verification_never_logs_the_raw_key()
{
    var fake = new FakeLogger<LicenseService>();
    const string key = "KR1-REALKEY-abcdef ...";          // the actual signed key
    var info = new LicenseInfo { Status = LicenseStatus.Valid, Licensee = "Acme", DaysRemaining = 48 };

    fake.LogLicense(info, Fingerprint(key));

    foreach (FakeLogRecord rec in fake.Collector.GetSnapshot())
    {
        Assert.DoesNotContain(key, rec.Message);
        foreach (var kv in rec.StructuredState ?? [])
            Assert.DoesNotContain(key, kv.Value ?? "");    // check the structured fields too
    }
}

Faites l’assertion à la fois sur le message rendu et sur l’état structuré, parce qu’une fuite peut se cacher dans l’un ou l’autre — un {Key} égaré dans un template apparaît dans le message, un BeginScope qui a capturé la clé apparaît dans l’état. Ce test est ce qui maintient la règle vraie à mesure que le code grandit : le jour où quelqu’un ajoute un quatrième événement qui passe la clé, il passe au rouge.

À retenir

Un contrôle de licence mérite d’être journalisé comme l’événement d’authentification qu’il est : un enregistrement structuré, ni silence ni vidage de données. Déclarez l’événement une seule fois sous forme de LoggerMessage généré à la compilation dont les paramètres sont une empreinte et les champs non secrets, de sorte que la clé brute soit impossible à passer par construction. Mappez LicenseStatus sur des niveaux que votre alerting existant comprend déjà — Information pour valide, Warning pour proche de l’expiration, Error pour invalide — et donnez à chacun un EventId stable pour qu’un tableau de bord compte les échecs sans comparaison de chaînes. Émettez-le à la vérification et aux transitions, pas à chaque requête. Enveloppez le travail dans un scope indexé par l’empreinte pour que le reste des logs se corrèle. Puis écrivez le seul test qui prouve que la clé n’atteint jamais le sink, et laissez-le garder la règle pour toujours. Le résultat est un log qu’un opérateur peut lire, qu’un backend peut interroger et qu’un ticket de support peut transporter en toute sécurité — parce que la seule chose qui ne doit pas voyager n’a jamais franchi la première ligne.

Essayez Nebula.NET

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