Eine MassTransit-Anwendung obfuskieren, ohne das Message-Routing zu zerstören
MassTransit leitet die Identität einer Nachricht — ihren MessageUrn, etwa urn:message:Contoso.Ordering.Contracts:OrderSubmitted — aus dem .NET-Namespace und dem Namen des Vertragstyps ab und benennt damit den Exchange bzw. das Topic des Brokers und stempelt ihn in den messageType-Header des Umschlags. Producer und Consumer, oft getrennt deployt, müssen sich darauf einigen. Wenn du nach dem Build obfuskierst, ändert das Umbenennen des Vertragstyps oder seines Namespace den URN, sodass der Producer in einen anderen Exchange veröffentlicht und einen anderen messageType stempelt, und bestehende Consumer still aufhören zu empfangen. Dieser Artikel erklärt genau, wie der URN aus dem Typ aufgebaut wird, warum die Message-Contract-Assembly die fragile Oberfläche ist, und wie du die Namespaces, Namen und Eigenschaften der Verträge mit einer Nebula.NET-Keep-List fixierst, während du Consumer, Sagas und Internes umbenennst.
Eine MassTransit-Anwendung ist ein verlockendes Obfuskierungsziel: Consumer, Sagas und Routing-Slip-Aktivitäten sind dort, wo deine Geschäftslogik lebt, und fast alles davon lässt sich ohne Nachdenken umbenennen, in seinen Strings verschlüsseln und virtualisieren. Aber MassTransit hat eine Naht, in die ein naiver Durchlauf geradewegs hineinrennt, und der Fehler ist von der schlimmsten Sorte — vollkommen still. Der Build gelingt, der Dienst startet, der Producer veröffentlicht, der Broker meldet keinen Fehler, und der Consumer, der gestern funktionierte, feuert schlicht nie. Keine Ausnahme, kein Stacktrace. Die Nachrichten gehen irgendwohin; sie gehen nur nicht dorthin.
Die Ursache ist spezifisch dafür, wie MassTransit entscheidet, was eine Nachricht ist, und sobald du sie siehst, ist die Lösung eine kurze Keep-List. In diesem Artikel geht es um diesen Mechanismus: wie MassTransit die Identität einer Nachricht genau aus ihrem Vertragstyp ableitet, warum diese Identität sowohl in die Broker-Topologie als auch in den Nachrichtenumschlag geschrieben wird, und wie du das wertvolle Innere mit Nebula.NET schützt, ohne die Namen zu verschieben, von denen die Leitung abhängt.
TL;DR
- MassTransit leitet den MessageUrn einer Nachricht —
urn:message:Namespace:TypeName— aus dem .NET-Namespace und dem Namen des Vertragstyps ab. - Dieser URN benennt den Exchange/das Topic des Brokers, in das der Producer veröffentlicht, und wird in den
messageType-Header des Umschlags gestempelt, damit Consumer ihn abgleichen können. - Benenne den Vertragstyp oder seinen Namespace um, und der URN ändert sich: Der Producer veröffentlicht in einen anderen Exchange und stempelt einen anderen
messageType, und bestehende Consumer hören still auf zu empfangen. - Die Message-Contract-Assembly ist die fragile Oberfläche — sie wird zwischen getrennt deployten Diensten geteilt. Consumer, Sagas und Internes werden frei umbenannt.
- Nimm die Namespaces, Typnamen und öffentlichen Eigenschaftsnamen der Verträge in die Keep-List auf (der Rumpf bindet ebenfalls über den Eigenschaftsnamen); obfuskiere alles andere.
Warum eine MassTransit-Anwendung überhaupt obfuskieren
Die Verträge sind dünn, aber die Consumer und Sagas sind das Produkt: das Betrugs-Scoring, das läuft, wenn eine Bestellung aufgegeben wird, der Settlement-State-Machine, die Preis-Neuberechnung, der Reconciliation-Workflow. Dieser Code läuft kopflos auf einem Server — oft einem, den dein Kunde administriert — und das deployte Artefakt ist eine DLL, die jeder mit Dateizugriff in einem Decompiler öffnen und als sauberes C# zurücklesen kann. Die Messaging-Schicht ist auch dort, wo sich die sensiblen Konstanten häufen: nachgelagerte Endpoint-URLs, Partner-Zugangsdaten, Queue- und Header-Namen, Signatur-Geheimnisse. Deinen eigenen ausgelieferten Code hier zu schützen ist genau der legitime Fall für Obfuskierung — die Kosten dafür zu erhöhen, deine Geschäftslogik aus dem Binary zurückzulesen, das du übergeben hast.
Wie MassTransit die Identität einer Nachricht aufbaut
Hier ist ein gewöhnlicher Nachrichtenvertrag und der Consumer, der ihn verarbeitet, jeweils in seinem eigenen Projekt:
namespace Contoso.Ordering.Contracts;
// The contract. Its namespace + name ARE the message's identity.
public record OrderSubmitted(Guid OrderId, Guid CustomerId, decimal Total);
namespace Contoso.Ordering.Fulfillment.Consumers;
public sealed class OrderSubmittedConsumer : IConsumer<OrderSubmitted>
{
private readonly IFulfillmentService _fulfillment;
public OrderSubmittedConsumer(IFulfillmentService fulfillment)
=> _fulfillment = fulfillment;
public async Task Consume(ConsumeContext<OrderSubmitted> context)
{
// proprietary — the reason this service exists
await _fulfillment.Reserve(context.Message.OrderId, context.Message.Total);
}
}
Der Producer veröffentlicht nach Typ:
await bus.Publish(new OrderSubmitted(orderId, customerId, 149.95m));
Nichts in diesem Publish-Aufruf benennt einen Exchange oder einen Routing-Key. MassTransit ermittelt es aus dem Typ. Für den Typ Contoso.Ordering.Contracts.OrderSubmitted berechnet es einen MessageUrn:
urn:message:Contoso.Ordering.Contracts:OrderSubmitted
Die Form ist fest: urn:message: + der Namespace des Typs + : + der Name des Typs. Diese eine Zeichenkette steuert zwei getrennte Dinge, auf zwei getrennten Deployments.
Erstens die Broker-Topologie. Der Standard-Entity-Name-Formatter nimmt den URN, entfernt das Präfix urn:message: und verwendet den Rest als Exchange-Namen (RabbitMQ) oder Topic-Namen (Azure Service Bus):
Contoso.Ordering.Contracts:OrderSubmitted
Der Producer veröffentlicht in diesen Exchange. Ein Consumer für OrderSubmitted bindet eine Queue an diesen Exchange. Sie finden einander nur, weil beide Seiten denselben Namen aus demselben Typ berechnen.
Zweitens der Nachrichtenumschlag. MassTransit wickelt deine Nachricht in einen Umschlag und schreibt den URN in den messageType-Header — als Array, weil eine Nachricht auch die von ihr implementierten Interfaces und ihre Basistypen tragen kann:
{
"messageId": "7b1e...-...",
"conversationId": "9c22...-...",
"sourceAddress": "rabbitmq://broker/orders-api",
"messageType": [
"urn:message:Contoso.Ordering.Contracts:OrderSubmitted"
],
"message": {
"orderId": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"customerId": "b8f9...",
"total": 149.95
}
}
Wenn ein Consumer den Umschlag empfängt, liest er messageType und prüft, ob ein URN in diesem Array zu einem Vertrag passt, den er konsumiert. Ist OrderSubmitted in der Liste, deserialisiert er das message-Objekt auf seine Kopie des Vertrags und ruft Consume auf. Wenn nicht, ist die Nachricht nicht für ihn — sie geht in die _skipped-Queue.
Beachte die zweite Abhängigkeit, die sich im message-Objekt verbirgt: der Rumpf ist über den Eigenschaftsnamen geschlüsselt — orderId, customerId, total. Der Consumer bindet diese Felder über den Namen zurück auf OrderId, CustomerId, Total. Der Vertrag hat also zwei Namensoberflächen, die über die Leitung gehen: den Namespace + Namen des Typs (den URN, für Routing und Abgleich) und die Namen seiner öffentlichen Eigenschaften (den Rumpf, für die Bindung).
Generische Nachrichten und Interface-Verträge
Zwei Varianten halten dieselbe Regel. Ist deine Nachricht generisch — etwa ein Batch<T>-Umschlag — verschachtelt der URN beide Typnamen:
urn:message:Contoso.Ordering.Contracts:Batch[[Contoso.Ordering.Contracts:OrderSubmitted]]
Sowohl das äußere Batch als auch das innere OrderSubmitted erscheinen, also müssen beide Namespaces und Namen bewahrt werden. Werden MassTransit-Verträge als Interfaces definiert — ein verbreitetes Muster, bei dem MassTransit die konkrete Implementierung zur Laufzeit generiert — leitet sich der URN aus dem Namespace und dem Namen des Interface ab, und die Eigenschaftsnamen stammen aus den Gettern des Interface. Das Interface-Typ und seine Mitglieder zu bewahren deckt es ab; der generierte Implementierungstyp ist intern und geht nie über die Leitung.
Warum die Vertrags-Assembly die fragile Oberfläche ist
Die Asymmetrie ist der ganze Punkt. Ein Consumer, eine Saga, eine Routing-Slip-Aktivität — MassTransit löst diese innerhalb eines Prozesses auf, aus seinem DI-Container und den Registrierungen, die du beim Start konfiguriert hast. Es sucht sie über die Typidentität, nicht über irgendeine in einen Broker oder eine Datenbank geschriebene Zeichenkette. Benenne OrderSubmittedConsumer in a um, und nichts bemerkt es: Die Registrierung zeigt weiter auf denselben Typ, der Container löst ihn weiter auf, die Queue, an die er gebunden ist, ist nach der Nachricht benannt, nicht nach dem Consumer.
Der Vertrag ist das Gegenteil. Er ist das Einzige, auf das sich zwei unabhängig deployte Dienste einigen müssen, und sie einigen sich über eine Zeichenkette — den URN — die jeder aus seiner eigenen Kopie des Typs berechnet. Es gibt keine geteilte Registry, keine Aushandlung; die Übereinkunft ist rein, dass beide Seiten den Typ gleich benennen. In dem Moment, in dem die Obfuskierung den Vertrag nur auf einer Seite umbenennt — weil du den Producer neu deployt hast, aber nicht den Consumer, oder den Obfuskator über eine Solution laufen ließest und nicht über die andere — divergieren die URNs und die beiden Dienste hören auf, miteinander zu sprechen, ohne irgendeinen Fehler irgendwo, denn aus Sicht des Brokers funktioniert alles: Er routet eine Nachricht in einen Exchange, und eine andere Queue ist still an einen anderen Exchange gebunden.
Das Audit: was umbenannt wird, was nicht
Gehe jeden Namen durch, der über die Leitung geht, und die Keep-List schreibt sich von selbst.
- Die Vertragstypen —
Contoso.Ordering.Contracts.OrderSubmittedund jeder andere Nachrichten-Record oder jedes Interface. Ihr Namespace und ihr Name bauen den URN auf, der den Exchange benennt undmessageTypefüllt. Bewahre die Namespaces und die Typnamen. - Die Vertragseigenschaften —
OrderId,CustomerId,Total. Dermessage-Rumpf des Umschlags ist über den Eigenschaftsnamen geschlüsselt und der Consumer bindet über den Namen zurück. Bewahre die Namen der öffentlichen Eigenschaften. Einetype-and-members-Keep-Regel deckt den Typ und seine Eigenschaften in einer Zeile ab. - Die generischen Argumenttypen — ist eine Nachricht
Batch<OrderSubmitted>, erscheinen sowohlBatchals auchOrderSubmittedim URN. Bewahre jeden Typ, der als Nachricht oder als generisches Argument einer Nachricht verwendet wird. - Die Consumer —
OrderSubmittedConsumer, dieIConsumer<T>-Implementierungen. Aufgelöst über Registrierung und Typidentität innerhalb des Prozesses; nichts auf der Leitung benennt sie. Frei umbenennen. - Die Sagas und State-Machines — die
MassTransitStateMachine, der Saga-Instanztyp. Die Saga korreliert über einen Eigenschaftswert (eineCorrelationId-GUID), der Daten sind, kein Symbol, und ihr persistierter Zustand ist über diesen Wert geschlüsselt, nicht über den Typnamen. Benenne die Logik um. (Wird die Saga persistiert und ihr Instanztyp nach Namen in einen Store serialisiert, behandle diesen Store genau wie einen Vertrag und bewahre die Mitglieder des Instanztyps — dieselbe Begründung wie bei jeder serialisierten Persistenz, behandelt in unseren Artikeln zu EF Core und Serialisierung.) - Die Endpoint-/Queue-Namen —
"orders-api", die Receive-Endpoint-Namen, die du als String-Literale übergibst. Das sind Daten, die du zur Laufzeit gleich reproduzierst; die String-Verschlüsselung lässt ihren Laufzeitwert unangetastet. Nichts zu tun. - Alles, was die Consumer und Sagas aufrufen —
IFulfillmentService, das Scoring, die Reconciliation, die privaten Helfer. Nichts außerhalb des Prozesses benennt diese. Umbenennen, verschlüsseln, virtualisieren.
Eine Oberfläche zum Bewahren also — die Vertragstypen, ihre Namespaces und ihre öffentlichen Eigenschaften — und alles andere ist Freiwild.
Die Keep-List
Modelliere die Verträge unter einem einzigen Namespace, damit eine einzige Regel sie abdeckt, und nebula.config.json liest sich so:
{
"schemaVersion": 1,
"inputs": [
"publish/Contoso.Ordering.Fulfillment.dll",
"publish/Contoso.Ordering.Contracts.dll"
],
"outputDirectory": "protected",
"preservePublicApi": false,
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "Contoso.Ordering.Contracts.*", "action": "keep", "scope": "type-and-members" },
{ "pattern": "Contoso.Ordering.*", "action": "rename" }
]
}
}
Die erste Regel fixiert jeden Typ unter Contoso.Ordering.Contracts und seine Mitglieder, sodass jeder Vertrag den Namespace und den Namen bewahrt, aus denen der URN aufgebaut wird, und die öffentlichen Eigenschaftsnamen, über die der Rumpf bindet — scope: "type-and-members" ist das, was OrderId bewahrt, nicht nur OrderSubmitted. Alles andere in den Assemblies — Consumer, Sagas, Dienste, Helfer — wird umbenannt. String-Verschlüsselung und Kontrollfluss-Obfuskierung wirken überall, auch innerhalb der Vertrags-Assembly, weil keine von beiden Namen anfasst.
Wenn du dich lieber nicht auf eine Namespace-Konvention verlässt, annotiere die Verträge direkt — das Attribut reist mit dem Code und kann nicht von einem Muster abdriften:
[Obfuscation(Exclude = true, ApplyToMembers = true)] // keep the URN + property names for routing/binding
public record OrderSubmitted(Guid OrderId, Guid CustomerId, decimal Total);
Solltest du die geteilte Vertrags-Assembly überhaupt obfuskieren?
Oft ist die sauberste Antwort: tu es nicht, oder bewahre sie ganz. Eine Vertrags-Assembly ist bewusst dünn — Records und Interfaces, ohne proprietäre Logik — sie umzubenennen verbirgt also nichts von Wert, während jeder umbenannte Name eine Chance ist, das Routing oder die Bindung über einen Dienst hinweg zu zerstören, den du nach einem anderen Zeitplan deployst. Zwei solide Muster:
- Lass das Vertragsprojekt unobfuskiert. Liefere es unverändert aus und lass Nebula.NET nur über die Consumer-/Saga-Assemblies laufen. Die Vertragsnamen sind keine Geheimnisse; deine Algorithmen sind es, und die stehen nicht in dieser Assembly.
- Obfuskiere sie mit bewahrter gesamter öffentlicher API. Bevorzugst du eine einzige, einheitliche Pipeline, bewahre jeden öffentlichen Typ und jedes Mitglied in der Vertrags-Assembly (die
type-and-members-Regel oben, oderpreservePublicApi: trueauf diese DLL beschränkt) und lass String-Verschlüsselung und Kontrollfluss weiter wirken. Du gewinnst Einheitlichkeit und verlierst nichts, denn es gab nichts zu verbergen.
So oder so gilt dieselbe Regel über jeden Dienst, der die Verträge referenziert: Der Namespace, der Typname und die öffentlichen Eigenschaften müssen überall dort, wo der URN berechnet wird, Byte für Byte identisch sein. Liefern zwei Dienste ihre eigene Kopie der Verträge aus, müssen beide Kopien auf dieselbe Weise bewahrt werden — die eine zu obfuskieren und die andere nicht ist genau, wie die URNs divergieren.
Den Schutz in den Build einklinken
Schütze die veröffentlichte Ausgabe, und nur auf dem Build-Server, damit das lokale Debuggen schnell und lesbar bleibt:
dotnet publish Contoso.Ordering.Fulfillment -c Release -o publish
nebula protect --config nebula.config.json
Hänge es in der CI hinter das Publish, damit das deployte Artefakt immer das geschützte ist:
<Target Name="NebulaProtect" AfterTargets="Publish"
Condition="'$(CI)' == 'true'">
<Exec Command="nebula protect --config nebula.config.json" />
</Target>
Liefere den Ordner protected/ aus, nicht den rohen Publish-Ordner. nebula protect gibt bei einem Fehlschlag einen Exit-Code ungleich null zurück, sodass ein kaputter Schutzschritt den Build scheitern lässt, statt eine Assembly auszuliefern, die einen Vertrag umbenannt hat, über den deine anderen Dienste noch routen.
Das Innere schützen
Den Namen eines Vertrags zu bewahren bedeutet nicht, irgendetwas lesbar zu lassen. Der URN bleibt stabil, während alles hinter dem Consumer undurchsichtig wird. String-Verschlüsselung verbirgt die nachgelagerten Endpoint-URLs, Zugangsdaten und Header-Namen, die die Messaging-Schicht ansammelt. Die stärksten Transformationen — Code-Virtualisierung und Ganzmethoden-Verschlüsselung, beide Pro-Funktionen in Nebula.NET — kommen auf die Kronjuwelen: die Scoring-, Settlement- oder Reconciliation-Logik, die die Consumer und Sagas umhüllen, angewandt auf diese Handvoll Methoden statt auf die ganze Assembly:
public sealed class FulfillmentService : IFulfillmentService
{
[Obfuscation(Feature = "virtualization")] // Pro — the algorithm, not the plumbing
public Task Reserve(Guid orderId, decimal total) { /* ... */ }
}
Umbenennung und Kontrollfluss-Obfuskierung laufen breit unter beiden, verwandeln den Rest der Consumer- und Saga-Interna in bedeutungslose Symbole und verworrene Verzweigungen, während der Vertrag, über den der Broker routet, genau dort bleibt, wo er war.
Verifizieren, dass es tatsächlich funktioniert
Unit-Tests fangen das nicht ab: Sie konstruieren einen Consumer und rufen Consume mit einem In-Memory-OrderSubmitted auf, routen nie einen echten Umschlag durch einen echten Broker gegen die geschützten DLLs. Zwei Prüfungen tun es. Erstens: Führe es Ende-zu-Ende gegen einen echten Broker aus — veröffentliche vom geschützten Producer und bestätige, dass der geschützte Consumer (idealerweise aus einem separat gebauten Artefakt deployt, um unabhängiges Deployment nachzuahmen) die Nachricht empfängt und verarbeitet. Eine Nachricht, die in _skipped landet oder schlicht nie ankommt, ohne Ausnahme, ist die exakte Signatur eines divergierten URN. MassTransits eigene Diagnostik hilft hier: Vergleiche den messageType eines veröffentlichten Umschlags mit dem URN, an den der Consumer gebunden ist; unterscheiden sie sich durch einen umbenannten Namespace oder Typ, hast du die fehlende Keep-Regel gefunden.
Zweitens: Öffne die ausgelieferte Assembly in einem Decompiler und lies sie so, wie es ein Angreifer tun wird. Glass.NET ist der kostenlose, rein statische Begleiter für diesen Durchgang: Lade die geschützte DLL, bestätige, dass Contoso.Ordering.Contracts.OrderSubmitted und seine Eigenschaften OrderId/CustomerId/Total weiterhin namentlich vorhanden sind — das müssen sie, um dem URN und dem Rumpf auf der Leitung zu entsprechen — und bestätige dann, dass es innerhalb von OrderSubmittedConsumer oder dem FulfillmentService, den er aufruft, nichts mehr zu lesen gibt.
Das Fazit
MassTransit identifiziert eine Nachricht über eine Zeichenkette — ihren MessageUrn, urn:message:Namespace:TypeName — abgeleitet aus dem .NET-Namespace und dem Namen des Vertragstyps. Dieser URN benennt den Exchange oder das Topic des Brokers, in das der Producer veröffentlicht, und wird in den messageType-Header des Umschlags gestempelt, sodass sich ein Producer und seine getrennt deployten Consumer nur deshalb auf das Routing einigen, weil beide denselben URN aus demselben Typ berechnen. Benenne den Vertragstyp oder seinen Namespace um, und der URN ändert sich auf einer Seite: Der Producer veröffentlicht in einen anderen Exchange, stempelt einen anderen messageType, und der Consumer empfängt still nichts — keine Ausnahme, nur Nachrichten, die nirgendwohin gehen. Der Rumpf bindet über den Eigenschaftsnamen, also sind die öffentlichen Eigenschaften des Vertrags ebenfalls tragend. Bewahre genau das, was über die Leitung geht — die Namespaces, Typnamen und öffentlichen Eigenschaften der Verträge — entscheide bewusst, ob die geteilte Vertrags-Assembly überhaupt obfuskiert werden soll, und übergib das gesamte Innere der Consumer und Sagas an Nebula.NET zum Umbenennen, String-Verschlüsseln und Virtualisieren. Veröffentliche dann eine echte Nachricht durch den geschützten Build, sieh zu, wie der richtige Consumer feuert, und lies die ausgelieferte DLL, um zu bestätigen, dass die Logik dahinter verschwunden ist.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.