RockyGuard

Blog

The public key isn't secret. That's not why we hide it.

·Rocky Software ·4 min read

engineering-notessecuritycryptography

Every RockyGuard integration embeds a public key. Your application carries it, verifies each licence’s signature against it, and runs entirely offline. A reasonable instinct is to hide that key — bury it, encrypt it, make it hard to extract.

That instinct is aimed at the wrong threat. A verification key is public. An attacker who reads it out of your binary has learned nothing they can use: they still cannot forge a licence, because forging needs your private key, and that never leaves your control. “White-box crypto to hide the public key” is a category error — there is no secret on the verification side to protect, and a “white-box Ed25519 verify” doesn’t exist for exactly that reason.

So reading the key is harmless. Overwriting it is the attack.

Substitution, not extraction

Embed the key the obvious way and it lands in your binary as a contiguous block of text:

static constexpr char PUBLIC_KEY[] = R"(-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA...
-----END PUBLIC KEY-----)";

One command finds it:

strings yourapp | grep -A2 "BEGIN PUBLIC KEY"

And a clean block of text is a clean thing to replace. An attacker generates their own keypair, overwrites those bytes with their own public key, and now your application faithfully verifies licences they signed. The signature check still “passes” — it’s just checking against the wrong key. Nothing was forged; the trust anchor was swapped.

For our own library this is the single highest-value attack in the system. The vendor public key baked into RockyGuard is what authenticates a vendor licence — the file that says which tier and how many seats an integrator is entitled to. Swap that key and you can mint your own vendor licence and grant yourself anything. It was, until this release, a plain PEM blob in the binary.

Remove the target, then make tampering evident

The fix is not to make the key unreadable. It’s to remove the clean grep-and-overwrite target, and to make a substitution fail closed instead of silently succeeding.

A build-time generator turns a PEM key into two independently masked byte tables plus a small function that reconstructs the key at runtime. No BEGIN PUBLIC KEY block appears anywhere in the binary — there is nothing for strings to find and nothing contiguous to overwrite. The two tables encode the same key under different keystreams, and the accessor reconstructs both and returns a key only if they agree:

std::string a = unmask(kMaskedA, kLen, kSeedA);
std::string b = unmask(kMaskedB, kLen, kSeedB);
if (a != b) return "";     // tampered — caller fails closed
return a;

An empty string is not a usable key: constructing a verifier from it throws, and every path that loads the key already handles that as a failure. So an attacker can no longer flip one blob — they have to patch both tables into a consistent forgery of their own key, in two places, past a cross-check designed to notice the difference.

Two layers that catch two different patches

The masked tables live inside the shipped binary, so they compose with the integrity checks we already had:

  • On disk, changing the tables changes the library’s bytes, which trips the signed .sig hash — the same mechanism that catches any other on-disk edit.
  • In memory, patching one table in the loaded image leaves the file on disk untouched, so the on-disk hash never sees it. The runtime cross-check does: it runs on the live copies and refuses the key when they disagree. That’s the blind spot the file hash structurally can’t cover, closed by a different layer.

We verified both. The full suite passes; the key’s base64 is gone from the compiled library and the example binary (it used to be there verbatim); a signed build reports Library integrity: Ok, and flipping a byte flips it to HashMismatch.

The ceiling, stated plainly

This is not encryption and we don’t dress it up as such. The values needed to reconstruct the key are in the binary, so a determined reverser can recover the key and, with more effort, produce a consistent two-table patch. What the technique buys is real but bounded: it deletes the low-effort grep-and-overwrite attack, and it hands our obfuscated build a reconstruct-and-compare code path to protect — masking without obfuscation only moves the goalpost by one patch; together they raise the floor meaningfully. The durable protections remain architectural: node-locking, the floating server, and binding real functionality to licence-unlocked data rather than a single boolean gate.

The same pattern ships for your key, not just ours. The examples folder now includes the generator and a worked hidden_key_example.cpp; run it on your public.pem to produce the masked header, and rebuild. It’s deterministic, so an unchanged key produces an unchanged file and rotating the key is just re-running the generator.


The plain string literal is still a supported, correct integration — this is the hardened option for threat models that include someone editing the shipped binary. The customer-facing summary is Customer Documentation §9.9. The download page has the demo bundle.