You've already forked wc-licensed-product-client
Security classes: - ResponseSignature: HMAC-SHA256 signing and verification - StringEncoder: XOR-based string obfuscation for source code - IntegrityChecker: Source file hash verification - SignatureException, IntegrityException for error handling SecureLicenseClient: - Verifies server response signatures - Prevents response tampering and replay attacks - Per-license derived signing keys - Optional code integrity checking Documentation: - docs/server-implementation.md with complete WordPress/WooCommerce integration guide for signing responses Tests: - 34 new security tests (66 total, all passing) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
394 lines
9.7 KiB
Markdown
394 lines
9.7 KiB
Markdown
# 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
|
|
<?php
|
|
/**
|
|
* Plugin Name: WC Licensed Product Signature
|
|
* Description: Adds response signing to WC Licensed Product API
|
|
* Version: 1.0.0
|
|
*/
|
|
|
|
namespace WcLicensedProduct\Security;
|
|
|
|
class ResponseSigner
|
|
{
|
|
private string $serverSecret;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->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(),
|
|
);
|
|
```
|