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
@@ -1,6 +1,7 @@
|
||||
app/node_modules/
|
||||
app/dist/
|
||||
dist/
|
||||
admin/src/Licensing/integrity-manifest.json
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
+91
-18
@@ -1,6 +1,6 @@
|
||||
# Aurora Admin v2 — Project Status
|
||||
|
||||
_Last updated: 2026-07-04 (latest session — real license-server integration)_
|
||||
_Last updated: 2026-07-04 (latest session — file-integrity tamper protection for the license gate)_
|
||||
|
||||
## What this project is
|
||||
|
||||
@@ -36,14 +36,22 @@ get back up to speed instead of relying on prior chat history.
|
||||
guessing from source.
|
||||
- **Zip packaging**: on macOS, build via `npm run build` in `app/`, `rsync`
|
||||
the repo (excluding `node_modules`, `.git`, `/dist`) into a scratch staging
|
||||
dir, then `zip -r -X` it into `dist/aurora-admin-v2.zip` — macOS `zip`
|
||||
produces forward-slash paths natively, no path-separator corruption issue
|
||||
like Windows `Compress-Archive`/PowerShell had. **Always delete the
|
||||
existing zip first** (`rm -f dist/aurora-admin-v2.zip`) — `zip -r` merges
|
||||
into an existing archive rather than replacing it, so stale entries (old
|
||||
content hashes) silently survive otherwise. If building on Windows again,
|
||||
the old PowerShell `System.IO.Compression.ZipFile` + `.Replace('\', '/')`
|
||||
approach from earlier sessions still applies.
|
||||
dir, **run `php bin/build-integrity-manifest.php <staging-dir>/aurora-admin`
|
||||
to generate `admin/src/Licensing/integrity-manifest.json` into the staged
|
||||
copy** (see "Tamper protection" below — this step is new, don't forget it
|
||||
or the shipped zip will have no manifest and Integrity::is_tampered()
|
||||
will treat it as a dev checkout, i.e. never lock anything), then `zip -r -X`
|
||||
it into `dist/aurora-admin-v2.zip` — macOS `zip` produces forward-slash
|
||||
paths natively, no path-separator corruption issue like Windows
|
||||
`Compress-Archive`/PowerShell had. **Always delete the existing zip
|
||||
first** (`rm -f dist/aurora-admin-v2.zip`) — `zip -r` merges into an
|
||||
existing archive rather than replacing it, so stale entries (old content
|
||||
hashes) silently survive otherwise. If building on Windows again, the old
|
||||
PowerShell `System.IO.Compression.ZipFile` + `.Replace('\', '/')` approach
|
||||
from earlier sessions still applies (the manifest-generation step would
|
||||
need a Windows-available PHP CLI too — LocalWP bundles one at
|
||||
`~/Library/Application Support/Local/lightning-services/php-*/bin/*/bin/php`
|
||||
on macOS; there's presumably an equivalent bundled binary on Windows).
|
||||
- **Fast local iteration**: the test site is LocalWP at
|
||||
`/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin`.
|
||||
Instead of rezipping + manually reinstalling for every small CSS/JS tweak,
|
||||
@@ -564,6 +572,60 @@ Creator, Admin Notices, Comments, and the Plugins page stay free.
|
||||
to licensing, just happened to surface while verifying Role Editor's
|
||||
unlock path.
|
||||
|
||||
## Tamper protection (file-integrity check on the license gate)
|
||||
|
||||
Added this session — the user's concern was specifically someone stripping
|
||||
out the license check from a distributed copy and giving away a "cracked"
|
||||
version that shows every paid feature unlocked with no license at all.
|
||||
|
||||
**Honest limit, stated up front (and to the user) before building this**:
|
||||
nothing living in plain, editable PHP can make server-side code truly
|
||||
tamper-proof — whoever has file access to their own server can always edit
|
||||
whatever they want, including this integrity check itself. What this
|
||||
*does* stop is the realistic, low-effort case: someone finds the one
|
||||
`if (!License::is_feature_available(...))` line and comments it out or
|
||||
inverts it. It does not stop someone who rewrites a whole file's logic to
|
||||
never call the gate at all, or who edits `Integrity.php`'s own comparison
|
||||
logic — that's an unavoidable ceiling for this approach, only closable
|
||||
with a compiled/encoded extension (its own real costs: hosting
|
||||
compatibility, trust), which wasn't in scope here.
|
||||
|
||||
- `admin/src/Licensing/Integrity.php`: `Integrity::is_tampered()` compares
|
||||
the sha256 of each "gate" file (the ones that implement or directly
|
||||
enforce the free/paid split) against
|
||||
`admin/src/Licensing/integrity-manifest.json`. **No manifest present
|
||||
(e.g. a git checkout) → never treated as tampered** — this only ever
|
||||
applies to a distributed zip, never blocks local dev. Result is cached
|
||||
in a static property per request (file hashing isn't free).
|
||||
- Protected files (kept in sync between `Integrity.php`'s doc comment and
|
||||
`bin/build-integrity-manifest.php`'s `$protected` array): `aurora-admin.php`,
|
||||
`AuroraAdmin.php`, `License.php`, `Integrity.php` itself, `SubApps.php`,
|
||||
`Analytics.php`, `AnalyticsData.php`, `WhiteLabel.php`, `Shell.php`.
|
||||
- `License::is_feature_available()` checks `Integrity::is_tampered()`
|
||||
*before* checking the actual license — a tampered install re-locks every
|
||||
paid feature regardless of an otherwise-valid, active license. This is
|
||||
the "re-lock paid features only" behavior the user explicitly chose over
|
||||
a harsher "block the whole plugin" option, specifically to avoid a false
|
||||
positive (a host mangling line endings, some other edge case) breaking
|
||||
the entire admin shell rather than just the paid tier.
|
||||
- `bin/build-integrity-manifest.php <staged-plugin-root>` generates the
|
||||
manifest — run against the **staged zip-build copy**, never against the
|
||||
git checkout (the manifest itself is a build artifact, gitignored, same
|
||||
"regenerate, don't version" pattern as `app/dist`). This is now a
|
||||
required step in the zip-build workflow (see "Zip packaging" above) —
|
||||
forgetting it means the shipped zip has no manifest and therefore no
|
||||
tamper protection at all.
|
||||
- **Verified live**, without a real license key, by faking an "active"
|
||||
license through a temporary mu-plugin that directly set the
|
||||
`aurora_admin_license_status`/`aurora_admin_settings` options (kept
|
||||
entirely outside any protected file, so it didn't itself trip the
|
||||
integrity check) — confirmed: (1) untampered + unlicensed → locked
|
||||
(baseline unaffected), (2) untampered + simulated-active-license →
|
||||
unlocked, (3) one protected file edited (a trailing comment appended to
|
||||
`SubApps.php`) while still "licensed" → re-locked immediately. All test
|
||||
artifacts (the mu-plugin, the tampered file, the test manifest) were
|
||||
removed from the LocalWP install afterward.
|
||||
|
||||
## Key bugs fixed (root causes, in case they resurface)
|
||||
|
||||
- **`.card` global class collision**: original Dashboard cards used
|
||||
@@ -680,15 +742,26 @@ shell/theming product stay free. That pass used a placeholder
|
||||
unrelated pre-existing 500 in Role Editor's REST endpoint along the way
|
||||
(pushed as commit `6d50260`).
|
||||
|
||||
Most recently: replaced that placeholder with real validation against the
|
||||
license server at `auroraplugins.com` (see "Real license-server
|
||||
integration" above) — `/validate`/`/activate`/`/deactivate` calls, 12-hour
|
||||
cached status, immediate re-check on a license-key change, and a status
|
||||
line in the Settings UI. Verified live end-to-end against the real server
|
||||
with a fake test key, which correctly came back locked with the server's
|
||||
own `"License key not found."` message. The distributable zip
|
||||
(`dist/aurora-admin-v2.zip`) was rebuilt fresh from source and should be
|
||||
re-verified/re-zipped again after this doc update if anything changed since.
|
||||
An earlier pass this session replaced that placeholder with real
|
||||
validation against the license server at `auroraplugins.com` (see "Real
|
||||
license-server integration" above) — `/validate`/`/activate`/`/deactivate`
|
||||
calls, 12-hour cached status, immediate re-check on a license-key change,
|
||||
and a status line in the Settings UI. Verified live end-to-end against the
|
||||
real server with a fake test key, which correctly came back locked with
|
||||
the server's own `"License key not found."` message (pushed as commit
|
||||
`540083e`).
|
||||
|
||||
Most recently: added file-integrity tamper protection (see "Tamper
|
||||
protection" above) so that stripping/commenting-out the license check in a
|
||||
distributed copy re-locks every paid feature, even with an otherwise-valid
|
||||
license — `Integrity::is_tampered()` checksums the gate files against a
|
||||
manifest generated at zip-build time. Verified live via a temporary
|
||||
mu-plugin that faked an active license without touching any protected
|
||||
file, confirming untampered+unlicensed stays locked, untampered+licensed
|
||||
unlocks, and one tampered file re-locks regardless of license status. The
|
||||
distributable zip (`dist/aurora-admin-v2.zip`) was rebuilt with the new
|
||||
manifest-generation step and should be re-verified/re-zipped again after
|
||||
this doc update if anything changed since.
|
||||
|
||||
All of it was live-verified in Chrome against the LocalWP site (build
|
||||
hot-patched into
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ define("AURORA_ADMIN_URL", plugins_url("aurora-admin/"));
|
||||
|
||||
require AURORA_ADMIN_PATH . "admin/src/App/AuroraAdmin.php";
|
||||
require AURORA_ADMIN_PATH . "admin/src/Options/Settings.php";
|
||||
require AURORA_ADMIN_PATH . "admin/src/Licensing/Integrity.php";
|
||||
require AURORA_ADMIN_PATH . "admin/src/Licensing/License.php";
|
||||
require AURORA_ADMIN_PATH . "admin/src/Utility/Assets.php";
|
||||
require AURORA_ADMIN_PATH . "admin/src/Shell/MenuSerializer.php";
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
/**
|
||||
* Generates admin/src/Licensing/integrity-manifest.json — sha256 hashes of
|
||||
* the files that implement the free/paid gate (see Integrity.php), used to
|
||||
* detect tampering in distributed copies of the plugin.
|
||||
*
|
||||
* Run against the STAGING directory right before zipping, not against the
|
||||
* git checkout — the manifest is a build artifact (same "regenerate, don't
|
||||
* version" pattern as app/dist) and must never ship in a git checkout,
|
||||
* since Integrity::is_tampered() treats "no manifest present" as "this is
|
||||
* a dev checkout, don't lock anyone out."
|
||||
*
|
||||
* Usage: php bin/build-integrity-manifest.php /path/to/staging/aurora-admin
|
||||
*/
|
||||
|
||||
$root = $argv[1] ?? null;
|
||||
if (!$root || !is_dir($root)) {
|
||||
fwrite(STDERR, "Usage: php build-integrity-manifest.php <path-to-staged-plugin-root>\n");
|
||||
exit(1);
|
||||
}
|
||||
$root = rtrim($root, "/") . "/";
|
||||
|
||||
// Keep in sync with Integrity::is_tampered()'s callers — every file that
|
||||
// implements or directly enforces the license gate.
|
||||
$protected = [
|
||||
"aurora-admin.php",
|
||||
"admin/src/App/AuroraAdmin.php",
|
||||
"admin/src/Licensing/License.php",
|
||||
"admin/src/Licensing/Integrity.php",
|
||||
"admin/src/Pages/SubApps.php",
|
||||
"admin/src/Analytics/Analytics.php",
|
||||
"admin/src/Rest/AnalyticsData.php",
|
||||
"admin/src/WhiteLabel/WhiteLabel.php",
|
||||
"admin/src/Shell/Shell.php",
|
||||
];
|
||||
|
||||
$manifest = [];
|
||||
foreach ($protected as $relative) {
|
||||
$full = $root . $relative;
|
||||
if (!file_exists($full)) {
|
||||
fwrite(STDERR, "Missing protected file, aborting: {$relative}\n");
|
||||
exit(1);
|
||||
}
|
||||
$manifest[$relative] = hash_file("sha256", $full);
|
||||
}
|
||||
|
||||
$out_path = $root . "admin/src/Licensing/integrity-manifest.json";
|
||||
file_put_contents($out_path, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
echo "Wrote " . count($manifest) . " hashes to {$out_path}\n";
|
||||
Reference in New Issue
Block a user