RockyGuard detects clock rollback by writing time anchors — small records of “the latest moment this installation has observed” — to several locations, then cross-checking them against the system clock on every verification. Each anchor is integrity-protected with an HMAC, so an attacker cannot simply edit one.
The salt for that HMAC was the anchor’s own filesystem path, exactly as the caller had spelled it.
That is the whole defect. Everything below follows from it.
Which anchor, or what it was called
An integrity tag should answer one question: is this the record I wrote? Ours answered a different one: is this the record I wrote, under the exact spelling of the path I used at the time?
Those come apart the moment the same file is reached by two different names. And filesystems offer many ways to do that without anyone intending to:
- a relative path in one run and an absolute path in the next
- an install directory that gets renamed or moved
- on Windows, drive-letter case, mapped drives, and junctions
Any of these turned an intact, untampered anchor into a failed integrity check. And an integrity failure on an anchor is reported as tampering — which the library deliberately never self-repairs, because silently rewriting a record that looks tampered with is precisely what you must not do.
So a customer with a perfectly valid licence, who had done nothing wrong, could be denied outright.
The case that made it unrecoverable
The sharp version came from a real integration.
load() resolved the local anchor relative to the process working directory.
check_expiry() resolved it against the licence file’s own directory. When
those happened to be the same directory — which is the common case, and the one
every quick-start example produces — both calls named one and the same file.
It was written under one salt and read under another. Every time.
The documented remedy for anchor trouble is “delete the state files and retry”. It never worked here, and could not have: the next run regenerated the identical mismatch. The remedy was not insufficient, it was structurally unable to help, and there was no sequence of user actions that recovered the installation.
That is the part of this bug that still bothers me most. A hard failure with a working remedy is an inconvenience. A hard failure whose documented remedy quietly cannot work sends the customer in circles and makes the support desk look like it is guessing.
The salt bought nothing
The uncomfortable question, once you see it: what was the path doing in the salt in the first place?
Nothing. It was not preventing an attack. An attacker who can write to the anchor location can write any path they like, so binding the tag to the spelling of that path adds no work for them. It only added a way for honest callers to lose.
The fix was to salt with a stable per-location identifier — which anchor is this, independent of how the filesystem happens to be addressing it today.
Migrating without a flag day
Anchors are anti-rollback state. You cannot ask customers to delete them, because deleting them is exactly what an attacker wants, and a release that instructed everyone to do it would be training them badly.
So migration is automatic and lossless. Anchors written by v1.3.0 and earlier still verify under the old scheme, keep their accumulated rollback history, and are rewritten in the current form on the next successful check. No customer action, no reset, no window during which rollback protection is weaker.
The fail-open default we found next to it
Auditing the same paths turned up something unrelated and worse in kind.
LicenseStatus::Valid is enumerator 0. A value-initialised LicenseResult{}
therefore reported Valid and converted to true.
No library path was affected — the library always assigns a real result before returning. But the shape of code this invites is common and reasonable:
rockyguard::LicenseResult result; // defaults to Valid == true
if (should_check()) {
result = verifier.check_expiry();
}
if (result) { // passes when the check never ran
enable_premium_features();
}
An integrator who declares a result up front and fills it in conditionally gets a
fail-open default, in a licensing library. LicenseResult::status now
defaults to a non-passing value, so that same code fails closed.
Enumerator 0 being the success case is an easy thing to choose and an easy thing to never re-examine. It is worth checking every default-constructible type you own for the same shape.
What shipped alongside
ROCKYGUARD_DEBUG_TIME_ANCHOR=1 now reports every anchor read — path,
timestamp, verdict — rather than only the online time fetch. That ordering
mattered: the tamper path returns before it ever reaches the fetch, so the
diagnostic was silent on precisely the failure people needed it for. The tamper
message also names each resolved anchor path inline, instead of pointing at a
documentation section, which is what would have made this bug obvious from a
single log line.
Test suite: 131, up from 129.
Upgrading to v1.3.1 or later is recommended for every deployment. The full entry is in the changelog.
The lesson generalises past licensing. If you are salting or keying an integrity tag, salt it with the identity of the thing you are protecting, never with an incidental string that happens to be nearby — a path, a filename, a URL, a display name. Those change for reasons that have nothing to do with security, and when they do, your integrity check reports an attack that did not happen.