You've already forked wc-licensed-product-client
Update server implementation documentation
- Add complete API endpoints reference with request/response formats - Add recursive key sorting for nested objects in signatures - Add comprehensive error codes table with HTTP status codes - Add rate limiting implementation with configurable limits - Add complete WordPress plugin example with all handlers - Add security sections: HTTPS, input sanitization, caching conflicts - Update PHP version requirement to 8.3 for consistency - Expand troubleshooting section with more scenarios Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,7 @@ No known bugs at the moment
|
||||
|
||||
### Version 0.1.1
|
||||
|
||||
No changes at the moment.
|
||||
No changes at the moment
|
||||
|
||||
### Version 0.2.0
|
||||
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
# Server-Side Response Signing Implementation
|
||||
# Server-Side Implementation Guide
|
||||
|
||||
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`.
|
||||
This document describes how to implement the WooCommerce Licensed Product REST API with response signing. The API allows external applications to validate, check status, and activate software licenses.
|
||||
|
||||
## Overview
|
||||
|
||||
The security model works as follows:
|
||||
The license server provides:
|
||||
|
||||
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
|
||||
1. **REST API endpoints** for license operations
|
||||
2. **Response signing** using HMAC-SHA256 for tamper protection
|
||||
3. **Rate limiting** to prevent abuse
|
||||
4. **Comprehensive error responses** for programmatic handling
|
||||
|
||||
This prevents attackers from:
|
||||
### Security Model
|
||||
|
||||
The signature system prevents attackers from:
|
||||
|
||||
- Faking valid license responses
|
||||
- Replaying old responses
|
||||
- Tampering with response data
|
||||
- Replaying old responses (timestamp validation)
|
||||
- Tampering with response data in transit
|
||||
|
||||
## Requirements
|
||||
|
||||
- PHP 7.4+ (8.0+ recommended)
|
||||
- PHP 8.3 or higher (to match client requirements)
|
||||
- WordPress 6.0+ with WooCommerce
|
||||
- A server secret stored securely (not in version control)
|
||||
|
||||
## Server Configuration
|
||||
@@ -29,7 +32,7 @@ This prevents attackers from:
|
||||
Add a secret key to your WordPress configuration:
|
||||
|
||||
```php
|
||||
// wp-config.php or secure configuration file
|
||||
// wp-config.php
|
||||
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
|
||||
```
|
||||
|
||||
@@ -45,7 +48,143 @@ php -r "echo bin2hex(random_bytes(32));"
|
||||
|
||||
**IMPORTANT:** Never commit this secret to version control!
|
||||
|
||||
## Implementation
|
||||
### 2. Configure Rate Limiting (Optional)
|
||||
|
||||
```php
|
||||
// wp-config.php
|
||||
define('WC_LICENSE_RATE_LIMIT', 30); // Requests per window
|
||||
define('WC_LICENSE_RATE_WINDOW', 60); // Window in seconds
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Base URL: `{site_url}/wp-json/wc-licensed-product/v1`
|
||||
|
||||
### POST /validate
|
||||
|
||||
Validate a license key for a specific domain.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"license_key": "ABCD-1234-EFGH-5678",
|
||||
"domain": "example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"license": {
|
||||
"product_id": 123,
|
||||
"expires_at": "2027-01-21",
|
||||
"version_id": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response (403):**
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": false,
|
||||
"error": "license_expired",
|
||||
"message": "This license has expired."
|
||||
}
|
||||
```
|
||||
|
||||
### POST /status
|
||||
|
||||
Get detailed license status information.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"license_key": "ABCD-1234-EFGH-5678"
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"status": "active",
|
||||
"domain": "example.com",
|
||||
"expires_at": "2027-01-21",
|
||||
"activations_count": 1,
|
||||
"max_activations": 3
|
||||
}
|
||||
```
|
||||
|
||||
### POST /activate
|
||||
|
||||
Activate a license on a specific domain.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"license_key": "ABCD-1234-EFGH-5678",
|
||||
"domain": "newdomain.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "License activated successfully."
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response (403):**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "max_activations_reached",
|
||||
"message": "Maximum number of activations reached."
|
||||
}
|
||||
```
|
||||
|
||||
## Error Codes
|
||||
|
||||
The API uses consistent error codes for programmatic handling:
|
||||
|
||||
| Error Code | HTTP Status | Description |
|
||||
| ---------- | ----------- | ----------- |
|
||||
| `license_not_found` | 404 | License key doesn't exist |
|
||||
| `license_expired` | 403 | License past expiration date |
|
||||
| `license_revoked` | 403 | License manually revoked by admin |
|
||||
| `license_inactive` | 403 | License not yet activated |
|
||||
| `license_invalid` | 403 | License invalid for requested operation |
|
||||
| `domain_mismatch` | 403 | License not authorized for this domain |
|
||||
| `max_activations_reached` | 403 | Maximum activations limit exceeded |
|
||||
| `activation_failed` | 500 | Server error during activation |
|
||||
| `rate_limit_exceeded` | 429 | Too many requests |
|
||||
|
||||
### Rate Limit Response
|
||||
|
||||
When rate limit is exceeded, include `retry_after`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "rate_limit_exceeded",
|
||||
"message": "Too many requests. Please try again later.",
|
||||
"retry_after": 45
|
||||
}
|
||||
```
|
||||
|
||||
Also include the `Retry-After` HTTP header.
|
||||
|
||||
## Response Signing Implementation
|
||||
|
||||
### Key Derivation
|
||||
|
||||
@@ -68,6 +207,31 @@ function derive_signing_key(string $licenseKey, string $serverSecret): string
|
||||
}
|
||||
```
|
||||
|
||||
### Recursive Key Sorting
|
||||
|
||||
**IMPORTANT:** Response data must have keys sorted recursively for consistent signatures:
|
||||
|
||||
```php
|
||||
/**
|
||||
* Recursively sort array keys for consistent JSON output.
|
||||
*
|
||||
* @param array $data The data to sort
|
||||
* @return array Sorted data
|
||||
*/
|
||||
function sort_keys_recursive(array $data): array
|
||||
{
|
||||
ksort($data);
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$data[$key] = sort_keys_recursive($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
```
|
||||
|
||||
### Response Signing
|
||||
|
||||
Sign every API response before sending:
|
||||
@@ -86,11 +250,11 @@ function sign_response(array $responseData, string $licenseKey, string $serverSe
|
||||
$timestamp = time();
|
||||
$signingKey = derive_signing_key($licenseKey, $serverSecret);
|
||||
|
||||
// Sort keys for consistent ordering
|
||||
ksort($responseData);
|
||||
// Sort keys recursively for consistent ordering
|
||||
$sortedData = sort_keys_recursive($responseData);
|
||||
|
||||
// Build signature payload
|
||||
$jsonBody = json_encode($responseData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$jsonBody = json_encode($sortedData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$payload = $timestamp . ':' . $jsonBody;
|
||||
|
||||
// Generate HMAC signature
|
||||
@@ -103,81 +267,356 @@ function sign_response(array $responseData, string $licenseKey, string $serverSe
|
||||
}
|
||||
```
|
||||
|
||||
### WordPress REST API Integration
|
||||
### Signature Algorithm
|
||||
|
||||
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);
|
||||
```text
|
||||
signature = HMAC-SHA256(
|
||||
key = derive_signing_key(license_key, server_secret),
|
||||
message = timestamp + ":" + canonical_json(response_body)
|
||||
)
|
||||
```
|
||||
|
||||
### Complete WordPress Plugin Example
|
||||
Where:
|
||||
|
||||
- `derive_signing_key` uses HKDF-like derivation
|
||||
- `canonical_json` sorts keys recursively, uses `JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE`
|
||||
- Result is hex-encoded (64 characters)
|
||||
|
||||
### Response 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` |
|
||||
|
||||
## Complete WordPress Plugin Implementation
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: WC Licensed Product Signature
|
||||
* Description: Adds response signing to WC Licensed Product API
|
||||
* Plugin Name: WC Licensed Product API
|
||||
* Description: License validation API with response signing
|
||||
* Version: 1.0.0
|
||||
* Requires PHP: 8.3
|
||||
*/
|
||||
|
||||
namespace WcLicensedProduct\Security;
|
||||
declare(strict_types=1);
|
||||
|
||||
class ResponseSigner
|
||||
namespace WcLicensedProduct;
|
||||
|
||||
/**
|
||||
* License API with response signing and rate limiting.
|
||||
*/
|
||||
final class LicenseApi
|
||||
{
|
||||
private const API_NAMESPACE = 'wc-licensed-product/v1';
|
||||
|
||||
private string $serverSecret;
|
||||
private int $rateLimit;
|
||||
private int $rateWindow;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->serverSecret = defined('WC_LICENSE_SERVER_SECRET')
|
||||
? WC_LICENSE_SERVER_SECRET
|
||||
: '';
|
||||
|
||||
$this->rateLimit = defined('WC_LICENSE_RATE_LIMIT')
|
||||
? (int) WC_LICENSE_RATE_LIMIT
|
||||
: 30;
|
||||
|
||||
$this->rateWindow = defined('WC_LICENSE_RATE_WINDOW')
|
||||
? (int) WC_LICENSE_RATE_WINDOW
|
||||
: 60;
|
||||
}
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
add_action('rest_api_init', [$this, 'registerRoutes']);
|
||||
add_filter('rest_post_dispatch', [$this, 'signResponse'], 10, 3);
|
||||
}
|
||||
|
||||
public function signResponse($response, $server, $request)
|
||||
public function registerRoutes(): void
|
||||
{
|
||||
register_rest_route(self::API_NAMESPACE, '/validate', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'handleValidate'],
|
||||
'permission_callback' => [$this, 'checkRateLimit'],
|
||||
'args' => $this->getValidateArgs(),
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/status', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'handleStatus'],
|
||||
'permission_callback' => [$this, 'checkRateLimit'],
|
||||
'args' => $this->getStatusArgs(),
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/activate', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'handleActivate'],
|
||||
'permission_callback' => [$this, 'checkRateLimit'],
|
||||
'args' => $this->getActivateArgs(),
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Request Validation
|
||||
// =========================================================================
|
||||
|
||||
private function getValidateArgs(): array
|
||||
{
|
||||
return [
|
||||
'license_key' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'validate_callback' => [$this, 'validateLicenseKeyFormat'],
|
||||
],
|
||||
'domain' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'validate_callback' => [$this, 'validateDomainFormat'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function getStatusArgs(): array
|
||||
{
|
||||
return [
|
||||
'license_key' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'validate_callback' => [$this, 'validateLicenseKeyFormat'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function getActivateArgs(): array
|
||||
{
|
||||
return $this->getValidateArgs(); // Same as validate
|
||||
}
|
||||
|
||||
public function validateLicenseKeyFormat($value): bool
|
||||
{
|
||||
return is_string($value) && strlen($value) <= 64 && strlen($value) >= 8;
|
||||
}
|
||||
|
||||
public function validateDomainFormat($value): bool
|
||||
{
|
||||
return is_string($value) && strlen($value) <= 255 && strlen($value) >= 1;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Rate Limiting
|
||||
// =========================================================================
|
||||
|
||||
public function checkRateLimit(\WP_REST_Request $request): bool|\WP_Error
|
||||
{
|
||||
$ip = $this->getClientIp();
|
||||
$key = 'license_rate_' . md5($ip);
|
||||
|
||||
$data = get_transient($key);
|
||||
|
||||
if ($data === false) {
|
||||
// First request in window
|
||||
set_transient($key, ['count' => 1, 'start' => time()], $this->rateWindow);
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($data['count'] >= $this->rateLimit) {
|
||||
$retryAfter = $this->rateWindow - (time() - $data['start']);
|
||||
|
||||
return new \WP_Error(
|
||||
'rate_limit_exceeded',
|
||||
'Too many requests. Please try again later.',
|
||||
[
|
||||
'status' => 429,
|
||||
'retry_after' => max(1, $retryAfter),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
$data['count']++;
|
||||
set_transient($key, $data, $this->rateWindow - (time() - $data['start']));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function getClientIp(): string
|
||||
{
|
||||
$headers = [
|
||||
'HTTP_CF_CONNECTING_IP', // Cloudflare
|
||||
'HTTP_X_FORWARDED_FOR', // Proxy
|
||||
'HTTP_X_REAL_IP', // Nginx
|
||||
'REMOTE_ADDR', // Direct
|
||||
];
|
||||
|
||||
foreach ($headers as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
$ip = $_SERVER[$header];
|
||||
// Handle comma-separated list (X-Forwarded-For)
|
||||
if (str_contains($ip, ',')) {
|
||||
$ip = trim(explode(',', $ip)[0]);
|
||||
}
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '0.0.0.0';
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// API Handlers
|
||||
// =========================================================================
|
||||
|
||||
public function handleValidate(\WP_REST_Request $request): \WP_REST_Response|\WP_Error
|
||||
{
|
||||
$licenseKey = $request->get_param('license_key');
|
||||
$domain = $request->get_param('domain');
|
||||
|
||||
// TODO: Replace with your license lookup logic
|
||||
$license = $this->findLicense($licenseKey);
|
||||
|
||||
if ($license === null) {
|
||||
return $this->errorResponse('license_not_found', 'License key not found.', 404);
|
||||
}
|
||||
|
||||
if ($license['status'] === 'revoked') {
|
||||
return $this->errorResponse('license_revoked', 'This license has been revoked.', 403);
|
||||
}
|
||||
|
||||
if ($license['status'] === 'expired' || $this->isExpired($license)) {
|
||||
return $this->errorResponse('license_expired', 'This license has expired.', 403);
|
||||
}
|
||||
|
||||
if ($license['status'] === 'inactive') {
|
||||
return $this->errorResponse('license_inactive', 'This license is inactive.', 403);
|
||||
}
|
||||
|
||||
if (!empty($license['domain']) && $license['domain'] !== $domain) {
|
||||
return $this->errorResponse('domain_mismatch', 'This license is not valid for this domain.', 403);
|
||||
}
|
||||
|
||||
return new \WP_REST_Response([
|
||||
'valid' => true,
|
||||
'license' => [
|
||||
'product_id' => $license['product_id'],
|
||||
'expires_at' => $license['expires_at'],
|
||||
'version_id' => $license['version_id'] ?? null,
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function handleStatus(\WP_REST_Request $request): \WP_REST_Response|\WP_Error
|
||||
{
|
||||
$licenseKey = $request->get_param('license_key');
|
||||
|
||||
$license = $this->findLicense($licenseKey);
|
||||
|
||||
if ($license === null) {
|
||||
return $this->errorResponse('license_not_found', 'License key not found.', 404);
|
||||
}
|
||||
|
||||
$status = $license['status'];
|
||||
if ($status === 'active' && $this->isExpired($license)) {
|
||||
$status = 'expired';
|
||||
}
|
||||
|
||||
return new \WP_REST_Response([
|
||||
'valid' => $status === 'active',
|
||||
'status' => $status,
|
||||
'domain' => $license['domain'] ?? '',
|
||||
'expires_at' => $license['expires_at'],
|
||||
'activations_count' => $license['activations_count'] ?? 0,
|
||||
'max_activations' => $license['max_activations'] ?? 1,
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function handleActivate(\WP_REST_Request $request): \WP_REST_Response|\WP_Error
|
||||
{
|
||||
$licenseKey = $request->get_param('license_key');
|
||||
$domain = $request->get_param('domain');
|
||||
|
||||
$license = $this->findLicense($licenseKey);
|
||||
|
||||
if ($license === null) {
|
||||
return $this->errorResponse('license_not_found', 'License key not found.', 404);
|
||||
}
|
||||
|
||||
if ($license['status'] === 'revoked') {
|
||||
return $this->errorResponse('license_invalid', 'This license is not valid.', 403);
|
||||
}
|
||||
|
||||
if ($this->isExpired($license)) {
|
||||
return $this->errorResponse('license_invalid', 'This license has expired.', 403);
|
||||
}
|
||||
|
||||
// Check if already activated on this domain
|
||||
if ($license['domain'] === $domain) {
|
||||
return new \WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => 'License is already activated for this domain.',
|
||||
], 200);
|
||||
}
|
||||
|
||||
// Check activation limit
|
||||
$activations = $license['activations_count'] ?? 0;
|
||||
$maxActivations = $license['max_activations'] ?? 1;
|
||||
|
||||
if ($activations >= $maxActivations && $license['domain'] !== $domain) {
|
||||
return $this->errorResponse(
|
||||
'max_activations_reached',
|
||||
'Maximum number of activations reached.',
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Replace with your activation logic
|
||||
$activated = $this->activateLicense($licenseKey, $domain);
|
||||
|
||||
if (!$activated) {
|
||||
return $this->errorResponse('activation_failed', 'Failed to activate license.', 500);
|
||||
}
|
||||
|
||||
return new \WP_REST_Response([
|
||||
'success' => true,
|
||||
'message' => 'License activated successfully.',
|
||||
], 200);
|
||||
}
|
||||
|
||||
private function errorResponse(string $code, string $message, int $status): \WP_REST_Response
|
||||
{
|
||||
$data = [
|
||||
'valid' => false,
|
||||
'success' => false,
|
||||
'error' => $code,
|
||||
'message' => $message,
|
||||
];
|
||||
|
||||
if ($code === 'rate_limit_exceeded') {
|
||||
$data['retry_after'] = $this->rateWindow;
|
||||
}
|
||||
|
||||
return new \WP_REST_Response($data, $status);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Response Signing
|
||||
// =========================================================================
|
||||
|
||||
public function signResponse(
|
||||
\WP_REST_Response $response,
|
||||
\WP_REST_Server $server,
|
||||
\WP_REST_Request $request
|
||||
): \WP_REST_Response {
|
||||
if (!$this->shouldSign($request)) {
|
||||
return $response;
|
||||
}
|
||||
@@ -198,13 +637,11 @@ class ResponseSigner
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function shouldSign($request): bool
|
||||
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');
|
||||
return str_starts_with($route, '/' . self::API_NAMESPACE . '/');
|
||||
}
|
||||
|
||||
private function createSignatureHeaders(array $data, string $licenseKey): array
|
||||
@@ -212,9 +649,11 @@ class ResponseSigner
|
||||
$timestamp = time();
|
||||
$signingKey = $this->deriveKey($licenseKey);
|
||||
|
||||
ksort($data);
|
||||
// Sort keys recursively for consistent ordering
|
||||
$sortedData = $this->sortKeysRecursive($data);
|
||||
|
||||
$payload = $timestamp . ':' . json_encode(
|
||||
$data,
|
||||
$sortedData,
|
||||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
|
||||
@@ -224,46 +663,84 @@ class ResponseSigner
|
||||
];
|
||||
}
|
||||
|
||||
private function sortKeysRecursive(array $data): array
|
||||
{
|
||||
ksort($data);
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$data[$key] = $this->sortKeysRecursive($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function deriveKey(string $licenseKey): string
|
||||
{
|
||||
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true);
|
||||
|
||||
return hash_hmac('sha256', $prk . "\x01", $this->serverSecret);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// License Storage (Replace with your implementation)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Find a license by key.
|
||||
*
|
||||
* TODO: Replace with your database/WooCommerce lookup
|
||||
*
|
||||
* @return array|null License data or null if not found
|
||||
*/
|
||||
private function findLicense(string $licenseKey): ?array
|
||||
{
|
||||
// Example structure - replace with actual database lookup
|
||||
// return [
|
||||
// 'license_key' => 'ABCD-1234-EFGH-5678',
|
||||
// 'product_id' => 123,
|
||||
// 'status' => 'active', // active, inactive, expired, revoked
|
||||
// 'domain' => 'example.com',
|
||||
// 'expires_at' => '2027-01-21', // or null for lifetime
|
||||
// 'version_id' => null,
|
||||
// 'activations_count' => 1,
|
||||
// 'max_activations' => 3,
|
||||
// ];
|
||||
|
||||
return null; // Replace with actual implementation
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a license on a domain.
|
||||
*
|
||||
* TODO: Replace with your database update logic
|
||||
*/
|
||||
private function activateLicense(string $licenseKey, string $domain): bool
|
||||
{
|
||||
// Update license record with new domain
|
||||
// Increment activations_count
|
||||
// Set status to 'active'
|
||||
|
||||
return false; // Replace with actual implementation
|
||||
}
|
||||
|
||||
private function isExpired(?array $license): bool
|
||||
{
|
||||
if ($license === null || empty($license['expires_at'])) {
|
||||
return false; // Lifetime license
|
||||
}
|
||||
|
||||
return strtotime($license['expires_at']) < time();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
add_action('init', function() {
|
||||
(new ResponseSigner())->register();
|
||||
// Initialize plugin
|
||||
add_action('plugins_loaded', function () {
|
||||
(new LicenseApi())->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
|
||||
@@ -291,6 +768,7 @@ echo "X-License-Timestamp: " . $headers['X-License-Timestamp'] . "\n";
|
||||
|
||||
```php
|
||||
use Magdev\WcLicensedProductClient\SecureLicenseClient;
|
||||
use Magdev\WcLicensedProductClient\Security\SignatureException;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
|
||||
$client = new SecureLicenseClient(
|
||||
@@ -307,6 +785,18 @@ try {
|
||||
}
|
||||
```
|
||||
|
||||
### Verify Signature Manually
|
||||
|
||||
```bash
|
||||
# Using curl to test the API
|
||||
curl -X POST https://your-site.com/wp-json/wc-licensed-product/v1/validate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"license_key":"ABCD-1234-EFGH-5678","domain":"example.com"}' \
|
||||
-i
|
||||
|
||||
# Check for X-License-Signature and X-License-Timestamp headers
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Timestamp Tolerance
|
||||
@@ -320,6 +810,8 @@ Adjust if needed:
|
||||
|
||||
```php
|
||||
// Client-side: custom tolerance
|
||||
use Magdev\WcLicensedProductClient\Security\ResponseSignature;
|
||||
|
||||
$signature = new ResponseSignature($key, timestampTolerance: 600); // 10 minutes
|
||||
```
|
||||
|
||||
@@ -344,19 +836,42 @@ $secrets = [
|
||||
$response->header('X-License-Signature-Version', 'v2');
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
### Sign 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.',
|
||||
];
|
||||
// Both success and error responses are signed by the filter
|
||||
// No additional code needed - the signResponse filter handles all responses
|
||||
```
|
||||
|
||||
$headers = sign_response($errorData, $licenseKey, $serverSecret);
|
||||
### HTTPS Required
|
||||
|
||||
Always serve the API over HTTPS:
|
||||
|
||||
```php
|
||||
// In your plugin, enforce HTTPS
|
||||
add_action('rest_api_init', function() {
|
||||
if (!is_ssl() && !defined('WP_DEBUG') || !WP_DEBUG) {
|
||||
wp_die('License API requires HTTPS');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Input Sanitization
|
||||
|
||||
Always sanitize and validate input:
|
||||
|
||||
```php
|
||||
// The WordPress REST API args handle this automatically
|
||||
// But add additional validation as needed
|
||||
$licenseKey = sanitize_text_field($request->get_param('license_key'));
|
||||
$domain = sanitize_text_field($request->get_param('domain'));
|
||||
|
||||
// Validate format
|
||||
if (!preg_match('/^[A-Z0-9\-]{8,64}$/i', $licenseKey)) {
|
||||
return new WP_Error('invalid_license_key', 'Invalid license key format');
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -372,22 +887,69 @@ $headers = sign_response($errorData, $licenseKey, $serverSecret);
|
||||
- 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)
|
||||
- JSON encoding differences (check recursive `ksort` and flags)
|
||||
- Nested objects not sorted consistently
|
||||
|
||||
### 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);
|
||||
// Server-side debugging
|
||||
add_filter('rest_post_dispatch', function($response, $server, $request) {
|
||||
if (!str_starts_with($request->get_route(), '/wc-licensed-product/v1/')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$data = $response->get_data();
|
||||
$sortedData = sort_keys_recursive($data);
|
||||
$json = json_encode($sortedData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
error_log('License API Debug:');
|
||||
error_log('Route: ' . $request->get_route());
|
||||
error_log('License Key: ' . $request->get_param('license_key'));
|
||||
error_log('Sorted JSON: ' . $json);
|
||||
error_log('Timestamp: ' . time());
|
||||
|
||||
return $response;
|
||||
}, 5, 3);
|
||||
```
|
||||
|
||||
```php
|
||||
// Client-side: use a PSR-3 logger
|
||||
$client = new SecureLicenseClient(
|
||||
// ...
|
||||
logger: new YourDebugLogger(),
|
||||
);
|
||||
```
|
||||
|
||||
### Rate Limit Issues
|
||||
|
||||
If legitimate users hit rate limits:
|
||||
|
||||
```php
|
||||
// Increase limits in wp-config.php
|
||||
define('WC_LICENSE_RATE_LIMIT', 60); // 60 requests
|
||||
define('WC_LICENSE_RATE_WINDOW', 60); // per minute
|
||||
|
||||
// Or implement per-license key rate limiting instead of per-IP
|
||||
$key = 'license_rate_' . md5($licenseKey);
|
||||
```
|
||||
|
||||
### Caching Conflicts
|
||||
|
||||
If a caching plugin modifies responses:
|
||||
|
||||
```php
|
||||
// Exclude license API from caching
|
||||
add_action('rest_api_init', function() {
|
||||
if (str_contains($_SERVER['REQUEST_URI'], '/wc-licensed-product/')) {
|
||||
// Disable page caching
|
||||
define('DONOTCACHEPAGE', true);
|
||||
|
||||
// Set no-cache headers
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user