A Keyright license is a claim — "this customer owns Pro, 5 seats, through next March" — and your application believes that claim for exactly one reason: a signature on it verifies against a public key your app carries. That design is the whole point of offline-first licensing. There is no server in the validation path to phone, nothing to be down, nothing to rate-limit; the app holds the public half of your signing key, checks the signature locally, and trusts what it reads.
Which raises an uncomfortable question. The public key is not secret — it is shipped, in the clear, inside an assembly the customer has on disk. What stops someone from replacing it?
Nothing, by default. And that is the single highest-leverage attack on client-side verification, because it does not require breaking any cryptography. This post is about why the attack works, what it looks like at the IL level, and how to make it expensive enough that it stops being worth doing.
The attack: substitute the trust anchor
Signature verification is asymmetric. Your issuing side holds a private key and signs the canonical license payload with it; your app holds the matching public key and verifies. Forging a signature that your public key accepts means recovering the private key from the public one, which for Ed25519 or RSA-2048 is not an afternoon's work — it is not anyone's work.
So a competent attacker does not attack the signature. They attack the anchor. The public key lives in your assembly as a constant — in a Keyright integration, typically a base64 string you pasted from the portal:
public static class LicensingConfig
{
// The PUBLIC half of your product signing key. Safe to ship — but not safe to trust blindly.
public const string PublicKeyBase64 =
"MCowBQYDK2VwAyEA9mF3rQ2pxK8vN1cWbYh6Zt0uQ4sJ7eD3rL5nX8kH2A=";
}
The attacker generates their own key pair. They overwrite PublicKeyBase64 with the base64 of their public key. Then they sign a license that says whatever they want — Pro, unlimited seats, no expiry — with their private key. Your verification code is byte-for-byte unchanged. It runs, it checks the signature against the key it finds embedded, and it passes, because the key it finds embedded is now theirs.
Nothing was decrypted, nothing was reverse-engineered. The attacker moved the root of trust and signed against the new root. Everything your app does downstream — gate features, honor seat caps, enforce expiry — is now driven by a license they wrote.
What the patch actually touches
The reason this is cheap is that the key sits in the assembly as one self-contained, recognizable object. Compile the const above and the reference to it is a single ldstr that loads the string out of the #US (user-string) heap:
// IL for: var pub = LicensingConfig.PublicKeyBase64;
ldstr "MCowBQYDK2VwAyEA9mF3rQ2pxK8vN1cWbYh6Zt0uQ4sJ7eD3rL5nX8kH2A="
stloc.0
Find that string in the heap — a base64 blob that decodes to a DER SubjectPublicKeyInfo is wildly distinctive; MCowBQYDK2Vw is the fixed prefix of every Ed25519 public key in that encoding — and you know exactly which bytes to overwrite. The attacker doesn't even need your tooling: a hex editor and a replacement string of equal length will do, and if theirs is shorter they pad the heap entry. One contiguous byte range, no logic to understand, and the edit survives untouched into every future version of your product because the key rarely changes.
That is the property we have to destroy. Not "can the key be read" — it is public, reading it is fine — but "can the key be replaced without the rest of the code noticing."
Hardening step 1: make the code check the key, not just use it
The first move is to stop treating whatever bytes are embedded as authoritative. Your app knows which key it should have. So have it confirm the key it is about to trust matches an expected fingerprint before it verifies anything with it:
static class KeyGuard
{
// SHA-256 of the DER-encoded public key you shipped with. Not a secret; an integrity anchor.
static readonly byte[] ExpectedFingerprint = Convert.FromHexString(
"7b2a1f9c4e0d8a63b5f1c24e6a8d0b3f9e5c7a12d4b60e8f1a3c5d7b9e0f2a4c");
public static ReadOnlySpan<byte> Trusted(string embeddedBase64)
{
byte[] der = Convert.FromBase64String(embeddedBase64);
byte[] actual = SHA256.HashData(der);
if (!CryptographicOperations.FixedTimeEquals(actual, ExpectedFingerprint))
throw new SecurityException("Licensing trust anchor failed integrity check.");
return der;
}
}
On its own this just moves the problem: now the attacker overwrites PublicKeyBase64 and ExpectedFingerprint, two edits instead of one. But it changes the shape of the attack in a useful way. A fingerprint is a fixed-length hash, not a recognizable key structure, so it is less obvious what it is for; and because the check is code, you can put it somewhere the attacker has to find, run it from more than one call site, and — the real win — fold the expected value into logic that does other things, so it cannot be nulled out without breaking them.
A naive integrity check stands alone and is easy to snip. A durable one is entangled. Suppose the fingerprint bytes are also used to derive an offset or a mask that something unrelated depends on — a lookup index, a validated length, a branch selector. Now an attacker who changes the fingerprint to match their key silently corrupts that other computation, and an attacker who leaves it alone fails the key check. There is no edit that satisfies both. This is exactly the kind of transform an obfuscator automates; Nebula's constant-encryption and control-flow passes exist to turn a lone comparable constant into a value that is reconstructed at runtime and consumed in several places, so there is no single ldc/ldstr to find and no single branch to snip.
Hardening step 2: carry more than one anchor
A single key is a single point of substitution. Keyright SDKs accept a signature that validates against your primary embedded key or against any key listed in AdditionalPublicKeysBase64:
var options = new LicenseVerifierOptions
{
PublicKeyBase64 = LicensingConfig.PublicKeyBase64,
AdditionalPublicKeysBase64 =
{
// a second, independently generated anchor — rotation today, defense in depth always
"MCowBQYDK2VwAyEAq7F2dN8kP0wZ5bYh3Rt9uL1cXe4sJ6D2rN5mV8kH0B=",
},
};
var verifier = new LicenseVerifier(options);
This exists for key rotation — ship the next key as an additional anchor, roll leases onto it, retire the old one — but it doubles as a substitution defense. There is no longer one blob to swap. An attacker has to locate and overwrite every trusted key, and if they miss one, your legitimately signed licenses still verify against the survivor while their forged license validates against none of the keys they didn't plant. Pair it with the fingerprint check — guard each anchor against its own expected hash — and a single surgical edit becomes several edits that each have to be exactly right.
Hardening step 3: don't let the edit be silent
Everything above raises the number and fragility of the edits. The last layer makes a successful edit visible and the build harder to produce at all.
Strong-name the assembly. A strong name is a hash of the assembly's contents signed by your identity key. Change one byte of the embedded public key and the strong-name hash no longer matches — the loader sees a different assembly identity, and any strong-name-dependent reference to it breaks. An attacker can strip the strong name and re-sign with their own key, but that is more work, it changes your publisher identity, and it gives any downstream integrity check something to notice.
Verify your own method bodies. Strong-naming is enforced by the host, which the attacker controls, so the durable version is to check integrity from inside your own code. This is what anti-tamper does: Nebula can hash the protected method bodies at build time and verify them at runtime, so an assembly whose IL — including the verification path and the key guard — has been edited refuses to run its protected code. The attacker now cannot patch the key check or the methods around it without tripping a check that is itself part of the protected set.
Report home when the check fails. A tamper check that only throws tells the attacker they were caught and tells you nothing. Nebula's tamper report-home posts a signal to an endpoint you choose when an integrity check fails in the field, so a build that someone swapped the key in — if it runs at all — surfaces as an event you can see, correlate and act on, instead of a silent crack circulating for months.
The honest ceiling
None of this makes substitution impossible. The code that decides whether a license is valid runs on a machine the attacker owns, and anything that runs on their machine can, with enough effort, be made to do something else. The goal was never a proof of impossibility — it was economics. A naked base64 key is a thirty-second edit that one person does once and shares forever. A fingerprinted, entangled, multi-anchored key in a strong-named, anti-tampered build that reports home is a research project that has to be redone every time you ship, and that tells you when it was attempted. Same attacker; completely different incentive.
That is the right way to read every client-side check, including this one. You are not building a wall. You are moving the cost of getting over it above the value of what is on the other side — and, with report-home, making sure that if someone pays that cost, you are the first to know rather than the last.
Top comments (0)