Add file-integrity check to protect the license gate from tampering
- New Integrity class checksums the files that implement/enforce the free/paid split (SubApps, Analytics, AnalyticsData, WhiteLabel, Shell, License itself, and the two bootstrap files) against a manifest generated at zip-build time. No manifest present (a dev checkout) is never treated as tampered. - License::is_feature_available() checks Integrity::is_tampered() before checking the actual license, so a distributed copy with the check stripped/commented out re-locks every paid feature even with an otherwise-valid license, rather than the plugin trusting whatever code happens to still be there. - bin/build-integrity-manifest.php generates the manifest against a staged zip-build copy (gitignored build artifact, same pattern as app/dist) — now a required step in the zip-build workflow. - Verified live: untampered+unlicensed stays locked, untampered+licensed unlocks, one tampered file re-locks regardless of license status (tested via a temporary mu-plugin faking an active license, kept outside any protected file so it didn't itself trip the check). This raises the bar against the realistic low-effort case (finding and deleting/inverting the one license-check line) but can't stop someone who rewrites a file's logic to never call the gate, or who edits this integrity check itself — an inherent limit of protection living in plain, editable PHP. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
540083e6e4
commit
cd66b70735
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
namespace AuroraAdmin\Licensing;
|
||||
|
||||
defined("ABSPATH") || exit();
|
||||
|
||||
/**
|
||||
* Detects tampering with the files that implement the free/paid gate
|
||||
* itself, so someone can't just delete/comment-out a license check and
|
||||
* redistribute a "cracked" copy that shows every paid feature with no
|
||||
* license at all.
|
||||
*
|
||||
* Compares each protected file's sha256 against integrity-manifest.json,
|
||||
* generated at zip-build time (bin/build-integrity-manifest.php) — not
|
||||
* committed to git, same "regenerate, don't version" pattern as app/dist.
|
||||
* A dev checkout with no manifest present is never treated as tampered
|
||||
* (see is_tampered()), so this only ever applies to the distributed zip.
|
||||
*
|
||||
* Honest limit: this stops editing/removing a check *inside* one of the
|
||||
* protected files (the realistic, low-effort tampering case — it changes
|
||||
* the file's hash). It cannot stop someone who edits this file itself to
|
||||
* neutralize the comparison, or who never lets WordPress load license-
|
||||
* gated code the normal way at all. No check living in plain, editable
|
||||
* PHP can close that gap — only a compiled/encoded extension could, and
|
||||
* that has its own real costs (hosting compatibility, trust). This raises
|
||||
* the bar; it does not make circumvention impossible.
|
||||
*/
|
||||
class Integrity
|
||||
{
|
||||
const MANIFEST_FILE = "admin/src/Licensing/integrity-manifest.json";
|
||||
|
||||
/** @var bool|null Cached for the request — file hashing isn't free. */
|
||||
private static $tampered = null;
|
||||
|
||||
public static function is_tampered()
|
||||
{
|
||||
if (self::$tampered !== null) {
|
||||
return self::$tampered;
|
||||
}
|
||||
|
||||
$manifest = self::load_manifest();
|
||||
if ($manifest === null) {
|
||||
// No manifest — a source checkout (dev) rather than a built zip.
|
||||
// Nothing to compare against, so never lock a dev environment out.
|
||||
self::$tampered = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($manifest as $relative_path => $expected_hash) {
|
||||
$full_path = AURORA_ADMIN_PATH . $relative_path;
|
||||
if (!file_exists($full_path) || hash_file("sha256", $full_path) !== $expected_hash) {
|
||||
self::$tampered = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
self::$tampered = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function load_manifest()
|
||||
{
|
||||
$path = AURORA_ADMIN_PATH . self::MANIFEST_FILE;
|
||||
if (!file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) file_get_contents($path), true);
|
||||
return is_array($decoded) && $decoded ? $decoded : null;
|
||||
}
|
||||
}
|
||||
@@ -77,12 +77,21 @@ class License
|
||||
/**
|
||||
* True for any feature not in PAID_FEATURES (nothing to gate) or when a
|
||||
* license is active; false only when the feature is paid and unlicensed.
|
||||
*
|
||||
* Checked first, ahead of an actual license: if the gate files
|
||||
* themselves have been tampered with (see Integrity::is_tampered()),
|
||||
* every paid feature re-locks regardless of license status — a modified
|
||||
* copy can't be redistributed showing paid features unlocked with no
|
||||
* license at all.
|
||||
*/
|
||||
public static function is_feature_available($feature)
|
||||
{
|
||||
if (!isset(self::PAID_FEATURES[$feature])) {
|
||||
return true;
|
||||
}
|
||||
if (Integrity::is_tampered()) {
|
||||
return false;
|
||||
}
|
||||
return self::is_active();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user