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:
nixpanel
2026-07-03 20:44:18 -05:00
co-authored by Claude Sonnet 5
parent 540083e6e4
commit cd66b70735
6 changed files with 222 additions and 18 deletions
+1
View File
@@ -1,6 +1,7 @@
app/node_modules/ app/node_modules/
app/dist/ app/dist/
dist/ dist/
admin/src/Licensing/integrity-manifest.json
*.log *.log
.DS_Store .DS_Store
Thumbs.db Thumbs.db
+91 -18
View File
@@ -1,6 +1,6 @@
# Aurora Admin v2 — Project Status # 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 ## What this project is
@@ -36,14 +36,22 @@ get back up to speed instead of relying on prior chat history.
guessing from source. guessing from source.
- **Zip packaging**: on macOS, build via `npm run build` in `app/`, `rsync` - **Zip packaging**: on macOS, build via `npm run build` in `app/`, `rsync`
the repo (excluding `node_modules`, `.git`, `/dist`) into a scratch staging 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` dir, **run `php bin/build-integrity-manifest.php <staging-dir>/aurora-admin`
produces forward-slash paths natively, no path-separator corruption issue to generate `admin/src/Licensing/integrity-manifest.json` into the staged
like Windows `Compress-Archive`/PowerShell had. **Always delete the copy** (see "Tamper protection" below — this step is new, don't forget it
existing zip first** (`rm -f dist/aurora-admin-v2.zip`) — `zip -r` merges or the shipped zip will have no manifest and Integrity::is_tampered()
into an existing archive rather than replacing it, so stale entries (old will treat it as a dev checkout, i.e. never lock anything), then `zip -r -X`
content hashes) silently survive otherwise. If building on Windows again, it into `dist/aurora-admin-v2.zip` — macOS `zip` produces forward-slash
the old PowerShell `System.IO.Compression.ZipFile` + `.Replace('\', '/')` paths natively, no path-separator corruption issue like Windows
approach from earlier sessions still applies. `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 - **Fast local iteration**: the test site is LocalWP at
`/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin`. `/Users/kris/Local Sites/aurora/app/public/wp-content/plugins/aurora-admin`.
Instead of rezipping + manually reinstalling for every small CSS/JS tweak, 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 to licensing, just happened to surface while verifying Role Editor's
unlock path. 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) ## Key bugs fixed (root causes, in case they resurface)
- **`.card` global class collision**: original Dashboard cards used - **`.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 unrelated pre-existing 500 in Role Editor's REST endpoint along the way
(pushed as commit `6d50260`). (pushed as commit `6d50260`).
Most recently: replaced that placeholder with real validation against the An earlier pass this session replaced that placeholder with real
license server at `auroraplugins.com` (see "Real license-server validation against the license server at `auroraplugins.com` (see "Real
integration" above) — `/validate`/`/activate`/`/deactivate` calls, 12-hour license-server integration" above) — `/validate`/`/activate`/`/deactivate`
cached status, immediate re-check on a license-key change, and a status calls, 12-hour cached status, immediate re-check on a license-key change,
line in the Settings UI. Verified live end-to-end against the real server and a status line in the Settings UI. Verified live end-to-end against the
with a fake test key, which correctly came back locked with the server's real server with a fake test key, which correctly came back locked with
own `"License key not found."` message. The distributable zip the server's own `"License key not found."` message (pushed as commit
(`dist/aurora-admin-v2.zip`) was rebuilt fresh from source and should be `540083e`).
re-verified/re-zipped again after this doc update if anything changed since.
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 All of it was live-verified in Chrome against the LocalWP site (build
hot-patched into hot-patched into
+70
View File
@@ -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;
}
}
+9
View File
@@ -77,12 +77,21 @@ class License
/** /**
* True for any feature not in PAID_FEATURES (nothing to gate) or when a * 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. * 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) public static function is_feature_available($feature)
{ {
if (!isset(self::PAID_FEATURES[$feature])) { if (!isset(self::PAID_FEATURES[$feature])) {
return true; return true;
} }
if (Integrity::is_tampered()) {
return false;
}
return self::is_active(); return self::is_active();
} }
+1
View File
@@ -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/App/AuroraAdmin.php";
require AURORA_ADMIN_PATH . "admin/src/Options/Settings.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/Licensing/License.php";
require AURORA_ADMIN_PATH . "admin/src/Utility/Assets.php"; require AURORA_ADMIN_PATH . "admin/src/Utility/Assets.php";
require AURORA_ADMIN_PATH . "admin/src/Shell/MenuSerializer.php"; require AURORA_ADMIN_PATH . "admin/src/Shell/MenuSerializer.php";
+50
View File
@@ -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";