Ein .NET-Assembly nach der Obfuskierung neu signieren: starke Namen und Authenticode
Das Umbenennen schreibt dein Assembly neu, was den starken Namen und die Authenticode-Signatur ungültig macht, mit denen es gebaut wurde — ein geschützter Build, der unsigniert ausgeliefert wird oder schlimmer mit einer nun gebrochenen Signatur, lädt nicht oder scheitert an der Prüfung. Nebula.NET signiert als letzten Pipeline-Schritt für dich neu: eine neue Starker-Name-Signatur über dem obfuskierten Image, assembly-übergreifende Public-Key-Token neu geschrieben, damit voneinander abhängige Assemblys sich noch auflösen, dann eine Authenticode-Signatur obendrauf. Hier ist die genaue Reihenfolge, wie du den Schlüssel einem Build-Server gibst, ohne ihn einzuchecken, und was InternalsVisibleTo kaputtmacht, wenn du es vergisst.
Signierung und Obfuskierung haben ein Reihenfolgenproblem, das jedes Team beim ersten Aktivieren des Schutzes in einer Release-Pipeline beißt. Sowohl ein starker Name als auch eine Authenticode-Signatur sind Signaturen über die Bytes deines Assemblys. Die Obfuskierung schreibt diese Bytes neu: einen Typ umbenennen ändert die Metadaten, eine Zeichenfolge verschlüsseln schreibt die IL neu, den Kontrollfluss abflachen ersetzt Methodenkörper. Ein Assembly, das du signiert und dann obfuskiert hast, trägt also eine Signatur, die nicht mehr zu seinem eigenen Inhalt passt: der CLR lehnt den starken Namen als manipuliert ab, und jede Authenticode-Signatur prüft als gebrochen. Der Build sieht auf deiner Maschine gut aus und lädt auf einer abgesicherten nicht.
Die Regel ist einfach — zuletzt signieren — aber das von Hand über einen voneinander abhängigen Satz von Assemblys zu tun, in der richtigen Reihenfolge, ohne den Schlüssel ins Repo zu lecken, ist fummelig. Nebula.NET faltet all das ans Ende der Pipeline. Dieser Beitrag beschreibt genau, was es tut, in welcher Reihenfolge, und die zwei Dinge (CI-Schlüssel und InternalsVisibleTo), die noch deine Aufmerksamkeit brauchen.
Warum das Umbenennen den starken Namen ungültig macht
Ein starker Name ist eine RSA-Signatur über einen Hash des Assembly-Inhalts, im Assembly selbst gespeichert, wobei der passende öffentliche Schlüssel Teil der Identität des Assemblys wird. Der CLR berechnet diesen Hash zur Ladezeit neu (für ein voll signiertes Assembly in einem Kontext, der es prüft) und vergleicht. Ändere ein Byte Metadaten oder IL nach dem Signieren und die Hashes divergieren:
> sn -vf MyApp.dll
Failed to verify assembly -- Strong name validation failed.
Das ist das erwartete Ergebnis, wenn man ein vorab signiertes Assembly obfuskiert. Das originale .snk ist immer noch der richtige Schlüssel; was falsch ist, ist, dass die Signatur über die Bytes vor der Obfuskierung berechnet wurde. Du kannst einen starken Namen nicht durch die Obfuskierung „erhalten” — du kannst ihn nur danach, über dem endgültigen Image, erneut anwenden. Das tut strongNameKeyFile:
{
"inputs": ["bin/Release/net8.0/MyApp.dll"],
"outputDirectory": "bin/Release/net8.0/obf",
"controlFlowObfuscation": true,
"encryptStrings": true,
"strongNameKeyFile": "keys/MyApp.snk"
}
Nebula obfuskiert, dann signiert es die Ausgabe mit diesem Schlüssel als letzten Metadaten-Schritt. Der öffentliche Schlüssel der Ausgabe — und damit ihre Starker-Name-Identität — ist, was das .snk trägt. Signieren und erneutes Signieren nach der Obfuskierung ist eine Pro-Funktion, zusammen mit dem restlichen Härten jenseits des Umbenennens.
Halte den Schlüssel von der Platte des Build-Servers fern
Ein .snk ins Repo einzuchecken, hebt den Sinn auf, eines zu haben. Verwende strongNameKeyEnvVar: es benennt eine Umgebungsvariable, die das Base64 des .snk enthält, das der Secret-Store deiner CI zur Build-Zeit einspeist. Es hat Vorrang vor strongNameKeyFile, sodass dieselbe Konfiguration auf dem Server einen echten signierten Build und auf einem Entwickler-Checkout ohne Secret einen unsignierten (oder verzögert signierten) Build erzeugt.
{
"inputs": ["bin/Release/net8.0/MyApp.dll"],
"outputDirectory": "bin/Release/net8.0/obf",
"strongNameKeyEnvVar": "MYAPP_SNK_BASE64",
"delaySign": false
}
# In CI, from the secret store — the key never touches the repo or the config:
export MYAPP_SNK_BASE64="$(cat /secrets/MyApp.snk | base64 -w0)"
nebula --config nebula.config.json
delaySign: true bettet nur den öffentlichen Schlüssel ein und lässt Platz für eine später mit sn -R angewandte Signatur — nützlich, wenn der private Schlüssel in einem HSM oder einem abgeschotteten Signierdienst lebt und den Build nie erreicht.
Die Reihenfolge, die nicht vertauscht werden darf
Wenn du auch mit Authenticode signierst, ist die Reihenfolge keine Vorliebe — sie wird davon erzwungen, was jede Signatur abdeckt. Die Starker-Name-Signatur lebt innerhalb der Metadaten des Assemblys, sodass das Berechnen die Datei verändert. Die Authenticode-Signatur wird über (fast) die ganze Datei berechnet und in ihr eingebettet. Daher:
Signiertest du Authenticode bevor du den starken Namen erneut anwendest, würde das Einbetten der Starker-Name-Signatur Bytes unter der Authenticode-Signatur neu schreiben und sie brechen. Nebula macht immer zuerst den starken Namen, dann Authenticode; du lieferst nur das Zertifikat:
{
"inputs": ["bin/Release/net8.0/MyApp.dll"],
"outputDirectory": "bin/Release/net8.0/obf",
"strongNameKeyEnvVar": "MYAPP_SNK_BASE64",
"authenticodeCertThumbprint": "9F2C…", // a cert already in the Windows store
"authenticodeTimestampUrl": "http://timestamp.digicert.com"
}
Bevorzuge authenticodeCertThumbprint (ein Zertifikat im Maschinenspeicher) oder ein .pfx, dessen Passwort aus authenticodeCertPassword über eine Umgebungsvariable statt einer Klartext-Zeichenfolge kommt. Setze immer authenticodeTimestampUrl: ein RFC-3161-Zeitstempel lässt die Signatur weiter prüfen, nachdem das Signierzertifikat abgelaufen ist. Authenticode braucht signtool aus dem Windows SDK auf dem Build-Agenten.
Multi-Assembly: ein Schlüssel über den ganzen Satz
Liefert dein Produkt voneinander abhängige Assemblys, signiere sie im selben Nebula-Lauf neu. Das Umbenennen ändert die Identität jedes Assemblys, und eine Assembly-Referenz trägt das Public-Key-Token des referenzierten Assemblys; Nebula schreibt die Referenz jedes Geschwisters auf das neue Token um, damit der Satz gegenseitig ladbar bleibt:
{
"inputs": ["bin/Release/net8.0/MyApp.dll", "bin/Release/net8.0/MyApp.Core.dll"],
"outputDirectory": "bin/Release/net8.0/obf",
"crossAssemblyRename": true,
"strongNameKeyEnvVar": "MYAPP_SNK_BASE64"
}
Signiere die zwei Assemblys getrennt, mit verschiedenen Schlüsseln oder in verschiedenen Läufen, und die Referenz der App auf MyApp.Core benennt ein Public-Key-Token, das das neu signierte MyApp.Core nicht mehr hat — eine FileLoadException beim ersten Aufruf hinein.
Die InternalsVisibleTo-Falle
Das Einzige, was das erneute Signieren stillschweigend bricht, ist eine Friend-Assembly-Beziehung. InternalsVisibleTo benennt den Friend über seinen öffentlichen Schlüssel, denn sonst könnte jeder dein Test-Assembly vortäuschen, um an deine Internals zu gelangen. Signierst du mit einem anderen .snk neu als dem, mit dem das Assembly ursprünglich gebaut wurde, ändert sich der öffentliche Schlüssel der Ausgabe, und das Attribut benennt noch den alten:
// In MyApp.csproj's AssemblyInfo — this names the OLD public key:
[assembly: InternalsVisibleTo("MyApp.Tests, PublicKey=0024000004800000…")]
Zur Laufzeit sieht der CLR, dass MyApp.Tests den benannten Schlüssel nicht mehr hat, und verweigert den Friend-Zugriff — eine MethodAccessException oder ein Ladefehler „friend assembly reference is invalid”. Zwei ehrliche Behebungen:
- Bevorzugt: liefere den Friend nicht aus.
InternalsVisibleToexistiert fast immer für ein Test-Assembly, das du nicht verteilst. Signiere deine ausgelieferten Assemblys mit deinem Release-Schlüssel und lass das Test-Assembly ganz aus dem Obfuskierungslauf heraus; das Attribut zählt nur, wenn das benannte Assembly tatsächlich gegen den geschützten Build geladen wird. - Wenn der Friend auch ausgeliefert wird: aktualisiere das
PublicKey=des Attributs auf den öffentlichen Schlüssel des Release-.snk(lies ihn mitsn -tp release.snk) und obfuskiere beide Assemblys zusammen mit diesem einen Schlüssel.
Vor dem Ausliefern prüfen
Das erneute Signieren ist der Schritt, der eine schnelle Kontrolle in der CI am meisten verdient, denn eine gebrochene Signatur zeigt sich nur auf einer Maschine, die sie erzwingt:
sn -vf obf/MyApp.dll # strong name verifies
signtool verify /pa /v obf/MyApp.dll # Authenticode chains + timestamp present
Führe die App dann aus dem Ausgabeordner aus — der echte Test, dass jede assembly-übergreifende Referenz sich noch gegen die neu signierten Geschwister auflöst.
Was mitzunehmen ist
Die Obfuskierung macht jede vor ihr berechnete Signatur ungültig, daher muss das Signieren danach erfolgen, über dem endgültigen Image, in fester Reihenfolge: obfuskieren, den starken Namen einbetten, Authenticode zuletzt signieren. Nebula macht alle drei als Schwanz der Pipeline — füttere es mit dem Starker-Name-Schlüssel über strongNameKeyEnvVar, damit er nie das Repo berührt, signiere einen voneinander abhängigen Satz in einem Lauf, damit die Public-Key-Token konsistent bleiben, und setze eine Zeitstempel-URL, damit Authenticode das Zertifikat überlebt. Die einzigen zwei Dinge, die dir bleiben, sind den Schlüssel aus der Versionskontrolle herauszuhalten und dich zu erinnern, dass InternalsVisibleTo einen Schlüssel benennt, der sich gerade geändert hat. Prüfe mit sn -vf und signtool verify in der CI und der Fehler „läuft hier, lädt dort nicht” erreicht nie einen Kunden.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.