RockyGuard

Blog

Don't check the licence. Make the feature need it.

·Rocky Software ·6 min read

engineering-notessecuritycryptography

A RockyGuard licence is signed, and the signature is not forgeable. We have written before about what an attacker does instead: they leave the signature alone and patch the branch that trusts it. if (!result) return 1; is one conditional jump, and a jump is one byte from becoming its opposite. Obfuscation raises the cost of finding that byte. It does not remove it — and it cannot touch the branch that matters most, the one in your code, in your binary, where the library never runs.

So the honest question is not “how do we make the branch harder to find.” It is “why is there a branch at all.”

The branch is a choice, not a requirement

The usual shape of a licence check is: verify, get a verdict, act on the verdict.

auto result = verifier.load("license.json");
if (result.status != LicenseStatus::Valid) return 1;   // <- the branch
run_premium_feature();

Everything an attacker wants lives on one side of that if. The valid path and the invalid path leave the program in the same state except for a single flag, so the flag is worth exactly one byte to flip. Nothing downstream consumes the cryptographic product of the verification — it consumes a boolean, and a boolean is cheap to forge locally.

The fix is to make the feature consume the cryptographic product directly.

Load-bearing licensing

Verification already computes something that only a genuine licence can produce. A load-bearing licence puts that something to work. The licence carries a wrapped data key; the wrapping key is derived from the signature itself — HKDF-SHA256(signature_bytes) — and the payload is sealed with AES-256-GCM. A real, correctly-signed licence unwraps to the real key. A forged licence, or a licence whose verification branch was patched to “pass,” unwraps to the wrong key, and AES-GCM authentication simply fails.

Then you seal a real feature asset under that key at build time, and open it at runtime:

#include <rockyguard/license_asset.h>

verifier.load("license.json");                 // no branch on the verdict
rockyguard::LicenseAsset asset(verifier, "premium");
auto data = asset.open(sealed_premium, sealed_premium_len);
render_premium(data);                          // garbage in, garbage out

There is no if. asset.open() returns the decrypted asset when the licence is genuine and an empty vector when it is not — and the empty or wrong bytes flow straight into render_premium, which produces nonsense there. The attacker did not get a working feature and a friendly error to suppress; they got a feature that does not work, for a reason that is thirty function calls away from anything labelled “licence.”

The point is not that this is harder to patch. It is that there is nothing to patch. The bytes the attacker needs — the plaintext asset, or the data key — never exist in the binary and never exist in a forged licence. They exist only after a genuine signature has been run through the KDF, which is exactly the thing they cannot fake.

What ships

Load-bearing protection is a small toolchain, not a paragraph of advice:

  • rg_bind_asset seals a plaintext asset under the data key at build time and emits it as a C header of ciphertext. It can take the key from an environment variable (--data-key-env), so neither the plaintext nor the key need enter your source tree.
  • A CMake helper wires the binder into your build as a normal custom command, regenerating the header when the asset changes.
  • rg_bind_lint watches for the ways this silently decays back into a boolean — a branch on unwrap_data_key(), a shared bool is_licensed(), a recovered key that never actually opens anything.
  • LicenseAsset opens the sealed blob at runtime. It has no ok(), no operator bool, nothing to branch on. An empty result is the failure mode, by design.

On the minting side, the licensing portal now generates one data key per customer and wraps it into every non-evaluation licence it issues automatically — so the load-bearing path is what a real paid licence uses, not a feature you have to remember to turn on. A per-customer key also means a leaked key is one customer’s problem, not everyone’s.

We measured it rather than assumed it

A claim like “there is nothing to patch” is worth exactly as much as the test behind it. So we built two statically-linked targets from the same library — one that verifies and branches, one that is load-bearing — and swept every candidate patch we could generate across their machine code: conditional jumps inverted, setcc forced to a constant, and calls stubbed to return true, then pairs of those combined. Every mutant runs against the forged licence from the incident that started this work, and is judged on what the program actually produced, not on what verdict it reported.

The branch-and-verify control falls to a single one-byte flip. The load-bearing target has produced the real output zero times — across every single-flip mutation on Windows and on Linux, and across every two-flip combination on Windows — because no code patch conjures a key that only a genuine signature derives.

The ceiling, stated plainly

Two things this does not do, stated plainly because the alternative is a customer discovering them the hard way.

It only protects the features you route through the key. RockyGuard cannot make a feature load-bearing on your behalf; if you keep the old if, the old if is still one byte from open. That is why it is incremental — harden the one feature worth the effort, leave the rest on the simple branch.

And it raises cost; it does not prevent. The cheapest break involves no patching at all: buy one genuine licence of the tier you want, run the unmodified application under a debugger on your own machine, and read the decrypted asset out of the heap after the app opens it. Obfuscation is irrelevant to that, and so is load-bearing. What load-bearing changes is the other attack — the one that scales, the one from the incident report: two one-byte patches, an afternoon, works on every machine forever. That break is the one we set out to make expensive, and measured that we did.

You choose, per feature, which side of that trade you want. The library’s job is to make the strong choice available and honest about its limits — not to pretend the limits are not there.


The simple branch is still a supported, correct integration — load-bearing is the hardened option for a feature worth the extra work. The customer-facing walkthrough is section 6.5 of the AI Integration Guide, and the download page has the demo bundle.