Volver a firmar un ensamblado .NET tras la ofuscación: nombres fuertes y Authenticode
El renombrado reescribe tu ensamblado, lo que invalida el nombre fuerte y la firma Authenticode con los que se compiló: una build protegida que se envía sin firmar, o peor, con una firma ya rota, no cargará o no verificará. Nebula.NET vuelve a firmar por ti como último paso de la canalización: una nueva firma de nombre fuerte sobre la imagen ofuscada, tokens de clave pública entre ensamblados reescritos para que los ensamblados interdependientes sigan resolviéndose, y luego una firma Authenticode encima. Aquí tienes el orden exacto, cómo entregar la clave a un servidor de compilación sin incluirla en el repositorio y qué rompe InternalsVisibleTo si lo olvidas.
La firma y la ofuscación tienen un problema de orden que muerde a cada equipo la primera vez que activa la protección en una canalización de publicación. Tanto un nombre fuerte como una firma Authenticode son firmas sobre los bytes de tu ensamblado. La ofuscación reescribe esos bytes: renombrar un tipo cambia los metadatos, cifrar una cadena reescribe el IL, aplanar el flujo de control reemplaza los cuerpos de los métodos. Así que un ensamblado que firmaste y luego ofuscaste lleva una firma que ya no coincide con su propio contenido: el CLR rechaza el nombre fuerte como manipulado y cualquier firma Authenticode verifica como rota. La build parece bien en tu máquina y no carga en una bloqueada.
La regla es simple —firma al final— pero hacerlo a mano a través de un conjunto interdependiente de ensamblados, en el orden correcto, sin filtrar la clave al repositorio, es engorroso. Nebula.NET pliega todo eso al final de la canalización. Este artículo es exactamente lo que hace, en qué orden, y las dos cosas (claves de CI e InternalsVisibleTo) que aún necesitan tu atención.
Por qué el renombrado invalida el nombre fuerte
Un nombre fuerte es una firma RSA sobre un hash del contenido del ensamblado, almacenada en el propio ensamblado, con la clave pública correspondiente pasando a formar parte de la identidad del ensamblado. El CLR recalcula ese hash en tiempo de carga (para un ensamblado totalmente firmado en un contexto que lo comprueba) y compara. Cambia un byte de metadatos o IL tras firmar y los hashes divergen:
> sn -vf MyApp.dll
Failed to verify assembly -- Strong name validation failed.
Ese es el resultado esperado de ofuscar un ensamblado prefirmado. El .snk original sigue siendo la clave correcta; lo que está mal es que la firma se calculó sobre los bytes previos a la ofuscación. No puedes “preservar” un nombre fuerte a través de la ofuscación; solo puedes reaplicarlo después, sobre la imagen final. Eso es lo que hace strongNameKeyFile:
{
"inputs": ["bin/Release/net8.0/MyApp.dll"],
"outputDirectory": "bin/Release/net8.0/obf",
"controlFlowObfuscation": true,
"encryptStrings": true,
"strongNameKeyFile": "keys/MyApp.snk"
}
Nebula ofusca, luego firma la salida con esa clave como paso final de metadatos. La clave pública de la salida —y por tanto su identidad de nombre fuerte— es la que lleve el .snk. Firmar y volver a firmar tras la ofuscación es una función Pro, junto con el resto del endurecimiento por encima del renombrado.
Mantén la clave fuera del disco del servidor de compilación
Incluir un .snk en el repositorio anula el sentido de tener uno. Usa strongNameKeyEnvVar: nombra una variable de entorno que contenga el base64 del .snk, que el almacén de secretos de tu CI inyecta en tiempo de compilación. Tiene prioridad sobre strongNameKeyFile, así que la misma configuración produce una build real firmada en el servidor y una build sin firmar (o con firma diferida) en una copia de desarrollador sin secreto.
{
"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 incrusta solo la clave pública y deja espacio para una firma aplicada más tarde con sn -R, útil cuando la clave privada vive en un HSM o un servicio de firma controlado y nunca llega a la build.
El orden que no debe invertirse
Cuando también firmas con Authenticode, el orden no es una preferencia: lo fuerza lo que cubre cada firma. La firma de nombre fuerte vive dentro de los metadatos del ensamblado, así que calcularla cambia el archivo. La firma Authenticode se calcula sobre (casi) todo el archivo y se incrusta en él. Por tanto:
Si firmaras con Authenticode antes de reaplicar el nombre fuerte, incrustar la firma de nombre fuerte reescribiría bytes bajo la firma Authenticode y la rompería. Nebula siempre hace nombre fuerte primero, luego Authenticode; tú solo aportas el certificado:
{
"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"
}
Prefiere authenticodeCertThumbprint (un certificado en el almacén de la máquina) o un .pfx cuya contraseña venga de authenticodeCertPassword aportada mediante una variable de entorno en lugar de una cadena en texto plano. Define siempre authenticodeTimestampUrl: una marca de tiempo RFC-3161 permite que la firma siga verificándose tras caducar el certificado de firma. Authenticode necesita signtool del SDK de Windows en el agente de compilación.
Multiensamblado: una clave para todo el conjunto
Si tu producto envía ensamblados interdependientes, vuelve a firmarlos en la misma ejecución de Nebula. El renombrado cambia la identidad de cada ensamblado, y una referencia de ensamblado lleva el token de clave pública del ensamblado referenciado; Nebula reescribe la referencia de cada hermano al nuevo token para que el conjunto siga siendo mutuamente cargable:
{
"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"
}
Firma los dos ensamblados por separado, con claves distintas o en ejecuciones distintas, y la referencia de la app a MyApp.Core nombrará un token de clave pública que el MyApp.Core refirmado ya no tiene: un FileLoadException en la primera llamada a él.
La trampa de InternalsVisibleTo
Lo único que volver a firmar rompe en silencio es una relación de ensamblado amigo. InternalsVisibleTo nombra al amigo por su clave pública, porque de lo contrario cualquiera podría suplantar tu ensamblado de pruebas para alcanzar tus internos. Si vuelves a firmar con un .snk distinto del que se construyó originalmente el ensamblado, la clave pública de la salida cambia y el atributo sigue nombrando la antigua:
// In MyApp.csproj's AssemblyInfo — this names the OLD public key:
[assembly: InternalsVisibleTo("MyApp.Tests, PublicKey=0024000004800000…")]
En tiempo de ejecución el CLR ve que MyApp.Tests ya no tiene la clave nombrada y rechaza el acceso de amigo: un MethodAccessException o un error de carga “friend assembly reference is invalid”. Dos arreglos honestos:
- Preferido: no envíes el amigo.
InternalsVisibleTocasi siempre existe para un ensamblado de pruebas que no distribuyes. Firma tus ensamblados enviados con tu clave de publicación y deja el ensamblado de pruebas fuera de la ejecución de ofuscación por completo; el atributo solo importa cuando el ensamblado nombrado se carga realmente contra la build protegida. - Si el amigo también se envía: actualiza el
PublicKey=del atributo a la clave pública del.snkde publicación (léela consn -tp release.snk) y ofusca ambos ensamblados juntos con esa única clave.
Verifica antes de enviar
Volver a firmar es el paso que más merece una comprobación rápida en CI, porque una firma rota solo aparece en una máquina que la exige:
sn -vf obf/MyApp.dll # strong name verifies
signtool verify /pa /v obf/MyApp.dll # Authenticode chains + timestamp present
Luego ejecuta la app desde la carpeta de salida: la prueba real de que cada referencia entre ensamblados aún se resuelve contra los hermanos refirmados.
Qué conviene recordar
La ofuscación invalida cada firma calculada antes de ella, así que la firma tiene que ocurrir después, sobre la imagen final, en un orden fijo: ofuscar, incrustar el nombre fuerte, firmar Authenticode al final. Nebula hace las tres cosas como cola de la canalización: aliméntala con la clave de nombre fuerte mediante strongNameKeyEnvVar para que nunca toque el repositorio, firma un conjunto interdependiente en una sola ejecución para que los tokens de clave pública queden consistentes, y define una URL de marca de tiempo para que Authenticode sobreviva al certificado. Lo único que te queda es mantener la clave fuera del control de versiones y recordar que InternalsVisibleTo nombra una clave que acaba de cambiar. Verifica con sn -vf y signtool verify en CI y el fallo “funciona aquí, no carga allí” nunca llega a un cliente.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.