diff --git a/.gitignore b/.gitignore index 9b2b663..9503113 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ app/node_modules/ app/dist/ dist/ +admin/src/Licensing/integrity-manifest.json *.log .DS_Store Thumbs.db diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 047af72..d7dd4f5 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -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 /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 ` 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 diff --git a/admin/src/Licensing/Integrity.php b/admin/src/Licensing/Integrity.php new file mode 100644 index 0000000..81e57e5 --- /dev/null +++ b/admin/src/Licensing/Integrity.php @@ -0,0 +1,70 @@ + $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; + } +} diff --git a/admin/src/Licensing/License.php b/admin/src/Licensing/License.php index 8fb89a7..db788cb 100644 --- a/admin/src/Licensing/License.php +++ b/admin/src/Licensing/License.php @@ -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(); } diff --git a/aurora-admin.php b/aurora-admin.php index 797ce28..4d2fabd 100644 --- a/aurora-admin.php +++ b/aurora-admin.php @@ -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"; diff --git a/bin/build-integrity-manifest.php b/bin/build-integrity-manifest.php new file mode 100644 index 0000000..1da02c1 --- /dev/null +++ b/bin/build-integrity-manifest.php @@ -0,0 +1,50 @@ +\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";