Ofuscar un servicio gRPC sin romper el contrato en el cable
gRPC pone dos clases de nombres en tu ensamblado, y la ofuscación tiene que tratarlas como opuestas. Los nombres que viajan por el cable —la ruta del servicio, la ruta del método, los números de campo de un mensaje protobuf— son un contrato del que depende cada par, y renombrarlos rompe la interoperabilidad tan seguro como cambiar una URL. Los nombres que nunca salen del proceso —tus nombres de método C#, los nombres de propiedad de tus mensajes, tus registros de DI— son libres de desaparecer. La trampa es que de qué clase es un nombre dado depende por completo de si fuiste contract-first con protoc o code-first con protobuf-net.Grpc, porque code-first deriva la identidad del cable de tus nombres .NET por reflexión, convirtiendo los nombres libres de nuevo en nombres portadores. Esta entrada lee el código generado y los metadatos para mostrar exactamente dónde vive la identidad del cable en cada estilo, por qué los números de campo de protobuf hacen que los miembros de los mensajes sean seguros de renombrar, y cómo Nebula fija la superficie del contrato mientras renombra todo lo que hay detrás.
Un ensamblado gRPC está lleno de nombres, y la ofuscación tiene que ordenarlos en dos montones cuyas reglas son exactamente opuestas.
En un montón están los nombres que viajan entre procesos: la ruta de servicio que marca un cliente, la ruta de método dentro de ella, los números de campo que identifican cada campo de un mensaje serializado. Estos son un contrato. Cada otro par —un servicio escrito en Go, un cliente móvil, la build del año pasado de tu propia pasarela— depende de que sean estables, y renombrar uno es indistinguible de cambiar una URL publicada. En el otro montón están los nombres que nunca salen del proceso: tus nombres de método C#, las propiedades de tus clases de mensaje, el tipo que pasas a MapGrpcService<T>. Esos son etiquetas que el CLR resuelve internamente, y pueden aplanarse a a, b, c sin efecto observable.
Toda la cuestión de ofuscar gRPC con seguridad es qué nombre está en qué montón, y la respuesta cambia por completo según hayas generado tu servicio desde un fichero .proto o lo hayas definido en C# con protobuf-net.Grpc.
Contract-first: la identidad del cable está fijada a fuego en el código generado
Empieza por el caso común: un .proto compilado por Grpc.Tools. El contrato se declara una vez, en un fichero que ni siquiera es C#:
syntax = "proto3";
package greet;
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string greeting = 1;
}
El generador convierte esto en un Greeter.g.cs cuyo trabajo es transportar la identidad del cable como literales. La superficie de enrutamiento tiene este aspecto (abreviada a lo que importa):
public static partial class Greeter
{
static readonly string __ServiceName = "greet.Greeter";
static readonly grpc::Method<HelloRequest, HelloReply> __Method_SayHello =
new grpc::Method<HelloRequest, HelloReply>(
grpc::MethodType.Unary,
__ServiceName, // "greet.Greeter"
"SayHello", // method name — a literal, not derived
__Marshaller_HelloRequest,
__Marshaller_HelloReply);
// ...
}
Lee lo que eso significa para el renombrado. La ruta que marca un cliente es /greet.Greeter/SayHello, y se ensambla a partir de los literales de cadena "greet.Greeter" y "SayHello" que están en el descriptor Method. No se deriva del nombre de ningún método C# en tiempo de ejecución. Así que tu implementación real:
public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext ctx) =>
Task.FromResult(new HelloReply { Greeting = $"Hello {request.Name}" });
puede tener su identificador C# renombrado a b y el despachador seguirá vinculándolo al literal "SayHello" del descriptor, porque el constructor de la definición del servicio del servidor mapea el nombre de método del descriptor al delegado: el identificador del método es irrelevante para el enrutamiento. Los nombres que debes conservar son los que están dentro del fichero generado: __ServiceName y los descriptores Method, porque esos literales son el contrato del cable.
Los números de campo hacen libres a los miembros del mensaje
Los mensajes son la otra mitad, y aquí protobuf te hace un favor. El HelloRequest generado serializa por número, no por nombre:
public void WriteTo(pb::CodedOutputStream output)
{
if (Name.Length != 0)
{
output.WriteRawTag(10); // (field 1 << 3) | wiretype 2 == the tag for "name"
output.WriteString(Name);
}
}
La etiqueta 10 codifica el número de campo 1 con tipo de cable 2 (delimitado por longitud). Esa etiqueta es lo que lee un par; la propiedad C# Name es solo un accesor local. Renombra Name a a y WriteTo sigue emitiendo la etiqueta 10, así que cada par que lee el campo 1 no se ve afectado. Los nombres de propiedad de los mensajes están firmemente en el montón de libres-de-renombrar: el número de campo lleva la identidad, y el número vive en el .proto, no en tus identificadores.
Dos salvedades. Primera, si expones transcodificación gRPC-JSON o una pasarela JSON, esa superficie se guía por el nombre de campo en camelCase, así que los nombres de campo se vuelven visibles en el cable y deben preservarse en los mensajes transcodificados. Segunda, los nombres de valor de enum se serializan como cadenas por el formateador JSON y son visibles a través de la reflexión del servidor, así que si dependes de cualquiera de los dos, fija los nombres de miembro del enum. El gRPC binario puro no depende de ninguno.
Code-first invierte los montones: tus nombres .NET son el contrato
Ahora el caso que hace tropezar a la gente. Con protobuf-net.Grpc no hay .proto ni descriptor generado lleno de literales. Escribes el contrato en C#:
[ServiceContract]
public interface IGreeter
{
[OperationContract]
Task<HelloReply> SayHelloAsync(HelloRequest request, CallContext ctx = default);
}
[ProtoContract]
public class HelloRequest
{
[ProtoMember(1)] public string Name { get; set; } = "";
}
Al arranque, la librería reflexiona sobre IGreeter y deriva la identidad del cable de los nombres .NET: la ruta del servicio viene del nombre de la interfaz (IGreeter → servicio Greeter por su convención de nombres por defecto), y cada ruta de método viene del nombre del método (SayHelloAsync). No hay literales que preservar porque los propios nombres son los literales. Renombra IGreeter a a y has renombrado la ruta del servicio; renombra SayHelloAsync a b y has movido el método. Una pasada de renombrado ciega cambia en silencio el contrato y cada cliente falla al encontrar el endpoint que solía llamar.
Los mensajes siguen siendo seguros —[ProtoMember(1)] fija el número de campo 1 sin importar cómo se llame la propiedad Name, exactamente como en contract-first— pero la superficie del servicio se ha movido al montón de hay-que-preservar. Dos maneras de gestionarlo, en orden de preferencia:
El arreglo robusto es dejar de depender de los identificadores por completo dando al contrato nombres explícitos:
[ServiceContract(Name = "greet.Greeter")]
public interface IGreeter
{
[OperationContract(Name = "SayHello")]
Task<HelloReply> SayHelloAsync(HelloRequest request, CallContext ctx = default);
}
Ahora la identidad del cable es "greet.Greeter"/"SayHello" de los atributos, los identificadores IGreeter y SayHelloAsync vuelven a ser libres de renombrar, y tu contrato ya no depende de cómo se llame tu código, lo cual es buena higiene aunque nunca ofusques. La alternativa, cuando no puedes editar los contratos, es fijar los nombres de tipo y de miembro de las interfaces de contrato para que la reflexión siga leyendo los nombres que espera.
Decirle a Nebula qué montón es cuál
Nebula no adivina. Para contract-first reconoce la superficie del servicio generado por la forma del descriptor y conserva la constante de nombre de servicio, los descriptores Method y (cuando se configura) los nombres de valor de enum, mientras renombra tus implementaciones, los identificadores de propiedad de los mensajes, el cableado de DI y los interceptores. Para code-first lo apuntas a los atributos de contrato para que trate los tipos y miembros [ServiceContract]/[OperationContract] como el conjunto a preservar.
<Nebula>
<!-- Contract-first: keep the wire identity the generator baked in. -->
<!-- Service-name constants and Method<,> descriptors in generated gRPC code. -->
<Preserve names="true" members="true"
attribute="System.CodeDom.Compiler.GeneratedCodeAttribute"
where="GrpcServices" />
<!-- Code-first: the .NET names ARE the contract surface. -->
<Preserve names="true" members="true" attribute="System.ServiceModel.ServiceContractAttribute" />
<Preserve names="true" members="true" attribute="System.ServiceModel.OperationContractAttribute" />
<!-- Enums whose value names hit the wire via JSON/reflection. -->
<Preserve memberNames="true" where="ProtoEnums" />
<!-- Everything else — service implementations, message property names,
interceptors, private helpers — gets full renaming. -->
</Nebula>
El registro de DI no necesita trato especial. builder.Services.AddGrpc() y app.MapGrpcService<GreeterService>() vinculan por Type, no por nombre, así que GreeterService puede renombrarse a d y el mapeo sigue resolviendo: el argumento genérico es un token de metadatos, igual que todas las referencias de tipo, y el renombrado lo actualiza de forma coherente. Del mismo modo, los marshallers, el CallContext y tus interceptores son fontanería interna que se renombra limpiamente.
La única regla que cubre ambos estilos
Quita las particularidades y gRPC obedece la misma regla que cualquier otro framework que cruza una frontera: un nombre es portador exactamente cuando algo fuera de la resolución de tokens del CLR lo lee. En el cable binario eso es la ruta de servicio, la ruta de método y los números de campo, y los números de campo significan que los miembros de tus mensajes son libres. A través de la transcodificación JSON o la reflexión, significa además los nombres de campo y los nombres de valor de enum. Y en code-first, la reflexión al arranque arrastra tus nombres de tipo y de miembro .NET a través de esa frontera, que es la razón misma por la que code-first cambia la respuesta.
Encuentra esa frontera, preserva precisamente los nombres que la cruzan y deja que Nebula renombre todo lo que se queda de tu lado. Conservas la protección sobre tus implementaciones de servicio y tu lógica de negocio —el código que de verdad merece protección— y el contrato en el cable nunca se mueve.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.