Skip to content
← Alle Beiträge
· Delta1 Labs KeyrightLizenzierungKryptografie

Kanonische Signatur von Lizenz-Payloads: warum Feldreihenfolge und Kodierung entscheiden, ob Ihre Signatur verifiziert

Eine Signatur wird über Bytes berechnet, nicht über Felder — wenn Ihr ausstellender Dienst und Ihr Client das Lizenz-Payload also auch nur geringfügig anders serialisieren, schlägt die Verifikation bei einer völlig echten Lizenz fehl. So kanonisieren Sie ein Payload ein einziges Mal (stabile Feldreihenfolge, kulturinvariante Zahlen- und Datumsformatierung, UTF-8, base64url), damit eine Signatur in .NET, Node, Python und Java identisch verifiziert.

Signaturen sind das Fundament der Offline-Lizenzierung — ein Client vertraut einer Lizenz, weil er mit einem eingebetteten öffentlichen Schlüssel verifizieren kann, dass Ihr ausstellender Dienst sie signiert hat und sich seither nichts geändert hat. Doch dieses Vertrauen ruht auf einem Detail, das leicht falsch zu machen und brutal zu debuggen ist: Eine Signatur wird nicht über Ihre Felder berechnet, sondern über Bytes. Der Prüfer leitet aus den empfangenen Claims erneut Bytes ab und fragt, ob die Signatur zu genau diesen Bytes passt. Unterscheiden sich die Bytes des Ausstellers und die des Prüfers um ein einziges Leerzeichen, einen umgeordneten Schlüssel oder eine anders formatierte Zahl, scheitert die Verifikation — bei einer völlig echten Lizenz. Bringen Sie zwei Sprachen ins Spiel, jede mit eigenem Serialisierer und eigenem Locale, und „die Signatur verifiziert nicht” wird zu einem teamübergreifenden Rätsel.

Die Heilung ist die Kanonisierung: das Festlegen einer einzigen deterministischen Byte-Darstellung des Payloads, die jeder Beteiligte — Ihr ausstellender Dienst in .NET und Ihre Clients in .NET, Node, Python und Java — identisch rekonstruiert. In diesem Artikel geht es darum, wie man das präzise macht, und um die konkreten, stillen Arten, auf die es sprachübergreifend kaputtgeht.

Eine Signatur signiert Bytes, keine Bedeutung

Beginnen wir mit dem, was die Primitive tatsächlich tun. ECDSA.SignData(bytes) oder RSA.SignData(bytes) nehmen ein Byte-Array, hashen es und signieren den Hash. VerifyData(bytes, signature) hasht die übergebenen Bytes und prüft sie gegen die Signatur. Keine dieser Funktionen weiß etwas über JSON, Felder oder Absicht. Zwei Payloads, die ein Mensch „dieselbe Lizenz” nennen würde, sind nur dann dasselbe, wenn sie zu denselben Bytes serialisieren.

Der eigentliche Vertrag zwischen Aussteller und Client lautet also nicht „diese Felder” — sondern „diese Bytes, auf genau diese Weise erzeugt”. Die Kanonisierung ist die schriftliche Spezifikation dieser Erzeugung. Wie die Anatomie eines signierten Lizenzschlüssels betont: Sie verifizieren zuerst die Signatur und vertrauen danach den Claims; die Kanonisierung ist das, was diese Verifikation überhaupt erst reproduzierbar macht.

Die kanonische Form, Feld für Feld

Ein kanonisches Payload ist eine Funktion von den Claims auf eine einzige Byte-Zeichenkette, bei der jeder Freiheitsgrad festgenagelt ist. Hier eine Form, die zwischen Sprachen gut reist:

  • Die Feldreihenfolge ist fest. Entweder sortieren Sie die Schlüssel lexikografisch nach ihren UTF-8-Bytes, oder Sie definieren eine explizite geordnete Feldliste in der Spezifikation. Beides funktioniert; die einzig falsche Antwort ist „die Reihenfolge, die der Serialisierer zufällig ausgegeben hat”. Die meisten JSON-Bibliotheken garantieren keine Reihenfolge, und manche ordnen beim Hin-und-Zurück um.
  • Zahlen verwenden invariante Kultur, keine Tausendertrennzeichen, kein führendes +. 5, nicht 5.0, nicht 5,00. Ganzzahlen bleiben Ganzzahlen. Müssen Sie Dezimalstellen mitführen, fixieren Sie Präzision und Dezimalpunkt (.) in der Spezifikation — lassen Sie niemals ein deutsches Locale 1,50 ausgeben, wo ein englisches 1.50 ausgibt.
  • Booleans sind kleingeschriebenes true / false. Nicht True (Pythons Standard-str(bool)), nicht 1/0.
  • Daten sind ISO-8601 in UTC mit abschließendem Z, bei fester Präzision. Entscheiden Sie ein für alle Mal, ob Sie Millisekunden einschließen, und schließen Sie stets den Offset ein. 2027-01-15T00:00:00Z — niemals eine Lokalzeit, niemals eine implizite Zone.
  • Zeichenketten sind UTF-8, und Sie wählen eine Unicode-Normalisierungsform (NFC ist die übliche Wahl), damit akzentuierte Zeichen plattformübergreifend Byte für Byte übereinstimmen.
  • Keine beiläufigen Leerzeichen zwischen Tokens und kein abschließender Zeilenumbruch.
  • Die Signatur ist base64url ohne Padding, mit dem -/_-Alphabet, damit sie sicher in Dateien und URLs eingebettet werden kann und überall gleich dekodiert.

Es geht nicht darum, dass gerade diese Entscheidungen die einzig richtigen sind — sondern darum, dass irgendeine Entscheidung schriftlich festgehalten und von jedem SDK befolgt werden muss. Die kanonische Form ist ein Protokoll, kein Implementierungsdetail.

Die kanonischen Bytes in C# erzeugen und signieren

Hier ein Beispiel auf der Ausstellerseite, das eine kanonische Zeichenkette mit einer festen Feldliste aufbaut und sie mit ECDSA auf der NIST-P-256-Kurve signiert. Die Claims werden in deterministischer Reihenfolge geschrieben, Zahlen und Daten mit CultureInfo.InvariantCulture formatiert, und das Ergebnis vor dem Signieren als UTF-8 kodiert.

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

static string Canonicalize(License lic)
{
    // Feste Feldreihenfolge, invariante Formatierung, '\n'-Trenner. Genau diese
    // Zeichenkette ist der Vertrag — jedes SDK muss sie Byte für Byte rekonstruieren.
    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: sortiert, mit Komma verbunden, damit die Reihenfolge der Quellliste nie zaehlt.
    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('/', '_');

// --- Ausstellerseite (der private Schluessel bleibt hier) ---
byte[] canonical = Encoding.UTF8.GetBytes(Canonicalize(license));
byte[] signature = ecdsaPrivate.SignData(canonical, HashAlgorithmName.SHA256);
string sigField = Base64Url(signature);

Zwei Details tragen das ganze Gewicht. ToString(CultureInfo.InvariantCulture) ist das, was verhindert, dass eine Maschine mit Komma-Dezimal-Locale seats oder irgendeine Zahl anders serialisiert als eine Maschine mit Punkt-Dezimal-Locale. Und das Sortieren von features vor dem Verbinden bedeutet, dass der Client seine Feature-Liste in beliebiger Reihenfolge senden und trotzdem die identische kanonische Zeile rekonstruieren kann. Die Trenner (\n zwischen den Zeilen, , zwischen den Features, = zwischen Schlüssel und Wert) sind durch die Spezifikation fixiert — der Client muss dieselben verwenden.

Nur mit dem öffentlichen Schlüssel verifizieren

Der Client besitzt nur den öffentlichen Schlüssel. Er parst die empfangenen Claims, rekonstruiert die kanonischen Bytes mit derselben Funktion, dekodiert die Signatur aus base64url und verifiziert. Er signiert nie etwas neu und vertraut keinem Feld, bis die Signatur stimmt.

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

// --- Clientseite (nur der oeffentliche Schluessel ist eingebettet) ---
byte[] canonical = Encoding.UTF8.GetBytes(Canonicalize(received)); // dieselbe Funktion
byte[] signature = FromBase64Url(received.Signature);

bool ok = ecdsaPublic.VerifyData(canonical, signature, HashAlgorithmName.SHA256);
if (!ok)
    return LicenseInfo.Invalid("Signatur nicht verifiziert"); // fail closed

// erst jetzt sind product, tier, seats, features vertrauenswuerdig

Beachten Sie, dass der FromBase64Url-Helfer das Padding vor dem Dekodieren wieder hinzufügt, weil .NETs Convert.FromBase64String es verlangt. Ein Node- oder Python-Dekodierer könnte die ungepolsterte Form direkt akzeptieren — genau die Art von sprachspezifischem Unterschied, die in der Spezifikation geklärt und nicht in der Produktion entdeckt werden muss. (Wo dieser Verifikationsschritt in einem vollständigen Offline-Ablauf sitzt — Node-Locking, Widerruf, Fail-Closed — lesen Sie in Offline-Lizenzvalidierung erklärt.)

Ein konkreter sprachübergreifender Fehltreffer

Hier der Fehler in Fleisch und Blut. Angenommen, der ausstellende Dienst signiert die obige kanonische Zeichenkette, und ein Node-Client implementiert Canonicalize neu. Ein Entwickler, der in der Oberfläche ein Testabzeichen anzeigen will, fügt der Client-Version der Funktion eine Zeile hinzu:

// Node-Client — FALSCH: haengt ein Feld an, das der Aussteller nie signiert hat
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;   // <-- zusaetzliche Zeile, nicht in der signierten Form
  return s;
}

Der Aussteller signierte sechs Felder. Der Client hasht sieben. Die Byte-Zeichenketten unterscheiden sich um das abschließende \nisTrial=false, sodass verify bei jeder Lizenz, die dieser Client sieht, false zurückgibt — echte eingeschlossen. Nichts sieht falsch aus: Die Claims sind alle korrekt, der Schlüssel ist korrekt, der Algorithmus passt. Der einzige Defekt ist, dass eine Seite eine andere Byte-Zeichenkette kanonisiert hat.

Die Lösung ist nicht, den Client „tolerant” zu machen. Sie besteht darin zu vereinbaren, dass die kanonische Form genau die sechs Felder sind, die der Aussteller signiert, und dass isTrial — falls es überhaupt Teil der Lizenz ist — auf beiden Seiten eines dieser signierten Felder ist, identisch formatiert, an seiner definierten Position. Ein Flag, das das Verhalten beeinflusst, aber nicht im signierten Payload steht, sind entweder unsignierte Daten, denen Sie nicht vertrauen dürfen, oder ein Feld, das Sie zu kanonisieren vergessen haben. Eine dritte Option gibt es nicht. Dieselbe Disziplin erledigt die subtileren Varianten dieses Bugs: einen Client, der Features mit , statt , verbindet, einen, der das Datum ohne das Z ausgibt, einen, der JSON.stringify die Schlüssel umordnen lässt, einen, der einen abschließenden Zeilenumbruch stehen lässt, den der Aussteller abgeschnitten hat.

Ausstellerseite (privater Schluessel)Claimsproduct, tier, seats…kanonische Bytesfeste Reihenfolge · UTF-8signieren(Bytes, priv)→ base64url-SignaturLizenz = Claims + SignaturClientseite (nur oeffentlicher Schluessel)empfangene Claimsaus Lizenz geparstkanonische Bytes+ zusaetzliche isTrial-Zeileverifizieren(Bytes, Sig, pub)hasht 7 FelderSCHEITERT(echt)gleiche Claims, aber die beiden kanonischenByte-Zeichenketten weichen ab → Signatur passt nicht

Die Fallstricke, benannt

Jeder davon ändert die Bytes, ohne die scheinbare Bedeutung zu ändern — deshalb sind sie so schwer zu erkennen:

  • Anhängen unsignierter Flags. Das durchgearbeitete Beispiel oben. Ein Marker, ein Schalter, ein Oberflächenhinweis — wenn eine Seite ihn in die kanonische Zeichenkette faltet und die andere nicht, verifiziert nichts.
  • Abschließende Leerzeichen und Zeilenumbrüche. Ein Serialisierer, der hübsch formatiert, ein Editor, der einen abschließenden Zeilenumbruch hinzufügt, ein WriteLine statt eines Write. Schneiden Sie bewusst ab und spezifizieren Sie das Ergebnis.
  • Unicode-Normalisierung. Ein Lizenznehmername mit einem akzentuierten Zeichen kann auf einem Betriebssystem komponiert (NFC) und auf einem anderen dekomponiert (NFD) gespeichert sein; die beiden sind optisch identisch und in Bytes verschieden. Normalisieren Sie vor dem Kodieren auf eine Form.
  • Zahlen- und Datums-Locale. Komma vs. Punkt als Dezimaltrenner, gruppierte Tausender, Daten mit oder ohne Millisekunden oder Offset. Fixieren Sie invariante Kultur und ein einziges Datumsformat.
  • base64 vs. base64url, gepolstert vs. ungepolstert. Die Alphabete +// vs. -/_ und die =-Padding-Regel müssen an beiden Enden übereinstimmen, sonst dekodieren die Signatur-Bytes falsch.
  • Byte-Reihenfolge in Binärkodierungen. Falls Sie jemals zu einem binären Layout statt zu Text kanonisieren — Ganzzahlen oder Längen als rohe Bytes packen — werden Big-Endian auf einer Plattform und Little-Endian auf einer anderen still auseinanderlaufen. Fixieren Sie die Byte-Reihenfolge (Big-Endian/Netzwerk ist die konventionelle Wahl) in der Spezifikation. Textuelle kanonische Formen umgehen das vollständig, was ein Grund ist, sie zu bevorzugen.
  • Schlüssel-Umordnung durch den Serialisierer. Viele JSON-Bibliotheken bewahren die Einfügereihenfolge nicht und manche ordnen beim Parsen/Neu-Ausgeben um. Verlassen Sie sich nie auf die ausgegebene Reihenfolge; sortieren Sie oder verwenden Sie eine explizite Feldliste.

Eine kanonische Form, schriftlich festgehalten

Der rote Faden ist einfach: Die Signatur ist ein Versprechen über Bytes, also müssen der Aussteller und jeder Client diese Bytes nach demselben Rezept herstellen. Die Kanonisierung ist dieses Rezept — feste Feldreihenfolge, invariante Zahlen, ISO-8601-UTC-Daten, UTF-8, eine gewählte Unicode-Form, base64url für die Signatur — ins Protokoll geschrieben und in .NET, Node, Python und Java identisch implementiert. Die SDKs von Keyright teilen sich genau eine solche kanonische Definition, damit eine vom ausstellenden Dienst signierte Lizenz in jeder Client-Sprache Byte für Byte verifiziert, und der Test, der zählt, ist nicht „verifiziert es in .NET?”, sondern „erzeugt dieselbe Lizenz, in jedem SDK geparst und rekonstruiert, die identischen kanonischen Bytes?”. Wenn ja, ist die sprachübergreifende Verifikation langweilig — genau das, was Sie von Kryptografie wollen. Wenn nein, steckt der Fehler fast nie in der Mathematik; er steckt in den Bytes, und das Rezept ist der Ort, an dem Sie ihn finden.

Nebula.NET testen

Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.