Files
aurora-wordpress-admin/admin/src/Licensing/License.php
T
nixpanelandClaude Sonnet 5 cd66b70735 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]>
2026-07-03 20:44:18 -05:00

205 lines
6.4 KiB
PHP

<?php
namespace AuroraAdmin\Licensing;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Single gate every paid feature funnels through, so the free/paid split
* lives in one place instead of being re-decided in each feature's code.
*
* Validates against the real license server: the "aurora-licensing" WP
* plugin running on auroraplugins.com, REST namespace
* /wp-json/aurora-licensing/v1/ (validate/activate/deactivate). Every
* result is cached in the aurora_admin_license_status option for
* STATUS_TTL, refreshed immediately (not just on expiry) whenever the
* license_key setting actually changes — see on_settings_updated().
*/
class License
{
/**
* Paid sub-apps/features, keyed by the id used to gate them, mapped to a
* human label used in upgrade prompts.
*/
const PAID_FEATURES = [
"role-editor" => "Role Editor",
"activity-log" => "Activity Log",
"database-explorer" => "Database Explorer",
"analytics" => "Analytics",
"white-label" => "White Label",
"remote-site-switcher" => "Remote Site Switcher",
];
const API_BASE = "https://auroraplugins.com/wp-json/aurora-licensing/v1/";
const STATUS_OPTION = "aurora_admin_license_status";
const STATUS_TTL = 12 * HOUR_IN_SECONDS;
public function __construct()
{
add_action("update_option_aurora_admin_settings", [self::class, "on_settings_updated"], 10, 2);
}
/**
* Fires on every save of the aurora_admin_settings option (not just the
* license field) — only acts when license_key actually changed, so
* saving an unrelated settings category never triggers a remote call.
* Deactivates the old key's activation on this domain before activating
* the new one, so switching keys doesn't leak a stale activation slot.
*/
public static function on_settings_updated($old_value, $value)
{
$old_key = is_array($old_value) ? trim((string) ($old_value["license_key"] ?? "")) : "";
$new_key = is_array($value) ? trim((string) ($value["license_key"] ?? "")) : "";
if ($old_key === $new_key) {
return;
}
if ($old_key !== "") {
self::call("deactivate", ["license_key" => $old_key, "domain" => self::domain()]);
}
if ($new_key === "") {
delete_option(self::STATUS_OPTION);
return;
}
self::activate($new_key);
}
public static function is_active()
{
$status = self::status();
return !empty($status["valid"]) && ($status["status"] ?? "") === "active";
}
/**
* 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();
}
public static function feature_label($feature)
{
return isset(self::PAID_FEATURES[$feature]) ? self::PAID_FEATURES[$feature] : "";
}
/**
* The license server's /validate response shape (see call()), cached in
* aurora_admin_license_status for STATUS_TTL so every paid-feature check
* across an admin page load — there are several per request — doesn't
* each cost a remote HTTP round trip. Re-validated immediately (not just
* on expiry) if the cached result was for a different key than what's
* currently saved, so a key change is never served stale data even if
* on_settings_updated() somehow didn't fire first.
*/
public static function status()
{
$key = trim((string) Settings::get("license_key", ""));
if ($key === "") {
return ["valid" => false, "status" => "none"];
}
$cached = get_option(self::STATUS_OPTION);
$fresh =
is_array($cached) &&
($cached["checked_key"] ?? null) === $key &&
($cached["checked_at"] ?? 0) > time() - self::STATUS_TTL;
if ($fresh) {
return $cached;
}
return self::validate($key);
}
public static function validate($key)
{
$result = self::call("validate", [
"license_key" => $key,
"domain" => self::domain(),
]);
$result["checked_key"] = $key;
$result["checked_at"] = time();
update_option(self::STATUS_OPTION, $result, false);
return $result;
}
private static function activate($key)
{
self::call("activate", [
"license_key" => $key,
"domain" => self::domain(),
]);
// /activate's response is a narrower shape ({activated, plan, ...} or
// an error) than /validate's ({valid, status, active_sites, ...}) —
// re-validate right after so the cached status has the full shape
// status()/is_active() and the Settings UI expect either way.
self::validate($key);
}
private static function domain()
{
// The server normalizes domain server-side (strips protocol/www/
// trailing slash) — a bare host is enough, no need to duplicate that
// normalization here.
return (string) parse_url(home_url(), PHP_URL_HOST);
}
/**
* POST $endpoint against the license server. Always returns a decoded
* array — network failures and non-JSON responses collapse into the
* same {valid:false, status:"unreachable", ...} shape as a real error
* response, so callers only ever need to branch on the response fields,
* never on WP_Error separately.
*/
private static function call($endpoint, $body)
{
$response = wp_remote_post(self::API_BASE . $endpoint, [
"timeout" => 8,
"headers" => ["Content-Type" => "application/json"],
"body" => wp_json_encode($body),
]);
if (is_wp_error($response)) {
return [
"valid" => false,
"status" => "unreachable",
"error" => "unreachable",
"message" => $response->get_error_message(),
];
}
$decoded = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($decoded)) {
return [
"valid" => false,
"status" => "unreachable",
"error" => "invalid_response",
"message" => "License server returned an unexpected response.",
];
}
return $decoded;
}
}