serverSecret = self::getServerSecret(); } /** * Register WordPress hooks */ public function register(): void { add_filter('rest_post_dispatch', [$this, 'signResponse'], 10, 3); } /** * Sign REST API response * * @param \WP_REST_Response $response The response object * @param \WP_REST_Server $server The REST server * @param \WP_REST_Request $request The request object * @return \WP_REST_Response */ public function signResponse($response, $server, $request) { // Only sign license API responses if (!$this->shouldSign($request)) { return $response; } $data = $response->get_data(); $licenseKey = $request->get_param('license_key'); if (empty($licenseKey) || !is_array($data) || empty($this->serverSecret)) { return $response; } $headers = $this->createSignatureHeaders($data, $licenseKey); foreach ($headers as $name => $value) { $response->header($name, $value); } return $response; } /** * Check if request should be signed */ private function shouldSign(\WP_REST_Request $request): bool { $route = $request->get_route(); return str_starts_with($route, '/wc-licensed-product/v1/validate') || str_starts_with($route, '/wc-licensed-product/v1/status') || str_starts_with($route, '/wc-licensed-product/v1/activate') || str_starts_with($route, '/wc-licensed-product/v1/update-check'); } /** * Create signature headers for response * * @param array $data The response data * @param string $licenseKey The license key from the request * @return array Associative array of headers */ private function createSignatureHeaders(array $data, string $licenseKey): array { $timestamp = time(); $signingKey = $this->deriveKey($licenseKey); // Recursively sort keys for consistent ordering (required by client implementation) $data = $this->recursiveKeySort($data); // Build signature payload $payload = $timestamp . ':' . json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); return [ 'X-License-Signature' => hash_hmac('sha256', $payload, $signingKey), 'X-License-Timestamp' => (string) $timestamp, ]; } /** * Recursively sort array keys alphabetically * * @param mixed $data The data to sort * @return mixed The sorted data */ private function recursiveKeySort(mixed $data): mixed { if (!is_array($data)) { return $data; } // Check if array is associative (has string keys) $isAssociative = array_keys($data) !== range(0, count($data) - 1); if ($isAssociative) { ksort($data); } // Recursively sort nested arrays foreach ($data as $key => $value) { $data[$key] = $this->recursiveKeySort($value); } return $data; } /** * Derive a unique signing key for a license * * Uses HKDF-like key derivation to create a unique signing key * for each license key, preventing cross-license signature attacks. * * @param string $licenseKey The license key * @return string The derived signing key (hex encoded) */ private function deriveKey(string $licenseKey): string { return self::deriveCustomerSecret($licenseKey, $this->serverSecret); } /** * Derive a customer-specific secret from a license key * * This secret is unique per license and can be shared with the customer * to verify signed API responses. Each customer gets their own secret * derived from their license key. * * Uses RFC 5869 HKDF via PHP's native hash_hkdf() function. * Parameters match the client library (SecureLicenseClient): * - IKM (input keying material): server_secret * - Length: 32 bytes (256 bits for SHA-256) * - Info: license_key (context-specific info) * * @param string $licenseKey The customer's license key * @param string $serverSecret The server's master secret * @return string The derived secret (64 hex characters) */ public static function deriveCustomerSecret(string $licenseKey, string $serverSecret): string { // RFC 5869 HKDF using PHP's native implementation // Must match client's ResponseSignature::deriveKey() exactly $binaryKey = hash_hkdf('sha256', $serverSecret, 32, $licenseKey); return bin2hex($binaryKey); } /** * Get the customer secret for a license key using the configured server secret * * @param string $licenseKey The customer's license key * @return string|null The derived secret, or null if server secret is not configured */ public static function getCustomerSecretForLicense(string $licenseKey): ?string { $serverSecret = self::getServerSecret(); if (empty($serverSecret)) { return null; } return self::deriveCustomerSecret($licenseKey, $serverSecret); } /** * Check if response signing is enabled * * @return bool True if server secret is configured */ public static function isSigningEnabled(): bool { return !empty(self::getServerSecret()); } /** * Get the server secret from constant or environment variable * * Checks in order: * 1. WC_LICENSE_SERVER_SECRET constant (preferred) * 2. WC_LICENSE_SERVER_SECRET environment variable (Docker fallback) * * @return string The server secret, or empty string if not configured */ public static function getServerSecret(): string { // First check the constant (standard WordPress configuration) if (defined('WC_LICENSE_SERVER_SECRET') && !empty(WC_LICENSE_SERVER_SECRET)) { return WC_LICENSE_SERVER_SECRET; } // Fallback to environment variable (Docker environments) $envSecret = getenv('WC_LICENSE_SERVER_SECRET'); if ($envSecret !== false && !empty($envSecret)) { return $envSecret; } // Also check $_ENV and $_SERVER (some PHP configurations) if (!empty($_ENV['WC_LICENSE_SERVER_SECRET'])) { return $_ENV['WC_LICENSE_SERVER_SECRET']; } if (!empty($_SERVER['WC_LICENSE_SERVER_SECRET'])) { return $_SERVER['WC_LICENSE_SERVER_SECRET']; } return ''; } }