$payload, 'signature' => str_repeat('0', 64)])) <= self::MAX_RESPONSE_BYTES; } public static function discovery_record(array $value): array { if (self::fits(['items' => [$value], 'cursor' => (string) PHP_INT_MAX, 'head' => PHP_INT_MAX, 'complete' => false])) { return $value; } // Keep the identity/version discoverable. Canonical record reads report // the transport restriction explicitly; never truncate published fields. $hint = array_intersect_key($value, array_flip(['id', 'type', 'locale', 'url', 'title', 'version', 'removed'])); // Hints cannot introduce another unbounded value (for example a // filtered permalink). They are never used as publishable content. $hint['title'] = wp_html_excerpt((string) ($hint['title'] ?? ''), 500, ''); if (strlen((string) ($hint['url'] ?? '')) > 2048) { $hint['url'] = ''; } return $hint + ['metadata_oversized' => true]; } public static function connection(): array { $state = get_option('chat4u_connection', []); return is_array($state) ? $state : []; } public static function origin(string $url): string { $parts = wp_parse_url($url); if (!$parts || !isset($parts['host'], $parts['scheme']) || isset($parts['user']) || isset($parts['pass'])) { return ''; } return strtolower($parts['scheme'] . '://' . $parts['host']) . (isset($parts['port']) && $parts['port'] !== 443 ? ':' . $parts['port'] : ''); } public static function canonical(array $values): string { $result = ''; foreach ($values as $value) { $value = (string) $value; $result .= strlen($value) . ':' . $value; } return $result; } public static function relocation(array $previous, array $body): array { if (!isset($previous['scope'], $previous['generation'], $previous['secret'], $previous['site_url']) || $previous['site_url'] === $body['site_url']) { return []; } $values = ['wordpress-relocate-v1', $previous['scope'], $previous['generation'], $body['pairing_id'], $body['site_uuid'], $body['blog_id'], $body['site_url'], $body['rest_url'], $body['environment'], hash('sha256', $body['secret'])]; return ['previous_installation_id' => $previous['scope'], 'previous_generation' => $previous['generation'], 'previous_proof' => hash_hmac('sha256', self::canonical($values), $previous['secret'])]; } public static function headers(array $credentials, string $method, string $url, string $body): array { $values = [ 'Protocol' => '1', 'Key' => '1', 'Scope' => (string) $credentials['scope'], 'Generation' => (string) $credentials['generation'], 'Environment' => $credentials['environment'], 'Request' => bin2hex(random_bytes(16)), 'Timestamp' => (string) time(), ]; $signature = hash_hmac('sha256', self::canonical(array_merge(array_values($values), [strtoupper($method), $url, hash('sha256', $body)])), $credentials['secret']); $headers = ['Content-Type' => 'application/json', 'X-Chat4U-Signature' => $signature, 'X-Chat4U-Target' => $url]; foreach ($values as $key => $value) { $headers['X-Chat4U-' . $key] = $value; } return $headers; } public static function credentials(WP_REST_Request $request): ?array { $state = self::connection(); if (($state['site_url'] ?? '') !== untrailingslashit(get_option('home')) || ($state['state'] ?? '') === 'needs_relink') { return null; } $scope = $request->get_header('X-Chat4U-Scope'); if ($request->get_route() === '/chat4u/v1/pairing-proof' && isset($state['pending']) && $state['pending']['scope'] === $scope && $state['pending']['expires'] >= time()) { return $state['pending']; } if (($state['state'] ?? '') === 'connected' && ($state['scope'] ?? '') === $scope) { return $state; } // Only a signed activation POST may adopt a server-confirmed scope. // Business reads cannot race ahead of the durable credential handoff. if ($request->get_route() === '/chat4u/v1/activate' && isset($state['pending']) && $state['pending']['expires'] >= time() && preg_match('/^[a-f0-9-]{36}$/Di', $scope) && ctype_digit((string) $request->get_header('X-Chat4U-Generation')) && (int) $request->get_header('X-Chat4U-Generation') > 0) { return array_merge($state['pending'], ['scope' => $scope, 'generation' => (int) $request->get_header('X-Chat4U-Generation')]); } return null; } public static function authorize(WP_REST_Request $request) { $credentials = self::credentials($request); $invalid = new WP_Error('chat4u_signature_invalid', __('The Chat4U connection needs verification.', 'chat4u'), ['status' => 401]); if (!$credentials) { return $invalid; } $names = ['Protocol', 'Key', 'Scope', 'Generation', 'Environment', 'Request', 'Timestamp']; $values = []; foreach ($names as $name) { $values[$name] = (string) $request->get_header('X-Chat4U-' . $name); } $target = (string) $request->get_header('X-Chat4U-Target'); $parts = wp_parse_url($target); $relative = ($parts['path'] ?? '/') . (isset($parts['query']) ? '?' . $parts['query'] : ''); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Compare the raw URI byte-for-byte with the signed target; never render it or use it in a query. Sanitizing would change authentication scope. $incoming = wp_unslash($_SERVER['REQUEST_URI'] ?? ''); $origins = array_column(Chat4U_Content::languages(), 'origin'); if (!in_array(self::origin($target), $origins, true) || $relative !== $incoming || $values['Protocol'] !== '1' || $values['Key'] !== '1' || $values['Scope'] !== (string) $credentials['scope'] || $values['Generation'] !== (string) $credentials['generation'] || $values['Environment'] !== $credentials['environment'] || !ctype_digit($values['Timestamp']) || abs(time() - (int) $values['Timestamp']) > 300 || !preg_match('/^[a-f0-9]{32}$/D', $values['Request'])) { return $invalid; } $signature = hash_hmac('sha256', self::canonical(array_merge(array_values($values), [$request->get_method(), $target, hash('sha256', $request->get_body())])), $credentials['secret']); if (!hash_equals($signature, (string) $request->get_header('X-Chat4U-Signature'))) { return $invalid; } if ($request->get_method() !== 'GET') { $key = 'chat4u_req_' . hash('sha256', $values['Scope'] . ':' . $values['Generation'] . ':' . $values['Request']); if (!add_option($key, time() + 600, '', false)) { return $invalid; } } return true; } public static function response(WP_REST_Request $request, array $value): WP_REST_Response { $credentials = self::credentials($request); $payload = base64_encode(wp_json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $signature = hash_hmac('sha256', 'response' . "\n" . $request->get_header('X-Chat4U-Signature') . "\n" . $payload, $credentials['secret']); $response = new WP_REST_Response(['payload' => $payload, 'signature' => $signature]); $response->header('Cache-Control', 'private, no-store'); return $response; } public static function remote(string $path, array $data, array $credentials) { $url = rtrim($credentials['api_url'], '/') . '/api/v1/wordpress/' . $path; $body = wp_json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $headers = self::headers($credentials, 'POST', $url, $body); $response = wp_safe_remote_post($url, ['timeout' => 8, 'redirection' => 0, 'headers' => $headers, 'body' => $body, 'limit_response_size' => 262144]); if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 401) { $error = json_decode(wp_remote_retrieve_body($response), true); if (($error['detail']['code'] ?? '') === 'wordpress_signature_invalid') { return new WP_Error('chat4u_connection_revoked', __('This website connection needs verification. Connect again below.', 'chat4u')); } } if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { return new WP_Error('chat4u_unreachable', __('Chat4U could not confirm the request. Your settings are saved; check again shortly.', 'chat4u')); } $value = json_decode(wp_remote_retrieve_body($response), true); if (!is_array($value) || !isset($value['payload'], $value['signature'])) { return new WP_Error('chat4u_response_invalid', __('The response could not be verified. Check the connection again.', 'chat4u')); } $expected = hash_hmac('sha256', "response\n" . $headers['X-Chat4U-Signature'] . "\n" . $value['payload'], $credentials['secret']); if (!hash_equals($expected, $value['signature'])) { return new WP_Error('chat4u_response_invalid', __('The response could not be verified. Check the connection again.', 'chat4u')); } return json_decode(base64_decode($value['payload'], true), true); } }