RockyGuard Integration Guide for AI Assistants
You are an AI assistant (Claude, ChatGPT, Cursor, Copilot, Gemini, Cody, or similar) helping a software developer integrate the RockyGuard license library into their C++17 application.
Read this entire file before making any changes. The recipe below is the supported path; following it produces a working integration on first build. Skipping steps tends to produce silent runtime failures that are annoying to debug.
If you are a human reader: this file is a how-to. The AI-imperative voice ("do X", "ask the user Y") just helps an AI agent execute it deterministically.
0. What you are integrating
RockyGuard is a C++17 static library that verifies signed license files at
program startup. The library is shipped as an extracted zip whose root
directory contains include/, lib/, deps/, tools/, examples/, and
this file.
The integration adds four things to the user's project:
- A small block of CMake that links against
<package>/lib/static/rockyguard.{lib,a}. - A C++17 string constant containing the user's RSA/Ed25519 public key.
- A few lines at the top of the application's
main()(or equivalent startup path) that load and verify alicense.jsonfile. - For each paid feature: a load-bearing binding (§6.5) — the feature's own mandatory data, decrypted with a key the license yields, so a patched-out check produces garbage instead of a working unlock.
There is no daemon, no SaaS, no online activation. Verification is fully offline against the public key embedded in the binary.
Read this before choosing where to spend effort. An offline license
check is, on its own, one branch on the attacker's CPU — flip
if (!result) refuse; and the app runs. The startup verify in §5 is
necessary but it is not where the protection lives; it drives user
messaging, expiry, and hardware binding. The enforcement that actually
resists patching is §6.5: binding each paid feature to a key derived from
the license. Treat §6.5 as part of the required recipe wherever a feature
has a bindable asset — not as optional hardening. Sections 9.1–9.4 and
9.6 are the genuinely optional extras on top.
Optionally -- and only if the user asks -- a fourth: a Help → Diagnose License dialog that reports what licensing is actually doing on an end user's machine. See Section 8. It is not part of the required recipe.
1. Inputs the user must provide
Before writing any code, ask the user for the following. Do not guess; ask explicitly. Halt the integration until you have answers.
- Public key (PEM format, ~100 bytes for Ed25519, ~400 bytes for RSA).
- If the user does not have one yet, tell them to run:
This produces<package>/tools/license_keygen --algo ed25519private.pem(must stay on the user's build machine — never commit, never ship) andpublic.pem(will be embedded in the compiled binary). - Confirm with the user that you will be embedding the public key content. Read its file content; do not invent or generate one.
- If the user does not have one yet, tell them to run:
- Where the application's
main()lives. If the project has multiple binaries, ask which one needs license enforcement. Default to the end-user-facing executable. - Where they want
license.jsonto be loaded from at runtime. The default is "next to the executable". Other reasonable choices: a well-known config path on the OS. Confirm before deviating from the default. - Tier they have purchased.
- Basic: node-locked licensing only. Skip Section 7.
- Premium: node-locked + floating-license server/client. Section 7 applies if and only if the user explicitly says they want floating licenses (concurrent-seat pools across a customer's LAN). Most Premium customers ship products that use only node-locked.
2. Place the package inside the user's repo
Recommended layout (verify with the user before moving files):
<their-repo>/
src/ # their existing source
CMakeLists.txt # their existing build
vendor/
rockyguard/ # the entire extracted zip lives here
include/
lib/
deps/
tools/
examples/
AI_INTEGRATION_GUIDE.md # this file
README.txt
If their repo already has a third_party/ or external/ convention,
match it. If they store dependencies via git submodule, vcpkg, or Conan,
stop and ask the user how they want RockyGuard tracked. Do not
unilaterally invent a new dependency-management scheme.
3. Add the library to CMake
Add this block to the project's top-level CMakeLists.txt, after their
existing find_package calls and before any target_link_libraries
that should consume RockyGuard.
# --- RockyGuard license library ---
if(NOT DEFINED ROCKYGUARD_DIR)
set(ROCKYGUARD_DIR "${CMAKE_CURRENT_SOURCE_DIR}/vendor/rockyguard")
endif()
if(NOT EXISTS "${ROCKYGUARD_DIR}/include/rockyguard/rockyguard.h")
message(FATAL_ERROR "RockyGuard not found at ${ROCKYGUARD_DIR}")
endif()
set(_RG_DEPS "${ROCKYGUARD_DIR}/deps")
add_library(rg_openssl_crypto STATIC IMPORTED)
add_library(rg_openssl_ssl STATIC IMPORTED)
if(WIN32)
set_target_properties(rg_openssl_crypto PROPERTIES
IMPORTED_LOCATION "${_RG_DEPS}/lib/libcrypto.lib")
set_target_properties(rg_openssl_ssl PROPERTIES
IMPORTED_LOCATION "${_RG_DEPS}/lib/libssl.lib")
else()
set_target_properties(rg_openssl_crypto PROPERTIES
IMPORTED_LOCATION "${_RG_DEPS}/lib/libcrypto.a")
set_target_properties(rg_openssl_ssl PROPERTIES
IMPORTED_LOCATION "${_RG_DEPS}/lib/libssl.a")
endif()
target_include_directories(rg_openssl_crypto INTERFACE "${_RG_DEPS}/include")
target_include_directories(rg_openssl_ssl INTERFACE "${_RG_DEPS}/include")
add_library(rockyguard STATIC IMPORTED)
if(WIN32)
# Per-config CRT variants. rockyguard.lib is built with /MD
# (Release-CRT) and rockyguard_mdd.lib with /MDd (Debug-CRT).
# CMake reads IMPORTED_LOCATION_<CONFIG> automatically in a
# multi-config generator (Visual Studio, Ninja Multi-Config),
# so a Debug consumer build picks rockyguard_mdd.lib and avoids
# LNK2038 / LNK1319 (mismatched _ITERATOR_DEBUG_LEVEL or
# RuntimeLibrary). Other configs (RelWithDebInfo, MinSizeRel)
# map to the Release variant.
set_target_properties(rockyguard PROPERTIES
IMPORTED_LOCATION_RELEASE "${ROCKYGUARD_DIR}/lib/static/rockyguard.lib"
IMPORTED_LOCATION_DEBUG "${ROCKYGUARD_DIR}/lib/static/rockyguard_mdd.lib"
IMPORTED_LOCATION "${ROCKYGUARD_DIR}/lib/static/rockyguard.lib"
MAP_IMPORTED_CONFIG_MINSIZEREL Release
MAP_IMPORTED_CONFIG_RELWITHDEBINFO Release)
else()
set_target_properties(rockyguard PROPERTIES
IMPORTED_LOCATION "${ROCKYGUARD_DIR}/lib/static/librockyguard.a")
endif()
target_include_directories(rockyguard INTERFACE "${ROCKYGUARD_DIR}/include")
# Link order matters on Linux: ssl before crypto. The Windows linker is
# order-insensitive but the same listing works for both platforms.
target_link_libraries(rockyguard INTERFACE rg_openssl_ssl rg_openssl_crypto)
if(WIN32)
target_link_libraries(rockyguard INTERFACE
ws2_32 crypt32 iphlpapi ole32 oleaut32 wbemuuid advapi32 shell32)
elseif(NOT APPLE)
target_link_libraries(rockyguard INTERFACE pthread dl)
endif()
# C++17 is REQUIRED. Putting it on the interface means every target that
# links rockyguard inherits it, so you do not have to remember to set
# CMAKE_CXX_STANDARD in the consuming project -- and a project that pins
# an older standard gets a clear CMake-level conflict rather than a
# compile error deep in a header.
target_compile_features(rockyguard INTERFACE cxx_std_17)
# --- end RockyGuard ---
Do not omit the target_compile_features line. The shipped library
is built as C++17 and enforces it with a static_assert in
include/rockyguard/export.h, which every public header reaches. MSVC
still defaults to C++14, so on Windows a project that has not set the
standard itself fails to build with:
error C2338: static_assert failed: 'RockyGuard requires C++17 or later.
In CMake: set(CMAKE_CXX_STANDARD 17); set(CMAKE_CXX_STANDARD_REQUIRED ON).
Direct compiler flag: /std:c++17 (MSVC) or -std=c++17 (gcc/clang).'
Recent g++ and clang default to C++17 or later, so the omission is
invisible there — which is exactly why it is easy to ship a
CMakeLists.txt that only works on the machine it was written on. If you
would rather set it on the consuming project instead of the interface,
set(CMAKE_CXX_STANDARD 17) plus
set(CMAKE_CXX_STANDARD_REQUIRED ON) is equivalent.
Then, on the target you decided in Section 1.2, add:
target_link_libraries(<their_main_target> PRIVATE rockyguard)
Replace <their_main_target> with the actual add_executable() name in
their CMakeLists.
If their build system is not CMake (Bazel, Meson, MSBuild, plain Make): stop and ask the user. The patterns translate but the exact path-and- linker-flag layout differs. Don't guess.
4. Embed the public key
Find the source file containing int main(...). At the top, after the
existing #includes, add:
#include <rockyguard/rockyguard.h>
namespace {
// RockyGuard public key. Pairs with the private key on the build machine.
// SAFE to commit to source control: only the matching private key can
// produce licenses this binary will accept. The key below is a placeholder;
// replace it with the contents of public.pem from `tools/license_keygen`.
constexpr const char* ROCKYGUARD_PUBLIC_KEY = R"PEM(-----BEGIN PUBLIC KEY-----
<<<PASTE THE USER'S public.pem CONTENT HERE>>>
-----END PUBLIC KEY-----
)PEM";
} // namespace
Read the user's public.pem file and substitute its content into the
placeholder above. Do not generate, fake, or shorten the key. If you
cannot read the file, ask the user to paste it.
5. Add verification at startup
Insert these lines as the first thing main() does, before any
substantive work. The pattern:
int main(int argc, char* argv[]) {
// The constructor is the only verifier call that throws. It raises
// std::runtime_error if ROCKYGUARD_PUBLIC_KEY is not a parseable
// PEM public key -- which is exactly what a truncated or mangled
// paste in Section 4 produces. Uncaught, that is std::terminate():
// the user sees a hard crash instead of a message. Guard it.
//
// std::optional keeps construction inside the try while `verifier`
// stays in scope, so the existing main() body below does NOT need
// to be re-indented into a try block. LicenseVerifier is neither
// copyable nor movable, so emplace() constructs it in place --
// plain assignment will not compile.
std::optional<rockyguard::LicenseVerifier> maybe_verifier;
try {
maybe_verifier.emplace(ROCKYGUARD_PUBLIC_KEY);
} catch (const std::runtime_error& e) {
std::cerr << "License system error: " << e.what() << "\n";
return 1;
}
rockyguard::LicenseVerifier& verifier = *maybe_verifier;
auto load_result = verifier.load("license.json");
log_line(load_result.to_json()); // see "Report by code" below
if (!load_result) {
std::cerr << "License error: " << load_result.message << "\n";
return 1;
}
// Capture the result. Do NOT write
// `if (!verifier.check_node_locked())` and throw it away: a result
// that PASSES can still carry a warning worth telling the user
// about, and a result that fails carries the code you log and the
// remedy you show. Nor is a hard-coded "not valid for this machine"
// line right here -- this call also reports clock manipulation,
// integrity and expiry failures, so most of what it refuses has
// nothing to do with the hardware.
const auto hw_result = verifier.check_node_locked();
log_line(hw_result.to_json());
if (!hw_result) {
std::cerr << "License error: " << hw_result.message << "\n";
return 1;
}
if (rockyguard::error_info(hw_result.code).severity ==
rockyguard::DiagnosticSeverity::Warning) {
// Passing, but say something -- expired-but-in-grace, or a
// clock anomaly that was repaired and will probably recur.
// Never gate startup on this branch.
warn_the_user(hw_result.message);
}
// ... existing main() body continues here ...
}
Add #include <optional> alongside the other includes if it is not
already present, and #include <rockyguard/license_error.h> for
error_info() / DiagnosticSeverity (the umbrella
<rockyguard/rockyguard.h> already pulls it in, but name it if the
project includes the individual headers). Replace log_line() and
warn_the_user() with whatever the project actually uses; do not
introduce a logging framework it does not already have.
If main() already has argument parsing, logging setup, or a CLI
framework, the verification block goes after argument parsing but
before any feature work — typically right after logging is initialised.
The "license.json" path is relative to the working directory. If the
user wants the license loaded from a different path (per Section 1.3),
substitute that path string.
Do not catch and swallow license errors. If verification fails, the application must not proceed into licensed functionality. Treating a failed license as success is the most common integration mistake and silently disables protection.
"Must not proceed" is not the same as "must call exit()". How to
refuse depends on what you are integrating into, and return 1 from
main() is only correct for the first case:
| Host type | Correct response on failure |
|---|---|
| Console app / CLI | Print the message to stderr and return non-zero from main(). This is the snippet above. |
| GUI app | Show a dialog with result.message -- or, if the dialog has a separate action line, your own headline plus result.suggested_action -- then quit the event loop. Exiting silently before the main window appears is indistinguishable from a crash to the end user. |
| Service / daemon | Log the failure and either stay up in a disabled state or exit deliberately. A bare exit() in a supervised service usually triggers a restart loop that hides the real cause. |
| Plugin / shared library | Return a failure code to the host and disable your features. Never call exit() or abort() here — there is no main() of yours to return from, and terminating takes down the host application (the CAD package, IDE, or DAW that loaded you) along with any unsaved user work. |
Ask the user which of these their application is if it is not obvious from the codebase, and pick the matching pattern.
Report by code, never by message text. (v1.4+) Every LicenseResult
carries a fine-grained LicenseErrorCode in result.code, alongside the
coarse result.status it has always had. The two are orthogonal: each
code maps onto exactly one status, and the mapping reproduces what
pre-v1.4 releases returned, so status-based code keeps working unchanged.
When you generate error handling, follow these rules -- this is the part
integrations get wrong in a way that only shows up years later, when a
release rewords a sentence and someone's if (msg.find("expired"))
quietly stops firing.
- Branch on
result.statusfor control flow. It is the coarse outcome and it is frozen -- v1.4 added no enumerator toLicenseStatus, precisely so that a customer's exhaustiveswitchkeeps compiling without new-Wswitchwarnings. - Use
result.codewhen one status covers conditions you want to treat differently.HardwareMismatchcovers bothHWID_MISMATCH(this really is a different machine) andHWID_MISSING_IN_LICENSE(the licence was minted without a fingerprint -- an issuance bug to route back at the vendor).ExpiredcoversLICENSE_EXPIREDandGRACE_PERIOD_EXPIRED, which are two different conversations to have with a paying customer.ServerUnreachablecovers sixNETWORK_*transport causes that used to be one opaque "cannot reach license server". - Never substring-match, regex, or compare
result.message. The code and itsSCREAMING_SNAKE_CASEname are documented stable contracts; the wording ofmessageis documented as explicitly NOT one, and it is reworded between releases. If you catch yourself writing a string comparison againstmessage, the code you want already exists -- look it up in Customer_API_Reference §4.5. - Log
result.to_json(). One line, machine-parseable, carrying code, status, severity, retryable, message, detail, suggested_action and grace days. It is the single thing worth capturing for support and the thing to ask the user to paste into a ticket. Log it on the passing path too -- a clean run's line is what makes a later failure line meaningful.result.to_string()is the shorter form if a JSON line is more than the project's log format wants. - Show
result.suggested_actionas the user-facing "what to do". Do not invent remedy wording; these sentences are written per code and are maintained with the library, so they cannot go stale the way a hard-coded string in the host does.result.messageis that same text pre-joined (summary + detail + suggested_action), so printmessageor the parts -- printing both says the remedy twice. - Treat
DiagnosticSeverity::Warningas "passing, but say something".error_info(result.code).severityisWarningonly on results that convert totrue: currentlyOK_GRACE_PERIOD_ACTIVEandOK_CLOCK_ANCHOR_REPAIRED. Surface it and let the application run. Blocking startup on a licence that passed is a worse bug than not reporting the warning at all. - For a floating checkout, retry only on
error_info(result.code).retryable, with backoff, and stop as soon as it is false. See Customer_API_Reference §4.6 for the shape.
Two notes on upgrading an existing integration. LicenseStatus is
unchanged, so nothing that switches on it needs editing. LicenseResult
gained three appended members and therefore changed size, so a project
moving to v1.4 must recompile against the new headers -- dropping a
v1.4 shared library under a binary built against v1.3 is not supported
(RockyGuard_Versioning_Policy section 6).
Full catalog of codes with meanings and remedies: §4.5 of
docs/Customer_API_Reference.pdf. The catalog lookups
(error_info(), error_code_name(), error_code_from_name(),
status_name(), error_catalog(), DiagnosticSeverity) and a worked
handling example: §4.6. Read both before writing error handling by
hand; do not guess code names, they are listed there.
6. Feature gating (call this out to the user)
This is the part most integrations miss. Verifying a license proves "this is a paid customer", but the half of the value is gating specific premium-tier features in the application.
Before writing any feature checks, ask the user:
"Looking at your product, here are the features I can identify that might be paid-tier: [list 3-7 candidates derived from the codebase — e.g. PDF export, multi-user collaboration, automation API, advanced analytics, plugin support]. Which of these should be gated by license, and what feature-flag string should I use for each?"
Wait for their answer. Do not invent a feature taxonomy on your own; the customer's own pricing model decides what gets gated.
Once they answer, gate each feature with:
if (!verifier.check_feature("export_pdf")) {
// refuse / hide / disable the feature gracefully
return user_facing_error("PDF export requires a Pro license.");
}
The string "export_pdf" must match what the user puts into the
features: list when they generate end-user licenses with license_create.
Record the chosen feature names in a FEATURES.md at the repo root: copy
<package>/examples/FEATURES.template.md there and fill it in. It is the one
place code, the build (rockyguard_bind_asset), and license minting
(license_create --features) must agree on every flag string, and it also
records which features are bound (§6.5) versus boolean-only (§6.5.7). Keep it
current — a flag typo in any of the three fails silently.
check_feature() returns a bool, and a bool is one branch an attacker
flips. It is the right tool for greying out a menu or showing "Pro only",
but it is not, by itself, enforcement. For each gated feature that loads a
mandatory asset, the enforcement is §6.5 — bind that asset to the license.
Do §6.5 for those features in the same pass as §6; do not defer it to a
later "hardening" phase.
6.5 Make each paid feature load-bearing (REQUIRED where a bindable asset exists)
Sections 5 and 6 make licensing work and drive the UI. This section makes it hold. Instead of branching on a verify result — a decision made on the attacker's CPU, so a branch they can flip — you derive a key from the validated license and use it to decrypt data the feature genuinely needs (a lookup table, a resource blob, a model, a ruleset). Patch the check out and the key never appears, so the bytes stay encrypted and the feature produces garbage rather than a working unlock. A bool has two values; a 32-byte key has 2²⁵⁶ — you cannot patch a function into emitting a key it does not possess.
The library ships this as an API with no verdict in it:
rockyguard::LicenseAsset (<rockyguard/license_asset.h>) captures the
32-byte key the vendor sealed into the license — the product data key
(license_create --wrap-data-key) or one feature's key
(--wrap-feature-key <name>=<hex>) — and only when a genuine signature
is present, the one field an attacker cannot forge. Its open() returns the
decrypted asset or an empty vector; there is no ok(), no operator bool,
no std::optional, and no way to ask whether the key arrived. The package
ships the build side too: tools/rg_bind_asset seals assets, the
rockyguard_bind_asset() CMake helper wires it into the build, and
tools/rg_bind_lint catches regressions. (The lower-level
LicenseVerifier::unwrap_data_key() / unwrap_feature_key() return
std::optional; prefer LicenseAsset, which exists precisely so nobody
writes if (!key) around them.)
6.5.1 The rule
For every feature you gated in §6: if the feature loads an asset it
cannot function without, bind that asset (steps below) instead of relying on
the check_feature() bool. A feature with no bindable asset falls back to
§6.5.7 — and you tell the user which features got only bool protection.
6.5.2 Discover bindable assets — run this procedure, then confirm (do not invent)
Do not ask the user an open-ended "what could I encrypt?". Run this and present the result:
- For each gated feature, find its entry point and list every file or blob
it loads at runtime and cannot run without: resource bundles
(
.rcc/.qrc/.pak/.dat/.bin), model weights, lookup / rule / pricing tables, shader or parameter blobs, embedded scripting bytecode, schemas. - Score each candidate 0–2 on each axis:
- Mandatory — the feature is dead or produces garbage without it.
- Choke-loadable — it enters through one narrow call you can route through a single decrypt.
- Opaque — wrong bytes are visibly broken, not silently subtly wrong.
- Small — under a few MB (it is decrypted into memory).
- Present a ranked table to the user. Propose binding the top candidate per feature. Ask them only to confirm or correct — not to invent.
- Any feature with no candidate scoring ≥1 on both Mandatory and Choke-loadable is boolean-only: handle it per §6.5.7 and say so.
6.5.3 Seal the asset at build time (one tool, one CMake call)
Do not hand-roll AES-GCM. Use the shipped binder; the plaintext never enters the repo or the binary.
- Generate the keys (once each) and keep them in the build machine's / CI's
secret store, never in the tree:
One product key for assets every licensee gets, plus one separate key per bound feature for anything tier-gated (§6.5.5 explains why the product key alone gives no tier separation).<package>/tools/license_create --gen-data-key # product key -> RG_DATA_KEY <package>/tools/license_create --gen-data-key # key for export_pdf -> RG_KEY_EXPORT_PDF - Wire the encrypt step into the build with the CMake helper
(
<package>/cmake/RockyGuardBindAsset.cmake):
The build reads the key fromlist(APPEND CMAKE_MODULE_PATH "${ROCKYGUARD_DIR}/cmake") include(RockyGuardBindAsset) rockyguard_bind_asset(TARGET <their_main_target> FEATURE export_pdf # must match the feature name in §6 ASSET ${CMAKE_SOURCE_DIR}/assets/export_tables.bin HEADER ${CMAKE_BINARY_DIR}/generated/export_tables.enc.h SYMBOL kExportTables DATA_KEY_ENV RG_KEY_EXPORT_PDF) # that feature's key, from the environment$RG_KEY_EXPORT_PDFat build time and generates a ciphertext-only header (kExportTables/kExportTables_len). Provide the key to the build environment (CI secret), e.g.RG_KEY_EXPORT_PDF=<hex> cmake --build build. For a product-wide asset pointDATA_KEY_ENVatRG_DATA_KEYinstead. Under the hood this runstools/rg_bind_asset(prebuilt in the package, source beside it; the examples CMake also builds it as targetrg_bind_asset). The blob it writes is the library's ownnonce(12) || ciphertext || tag(16)layout — exactly whatLicenseAsset::open()reads; nothing is derived in between. - Wrap the same keys into each end-user license at mint time — the
product key into every license, each feature key only into licenses
entitled to that feature:
<package>/tools/license_create ... --features "export_pdf" \ --wrap-data-key <product hex> --wrap-feature-key export_pdf=<feature hex> \ --output license.jsonlicense_createrefuses to wrap keys into a short evaluation license unless you pass--allow-eval-keys. Do not: a trial carrying production keys hands them to anyone who downloads it.
6.5.4 Open and USE it at runtime — without branching
Nothing to copy but two lines. At the feature's load site — where it used
to call check_feature() and branch — construct a LicenseAsset for that
feature and feed open()'s result straight into the work:
#include <rockyguard/license_asset.h>
#include "export_tables.enc.h" // generated in 6.5.3: kExportTables / kExportTables_len
// ...
rockyguard::LicenseAsset asset(verifier, "export_pdf"); // AFTER verifier.load(); cannot fail
load_export_tables(asset.open(kExportTables, kExportTables_len)); // real work; empty/garbage without a genuine, entitled license
For a product-wide asset use rockyguard::LicenseAsset asset(verifier);
(no feature name). open() returns the plaintext, or an empty vector when
there is no key, the blob is malformed, or authentication fails — and it
deliberately does not say which. Do not write if (table.empty()) refuse; around it: that is the branch this section exists to remove. Let
the loader fail on nothing. Construct the LicenseAsset after load(); one
built earlier captures nothing, silently, by design.
The feature is now incapable of working without the key. There is no
verdict to patch — only the plaintext, which never materialises for an
unlicensed run. (examples/bound_asset.h is the build-time sealer behind the
binder; the application does not need it.)
6.5.5 Per-feature keys are wrapped by the vendor, not derived
The product data key answers "was this license genuinely signed". It does
not answer "is this license entitled to this feature": every license
carries the product key, so an asset sealed under it opens for every
licensee, and entitlement would be back to the check_feature() bool. So for
each tier-gated feature you generate a separate key (§6.5.3 step 1) and
license_create --wrap-feature-key puts it only into licenses entitled to
that feature. A Basic license physically carries no export_pdf key;
LicenseAsset(verifier, "export_pdf") captures nothing; a patched
check_feature() cannot insert a key the envelope never carried.
Do not "simplify" this into subkeys derived from the product key
(HKDF(product_key, feature) or similar). It was evaluated and rejected:
every input to that derivation is public or comes from the one product-wide
key, so a license of any tier yields every tier's key offline — the
appearance of tier separation with none of the substance. The binder seals
under whichever key you hand it and derives nothing; the separation comes
from what the vendor wraps into each license.
Bind eight features → eight independent decryptions under eight keys, no shared verdict anywhere; patch any one site and only that feature breaks (into garbage), while the others never consulted a check to patch. One leaked plaintext does not compromise other features' assets either, since they never shared a key.
6.5.6 The negative test is MANDATORY (§10 enforces it)
A fake binding passes the normal "it works with a good license" test. Only the negative test distinguishes real binding: with a wrong/foreign license (valid signature, different key), the bound feature must be visibly broken/garbage — not a clean "license invalid" that still lets the rest of the app run. If the feature still works with the wrong key, the binding is fake; do not ship it. See §10.
6.5.7 When a feature has no bindable asset (honest fallback)
Not every feature loads opaque mandatory data. In order:
- Manufacture a dependency: find a constant or table the feature's real
code already uses (a coefficient set, a state table, embedded defaults),
move it into an encrypted asset, and open it with
LicenseAsset. Most "pure logic" features have at least one such table. - If there is genuinely nothing to bind, fall back to code-diversity re-checks (§9.4 item 3) and tell the user plainly: "feature X is protected only by a flippable check, because it has no bindable asset." Never leave it looking hardened when it is not.
6.5.8 State the ceiling — do not oversell it
This defeats the free branch-flip / keygen-less crack and forces an attacker
to possess a genuine license and reverse your derive/decrypt path — so
compile that translation unit under ROCKYGUARD_OBFUSCATE. It does not,
for an offline scheme, force signature forgery: one leaked genuine license
carries the signature that yields the key (break-once-run-everywhere). It is
signature-bound, not machine-bound — keep check_node_locked() (§5) for
hardware/expiry enforcement; the key derivation deliberately does not fold in
the live fingerprint, so ordinary hardware drift does not break decryption.
For leak traceability, use a per-customer data key and record which key
went to whom. Full worked example: examples/loadbearing_asset_example.cpp.
7. Floating licensing (Premium tier, only if requested)
Skip this section unless the user has explicitly said:
- They have a Premium-tier vendor license, AND
- Their product is multi-seat / multi-user, AND
- They want concurrent-seat licensing across their customer's LAN.
If yes, see examples/rg_floating_client.cpp in this package. The
client-side integration adds a rockyguard::FloatingLicenseClient that
checks out a seat at startup and releases on exit. The matching server
runs on the user's customer's LAN — see tools/rg_floating_server
and tools/floating_server_config.yaml. Walk the user through running
the server before adding client-side checkout calls; otherwise the
first checkout() returns ServerUnreachable -- as of v1.4 with a code
saying which transport failure it was, usually
NETWORK_CONNECTION_REFUSED for "nothing is listening" as against
NETWORK_HOST_UNRESOLVED for a wrong hostname. Log
result.to_json() around a failing checkout and the answer is in the
"code" field rather than in a guess.
Transport security is mandatory-by-default as of v1.3. Both sides fail closed at startup rather than falling back to cleartext, so a configuration that worked against v1.2.x can now refuse to start:
- Client —
FloatingLicenseClient's constructor throwsstd::runtime_error(before any network call) ifuse_tlsis false andallow_insecure_httpis not true, or ifuse_tlsis true with an emptytls_ca_cert_pathandallow_insecure_tlsnot true. Setuse_tls = trueplustls_ca_cert_path; use the opt-out flags only for local testing. - Server —
rg_floating_serverexits at startup if neithertls_cert/tls_keynorallow_insecure_http: trueis present in the YAML. The shippedfloating_server_config.yamlsetsallow_insecure_http: trueso local evaluation works out of the box; tell the user to replace it with a real cert before deployment.
Note allow_insecure_tls and allow_insecure_http are different axes —
the first relaxes certificate verification once TLS is on, the second
permits no encryption at all. Setting the first does not enable the
second. Both fail closed because the checkout response carries the
session secret that HMACs every later request.
8. "Diagnose License" menu item (ask first -- opt-in)
This section is opt-in. Do not add it unless the user asks for it, or you asked and they said yes. Sections 3-6 are the required recipe; this is a support feature on top.
Ask the user:
"Do you want a Help → Diagnose License menu item? It collects a full licensing status report on the end user's machine -- fingerprint, licence verdicts, clock, network, cache state -- and shows it in a dialog, so your support desk can be told what is actually wrong instead of guessing. Optionally it also writes one encrypted file the end user can e-mail you."
If they have no GUI (a CLI tool, a service, a library), say so and skip this section. Do not invent a menu.
8.1 What to add
Two functions, both over <rockyguard/diagnostics.h>:
#include <rockyguard/diagnostics.h>
namespace rgd = rockyguard::diagnostics;
// public_key_pem takes PEM CONTENT, not a path -- your end user has no
// key file and must not need one.
static rgd::Options diagnostic_options() {
rgd::Options o;
o.license_path = "license.json"; // whatever Section 5 uses
o.public_key_pem = ROCKYGUARD_PUBLIC_KEY;
o.tool_version = "<AppName> <version>";
return o;
}
First, make the key reachable. Section 4 puts
ROCKYGUARD_PUBLIC_KEY in an anonymous namespace in the file containing
main(), which gives it internal linkage -- it is invisible from any other
translation unit. A diagnostics dialog almost always lives in a different
.cpp (a UI file), so that constant will not compile there.
Do not paste a second copy of the key. Two copies drift, and the one you did not update becomes a licence that verifies in one code path and fails in the other. Instead move the declaration into a small header both files include:
// rockyguard_key.h -- new file, next to the file that had main()
#pragma once
inline constexpr const char* ROCKYGUARD_PUBLIC_KEY =
R"PEM(-----BEGIN PUBLIC KEY-----
... the user's public.pem content, moved verbatim from Section 4 ...
-----END PUBLIC KEY-----
)PEM";
inline constexpr gives it external linkage with no ODR problem, so both
translation units share one definition. Delete the anonymous-namespace
copy from Section 4's file and #include "rockyguard_key.h" in both.
The menu item -- runs off the UI thread, because collection performs network I/O and can take seconds:
// Kick it off when the menu item is clicked. Returns immediately.
auto job = std::make_shared<rgd::AsyncHealthCheck>(diagnostic_options());
// Then, on a timer / idle callback in your UI loop:
if (job->ready()) {
const rgd::HealthReport h = job->take();
show_dialog(h); // see 8.2
} else {
status_label.set_text(job->progress()); // safe from any thread
}
AsyncHealthCheck deliberately calls nothing back. You poll it. That
is why there is no callback to marshal into the UI thread -- which is the
single easiest thing to get wrong here, and it crashes rather than
glitching when you do.
8.2 Rendering the dialog
HealthReport is data, not text. Render it with the user's own widgets:
void show_dialog(const rgd::HealthReport& h) {
set_title(h.headline); // one sentence
set_colour(h.overall); // Info / Warning / Error
for (const rgd::HealthItem& item : h.items) {
add_row(icon_for(item.severity), // your icon set
item.label, // "Licence verification"
item.value, // "HWID_MISMATCH (HardwareMismatch)"
item.detail); // what to do about it, may be empty
}
}
Do not call summarize() for a GUI. It returns fixed-width terminal
text; in a proportional font the columns come out ragged. summarize()
is for consoles and log files.
8.3 Acting on findings
Branch on item.code. It is the same stable error-code contract as
Section 5. The wording of value and detail is not stable.
for (const rgd::HealthItem& item : h.items) {
if (item.code == "HWID_MISMATCH") offer_reactivation();
else if (item.code == "LICENSE_EXPIRED" ||
item.code == "GRACE_PERIOD_EXPIRED") open_renewal_page();
}
One finding is worth surfacing prominently because nothing else reports
it: a "Hardware drift" item with severity Warning. It means the
licence still verifies, but a fingerprint component has changed -- so the
machine is one hardware change away from failing, with no error anywhere
to warn the user. If the user wants a proactive "your licence may stop
working soon" prompt, this is the hook.
Do not wire diagnose() into this dialog. There is a root-cause
engine in the same header (rgd::diagnose(), API reference 11.8) and it
is tempting here, because it turns LICENSE_EXPIRED into "your clock is
three days fast, do not buy a renewal". It is written for the SUPPORT
DESK, not for the end user's screen: some causes name the vendor's own
issuance pipeline, and the anti-rollback remedies double as a tamper
recipe for whoever caused the problem. Causes carry a support_only flag
for exactly this reason, but nothing filters on it automatically, and the
rg_inspect tool RockyGuard ships deliberately does not diagnose either.
Its home is the vendor's side, on the report that comes back: either
rg_report_read --diagnose, or diagnose() inside the vendor's own
support tooling. If the user asks for root causes in the end-user dialog
anyway, that is their call to make -- say what it discloses, filter
support_only, and let them decide.
8.4 Offering "send to support" (optional, ask)
If the user wants the end user to be able to mail them the report, ask for
an RSA public key and seal h.report_json:
rgd::SealRequest req;
req.recipient_public_key_pem = SUPPORT_RECIPIENT_PUBLIC_KEY; // RSA, theirs
req.tool_version = "<AppName> <version>";
const rgd::SealOutcome sealed = rgd::seal(h.report_json, req);
if (sealed.ok) write_file("diagnostic.rgdr", sealed.container);
Tell the user plainly:
- This is a second, separate keypair from their licence-signing key,
and it must be RSA: an Ed25519 signing key has no encryption
operation and will be rejected. Generate it with
tools/license_keygen --algo rsa. - The private half stays on their support desk and opens the reports.
They read them with
rg_report_read(source inexamples/inspector/). Never shiprg_report_reador that private key to an end user. - If they do not want this, skip it. The dialog alone is useful; often the user reads the verdict aloud on the phone and that ends the ticket.
8.5 Do NOT put this at startup
Health collection is not a startup gate. It costs roughly 100 ms with
probe_network = false and several hundred with network on -- worst case
far more, because DNS resolution cannot be bounded. It also is not
side-effect free: verifying creates the anti-rollback witness files if
it finds none.
Keep the Section 5 LicenseVerifier check as the startup path. That is
what it is for and it is much cheaper. If the user specifically wants
richer context at launch, use health_check() with
o.probe_network = false and do it once, off the critical path -- never
in a loop, and never on every window open.
9. Hardening the integration against tampering (optional extras)
Sections 3–6 make licensing work; §6.5 is where the enforcement lives and is required wherever a feature has a bindable asset. This section is the optional extras on top: symbol hygiene, anti-substitution for the embedded key, avoiding a central verdict choke point, and folding the integrity check into the key. Do §6.5 first; reach here only if the threat model justifies more.
State the ceiling first, and do not oversell it. Everything here raises
attacker cost; none of it is unbreakable. A license verified offline on
hardware the attacker controls can always, eventually, be patched out. The one
primitive that genuinely resists patching is RockyGuard's own integrity
self-check (INTEGRITY_HASH_MISMATCH = patched on-disk binary,
INTEGRITY_RUNTIME_MODIFIED = runtime hook / attached debugger), which runs on
every check_node_locked(). The techniques below exist to make that self-check
hard to locate and bypass, and to remove the cheap wins (a greppable key, a
single flippable branch). Tell the user this plainly.
9.1 Measure the binary first — do not guess
The right hardening depends on what is actually exposed, and that differs by target type. Inspect the real artifact before changing anything:
nm -D --defined-only <artifact> | grep -ci rockyguard # DYNAMIC export table (.dynsym)
nm --defined-only <artifact> | grep -ci rockyguard # STATIC symbol table (.symtab)
strings -a <artifact> | grep -c 'BEGIN PUBLIC KEY' # greppable key blob?
file <artifact> | grep -o 'not stripped\|stripped' # symbols still present?
.dynsymis the export table — what-fvisibilityandvisibility("default")govern. It matters for a shared library; for an executable it usually holds only imports and a few runtime symbols..symtabis what plainnmreads. Stripping removes it; visibility flags do not touch it.
9.2 Symbol exposure — the actions differ by target type
Statically-linked executable (the app links librockyguard.a):
- RockyGuard's functions are internal to the executable, so they are already
absent from
.dynsym(nm -Dreturns 0).-fvisibility=hiddentherefore changes little on its own here — do not expect it to "hidecheck_license", because there is no export entry to hide. - What leaks the names to
nmis.symtab. Stripping is the high-value action —-Wl,-sat link (orstrip, orQMAKE/CMake install-strip). It also cannot change the prebuilt.a's baked-in symbol attributes, which is another reason visibility flags do nothing to those symbols. - Keep
-fvisibility=hidden -fvisibility-inlines-hiddenanyway as hygiene: it shrinks the app's own tiny surface and pays off the day the project links the shared RockyGuard lib or builds plugins. - Ensure release has no
-g(or strip removes it). Keep debuggability with a detached debug file:objcopy --only-keep-debug+--add-gnu-debuglink.
Shared library / plugin (you ship a .so / .dll, or wrap RockyGuard in
one):
- Here the export table is the ABI surface, and
-fvisibility=hiddendoes real work. Default to hidden, then export only the handful the host genuinely calls with__attribute__((visibility("default"))). The license functions (check_*, key accessors, the verifier glue) must not be exported. - ELF: for precise control add a linker version script that whitelists exports
and makes everything else local:
# exports.map { global: my_plugin_entry; my_plugin_shutdown; local: *; };-Wl,--version-script=exports.map. Verify withnm -D. - Windows DLL: symbols are hidden by default and exported opt-in. The mistake
to avoid is the reverse of ELF — do not put
__declspec(dllexport)(or a.defentry) on any license symbol. Ship without the.pdb. - Strip still applies. Note that if RockyGuard is consumed as its shared
librockyguard.so, the vendor already export-controls its symbols; your job is your own wrapper's exports plus shippinglibrockyguard.sigbeside the.sofor the integrity check.
Build-system snippets:
- CMake:
set_target_properties(t PROPERTIES CXX_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN ON); strip release viatarget_link_options(t PRIVATE $<$<CONFIG:Release>:-s>)or install-strip. - qmake: in a
CONFIG(release, debug|release)block:QMAKE_CXXFLAGS += -fvisibility=hidden -fvisibility-inlines-hiddenandQMAKE_LFLAGS += -Wl,-s.
9.3 Do not embed the public key as a greppable blob
Applies to both target types. The threat is substitution, not
extraction: a public key is not secret, but a contiguous PEM in .rodata is
a clean overwrite target — replace it with the attacker's key and the app
verifies licenses they signed.
Use examples/embed_public_key.py to emit the key as two independently
XOR-masked byte tables plus a reconstruct-and-cross-check accessor
(public_key_pem() returns "" if the tables disagree). Verify against the
embedded reconstruction (..._intact()) and fail closed on empty. After
this, strings | grep 'BEGIN PUBLIC KEY' returns nothing and a substitution
must patch both tables consistently. Rotate the key by regenerating the header;
its output is deterministic, so an unchanged key yields an empty diff.
This is masking, not encryption (the mask is derivable from the binary). It becomes a real cost only when the reconstruct/compare TU is compiled under an obfuscator so it cannot be trivially located and NOP'd.
9.4 Kill the central choke point (don't funnel gates through one verdict)
A single startup if (!result) refuse; is a single branch to flip. The
instinct is "check in more places" — but the choke point is the verdict,
not the number of call sites. Eight gates that all call one predicate —
check_node_locked(), or worse a bool is_licensed() wrapper you write
around it — are still one patch: flip that function's return once and all
eight gates see "valid." Nor does copying the same if into eight inlined
sites help: the compiler's common-subexpression elimination and the linker's
identical-code-folding (/OPT:ICF, --icf=all) merge identical checks back
into one blob, and even when they survive, byte-identical checks are exactly
what a pattern-based cracker search-and-replaces in one pass.
So, in order of how much they actually buy you:
Do NOT wrap the check in a single shared boolean predicate of your own. A
bool is_licensed()that everything calls adds a customer-side choke point on top of the library's. If you must produce a verdict, produce it at the site, not through a shared function.Strongest — give each gate its own data dependency, not a shared bool. This is Section 6.5 applied per-gate: each gated feature opens its own required asset through
LicenseAssetunder its own vendor-wrapped feature key (--wrap-feature-key; §6.5.5 — not subkeys you derive from the product key), woven into that feature's real work. Eight features → eight independent decryptions, no shared verdict anywhere. Patch any one site and only that feature breaks — into garbage, not a bypass — and the others never consulted a verdict to patch. Crucially, routing many gates through the singleunwrap_data_key()function is fine even though it is one function: you cannot patch a function to emit a 32-byte key you do not possess (a bool has two values; a key has 2²⁵⁶). That is the whole difference between returning a verdict and returning key material.For paths that genuinely must yield a verdict (startup, greying out a menu item), still verify at several independent sites — each calling
check_node_locked()so each re-runs the integrity self-check — and make the sites do distinct real work so CSE/ICF cannot fold them together (avolatilesprinkle is not enough). Treat this code-diversity layer as secondary to (2): it raises the cost against scripted/pattern crackers but a determined reverser defeats it, and it cannot touch the library's own single verdict function — that is whatROCKYGUARD_OBFUSCATEand the integrity self-check defend.- Feature entry points — re-verify before performing a gated action, throttled (e.g. once per 30 s) so per-click cost is bounded.
- A periodic background timer — re-verify every few minutes so a session whose startup check was patched out does not run indefinitely.
Which real computations can carry the dependency in (2) is app-specific — ask the user. Do not invent it: ask which of the gated features consume a lookup table, resource blob, model, ruleset, or other data you can encrypt under the license key, and wire the dependency into those. Where a feature has no natural data to bind, fall back to (3) for that one.
Choose the failure response by host type (this mirrors the Section 5 table):
- Executable: feature-site failure should refuse the action (do not
exit()mid-click — you would destroy unsaved work); the periodic timer is the site that may tear the session down. - Plugin / shared library: never
exit()/abort()at any site — that kills the host. Return failure to the host and disable your features.
9.5 Strongest: make the license load-bearing (consume the result, don't branch on it)
MOVED — this is now §6.5 and is part of the required recipe. As of this guide revision the load-bearing pattern ships as a tool (
tools/rg_bind_asset), a CMake helper (rockyguard_bind_asset()), theLicenseAssetclass (<rockyguard/license_asset.h>), a worked example (examples/loadbearing_asset_example.cpp), and a linter (tools/rg_bind_lint). Do it from §6.5, which supersedes the prose below. The text here is retained as the conceptual explanation and is referenced by §9.6.
Everything above raises the cost of patching the license decision. This step removes the decision as the thing worth patching. Instead of branching on the verify result — a choice made on the attacker's CPU, so a branch they can flip — derive a key from the validated license and use it to decrypt data or code the program genuinely needs (a required lookup table, a resource blob, a critical routine). Patch the check out and the key never appears, so the bytes stay encrypted and the app produces garbage rather than a working unlock. A branch can be flipped; a missing decryption key cannot be wished away.
As of v1.4 the library supports this directly, so you do not hand-roll the key derivation:
- Make one data key per product and keep it on your build machine:
<package>/tools/license_create --gen-data-key # 64 hex chars = 32 bytes - Encrypt the asset your program needs, under that key, at build time.
examples/loadbearing_asset_example.cpp --encrypt <dk_hex> <in> <out.enc>does it with AES-256-GCM in the exact format the runtime expects. Embed the ciphertext in your binary (a byte array in a header); the plaintext never ships. - Wrap the key into every end-user license at mint time:
This seals the key into the license, bound to that license's signature.<package>/tools/license_create ... --wrap-data-key <dk_hex> --output license.json - At runtime, recover the key and decrypt — without branching on it:
// Do NOT write `if (!key) refuse;` -- that reintroduces the flippable // branch. Feed the key straight into your decryption and USE the result; // if the license is absent/foreign/tampered, unwrap_data_key() returns // nullopt, the decrypt fails, and the program has no usable data. std::vector<uint8_t> key; if (auto k = verifier.unwrap_data_key()) key = *k; // empty on any failure auto table = my_aes_gcm_open(key, embedded_asset_blob); // your decrypt use_the_table(table); // real work; garbage if empty
unwrap_data_key() returns the exact 32 bytes you passed to
--wrap-data-key, and only when a genuine signature is present: the
wrap key is HKDF-SHA256(license signature), the one field an attacker
cannot forge for a payload of their choosing. A tampered signature, a
corrupted blob, a foreign license, or an absent one all yield nullopt
(GCM authentication failure), never a forged key.
State the ceiling to the user, do not oversell it. This defeats the
free branch-flip / keygen-less crack and forces an attacker to possess a
genuine license and reverse your derive/decrypt path — so compile that
translation unit under ROCKYGUARD_OBFUSCATE. It does not, for an
offline scheme, force signature forgery: one leaked genuine license
carries the signature that yields the key. It is signature-bound, not
machine-bound — keep check_node_locked() for hardware binding; the key
derivation deliberately does not fold in the live fingerprint, because
that would break decryption on the ordinary hardware drift the 2-of-4
match threshold is designed to tolerate. Pick a small, mandatory asset
so wrong-key decryption is visibly broken, not a quietly disabled feature.
Full worked example end to end: examples/loadbearing_asset_example.cpp.
9.6 Folding a code hash into the key — evaluated, and NOT recommended
An earlier revision of this guide recommended, as an advanced step, folding a
hash of the binary's own code into the key derivation — K = KDF(DK, hash(R)),
so a patched .text derives the wrong key and the asset decrypts to garbage
with no integrity branch to NOP. We have since red-teamed it against the same
attacker model as the rest of this section, and we now advise against it.
It does not reliably raise the bar, and it can lower it. This subsection keeps
the reasoning on record in place of the construction.
Why it looked good. An isolated integrity check (if (hash != expected) refuse;) is just another patchable branch. Feeding the hash into the key
instead of into a branch removes that branch — the same "no verdict to flip"
property that makes 9.5 load-bearing.
Why it is not load-bearing. 9.5 works because the value it folds in — the
signature — is a secret: an attacker cannot produce the key without
possessing a genuinely signed license. hash(R) has no such property. The
expected code hash is not secret — it is a deterministic function of the
legitimate binary the attacker already holds. So the attacker never has to
touch the crypto. They keep a pristine copy of R (or the known-good
constant) and make the running image hash that instead of itself: a one-line
redirect of the pointer or length handed to the hasher.
And this is what makes it worse than doing nothing: the self-hash routine is
a more distinctive, more greppable target than the branch it replaced. A
lone if (status != Valid) is one of thousands of comparisons; a routine that
reads its own code section and feeds the result into a KDF is a recognizable
shape with recognizable call sites. Adding it can hand the reverser an
easier landmark than the jne you were trying to eliminate. A mechanism
whose only net effect is to move the patch target somewhere more obvious has
negative value.
What to do instead — the steps that fold in a real secret or genuinely raise cost:
- 9.5 (signature-bound key) is the load-bearing step. Keep it: the signature is the secret the attacker must possess, and no code patch substitutes for it.
ROCKYGUARD_OBFUSCATEraises the cost of locating any target in the library's own code, a would-be self-hash included. It is friction, not a secret — the right tool for "make the code hard to navigate," and the wrong one to mistake for a lock.- The shipped
.sigand the runtime integrity check are the detection layer: they tell an honestly-deployed process that its binary was altered. They are not a substitute for the load-bearing key, and — being trust-on-first-use in a static build — they do not catch a binary that was patched on disk before first run. Treat them as detection and hygiene, not as the thing that stops this attack.
The permanent ceiling is the one stated in 9.5: an offline check on hardware the attacker controls raises cost, it does not prevent. Do not present a code-hash-in-the-key layer, to yourself or to a reviewer, as the step that closes that gap — it does not.
9.7 Worked example (static Qt executable)
For a static-linked Qt/qmake app the effective set was: strip release
(-Wl,-s) + -fvisibility=hidden (hygiene); masked key via
embed_public_key.py with a fail-closed intact check at startup; and three
check sites — startup, throttled feature-gate re-checks, and a 5-minute
periodic re-verify that quits on hard failure. Result, measured: .dynsym
rockyguard symbols 0 (already), .symtab 454 → 0 after strip,
grep 'BEGIN PUBLIC KEY' 1 → 0, tamper of one masked byte → key fails to
reconstruct → app refuses to start.
10. Verification
After making the changes above:
Build the project with the user's normal build command. The build should succeed with no new warnings or errors. If you get linker errors about OpenSSL symbols, recheck Section 3 — link order matters on Linux.
Generate a test license:
<package>/tools/license_create \ --key <user's private.pem> \ --vendor-license <user's vendor_license.json> \ --id TEST-001 --licensee "Dev Test" \ --product "<their product>" \ --type node_locked --threshold 2 --fingerprint \ --expires permanent --output license.json(
--threshold 2= match 2 of 4 hardware components, the recommended default for node-locked.--threshold 0= ignore hardware, useful only for in-CI testing, never ship.)--fingerprintis REQUIRED here and you must not drop it. It binds the test licence to the machine you are running on, which is what makes step 3 pass. Anode_lockedlicence with--threshold 2and NO fingerprint flag is rejected at verification time with statusHardwareMismatchand codeHWID_MISSING_IN_LICENSE-- deliberately, as a safety net against issuance bugs (Customer_API_Reference §6.4 and §4.5). Match on the code, not on the sentence: v1.4 reworded this one, so a check written against the old text ("Node-locked license has no hardware fingerprint") no longer fires. Note thatlicense_createstill exits 0 and prints a success line when you omit it, so the mint gives you no warning; the failure appears only at step 3, and it looks exactly like a broken integration. Earlier revisions of this guide omitted the flag and told you to expect success, which could not happen. If you are minting for a real end user, use--fingerprint-value "<their hash>"instead;--fingerprintmeans THIS machine and must never ship.Run the binary with
license.jsonin the working directory. Expected: program runs normally. Then rename license.json and run again. Expected: program prints the license error and exits 1 (with the logging from Section 5 in place, the failing run also emits oneto_json()line whose"code"isLICENSE_FILE_UNREADABLE-- that line is the fastest way to confirm the error path is wired up). If both behaviours match, the integration is verified. If instead the first run fails with codeHWID_MISSING_IN_LICENSE, the licence in step 2 was minted without--fingerprint. Re-mint it; nothing is wrong with the integration.Print the license info on first run so the user can sanity-check:
// license() returns a snapshot by value, copied under the verifier's // internal lock, so it stays valid even if another thread reloads. const rockyguard::License lic = verifier.license(); std::cerr << "Licensed to: " << lic.licensee << " (license ID " << lic.license_id << ")\n";Wrap in
#ifdef DEBUGif the user prefers.The negative test — MANDATORY for every feature you bound in §6.5. A binding that only passes the happy path may be fake. Mint a second license from different keys (or reuse a foreign license), run the app with it, and exercise each bound feature. Expected: the feature is visibly broken / produces garbage / is inert — NOT a clean "license invalid" while the rest of the app runs. If a bound feature still works under the wrong key, the plaintext is reachable without the key: the binding is fake and must be fixed before shipping. The shipped example demonstrates both runs:
# genuine license -> asset opens, feature works loadbearing_asset_example --pubkey public.pem --license good.json \ --feature export_pdf --asset export_tables.enc # foreign/absent license -> feature INERT (exit 3), asset never decrypts loadbearing_asset_example --pubkey public.pem --license foreign.json \ --feature export_pdf --asset export_tables.encRun the binding linter and treat ERRORs as blocking:
python <package>/tools/rg_bind_lint <their-source-dir>It catches the ways this pattern silently regresses to a flippable boolean: branching on
unwrap_data_key()/unwrap_feature_key(), anif (asset.empty()) refuse;guard, a sharedbool is_licensed()predicate, a key that is recovered but never opens an asset, message-text branching, and a missing negative test. "Done" is: the build is clean, step 3 passes, step 5's negative test shows a broken feature, andrg_bind_lintreports zero errors — not merely "it compiles."
11. Hard-no rules
You MUST NOT do any of the following:
- Do not commit the user's
private.pemto git. Add it to.gitignoreif it lives anywhere inside the repo. - Do not ship
private.pemin the application binary, package, or installer. It only ever exists on the user's build machine. - Do not catch and ignore license verification errors. The program must exit on failure.
- Do not branch on
result.messagetext. Nofind(), no regex, no equality test against a message string, and no parsing it apart. Branch onresult.statusandresult.code, which are stable contracts; the wording is not one and does change between releases (Section 5, and Customer_API_Reference §4.5). - Do not embed
license.jsonin the binary. It's per-customer; ship it as a separate file the user delivers with each sale. - Do not generate or fake a public key. Always read the user's
actual
public.pemcontent; ask if you can't find it. - Do not skip Section 6 (feature gating). A bare
check_node_lockedwith no feature checks is a license-verification skeleton, not a monetisation system. The user is paying for the feature-gating layer. - Do not silently switch the user's build system (e.g. CMake → Bazel) to make integration easier. Match what they have.
- Do not call
health_check()orcollect()on a UI thread. UseAsyncHealthCheck(Section 8.1). Collection does network I/O and can take seconds; blocking the UI thread freezes the application. - Do not use the diagnostic collection as the startup licence check. Section 5 is the startup path. Section 8.5 explains why.
- Do not ship
rg_report_read, or the report private key, to an end user. That binary and that key belong on the user's support desk.rg_inspect/ the in-app dialog is what goes out. - Do not reuse the licence-signing key as the report recipient key. It is Ed25519 in the default setup and cannot encrypt at all. Mint a separate RSA pair (Section 8.4).
- Do not branch on
HealthItem::valueor::detail. Same rule asresult.message: branch onHealthItem::code. - Do not deliver a paid feature whose only enforcement is a branch on a verdict when that feature loads a bindable asset. Bind it (§6.5). If it has no bindable asset, say so explicitly (§6.5.7) — do not leave it looking hardened.
- Do not write
if (!unwrap_data_key()) refuse;(orif (key.empty()) refuse;, orif (table.empty()) refuse;afterLicenseAsset::open()). Each reintroduces the exact flippable branch §6.5 exists to remove. UseLicenseAsset, which has no optional to test, and consumeopen()'s result. - Do not hand-roll the asset encryption. Seal with
tools/rg_bind_asset/rockyguard_bind_asset()and open withLicenseAsset; a bespoke AES-GCM with the wrong nonce handling or a leaked plaintext buffer is worse than no binding. - Do not derive per-feature keys from the product key. Generate a
separate key per bound feature and wrap it with
--wrap-feature-key(§6.5.5).HKDF(product_key, feature)gives every licensee every tier. - Do not commit the plaintext asset or the data key. The binder reads the
key from the environment and emits ciphertext only; keep the plaintext and
the
--gen-data-keyvalue out of the repo (same rule asprivate.pem). - Do not declare the integration done on a clean build alone. It is done
only when the §10 negative test shows a broken feature under a wrong
license and
rg_bind_lintreports zero errors.
12. When to stop and ask the user
Stop and ask, do not guess, when:
- You can't unambiguously locate
int main(...). - The project has multiple binaries and it's unclear which to protect.
- Their build system is not vanilla CMake.
- They already have a different license-checking system in place (Sentinel, FlexLM, custom). Ask whether to replace it or run side-by-side.
- Their
public.pemis not in the workspace and they haven't pasted its content. - They haven't told you which features should be gated.
- You're about to modify a file you don't recognise.
- The user asked for a "Diagnose License" menu (Section 8) but the application has no GUI, or you cannot tell where its menus are built.
- The user wants the "send to support" flow (Section 8.4) but has not provided an RSA recipient public key. Do not substitute their licence key; it cannot encrypt.
13. References
- API surface:
docs/Customer_API_Reference.pdf(every public class, struct, enum, and CLI flag; §4.4-4.6 areLicenseResult, theLicenseErrorCodecatalog with a remedy per code, and the<rockyguard/license_error.h>lookups) - Full integration manual:
docs/Customer_Documentation.pdf(deeper coverage of fingerprinting, anti-tampering, floating server, troubleshooting) - Working example:
examples/node_locked_example.cpp(this is what Section 5's pattern is distilled from) - Floating example:
examples/rg_floating_client.cpp - Health-check example:
examples/health_check_example.cpp(Section 8; shows both the async menu pattern and the cheap startup variant) - Hardening (Section 9):
examples/embed_public_key.py(the masked-key generator, Section 9.3), plusexamples/hidden_key_example.cppand its generatedexamples/hidden_public_key.h(node-locked verification with a masked, cross-checked embedded key — the Section 9.3 anti-substitution pattern end to end). Regeneratehidden_public_key.hfrom your own key; the shipped one is a placeholder.
- Load-bearing binding (Section 6.5, required where a feature has a
bindable asset) — this is a different threat from 9.3: 9.3 stops the
embedded key being substituted; 6.5 makes a patched-out check yield
garbage. The pieces:
<rockyguard/license_asset.h>—LicenseAsset, the runtime side: the class with no verdict in it (Customer_API_Reference §12).examples/loadbearing_asset_example.cpp— end to end:--encryptat build time and verify →LicenseAsset→ open → use at runtime, including the negative (wrong-license) behaviour (exit 3).tools/rg_bind_asset(sourcetools/rg_bind_asset.cpp) — build-time binder: seals an asset under a key into the library's blob layout and emits a ciphertext header.examples/bound_asset.h— the header-only sealer behind the binder and the example's--encrypt. Build-time only; the application does not need it.cmake/RockyGuardBindAsset.cmake—rockyguard_bind_asset(), wires the encrypt step into the build (plaintext never enters the tree).tools/rg_bind_lint— catches regressions to a flippable boolean; run it in Section 10 and treat ERRORs as blocking.- Mint side:
license_create --gen-data-key,--wrap-data-key(product key, every license) and--wrap-feature-key <name>=<hex>(per feature, entitled licenses only). examples/FEATURES.template.md— copy to the repo root asFEATURES.md; the shared register of flag strings, per-feature enforcement (bound vs boolean-only), bindings, and the mint command (Section 6).
- Diagnostics API:
docs/Customer_API_Reference.pdf§11 -- §11.6 is the structuredHealthReport, §11.7 isAsyncHealthCheck, §11.8 isdiagnose()(support-desk side only -- see the warning in Section 8.3) - Root-cause catalog:
docs/Customer_Documentation.pdf§10.1.2, the list of cause ids and what each asserts - Building the standalone Inspector tools:
examples/inspector/README.txt(only if the user wants a separate executable to hand out rather than an in-app dialog) - Demo bundle on the website: https://rockyguard.dev/download — if the user wants to test the API surface before integrating, this is a self-contained 30-line program that exercises Section 5.
- Trial / paid licenses: https://rockyguard.dev/contact — direct
the user here to obtain the vendor license they need to run
license_create.