diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ed73a7..f9a42d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - PSR-6 caching support (optional) - PSR dependencies (`psr/log`, `psr/cache`, `psr/http-client`) - PHPUnit test suite with 32 tests covering DTOs, exceptions, and client +- `SecureLicenseClient` with response signature verification (HMAC-SHA256) +- `ResponseSignature` class for signing and verifying API responses +- `StringEncoder` for basic string obfuscation in source code +- `IntegrityChecker` for verifying source file integrity +- `SignatureException` and `IntegrityException` for security errors +- Server implementation documentation (`docs/server-implementation.md`) +- Security test suite (34 additional tests) ### Changed diff --git a/README.md b/README.md index f8cf32b..50e08c5 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ composer require magdev/wc-licensed-product-client ## Features - Object-oriented client library +- **Secure client with response signature verification (HMAC-SHA256)** - PSR-3 logging support - PSR-6 caching support - PSR-18 HTTP client compatible @@ -23,6 +24,7 @@ composer require magdev/wc-licensed-product-client - License activation on domains - License status checking - Comprehensive exception handling +- Code integrity verification - Built on Symfony HttpClient ## Usage @@ -105,6 +107,37 @@ try { } ``` +## Secure Client + +For enhanced security, use `SecureLicenseClient` which verifies response signatures: + +```php +use Magdev\WcLicensedProductClient\SecureLicenseClient; +use Magdev\WcLicensedProductClient\Security\SignatureException; +use Symfony\Component\HttpClient\HttpClient; + +$client = new SecureLicenseClient( + httpClient: HttpClient::create(), + baseUrl: 'https://your-wordpress-site.com', + serverSecret: 'shared-secret-with-server', // Must match server configuration +); + +try { + $licenseInfo = $client->validate('ABCD-1234-EFGH-5678', 'example.com'); +} catch (SignatureException $e) { + // Response signature invalid - possible tampering! +} +``` + +**Important:** The secure client requires the server to sign responses. See [docs/server-implementation.md](docs/server-implementation.md) for server setup instructions. + +### Security Features + +- **Response Signatures**: HMAC-SHA256 verification prevents response tampering +- **Timestamp Validation**: Prevents replay attacks (5-minute tolerance) +- **Per-License Keys**: Each license has a unique verification key +- **Code Integrity**: Optional verification of source file integrity + ## Testing Run the test suite with PHPUnit: diff --git a/docs/server-implementation.md b/docs/server-implementation.md new file mode 100644 index 0000000..97351ad --- /dev/null +++ b/docs/server-implementation.md @@ -0,0 +1,393 @@ +# Server-Side Response Signing Implementation + +This document describes how to implement response signing on the server side (e.g., in the WooCommerce Licensed Product plugin) to work with the `SecureLicenseClient`. + +## Overview + +The security model works as follows: + +1. Server generates a unique signature for each response using HMAC-SHA256 +2. Signature includes a timestamp to prevent replay attacks +3. Client verifies the signature using a shared secret +4. Invalid signatures cause the client to reject the response + +This prevents attackers from: + +- Faking valid license responses +- Replaying old responses +- Tampering with response data + +## Requirements + +- PHP 7.4+ (8.0+ recommended) +- A server secret stored securely (not in version control) + +## Server Configuration + +### 1. Store the Server Secret + +Add a secret key to your WordPress configuration: + +```php +// wp-config.php or secure configuration file +define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars'); +``` + +Generate a secure secret: + +```bash +# Using OpenSSL +openssl rand -hex 32 + +# Or using PHP +php -r "echo bin2hex(random_bytes(32));" +``` + +**IMPORTANT:** Never commit this secret to version control! + +## Implementation + +### Key Derivation + +Each license key gets a unique signing key derived from the server secret: + +```php +/** + * Derive a unique signing key for a license. + * + * @param string $licenseKey The license key + * @param string $serverSecret The server's master secret + * @return string The derived key (hex encoded) + */ +function derive_signing_key(string $licenseKey, string $serverSecret): string +{ + // HKDF-like key derivation + $prk = hash_hmac('sha256', $licenseKey, $serverSecret, true); + + return hash_hmac('sha256', $prk . "\x01", $serverSecret); +} +``` + +### Response Signing + +Sign every API response before sending: + +```php +/** + * Sign an API response. + * + * @param array $responseData The response body (before JSON encoding) + * @param string $licenseKey The license key from the request + * @param string $serverSecret The server's master secret + * @return array Headers to add to the response + */ +function sign_response(array $responseData, string $licenseKey, string $serverSecret): array +{ + $timestamp = time(); + $signingKey = derive_signing_key($licenseKey, $serverSecret); + + // Sort keys for consistent ordering + ksort($responseData); + + // Build signature payload + $jsonBody = json_encode($responseData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $payload = $timestamp . ':' . $jsonBody; + + // Generate HMAC signature + $signature = hash_hmac('sha256', $payload, $signingKey); + + return [ + 'X-License-Signature' => $signature, + 'X-License-Timestamp' => (string) $timestamp, + ]; +} +``` + +### WordPress REST API Integration + +Example integration with WooCommerce REST API: + +```php +/** + * Add signature headers to license API responses. + */ +add_filter('rest_post_dispatch', function($response, $server, $request) { + // Only sign license API responses + if (!str_starts_with($request->get_route(), '/wc-licensed-product/v1/')) { + return $response; + } + + // Get the response data + $data = $response->get_data(); + + // Get the license key from the request + $licenseKey = $request->get_param('license_key'); + + if (empty($licenseKey) || !is_array($data)) { + return $response; + } + + // Sign the response + $serverSecret = defined('WC_LICENSE_SERVER_SECRET') + ? WC_LICENSE_SERVER_SECRET + : ''; + + if (empty($serverSecret)) { + // Log warning: server secret not configured + return $response; + } + + $signatureHeaders = sign_response($data, $licenseKey, $serverSecret); + + // Add headers to response + foreach ($signatureHeaders as $name => $value) { + $response->header($name, $value); + } + + return $response; +}, 10, 3); +``` + +### Complete WordPress Plugin Example + +```php +serverSecret = defined('WC_LICENSE_SERVER_SECRET') + ? WC_LICENSE_SERVER_SECRET + : ''; + } + + public function register(): void + { + add_filter('rest_post_dispatch', [$this, 'signResponse'], 10, 3); + } + + public function signResponse($response, $server, $request) + { + 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; + } + + private function shouldSign($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'); + } + + private function createSignatureHeaders(array $data, string $licenseKey): array + { + $timestamp = time(); + $signingKey = $this->deriveKey($licenseKey); + + ksort($data); + $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, + ]; + } + + private function deriveKey(string $licenseKey): string + { + $prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true); + + return hash_hmac('sha256', $prk . "\x01", $this->serverSecret); + } +} + +// Initialize +add_action('init', function() { + (new ResponseSigner())->register(); +}); +``` + +## Response Format + +### Headers + +Every signed response includes: + +| Header | Description | Example | +|--------|-------------|---------| +| `X-License-Signature` | HMAC-SHA256 signature (hex) | `a1b2c3d4...` (64 chars) | +| `X-License-Timestamp` | Unix timestamp when signed | `1706000000` | + +### Signature Algorithm + +```text +signature = HMAC-SHA256( + key = derive_signing_key(license_key, server_secret), + message = timestamp + ":" + canonical_json(response_body) +) +``` + +Where: + +- `derive_signing_key` uses HKDF-like derivation (see above) +- `canonical_json` sorts keys alphabetically, no escaping of slashes/unicode +- Result is hex-encoded (64 characters) + +## Testing + +### Verify Signing Works + +```php +// Test script +$serverSecret = 'test-secret-key-for-development-only'; +$licenseKey = 'ABCD-1234-EFGH-5678'; +$responseData = [ + 'valid' => true, + 'license' => [ + 'product_id' => 123, + 'expires_at' => '2027-01-21', + 'version_id' => null, + ], +]; + +$headers = sign_response($responseData, $licenseKey, $serverSecret); + +echo "X-License-Signature: " . $headers['X-License-Signature'] . "\n"; +echo "X-License-Timestamp: " . $headers['X-License-Timestamp'] . "\n"; +``` + +### Test with Client + +```php +use Magdev\WcLicensedProductClient\SecureLicenseClient; +use Symfony\Component\HttpClient\HttpClient; + +$client = new SecureLicenseClient( + httpClient: HttpClient::create(), + baseUrl: 'https://your-site.com', + serverSecret: 'same-secret-as-server', +); + +try { + $info = $client->validate('ABCD-1234-EFGH-5678', 'example.com'); + echo "License valid! Product ID: " . $info->productId; +} catch (SignatureException $e) { + echo "Signature verification failed - possible tampering!"; +} +``` + +## Security Considerations + +### Timestamp Tolerance + +The client allows a 5-minute window for timestamp verification. This: + +- Prevents replay attacks (old responses rejected) +- Allows for reasonable clock skew between server and client + +Adjust if needed: + +```php +// Client-side: custom tolerance +$signature = new ResponseSignature($key, timestampTolerance: 600); // 10 minutes +``` + +### Secret Key Rotation + +To rotate the server secret: + +1. Deploy new secret to server +2. Update client configurations +3. Old signatures become invalid immediately + +For zero-downtime rotation, implement versioned secrets: + +```php +// Server supports both old and new secrets during transition +$secrets = [ + 'v2' => 'new-secret', + 'v1' => 'old-secret', +]; + +// Add version to signature header +$response->header('X-License-Signature-Version', 'v2'); +``` + +### Error Responses + +Sign error responses too! Otherwise attackers could craft fake error messages: + +```php +// Sign both success and error responses +$errorData = [ + 'valid' => false, + 'error' => 'license_expired', + 'message' => 'This license has expired.', +]; + +$headers = sign_response($errorData, $licenseKey, $serverSecret); +``` + +## Troubleshooting + +### "Response is not signed by the server" + +- Server not configured with `WC_LICENSE_SERVER_SECRET` +- Filter not registered (check plugin activation) +- Route mismatch (check `shouldSign()` paths) + +### "Response signature verification failed" + +- Different secrets on server/client +- Clock skew > 5 minutes +- Response body modified after signing (e.g., by caching plugin) +- JSON encoding differences (check `ksort` and flags) + +### Debugging + +Enable detailed logging: + +```php +// Server-side +error_log('Signing response for: ' . $licenseKey); +error_log('Timestamp: ' . $timestamp); +error_log('Payload: ' . $payload); +error_log('Signature: ' . $signature); + +// Client-side: use a PSR-3 logger +$client = new SecureLicenseClient( + // ... + logger: new YourDebugLogger(), +); +``` diff --git a/src/SecureLicenseClient.php b/src/SecureLicenseClient.php new file mode 100644 index 0000000..12e2353 --- /dev/null +++ b/src/SecureLicenseClient.php @@ -0,0 +1,302 @@ +logger = $logger ?? new NullLogger(); + $this->encoder = $encoder ?? new StringEncoder(); + $this->apiPath = $this->encoder->decode(self::ENCODED_API_PATH); + + 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) { + $this->logger->error('License API request failed', [ + 'endpoint' => $endpoint, + 'error' => $e->getMessage(), + ]); + + throw new LicenseException( + 'Failed to communicate with license server: ' . $e->getMessage(), + 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 + $normalized[$name] = is_array($values) ? ($values[0] ?? '') : $values; + } + + 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; + } +} diff --git a/src/Security/IntegrityChecker.php b/src/Security/IntegrityChecker.php new file mode 100644 index 0000000..a99db65 --- /dev/null +++ b/src/Security/IntegrityChecker.php @@ -0,0 +1,138 @@ + $expectedHashes Map of relative file paths to expected SHA256 hashes + * @param string $basePath Base path for resolving relative file paths + */ + public function __construct( + private readonly array $expectedHashes, + private readonly string $basePath, + ) { + } + + /** + * Verify all registered files have not been modified. + * + * @throws IntegrityException If any file has been modified or is missing + */ + public function verify(): void + { + $failures = []; + + foreach ($this->expectedHashes as $relativePath => $expectedHash) { + $fullPath = $this->basePath . '/' . $relativePath; + + if (!file_exists($fullPath)) { + $failures[] = "Missing file: {$relativePath}"; + continue; + } + + $actualHash = $this->hashFile($fullPath); + + if (!hash_equals($expectedHash, $actualHash)) { + $failures[] = "Modified file: {$relativePath}"; + } + } + + if (!empty($failures)) { + throw new IntegrityException( + 'Integrity check failed: ' . implode(', ', $failures), + $failures + ); + } + } + + /** + * Check if all files pass integrity verification. + * + * @return bool True if all files are intact + */ + public function isValid(): bool + { + try { + $this->verify(); + return true; + } catch (IntegrityException) { + return false; + } + } + + /** + * Generate hashes for a list of files. + * + * Use this during build/release to create the hash manifest. + * + * @param array $relativePaths List of file paths relative to base path + * @return array Map of relative paths to SHA256 hashes + */ + public function generateHashes(array $relativePaths): array + { + $hashes = []; + + foreach ($relativePaths as $relativePath) { + $fullPath = $this->basePath . '/' . $relativePath; + + if (!file_exists($fullPath)) { + throw new \InvalidArgumentException("File not found: {$relativePath}"); + } + + $hashes[$relativePath] = $this->hashFile($fullPath); + } + + return $hashes; + } + + /** + * Generate PHP code for embedding hashes in source. + * + * @param array $relativePaths Files to hash + * @return string PHP array code that can be embedded in source + */ + public function generateHashesAsPhpCode(array $relativePaths): string + { + $hashes = $this->generateHashes($relativePaths); + + $lines = ["["]; + foreach ($hashes as $path => $hash) { + $lines[] = " '{$path}' => '{$hash}',"; + } + $lines[] = "]"; + + return implode("\n", $lines); + } + + private function hashFile(string $path): string + { + $content = file_get_contents($path); + + if ($content === false) { + throw new \RuntimeException("Cannot read file: {$path}"); + } + + // Normalize line endings for consistent hashing across platforms + $content = str_replace(["\r\n", "\r"], "\n", $content); + + return hash(self::ALGORITHM, $content); + } +} diff --git a/src/Security/IntegrityException.php b/src/Security/IntegrityException.php new file mode 100644 index 0000000..2520909 --- /dev/null +++ b/src/Security/IntegrityException.php @@ -0,0 +1,24 @@ + $failures List of specific failures + */ + public function __construct( + string $message, + public readonly array $failures = [], + ) { + parent::__construct($message, 'integrity_check_failed'); + } +} diff --git a/src/Security/ResponseSignature.php b/src/Security/ResponseSignature.php new file mode 100644 index 0000000..98c44f6 --- /dev/null +++ b/src/Security/ResponseSignature.php @@ -0,0 +1,144 @@ +isTimestampValid($timestamp)) { + return false; + } + + $expectedSignature = $this->sign($responseData, $timestamp); + + return hash_equals($expectedSignature, $signature); + } + + /** + * Generate a signature for response data. + * + * Used by the server to sign responses. Included here for documentation + * and testing purposes. + * + * @param array $responseData The response body to sign + * @param int $timestamp Unix timestamp of the response + * @return string The HMAC signature (hex encoded) + */ + public function sign(array $responseData, int $timestamp): string + { + $payload = $this->buildSignaturePayload($responseData, $timestamp); + + return hash_hmac(self::ALGORITHM, $payload, $this->secretKey); + } + + /** + * Extract signature from response headers. + */ + public static function extractSignature(array $headers): ?string + { + return $headers[self::SIGNATURE_HEADER] ?? $headers[strtolower(self::SIGNATURE_HEADER)] ?? null; + } + + /** + * Extract timestamp from response headers. + */ + public static function extractTimestamp(array $headers): ?int + { + $value = $headers[self::TIMESTAMP_HEADER] ?? $headers[strtolower(self::TIMESTAMP_HEADER)] ?? null; + + return $value !== null ? (int) $value : null; + } + + /** + * Get the header name for the signature. + */ + public static function getSignatureHeaderName(): string + { + return self::SIGNATURE_HEADER; + } + + /** + * Get the header name for the timestamp. + */ + public static function getTimestampHeaderName(): string + { + return self::TIMESTAMP_HEADER; + } + + /** + * Derive a unique key from the license key and server secret. + * + * Uses HKDF-like key derivation to create a unique key per license. + */ + public static function deriveKey(string $licenseKey, string $serverSecret): string + { + // Use HKDF expansion with license key as info + $prk = hash_hmac('sha256', $licenseKey, $serverSecret, true); + + return hash_hmac('sha256', $prk . "\x01", $serverSecret); + } + + private function buildSignaturePayload(array $responseData, int $timestamp): string + { + // Sort keys for consistent ordering + ksort($responseData); + + // Create deterministic JSON representation + $jsonBody = json_encode($responseData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + // Combine timestamp and body for signature + return $timestamp . ':' . $jsonBody; + } + + private function isTimestampValid(int $timestamp): bool + { + $now = time(); + $diff = abs($now - $timestamp); + + return $diff <= $this->timestampTolerance; + } +} diff --git a/src/Security/SignatureException.php b/src/Security/SignatureException.php new file mode 100644 index 0000000..bccbe50 --- /dev/null +++ b/src/Security/SignatureException.php @@ -0,0 +1,18 @@ +expandKey(strlen($plaintext)); + $encoded = ''; + + for ($i = 0; $i < strlen($plaintext); $i++) { + $encoded .= chr(ord($plaintext[$i]) ^ ord($keyBytes[$i])); + } + + return base64_encode($encoded); + } + + /** + * Decode an obfuscated string at runtime. + * + * @param string $encoded The base64-encoded obfuscated string + * @return string The original plaintext + */ + public function decode(string $encoded): string + { + $decoded = base64_decode($encoded, true); + + if ($decoded === false) { + throw new \InvalidArgumentException('Invalid encoded string'); + } + + $keyBytes = $this->expandKey(strlen($decoded)); + $plaintext = ''; + + for ($i = 0; $i < strlen($decoded); $i++) { + $plaintext .= chr(ord($decoded[$i]) ^ ord($keyBytes[$i])); + } + + return $plaintext; + } + + /** + * Generate encoded constants for use in source code. + * + * This is a helper method for developers to generate encoded strings + * that can be embedded in the obfuscated version of the client. + * + * @param array $strings Map of constant names to values + * @return array Map of constant names to encoded values + */ + public function generateEncodedConstants(array $strings): array + { + $result = []; + + foreach ($strings as $name => $value) { + $result[$name] = $this->encode($value); + } + + return $result; + } + + /** + * Expand the key to match the required length using a simple KDF. + */ + private function expandKey(int $length): string + { + $expanded = ''; + $counter = 0; + + while (strlen($expanded) < $length) { + $expanded .= hash('sha256', $this->key . pack('N', $counter), true); + $counter++; + } + + return substr($expanded, 0, $length); + } +} diff --git a/tests/Security/IntegrityCheckerTest.php b/tests/Security/IntegrityCheckerTest.php new file mode 100644 index 0000000..81e8163 --- /dev/null +++ b/tests/Security/IntegrityCheckerTest.php @@ -0,0 +1,218 @@ +tempDir = sys_get_temp_dir() . '/integrity_test_' . uniqid(); + mkdir($this->tempDir, 0755, true); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->tempDir); + } + + #[Test] + public function itPassesForUnmodifiedFiles(): void + { + $this->createFile('test.php', 'tempDir); + $hashes = $checker->generateHashes(['test.php']); + + $verifier = new IntegrityChecker($hashes, $this->tempDir); + $verifier->verify(); + + self::assertTrue($verifier->isValid()); + } + + #[Test] + public function itFailsForModifiedFiles(): void + { + $this->createFile('test.php', 'tempDir); + $hashes = $checker->generateHashes(['test.php']); + + // Modify the file + $this->createFile('test.php', 'tempDir); + + $this->expectException(IntegrityException::class); + $this->expectExceptionMessage('Modified file: test.php'); + + $verifier->verify(); + } + + #[Test] + public function itFailsForMissingFiles(): void + { + $this->createFile('test.php', 'tempDir); + $hashes = $checker->generateHashes(['test.php']); + + // Delete the file + unlink($this->tempDir . '/test.php'); + + $verifier = new IntegrityChecker($hashes, $this->tempDir); + + $this->expectException(IntegrityException::class); + $this->expectExceptionMessage('Missing file: test.php'); + + $verifier->verify(); + } + + #[Test] + public function itReturnsFailureDetailsInException(): void + { + $this->createFile('file1.php', 'content1'); + $this->createFile('file2.php', 'content2'); + + $checker = new IntegrityChecker([], $this->tempDir); + $hashes = $checker->generateHashes(['file1.php', 'file2.php']); + + // Modify both files + $this->createFile('file1.php', 'modified1'); + $this->createFile('file2.php', 'modified2'); + + $verifier = new IntegrityChecker($hashes, $this->tempDir); + + try { + $verifier->verify(); + self::fail('Expected IntegrityException'); + } catch (IntegrityException $e) { + self::assertCount(2, $e->failures); + self::assertContains('Modified file: file1.php', $e->failures); + self::assertContains('Modified file: file2.php', $e->failures); + } + } + + #[Test] + public function itIsValidReturnsFalseForModifiedFiles(): void + { + $this->createFile('test.php', 'original'); + + $checker = new IntegrityChecker([], $this->tempDir); + $hashes = $checker->generateHashes(['test.php']); + + $this->createFile('test.php', 'modified'); + + $verifier = new IntegrityChecker($hashes, $this->tempDir); + + self::assertFalse($verifier->isValid()); + } + + #[Test] + public function itGeneratesConsistentHashes(): void + { + $this->createFile('test.php', 'tempDir); + $checker2 = new IntegrityChecker([], $this->tempDir); + + $hash1 = $checker1->generateHashes(['test.php']); + $hash2 = $checker2->generateHashes(['test.php']); + + self::assertSame($hash1, $hash2); + } + + #[Test] + public function itNormalizesLineEndings(): void + { + // Create file with Unix line endings + $this->createFile('test.php', "line1\nline2\n"); + + $checker = new IntegrityChecker([], $this->tempDir); + $hash1 = $checker->generateHashes(['test.php']); + + // Create same file with Windows line endings + $this->createFile('test.php', "line1\r\nline2\r\n"); + + $hash2 = $checker->generateHashes(['test.php']); + + self::assertSame($hash1['test.php'], $hash2['test.php']); + } + + #[Test] + public function itGeneratesPhpCode(): void + { + $this->createFile('test.php', 'content'); + + $checker = new IntegrityChecker([], $this->tempDir); + $code = $checker->generateHashesAsPhpCode(['test.php']); + + self::assertStringContainsString('[', $code); + self::assertStringContainsString(']', $code); + self::assertStringContainsString("'test.php'", $code); + } + + #[Test] + public function itThrowsForNonExistentFileInGenerate(): void + { + $checker = new IntegrityChecker([], $this->tempDir); + + $this->expectException(\InvalidArgumentException::class); + + $checker->generateHashes(['nonexistent.php']); + } + + #[Test] + public function itHandlesSubdirectories(): void + { + mkdir($this->tempDir . '/subdir', 0755, true); + $this->createFile('subdir/test.php', 'content'); + + $checker = new IntegrityChecker([], $this->tempDir); + $hashes = $checker->generateHashes(['subdir/test.php']); + + self::assertArrayHasKey('subdir/test.php', $hashes); + + $verifier = new IntegrityChecker($hashes, $this->tempDir); + self::assertTrue($verifier->isValid()); + } + + private function createFile(string $relativePath, string $content): void + { + $fullPath = $this->tempDir . '/' . $relativePath; + $dir = dirname($fullPath); + + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + file_put_contents($fullPath, $content); + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + + foreach ($files as $file) { + $path = $dir . '/' . $file; + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + + rmdir($dir); + } +} diff --git a/tests/Security/ResponseSignatureTest.php b/tests/Security/ResponseSignatureTest.php new file mode 100644 index 0000000..83a8ad7 --- /dev/null +++ b/tests/Security/ResponseSignatureTest.php @@ -0,0 +1,181 @@ + true, 'license' => ['product_id' => 123]]; + + $sig = $signature->sign($data, $timestamp); + + self::assertTrue($signature->verify($data, $sig, $timestamp)); + } + + #[Test] + public function itRejectsInvalidSignature(): void + { + $signature = new ResponseSignature(self::SECRET_KEY); + $timestamp = time(); + $data = ['valid' => true]; + + self::assertFalse($signature->verify($data, 'invalid-signature', $timestamp)); + } + + #[Test] + public function itRejectsTamperedData(): void + { + $signature = new ResponseSignature(self::SECRET_KEY); + $timestamp = time(); + $originalData = ['valid' => true]; + $tamperedData = ['valid' => false]; + + $sig = $signature->sign($originalData, $timestamp); + + self::assertFalse($signature->verify($tamperedData, $sig, $timestamp)); + } + + #[Test] + public function itRejectsExpiredTimestamp(): void + { + $signature = new ResponseSignature(self::SECRET_KEY, timestampTolerance: 60); + $oldTimestamp = time() - 120; // 2 minutes ago + $data = ['valid' => true]; + + $sig = $signature->sign($data, $oldTimestamp); + + self::assertFalse($signature->verify($data, $sig, $oldTimestamp)); + } + + #[Test] + public function itAcceptsTimestampWithinTolerance(): void + { + $signature = new ResponseSignature(self::SECRET_KEY, timestampTolerance: 300); + $recentTimestamp = time() - 60; // 1 minute ago + $data = ['valid' => true]; + + $sig = $signature->sign($data, $recentTimestamp); + + self::assertTrue($signature->verify($data, $sig, $recentTimestamp)); + } + + #[Test] + public function itDerivesUniqueKeysPerLicense(): void + { + $key1 = ResponseSignature::deriveKey('LICENSE-001', self::SERVER_SECRET); + $key2 = ResponseSignature::deriveKey('LICENSE-002', self::SERVER_SECRET); + + self::assertNotSame($key1, $key2); + self::assertSame(64, strlen($key1)); // SHA256 hex = 64 chars + } + + #[Test] + public function itProducesDeterministicKeys(): void + { + $key1 = ResponseSignature::deriveKey(self::LICENSE_KEY, self::SERVER_SECRET); + $key2 = ResponseSignature::deriveKey(self::LICENSE_KEY, self::SERVER_SECRET); + + self::assertSame($key1, $key2); + } + + #[Test] + public function itCreatesFromLicenseKey(): void + { + $signature = ResponseSignature::fromLicenseKey(self::LICENSE_KEY, self::SERVER_SECRET); + $timestamp = time(); + $data = ['valid' => true]; + + $sig = $signature->sign($data, $timestamp); + + // Verify with same derived key + $signature2 = ResponseSignature::fromLicenseKey(self::LICENSE_KEY, self::SERVER_SECRET); + self::assertTrue($signature2->verify($data, $sig, $timestamp)); + } + + #[Test] + public function itExtractsSignatureFromHeaders(): void + { + $headers = [ + 'X-License-Signature' => 'abc123', + 'Content-Type' => 'application/json', + ]; + + self::assertSame('abc123', ResponseSignature::extractSignature($headers)); + } + + #[Test] + public function itExtractsTimestampFromHeaders(): void + { + $headers = [ + 'X-License-Timestamp' => '1706000000', + 'Content-Type' => 'application/json', + ]; + + self::assertSame(1706000000, ResponseSignature::extractTimestamp($headers)); + } + + #[Test] + public function itReturnsNullForMissingHeaders(): void + { + $headers = ['Content-Type' => 'application/json']; + + self::assertNull(ResponseSignature::extractSignature($headers)); + self::assertNull(ResponseSignature::extractTimestamp($headers)); + } + + #[Test] + public function itHandlesLowercaseHeaders(): void + { + $headers = [ + 'x-license-signature' => 'abc123', + 'x-license-timestamp' => '1706000000', + ]; + + self::assertSame('abc123', ResponseSignature::extractSignature($headers)); + self::assertSame(1706000000, ResponseSignature::extractTimestamp($headers)); + } + + #[Test] + public function itProducesConsistentSignaturesForSameData(): void + { + $signature = new ResponseSignature(self::SECRET_KEY); + $timestamp = 1706000000; + $data = ['b' => 2, 'a' => 1]; // Unsorted + + $sig1 = $signature->sign($data, $timestamp); + $sig2 = $signature->sign($data, $timestamp); + + self::assertSame($sig1, $sig2); + } + + #[Test] + public function itSortsKeysForConsistentSignatures(): void + { + $signature = new ResponseSignature(self::SECRET_KEY); + $timestamp = 1706000000; + + $data1 = ['a' => 1, 'b' => 2]; + $data2 = ['b' => 2, 'a' => 1]; + + $sig1 = $signature->sign($data1, $timestamp); + $sig2 = $signature->sign($data2, $timestamp); + + self::assertSame($sig1, $sig2); + } +} diff --git a/tests/Security/StringEncoderTest.php b/tests/Security/StringEncoderTest.php new file mode 100644 index 0000000..dc1e7bc --- /dev/null +++ b/tests/Security/StringEncoderTest.php @@ -0,0 +1,148 @@ +encode($original); + $decoded = $encoder->decode($encoded); + + self::assertSame($original, $decoded); + self::assertNotSame($original, $encoded); + } + + #[Test] + public function itEncodesApiPaths(): void + { + $encoder = new StringEncoder(); + $path = '/wp-json/wc-licensed-product/v1'; + + $encoded = $encoder->encode($path); + $decoded = $encoder->decode($encoded); + + self::assertSame($path, $decoded); + self::assertStringNotContainsString('wp-json', $encoded); + } + + #[Test] + public function itEncodesEndpointNames(): void + { + $encoder = new StringEncoder(); + $endpoints = ['validate', 'status', 'activate']; + + foreach ($endpoints as $endpoint) { + $encoded = $encoder->encode($endpoint); + $decoded = $encoder->decode($encoded); + + self::assertSame($endpoint, $decoded); + } + } + + #[Test] + public function itHandlesEmptyString(): void + { + $encoder = new StringEncoder(); + + $encoded = $encoder->encode(''); + $decoded = $encoder->decode($encoded); + + self::assertSame('', $decoded); + } + + #[Test] + public function itHandlesUnicodeStrings(): void + { + $encoder = new StringEncoder(); + $original = 'Ümläut and émojis 🔐'; + + $encoded = $encoder->encode($original); + $decoded = $encoder->decode($encoded); + + self::assertSame($original, $decoded); + } + + #[Test] + public function itProducesDifferentOutputWithDifferentKeys(): void + { + $encoder1 = new StringEncoder('key1'); + $encoder2 = new StringEncoder('key2'); + $original = 'test string'; + + $encoded1 = $encoder1->encode($original); + $encoded2 = $encoder2->encode($original); + + self::assertNotSame($encoded1, $encoded2); + } + + #[Test] + public function itRequiresSameKeyForDecoding(): void + { + $encoder1 = new StringEncoder('key1'); + $encoder2 = new StringEncoder('key2'); + $original = 'test string'; + + $encoded = $encoder1->encode($original); + $decodedWithWrongKey = $encoder2->decode($encoded); + + self::assertNotSame($original, $decodedWithWrongKey); + } + + #[Test] + public function itGeneratesEncodedConstants(): void + { + $encoder = new StringEncoder(); + $constants = [ + 'API_PATH' => '/wp-json/wc-licensed-product/v1', + 'VALIDATE' => 'validate', + 'STATUS' => 'status', + ]; + + $encoded = $encoder->generateEncodedConstants($constants); + + self::assertCount(3, $encoded); + self::assertArrayHasKey('API_PATH', $encoded); + self::assertArrayHasKey('VALIDATE', $encoded); + self::assertArrayHasKey('STATUS', $encoded); + + // Verify all can be decoded back + foreach ($constants as $name => $value) { + self::assertSame($value, $encoder->decode($encoded[$name])); + } + } + + #[Test] + public function itThrowsOnInvalidEncodedString(): void + { + $encoder = new StringEncoder(); + + $this->expectException(\InvalidArgumentException::class); + + $encoder->decode('not-valid-base64!!!'); + } + + #[Test] + public function itHandlesLongStrings(): void + { + $encoder = new StringEncoder(); + $original = str_repeat('A', 10000); + + $encoded = $encoder->encode($original); + $decoded = $encoder->decode($encoded); + + self::assertSame($original, $decoded); + } +}