Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 58 |
|
0.00% |
0 / 4 |
CRAP | |
0.00% |
0 / 1 |
| LicenseWebhookReceiver | |
0.00% |
0 / 58 |
|
0.00% |
0 / 4 |
420 | |
0.00% |
0 / 1 |
| register | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| registerRoute | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
| handle | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
90 | |||
| validateHmac | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
90 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * F118 — License Webhook Receiver (client side). |
| 4 | * |
| 5 | * Receives POST {site_url}/wp-json/swapads-client/v1/license/status from the |
| 6 | * server when an admin pauses/activates/deletes the license via the Clients |
| 7 | * submenu. Validates the HMAC signature, then mutates local license options |
| 8 | * to match the server state. |
| 9 | * |
| 10 | * Why this is needed: |
| 11 | * - When server pauses a license, the client must stop calling the server. |
| 12 | * - When server deletes a license, the client must clear all credentials |
| 13 | * so the operator can re-activate with a different server later. |
| 14 | * - Without F118, a paused client keeps polling the server indefinitely. |
| 15 | * |
| 16 | * Auth: |
| 17 | * - X-SwapAds-License-Id : the license id (matches local OPTION_KEY's id) |
| 18 | * - X-SwapAds-Timestamp : UNIX seconds; must be within 5 min |
| 19 | * - X-SwapAds-Signature : HMAC-SHA256 of "license_status:<license_id>:<ts>:<status>:<license_key>" |
| 20 | * using the license_key as the secret. |
| 21 | * (TODO-CLIENT-API-004 2026-08-01: license_key now bound into |
| 22 | * payload so body-modification attacks are detected.) |
| 23 | * - X-SwapAds-Source : should be "swapads-server" |
| 24 | * |
| 25 | * Body (JSON): |
| 26 | * { |
| 27 | * license_key: string, |
| 28 | * status: "active"|"paused"|"deleted", |
| 29 | * source: "swapads-server-f118" |
| 30 | * } |
| 31 | * |
| 32 | * @package SwapAds\Client\License |
| 33 | * @since 1.5.6 |
| 34 | */ |
| 35 | |
| 36 | declare(strict_types=1); |
| 37 | |
| 38 | namespace SwapAds\Client\License; |
| 39 | |
| 40 | use SwapAds\Client\License\FreemiusAutoActivator; |
| 41 | use SwapAds\Client\Security\EncryptedOption; |
| 42 | use SwapAds\Client\Statistics\StatisticsQueue; |
| 43 | use WP_REST_Request; |
| 44 | use WP_REST_Response; |
| 45 | |
| 46 | /** |
| 47 | * Class LicenseWebhookReceiver. |
| 48 | * |
| 49 | * @since 1.5.6 |
| 50 | */ |
| 51 | final class LicenseWebhookReceiver |
| 52 | { |
| 53 | /** |
| 54 | * REST route path (relative to swapads-client/v1). |
| 55 | */ |
| 56 | public const ROUTE_PATH = '/license/status'; |
| 57 | |
| 58 | /** |
| 59 | * HMAC window — request must be within ±5 min of server time. |
| 60 | */ |
| 61 | public const HMAC_WINDOW_SECONDS = 300; |
| 62 | |
| 63 | /** |
| 64 | * Header names (also used as $_SERVER keys with HTTP_ prefix). |
| 65 | */ |
| 66 | public const HDR_LICENSE_ID = 'X-SwapAds-License-Id'; |
| 67 | public const HDR_TIMESTAMP = 'X-SwapAds-Timestamp'; |
| 68 | public const HDR_SIGNATURE = 'X-SwapAds-Signature'; |
| 69 | public const HDR_SOURCE = 'X-SwapAds-Source'; |
| 70 | |
| 71 | /** |
| 72 | * Register the route. |
| 73 | */ |
| 74 | public static function register(): void |
| 75 | { |
| 76 | add_action('rest_api_init', [self::class, 'registerRoute']); |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * register_rest_route callback. |
| 81 | */ |
| 82 | public static function registerRoute(): void |
| 83 | { |
| 84 | // Phase 1 (F118): accept POST only. DELETE would be a separate route. |
| 85 | register_rest_route('swapads-client/v1', self::ROUTE_PATH, [ |
| 86 | 'methods' => 'POST', |
| 87 | 'callback' => [self::class, 'handle'], |
| 88 | 'permission_callback' => '__return_true', |
| 89 | ]); |
| 90 | } |
| 91 | |
| 92 | /** |
| 93 | * Handle the incoming POST. |
| 94 | * |
| 95 | * @param WP_REST_Request $request |
| 96 | * |
| 97 | * @return WP_REST_Response |
| 98 | */ |
| 99 | public static function handle(WP_REST_Request $request): WP_REST_Response |
| 100 | { |
| 101 | $validation = self::validateHmac($request); |
| 102 | if ($validation !== true) { |
| 103 | return new WP_REST_Response(['error' => $validation], 401); |
| 104 | } |
| 105 | |
| 106 | $body = (array) $request->get_json_params(); |
| 107 | $status = (string) ($body['status'] ?? ''); |
| 108 | $licenseKeyBody = (string) ($body['license_key'] ?? ''); |
| 109 | if (!in_array($status, ['active', 'paused', 'deleted'], true)) { |
| 110 | return new WP_REST_Response(['error' => 'invalid_status'], 400); |
| 111 | } |
| 112 | // TODO-CLIENT-API-004 (2026-08-01): require a syntactically-valid |
| 113 | // license_key in the body. validateHmac() now binds it into the |
| 114 | // HMAC payload, so this MUST match what was signed. |
| 115 | if (!preg_match('/^[A-Za-z0-9_-]{8,128}$/', $licenseKeyBody)) { |
| 116 | return new WP_REST_Response(['error' => 'invalid_license_key'], 400); |
| 117 | } |
| 118 | $localKey = (string) LicenseManager::key(); |
| 119 | if ($localKey === '') { |
| 120 | return new WP_REST_Response(['error' => 'local_license_missing'], 412); |
| 121 | } |
| 122 | if (!hash_equals($localKey, $licenseKeyBody)) { |
| 123 | return new WP_REST_Response(['error' => 'license_key_mismatch'], 403); |
| 124 | } |
| 125 | |
| 126 | // Match the LicenseManager option keys. |
| 127 | switch ($status) { |
| 128 | case 'paused': |
| 129 | update_option(LicenseManager::OPTION_STATUS, 'paused'); |
| 130 | return new WP_REST_Response(['ok' => true, 'action' => 'paused'], 200); |
| 131 | |
| 132 | case 'active': |
| 133 | update_option(LicenseManager::OPTION_STATUS, 'active'); |
| 134 | return new WP_REST_Response(['ok' => true, 'action' => 'activated'], 200); |
| 135 | |
| 136 | case 'deleted': |
| 137 | // Clear all local credentials so the operator can re-activate later. |
| 138 | delete_option(LicenseManager::OPTION_KEY); |
| 139 | delete_option(LicenseManager::OPTION_SECRET); |
| 140 | delete_option(LicenseManager::OPTION_STATUS); |
| 141 | // Reset auto-inject + audience cache so a fresh activation is clean. |
| 142 | delete_option('swapads_client_audience_cache'); |
| 143 | delete_option('swapads_client_pending_audience_declaration'); |
| 144 | // TODO-CLIENT-API-004 (2026-08-01): ALSO drop: |
| 145 | // - the server HMAC secret (otherwise it lingers and could be |
| 146 | // reused if the operator re-activates later) |
| 147 | // - the pending statistics queue (otherwise orphans accumulate |
| 148 | // in wp_options under the previous license's identity) |
| 149 | EncryptedOption::delete(FreemiusAutoActivator::OPTION_SERVER_SECRET); |
| 150 | delete_option(StatisticsQueue::OPTION_KEY); |
| 151 | return new WP_REST_Response(['ok' => true, 'action' => 'deleted_local_credentials_cleared'], 200); |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | /** |
| 156 | * Validate the HMAC signature on the incoming request. |
| 157 | * |
| 158 | * @param WP_REST_Request $request |
| 159 | * |
| 160 | * @return bool|string true on success, error code string on failure. |
| 161 | */ |
| 162 | private static function validateHmac(WP_REST_Request $request) |
| 163 | { |
| 164 | $licenseId = (int) $request->get_header(self::HDR_LICENSE_ID); |
| 165 | $timestamp = (string) $request->get_header(self::HDR_TIMESTAMP); |
| 166 | $signature = (string) $request->get_header(self::HDR_SIGNATURE); |
| 167 | $source = (string) $request->get_header(self::HDR_SOURCE); |
| 168 | |
| 169 | if ($licenseId <= 0 || $timestamp === '' || $signature === '') { |
| 170 | return 'missing_headers'; |
| 171 | } |
| 172 | if ($source !== 'swapads-server') { |
| 173 | return 'invalid_source'; |
| 174 | } |
| 175 | // Window check. |
| 176 | $ts = (int) $timestamp; |
| 177 | if (abs(time() - $ts) > self::HMAC_WINDOW_SECONDS) { |
| 178 | return 'timestamp_out_of_window'; |
| 179 | } |
| 180 | // HMAC secret is the license_key (locally stored). |
| 181 | $licenseKey = LicenseManager::key(); |
| 182 | if (!is_string($licenseKey) || $licenseKey === '') { |
| 183 | return 'license_not_activated'; |
| 184 | } |
| 185 | // Body status (we need the status to bind into the HMAC payload). |
| 186 | $body = (array) $request->get_json_params(); |
| 187 | $status = (string) ($body['status'] ?? ''); |
| 188 | // TODO-CLIENT-API-004 (2026-08-01): bind license_key into HMAC payload |
| 189 | // so a body-modification attack that changes license_key is detected. |
| 190 | $licenseKeyBody = (string) ($body['license_key'] ?? ''); |
| 191 | $payload = sprintf('license_status:%d:%d:%s:%s', $licenseId, $ts, $status, $licenseKeyBody); |
| 192 | $expected = hash_hmac('sha256', $payload, $licenseKey); |
| 193 | if (!hash_equals($expected, $signature)) { |
| 194 | return 'bad_signature'; |
| 195 | } |
| 196 | return true; |
| 197 | } |
| 198 | } |