| Code Coverage
| Lines | Functions and Methods | Classes and Traits
Total | 0.00% covered (danger) | 0.00% | 0 / 58 | 0.00% covered (danger) | 0.00% | 0 / 4 | CRAP | 0.00% covered (danger) | 0.00% | 0 / 1
LicenseWebhookReceiver | 0.00% covered (danger) | 0.00% | 0 / 58 | 0.00% covered (danger) | 0.00% | 0 / 4 | 420 | 0.00% covered (danger) | 0.00% | 0 / 1
register | 0.00% covered (danger) | 0.00% | 0 / 1 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
registerRoute | 0.00% covered (danger) | 0.00% | 0 / 5 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
handle | 0.00% covered (danger) | 0.00% | 0 / 30 | 0.00% covered (danger) | 0.00% | 0 / 1 | 90 |
validateHmac | 0.00% covered (danger) | 0.00% | 0 / 22 | 0.00% covered (danger) | 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 | }