# 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. Each license key has a unique derived secret (not the master secret) 4. Client verifies the signature using their per-license secret 5. Invalid signatures cause the client to reject the response This prevents attackers from: - Faking valid license responses - Replaying old responses - Tampering with response data - Using one customer's secret to verify another customer's responses ## Requirements - PHP 8.3+ - 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 using RFC 5869 HKDF: ```php /** * Derive a unique signing key for a license. * * Uses PHP's native hash_hkdf() function per RFC 5869. * * @param string $licenseKey The license key (used as "info" context) * @param string $serverSecret The server's master secret (used as IKM) * @return string The derived key (hex encoded, 64 characters) */ function derive_signing_key(string $licenseKey, string $serverSecret): string { // HKDF key derivation per RFC 5869 // IKM: server_secret, Length: 32 bytes, Info: license_key return bin2hex(hash_hkdf('sha256', $serverSecret, 32, $licenseKey)); } ``` **Important:** This uses PHP's native `hash_hkdf()` function (available since PHP 7.1.2). The parameters are: - **Algorithm:** sha256 - **IKM (Input Keying Material):** server_secret - **Length:** 32 bytes (256 bits) - **Info:** license_key (context-specific information) ### 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); // Recursively sort keys for consistent ordering (important for nested arrays!) $responseData = recursive_key_sort($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, ]; } /** * Recursively sort array keys alphabetically. */ function recursive_key_sort(array $data): array { ksort($data); foreach ($data as $key => $value) { if (is_array($value)) { $data[$key] = recursive_key_sort($value); } } return $data; } ``` ### 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); $data = $this->recursiveKeySort($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 recursiveKeySort(array $data): array { ksort($data); foreach ($data as $key => $value) { if (is_array($value)) { $data[$key] = $this->recursiveKeySort($value); } } return $data; } private function deriveKey(string $licenseKey): string { // HKDF key derivation per RFC 5869 return bin2hex(hash_hkdf('sha256', $this->serverSecret, 32, $licenseKey)); } } // 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 RFC 5869 HKDF: `hash_hkdf('sha256', server_secret, 32, license_key)` - `canonical_json` recursively 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 ``` ### Per-License Secrets Each customer receives a unique secret derived from their license key. This means: - Customers only know their own secret, not the master server secret - If one customer's secret is leaked, other customers are not affected - The server uses HKDF-like derivation to create unique secrets #### How Customers Get Their Secret Customers can find their per-license verification secret in their account: 1. Log in to the store 2. Go to My Account > Licenses 3. Click "API Verification Secret" under any license 4. Copy the 64-character hex string This secret is automatically derived from the customer's license key and the server's master secret. #### Using the Customer Secret ```php use Magdev\WcLicensedProductClient\SecureLicenseClient; use Symfony\Component\HttpClient\HttpClient; // Customer uses their per-license secret (from account page) $client = new SecureLicenseClient( httpClient: HttpClient::create(), baseUrl: 'https://shop.example.com', serverSecret: 'customer-secret-from-account-page', // 64 hex chars ); $info = $client->validate('XXXX-XXXX-XXXX-XXXX', 'example.com'); ``` ### Secret Key Rotation To rotate the server secret: 1. Deploy new secret to server 2. All per-license secrets change automatically (they're derived) 3. Customers must copy their new secret from their account page 4. 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(), ); ```