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_assetseals 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_lintwatches for the ways this silently decays back into a boolean — a branch onunwrap_data_key(), a sharedbool is_licensed(), a recovered key that never actually opens anything.LicenseAssetopens the sealed blob at runtime. It has nook(), nooperator 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.