DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Re-signing a .NET assembly after obfuscation: strong names and Authenticode

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.
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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…")]
Enter fullscreen mode Exit fullscreen mode

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. InternalsVisibleTo almost 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 with sn -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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)