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
36declare(strict_types=1);
37
38namespace SwapAds\Client\License;
39
40use SwapAds\Client\License\FreemiusAutoActivator;
41use SwapAds\Client\Security\EncryptedOption;
42use SwapAds\Client\Statistics\StatisticsQueue;
43use WP_REST_Request;
44use WP_REST_Response;
45
46/**
47 * Class LicenseWebhookReceiver.
48 *
49 * @since 1.5.6
50 */
51final 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}