logger = $logger ?? new NullLogger(); $this->encoder = $encoder ?? new StringEncoder(); $this->apiPath = $this->encoder->decode(self::ENCODED_API_PATH); $this->validateBaseUrl($baseUrl, $allowInsecureHttp); if ($this->verifyIntegrity) { $this->checkIntegrity(); } } public function validate(string $licenseKey, string $domain): LicenseInfo { $cacheKey = $this->buildCacheKey('validate', $licenseKey, $domain); if ($this->cache !== null) { $item = $this->cache->getItem($cacheKey); if ($item->isHit()) { $this->logger->debug('License validation cache hit', ['domain' => $domain]); return $item->get(); } } $this->logger->info('Validating license', ['domain' => $domain]); $endpoint = $this->encoder->decode(self::ENCODED_VALIDATE); $response = $this->secureRequest($endpoint, [ 'license_key' => $licenseKey, 'domain' => $domain, ], $licenseKey); $result = LicenseInfo::fromArray($response['license']); if ($this->cache !== null) { $item = $this->cache->getItem($cacheKey); $item->set($result); $item->expiresAfter($this->cacheTtl); $this->cache->save($item); } $this->logger->info('License validated successfully', [ 'domain' => $domain, 'product_id' => $result->productId, ]); return $result; } public function status(string $licenseKey): LicenseStatus { $cacheKey = $this->buildCacheKey('status', $licenseKey); if ($this->cache !== null) { $item = $this->cache->getItem($cacheKey); if ($item->isHit()) { $this->logger->debug('License status cache hit'); return $item->get(); } } $this->logger->info('Checking license status'); $endpoint = $this->encoder->decode(self::ENCODED_STATUS); $response = $this->secureRequest($endpoint, [ 'license_key' => $licenseKey, ], $licenseKey); $result = LicenseStatus::fromArray($response); if ($this->cache !== null) { $item = $this->cache->getItem($cacheKey); $item->set($result); $item->expiresAfter($this->cacheTtl); $this->cache->save($item); } $this->logger->info('License status retrieved', [ 'status' => $result->status->value, 'valid' => $result->valid, ]); return $result; } public function activate(string $licenseKey, string $domain): ActivationResult { $this->logger->info('Activating license', ['domain' => $domain]); $endpoint = $this->encoder->decode(self::ENCODED_ACTIVATE); $response = $this->secureRequest($endpoint, [ 'license_key' => $licenseKey, 'domain' => $domain, ], $licenseKey); $result = ActivationResult::fromArray($response); // Invalidate related cache entries after activation if ($this->cache !== null) { $this->cache->deleteItem($this->buildCacheKey('validate', $licenseKey, $domain)); $this->cache->deleteItem($this->buildCacheKey('status', $licenseKey)); } $this->logger->info('License activation completed', [ 'domain' => $domain, 'success' => $result->success, ]); return $result; } /** * @throws LicenseException * @throws SignatureException */ private function secureRequest(string $endpoint, array $payload, string $licenseKey): array { $url = rtrim($this->baseUrl, '/') . $this->apiPath . '/' . $endpoint; try { $response = $this->httpClient->request('POST', $url, [ 'json' => $payload, 'headers' => [ 'Accept' => 'application/json', 'Content-Type' => 'application/json', ], ]); $statusCode = $response->getStatusCode(); $data = $response->toArray(false); $headers = $this->normalizeHeaders($response->getHeaders(false)); // Verify response signature $this->verifySignature($data, $headers, $licenseKey); if ($statusCode >= 400) { $this->logger->warning('License API error response', [ 'endpoint' => $endpoint, 'status_code' => $statusCode, 'error' => $data['error'] ?? 'unknown', ]); throw LicenseException::fromApiResponse($data, $statusCode); } return $data; } catch (LicenseException | SignatureException $e) { throw $e; } catch (\Throwable $e) { // Log full error for debugging but sanitize for user-facing message $this->logger->error('License API request failed', [ 'endpoint' => $endpoint, 'exception_class' => $e::class, 'error_code' => $e->getCode(), ]); throw new LicenseException( 'Failed to communicate with license server', null, 0, $e ); } } private function verifySignature(array $data, array $headers, string $licenseKey): void { $signature = ResponseSignature::extractSignature($headers); $timestamp = ResponseSignature::extractTimestamp($headers); if ($signature === null || $timestamp === null) { $this->logger->warning('Response missing signature headers'); throw new SignatureException('Response is not signed by the server'); } $verifier = ResponseSignature::fromLicenseKey($licenseKey, $this->serverSecret); if (!$verifier->verify($data, $signature, $timestamp)) { $this->logger->warning('Response signature verification failed'); throw new SignatureException(); } $this->logger->debug('Response signature verified'); } private function normalizeHeaders(array $headers): array { $normalized = []; foreach ($headers as $name => $values) { // HTTP client returns arrays of values; take the first one // Empty arrays or empty strings should be treated as missing (null) if (is_array($values)) { $value = $values[0] ?? null; $normalized[$name] = ($value !== '' && $value !== null) ? $value : null; } else { $normalized[$name] = ($values !== '' && $values !== null) ? $values : null; } } return $normalized; } private function checkIntegrity(): void { $criticalFiles = [ 'src/SecureLicenseClient.php', 'src/Security/ResponseSignature.php', 'src/Security/SignatureException.php', ]; // In production, these hashes would be embedded // This is a placeholder showing the mechanism $hashes = $this->getExpectedHashes(); if (empty($hashes)) { $this->logger->debug('Integrity check skipped: no hashes configured'); return; } $basePath = dirname(__DIR__); $checker = new IntegrityChecker($hashes, $basePath); try { $checker->verify(); $this->logger->debug('Integrity check passed'); } catch (IntegrityException $e) { $this->logger->critical('Integrity check failed', [ 'failures' => $e->failures, ]); throw $e; } } /** * Get expected file hashes for integrity checking. * * In a production obfuscated build, these would be hardcoded. * Override this method or inject hashes via configuration. * * @return array */ protected function getExpectedHashes(): array { // Placeholder: in production, return actual hashes return []; } private function buildCacheKey(string $operation, string $licenseKey, ?string $domain = null): string { $key = 'wc_license_secure_' . $operation . '_' . hash('sha256', $licenseKey); if ($domain !== null) { $key .= '_' . hash('sha256', $domain); } return $key; } /** * Validate the base URL to prevent SSRF attacks. * * @throws \InvalidArgumentException If the URL is invalid or potentially dangerous */ private function validateBaseUrl(string $url, bool $allowInsecureHttp): void { if ($url === '') { throw new \InvalidArgumentException('Base URL cannot be empty'); } $parsed = parse_url($url); if ($parsed === false || !isset($parsed['scheme'], $parsed['host'])) { throw new \InvalidArgumentException('Invalid base URL format'); } $scheme = strtolower($parsed['scheme']); $host = strtolower($parsed['host']); // Require HTTPS unless explicitly allowed for localhost if ($scheme !== 'https') { if ($scheme !== 'http') { throw new \InvalidArgumentException('Base URL must use HTTP or HTTPS scheme'); } $isLocalhost = $host === 'localhost' || $host === '127.0.0.1'; if (!$allowInsecureHttp && !$isLocalhost) { throw new \InvalidArgumentException( 'Base URL must use HTTPS for non-localhost hosts. ' . 'Set allowInsecureHttp=true to allow HTTP (not recommended for production).' ); } } // Resolve hostname and check for private IPs $ip = gethostbyname($host); if ($ip !== $host && $this->isPrivateIp($ip)) { throw new \InvalidArgumentException( 'Base URL resolves to a private IP address, which is not allowed' ); } } /** * Check if an IP address is in a private range. */ private function isPrivateIp(string $ip): bool { $ipLong = ip2long($ip); if ($ipLong === false) { return false; } foreach (self::PRIVATE_IP_RANGES as $range) { [$subnet, $bits] = explode('/', $range); $subnetLong = ip2long($subnet); $mask = -1 << (32 - (int) $bits); if (($ipLong & $mask) === ($subnetLong & $mask)) { return true; } } return false; } }