Die Lizenz-Payload versionieren: Claims hinzufügen, ohne die bereits ausgelieferten Clients zu brechen
Eine signierte Lizenz ist eine Menge von Claims, in Bytes eingefroren, und sobald du einen Client auslieferst, der sie liest, wird dieses Byte-Layout zu einem Vertrag, den du nicht einseitig ändern kannst. Dann verlangt das Produktmanagement einen neuen Claim — ein Sitz-Limit, eine Regionssperre, eine Funktionsliste — und du stellst fest, dass die Hälfte deiner Installationsbasis einen Parser ausführt, der ihn noch nie gesehen hat. Dieser Beitrag zeigt, wie sich Lizenz-Payloads im Keyright-Stil sicher weiterentwickeln: eine von der Signatur abgedeckte Schema-Version, ein Open-World-Parsing, das unbekannte Claims verifizierbar hält, eine Liste kritischer Claims, damit ein alter Client ablehnt, was er nicht durchsetzen kann, statt es still zu ignorieren, eine Schema-Aushandlung bei der Aktivierung, damit die ausstellende Seite nie etwas ausgibt, das ein Client nicht lesen kann, und die Golden-File-Tests, die verhindern, dass eine Formatänderung unbemerkt in die Produktion gelangt.
Ein Lizenzschlüssel ist eine Menge von Claims, die der ausstellende Dienst signiert und der Client verifiziert: für wen, welches Produkt, welche Edition, wann sie abläuft, an welche Maschine sie gebunden ist. Die erste Version ist leicht. Du definierst einen Record, serialisierst ihn, signierst die Bytes, lieferst einen Client aus, der die Signatur prüft und die Felder liest, und es funktioniert.
Der Ärger beginnt an dem Tag, an dem du einen neuen Claim brauchst. Das Produkt will ein Sitz-Limit. Der Vertrieb will eine Regionsbeschränkung. Die Entwicklung will eine Funktionsliste, damit ein Schlüssel eine Teilmenge von Modulen freischaltet. Jeder davon ist eine Änderung der Payload, und die Payload wird bereits von jeder Kopie deiner Software im Feld geparst, einschließlich jener, die ein Jahr lang nicht aktualisieren. Einige dieser Installationen werden die neue Payload lesen und das Feld, das sie nicht verstehen, still ignorieren; ob das korrekt ist, hängt vollständig davon ab, was das Feld bedeutet. Ignoriere einen Anzeigenamen und nichts passiert. Ignoriere ein Sitz-Limit und du hast gerade allen, die nicht aktualisiert haben, unbegrenzte Sitze geschenkt.
In diesem Beitrag geht es darum, diese Weiterentwicklung bewusst zu betreiben: eine von der Signatur abgedeckte Version, ein Parsing, das toleriert, was es nicht versteht, ohne die Verifikation zu schwächen, eine Möglichkeit, die Claims zu markieren, die ein alter Client nicht ignorieren darf, eine Aushandlung, damit der Server nie eine Payload ausgibt, die ein Client nicht lesen kann, und Tests, die das Ganze über Releases hinweg zusammenhalten.
Die Payload ist ein Vertrag, den die Signatur einfriert
Beginne mit der Form, die du bereits hast. Eine Payload der ersten Generation im Keyright-Stil könnte dieser Record sein, zu kanonischem JSON serialisiert und als abgelöste Signatur über diese Bytes signiert:
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
}
Das Wichtige an diesem Record sind nicht die Felder; es ist, dass die Kodierung dieser Felder nun fixiert ist. Die Signatur deckt genau die Bytes ab, die der ausstellende Dienst erzeugt hat, also hängt die Verifikation auf dem Client davon ab, diese Bytes zu reproduzieren — oder besser: sie nie zu verändern. Wir haben im Beitrag über kanonisches Signieren erklärt, warum Feldreihenfolge und Kodierung zählen; hier ist die Konsequenz, dass das Format ein Vertrag zwischen zwei Parteien ist, die nach völlig verschiedenen Zeitplänen aktualisieren. Der ausstellende Dienst aktualisiert, wenn du ihn deployst. Der Client aktualisiert, wenn dem Kunden danach ist.
Jede Änderung der Payload muss daher an einer einzigen Frage gemessen werden: Was tut der älteste Client, den ich noch unterstütze, wenn er das sieht? Es gibt nur drei ehrliche Antworten — er liest es korrekt, er ignoriert es harmlos, oder er ignoriert es gefährlich — und das gesamte folgende Design existiert, um sicherzustellen, dass die dritte nie still geschieht.
Regel eins: Die Schema-Version ist ein Claim und sie ist signiert
Setz von Tag eins an einen schema-Integer in die Payload, auch wenn er 1 ist und nichts ihn liest. Weil er innerhalb der signierten Bytes steckt, kann niemand eine Payload auf eine Version mit schwächerer Semantik herabstufen, ohne die Signatur zu brechen. Weil er ein schlichter Integer ist, kann der Client darauf Entscheidungen treffen, bevor er sonst irgendetwas versteht.
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; }
}
Die Schema-Zahl ist nicht „die Version des Clients, der das lesen kann”. Sie ist „die Version des Vokabulars, in dem diese Payload geschrieben wurde”. Ein Schema-1-Client kann eine Schema-2-Payload durchaus lesen, solange die Schema-2-Ergänzungen Dinge sind, die er ignorieren darf — und das entscheiden die nächsten zwei Regeln.
Regel zwei: Verifiziere die Bytes, dann parse Open-World
Der Parser muss Claims tolerieren, die er nicht kennt. In System.Text.Json ist das das [JsonExtensionData]-Dictionary oben: jede Eigenschaft, die der Record nicht deklariert, wird gesammelt statt abgelehnt. Das ist Open-World-Lesen, und es ist das, was einen alten Client eine Payload mit neuen informativen Claims akzeptieren lässt.
Die Falle ist, Open-World-Lesen mit Closed-World-Verifikation zu kombinieren. Wenn der Client die Payload in seinen Record deserialisiert, dann den Record neu serialisiert und die Signatur über das verifiziert, erzeugt ein alter Client, der ein unbekanntes Feld verworfen (oder umsortiert) hat, andere Bytes, und die Signatur scheitert an einer völlig gültigen Lizenz. Die Reihenfolge der Operationen muss sein: die Signatur über die Rohbytes genau so, wie empfangen, verifizieren, und erst dann diese Bytes an den Parser übergeben.
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;
}
Beachte das Tor in Schritt 3. Es sagt nicht „lehne alles ab, was neuer ist als ich”. Es sagt „lehne ab, was ich nicht einmal anfangen kann zu interpretieren” — mehr als ein Schema voraus oder ein Schema, das nie existierte. Ein Schema voraus wird ausdrücklich durchgelassen, denn die Regel der kritischen Claims ist im Begriff, das ordentlich zu handhaben.
Regel drei: Kritische Claims — der alte Client muss ablehnen, was er nicht durchsetzen kann
Das X.509-Zertifikatformat löste genau dieses Problem vor Jahrzehnten mit dem critical-Flag auf Erweiterungen: eine Erweiterung, die ein Prüfer nicht erkennt, darf ignoriert werden, es sei denn, sie ist als kritisch markiert, dann muss das Zertifikat abgelehnt werden. Lizenzierung hat dieselben zwei Arten von Claim, und dieselbe Antwort funktioniert.
Ein informativer Claim — ein Anzeigename, ein Support-Kontakt, eine Bestellreferenz — darf von einem Client ignoriert werden, der nie von ihm gehört hat. Nichts an der Durchsetzung ändert sich.
Ein einschränkender Claim — ein Sitz-Limit, eine Regionssperre, eine API-Ratenobergrenze, eine Abdeckungsgrenze „dieser Schlüssel darf nur vor diesem Datum veröffentlichte Versionen ausführen” — ändert, was der Client tun darf. Ein Client, der ihn ignoriert, zeigt nicht bloß weniger; er gewährt zu viel. Der alte Client muss wissen, dass etwas, das er nicht versteht, ihn einschränkt, und fail-closed gehen.
Die Critical-Liste macht das erkennbar, ohne dass der alte Client den Claim selbst versteht. Der ausstellende Dienst listet die Namen aller einschränkenden Claims auf, die er in die Payload gelegt hat; der Client prüft, ob er jeden Namen in dieser Liste kennt, und lehnt die Lizenz ab — mit einem klaren Grund „Update erforderlich”, nicht einem generischen Fehler — falls ihm einer unbekannt ist.
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);
}
Das ist dieselbe Fail-closed-Haltung, die Keyrights Validate() überall einnimmt — es wirft nie, es gibt eine Free-Edition mit einem Grund zurück — angewandt auf das Format selbst. Der alte Client tut nicht so als ob. Er sagt „es gibt eine Einschränkung auf dieser Lizenz, die ich zu alt bin zu ehren”, was genau die Meldung ist, die der Support im Log sehen will.
Ein Detail, das man richtig machen sollte: Critical muss selbst im Vokabular von Schema 1 stehen, sonst kann ein Schema-1-Client sie nicht sehen. Deshalb kommt die Liste am ersten Tag als leeres Array hinein. Wenn du sie nachträglich an ein bereits ausgeliefertes Format anbaust, hast du eine Schema-Generation, in der die einzigen sicheren Ergänzungen informativ sind — und das ist ein starkes Argument, die Liste jetzt hinzuzufügen, bevor du sie brauchst.
Regel vier: Niemals umwidmen, niemals verengen
Zwei Änderungen sehen aus wie Versionierung und sind in Wirklichkeit Bruch.
Das Umwidmen eines Claims — den Namen edition behalten, aber die Menge der Strings ändern, die er enthalten kann, oder exp behalten, aber von Sekunden auf Millisekunden wechseln — bricht jeden alten Client auf eine Weise, die keine Versionsprüfung erfasst, weil das Feld sauber parst und etwas anderes bedeutet. Wenn sich die Bedeutung eines Claims ändert, ist es ein neuer Claim mit neuem Namen, und der alte wird entweder für eine Auslaufphase weiter befüllt oder in einem großen Schema-Sprung entfernt, den alte Clients am Tor ablehnen.
Das Verengen eines Standardwerts — ein Claim, der in Schema 1 optional und großzügig war und in Schema 2 pflicht oder einschränkend wird — ist eine getarnte Einschränkung. Wenn abwesendes SeatCap in Schema 1 „unbegrenzt” bedeutete und du willst, dass abwesend in Schema 2 „ein Sitz” bedeutet, gewähren alte Clients unbegrenzte Sitze bei jedem Schema-2-Schlüssel, der den Claim weglässt. Behalte den großzügigen Standard und mach die Einschränkung explizit und kritisch.
Die Regel, die beides abdeckt: Innerhalb einer Schema-Generation sind Ergänzungen die einzige Änderung; die Bedeutung eines bestehenden Claims und das Verhalten bei seiner Abwesenheit sind eingefroren. Ein großer Sprung (Schema 2 → 3) ist der Ort, an dem du sie brechen darfst, und das Tor in VerifyAndRead plus die kritische Liste garantieren, dass alte Clients ablehnen statt falsch zu lesen.
Aushandlung: Der Server sollte nicht ausgeben, was der Client nicht lesen kann
Alles Bisherige ist defensiv: der Client schützt sich vor einer Payload, die in einem neueren Vokabular geschrieben ist. Die Ergänzung ist, dass die ausstellende Seite weiß, was jeder Client lesen kann, was einen Integer auf der Aktivierungsanfrage kostet, die du ohnehin schon sendest.
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; }
}
Damit hat der Dienst eine echte Wahl, wenn eine Lizenz einen Claim braucht, für den der Client zu alt ist:
- Ihn im älteren Vokabular ausdrücken, wo möglich. Ein Sitz-Limit von 1 für einen Schema-1-Client kann als node-locked Schema-1-Schlüssel ausgegeben werden, den dieser Client nativ durchsetzt.
- Das neuere Schema ausgeben und die kritische Liste ihre Arbeit tun lassen. Der Client geht mit „Update erforderlich” fail-closed — korrekt, aber es lässt den Nutzer mit einer nicht funktionierenden Installation zurück, bis er aktualisiert.
- Die Aktivierung mit einem präzisen Grund ablehnen — „diese Lizenz erfordert Client 3.2 oder neuer” — damit die Meldung den Nutzer in dem Moment erreicht, in dem er handeln kann, nicht als kryptischer Fehler später.
Welche du wählst, ist eine Produktentscheidung; worauf es ankommt, ist, dass der Server mit Information entscheidet statt zu raten. Der Dienst erfasst außerdem MaxSchema pro Aktivierung, was die Frage beantwortet, die sonst jede Formatänderung lähmt: Wie viele meiner Kunden sind noch auf Schema 1? Wenn diese Zahl null erreicht, kannst du den alten Emitter stilllegen; bis dahin weißt du genau, was du brichst.
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;
}
Golden Files: den Vertrag testbar machen
Ein Formatvertrag, der nicht unter Test steht, driftet. Die Abhilfe ist unspektakulär und sehr wirksam: committe echte signierte Payloads und führe sie in der CI in beide Richtungen aus.
Vorwärts: Für jedes Schema, das du je ausgegeben hast, halte ein Verzeichnis signierter Goldens — ein gültiger Schlüssel, ein abgelaufener, einer mit jedem optionalen Claim vorhanden, einer mit eingeschleustem unbekanntem informativem Claim, einer mit eingeschleustem unbekanntem kritischem Claim — und prüfe das Urteil des aktuellen Parsers über jedes. Das Golden mit unbekanntem Kritischem muss zu UpdateRequired auswerten; das mit unbekanntem Informativem zu Paid. Beim ersten Mal, dass jemand den Parser „aufräumt”, sodass er unbekannte Felder ablehnt, scheitert dies.
Rückwärts: Referenziere das älteste Client-Paket, das du noch unterstützt, als gepinnte NuGet-Version im Testprojekt, nicht als Quellcode. Baue Payloads mit dem aktuellen Server-Emitter in jedem Schema, das er erzeugen kann, und schick jede durch VerifyAndRead und Evaluate des alten Clients. Der alte Client muss jede verifizieren (die Bytes sind kanonisch; die Signatur hält, egal welches Vokabular), muss die informativen Ergänzungen akzeptieren und muss genau die Payloads ablehnen, die einen kritischen Claim tragen, den er nicht kennt.
[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);
}
Zwei Assertions dieser Suite tragen das meiste Gewicht: eine Signatur, die gestern verifizierte, verifiziert heute, und ein alter Client gewährt nie zu viel. Alles andere über die Formatentwicklung ist eine Folge davon, diese beiden grün zu halten.
Was du tatsächlich auslieferst
Die Mechanik passt in wenige Zeilen: ein schema-Claim innerhalb der signierten Bytes; Signaturverifikation über die Rohbytes vor dem Parsen; ein Parser, der unbekannte Claims behält statt sie abzulehnen; eine critical-Liste, damit ein alter Client die Einschränkungen ablehnt, die er nicht durchsetzen kann, und die Information ignoriert, die er ignorieren kann; ein Fähigkeitsbericht von einem Integer bei der Aktivierung, damit der Server ausgibt, was der Client lesen kann, oder mit einem Grund ablehnt; und signierte Golden Files, in der CI vorwärts und rückwärts ausgeführt. Innerhalb einer Schema-Generation fügst du nur hinzu, widmest nie um, verengst nie einen Standardwert. Mit diesem Aufbau hört „wir brauchen einen neuen Claim” auf, ein flottenweites Update-Ereignis zu sein, und wird eine gewöhnliche Änderung: die Clients, die ihn lesen können, tun es, die, die es nicht können, gehen fail-closed mit einer Meldung, die genau sagt warum, und die Signatur hört nie auf zu bedeuten, was sie bedeutete.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.