Re-signing a .NET assembly after obfuscation: strong names and Authenticode
Renaming rewrites your assembly, which invalidates the strong name and the Authenticode signature it was built with — a protected build that ships unsigned, or worse ships with a now-broken signature, will fail to load or fail to verify. Nebula.NET re-signs for you as the last step of the pipeline: a new strong-name signature over the obfuscated image, cross-assembly public-key tokens rewritten so interdependent assemblies still resolve, then an Authenticode signature on top. Here is the exact order, how to feed the key to a build server without checking it in, and what InternalsVisibleTo breaks if you forget.
Signing and obfuscation have an ordering problem that bites every team the first time they turn on protection in a release pipeline. Both a strong name and an Authenticode signature are signatures over the bytes of your assembly. Obfuscation rewrites those bytes — renaming a type changes the metadata, encrypting a string rewrites the IL, flattening control flow replaces method bodies. So an assembly you signed and then obfuscated carries a signature that no longer matches its own content: the CLR rejects the strong name as tampered, and any Authenticode signature verifies as broken. The build looks fine on your machine and fails to load on a locked-down one.
The rule is simple — sign last — but doing it by hand across an interdependent set of assemblies, in the right order, without leaking the key to the repo, is fiddly. Nebula.NET folds all of it into the end of the pipeline. This post is exactly what it does, in what order, and the two things (CI keys and InternalsVisibleTo) that still need your attention.
Why renaming invalidates the strong name
A strong name is an RSA signature over a hash of the assembly’s content, stored in the assembly itself, with the matching public key becoming part of the assembly’s identity. The CLR recomputes that hash at load time (for a fully-signed assembly in a context that checks it) and compares. Change one byte of metadata or IL after signing and the hashes diverge:
> sn -vf MyApp.dll
Failed to verify assembly -- Strong name validation failed.
That is the expected result of obfuscating a pre-signed assembly. The original .snk is still the right key; what is wrong is that the signature was computed over the pre-obfuscation bytes. You cannot “preserve” a strong name through obfuscation — you can only re-apply it afterward, over the final image. That is what strongNameKeyFile does:
{
"inputs": ["bin/Release/net8.0/MyApp.dll"],
"outputDirectory": "bin/Release/net8.0/obf",
"controlFlowObfuscation": true,
"encryptStrings": true,
"strongNameKeyFile": "keys/MyApp.snk"
}
Nebula obfuscates, then signs the output with that key as the final metadata step. The output’s public key — and therefore its strong-name identity — is whatever the .snk carries. Signing and re-signing after obfuscation is a Pro feature, along with the rest of the hardening above renaming.
Keep the key off the build server’s disk
Checking a .snk into the repo defeats the point of having one. Use strongNameKeyEnvVar instead: it names an environment variable holding the base64 of the .snk, which your CI secret store injects at build time. It takes precedence over strongNameKeyFile, so the same config produces a real signed build on the server and an unsigned (or delay-signed) build on a developer checkout that has no secret.
{
"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 embeds only the public key and leaves room for a signature applied later with sn -R — useful when the private key lives in an HSM or a gated signing service and never reaches the build at all.
The order that must not be swapped
When you also Authenticode-sign, the ordering is not a preference — it is forced by what each signature covers. The strong-name signature lives inside the assembly’s metadata, so computing it changes the file. The Authenticode signature is computed over (almost) the whole file and embedded in it. Therefore:
If you Authenticode-signed before re-applying the strong name, embedding the strong-name signature would rewrite bytes underneath the Authenticode signature and break it. Nebula always does strong name first, then Authenticode — you just supply the cert:
{
"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"
}
Prefer authenticodeCertThumbprint (a cert in the machine store) or a .pfx whose password comes from authenticodeCertPassword supplied via an env var over an inline string. Always set authenticodeTimestampUrl: an RFC-3161 timestamp lets the signature keep verifying after the signing certificate expires. Authenticode needs signtool from the Windows SDK on the build agent.
Multi-assembly: one key across the set
If your product ships interdependent assemblies, re-sign them in the same Nebula run. Renaming changes each assembly’s identity, and an assembly reference carries the referenced assembly’s public-key token; Nebula rewrites every sibling’s reference to the new token so the set stays mutually loadable:
{
"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"
}
Sign the two assemblies separately, with different keys or in different runs, and the app’s reference to MyApp.Core will name a public-key token that the re-signed MyApp.Core no longer has — a FileLoadException at first call into it.
The InternalsVisibleTo trap
The one thing re-signing silently breaks is a friend-assembly relationship. InternalsVisibleTo names the friend by its public key, because otherwise anyone could impersonate your test assembly to reach your internals. If you re-sign with a different .snk than the assembly was originally built with, the output’s public key changes, and the attribute still names the old one:
// In MyApp.csproj's AssemblyInfo — this names the OLD public key:
[assembly: InternalsVisibleTo("MyApp.Tests, PublicKey=0024000004800000…")]
At runtime the CLR sees that MyApp.Tests no longer has the named key and refuses the friend access — MethodAccessException or a “friend assembly reference is invalid” load error. Two honest fixes:
- Preferred: don’t ship the friend.
InternalsVisibleToalmost always exists for a test assembly you do not distribute. Sign your shipped assemblies with your release key and leave the test assembly out of the obfuscation run entirely; the attribute only matters when the named assembly is actually loaded against the protected build. - If the friend ships too: update the attribute’s
PublicKey=to the public key of the release.snk(read it withsn -tp release.snk) and obfuscate both assemblies together with that one key.
Verify before you ship
Re-signing is the step most worth a smoke check in CI, because a broken signature only shows up on a machine that enforces it:
sn -vf obf/MyApp.dll # strong name verifies
signtool verify /pa /v obf/MyApp.dll # Authenticode chains + timestamp present
Then run the app from the output folder — the real test that every cross-assembly reference still resolves against the re-signed siblings.
What to take away
Obfuscation invalidates every signature computed before it, so signing has to happen after, over the final image, in a fixed order: obfuscate, embed the strong name, Authenticode-sign last. Nebula does all three as the tail of the pipeline — feed it the strong-name key through strongNameKeyEnvVar so it never hits the repo, sign an interdependent set in one run so the public-key tokens stay consistent, and set a timestamp URL so Authenticode outlives the cert. The only two things left to you are keeping the key out of source control and remembering that InternalsVisibleTo names a key that just changed. Verify with sn -vf and signtool verify in CI and the “works here, won’t load there” failure never reaches a customer.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.