Files
wc-licensed-product-client/docs/server-implementation.md
2026-01-22 16:19:20 +01:00

9.7 KiB

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:

// wp-config.php or secure configuration file
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');

Generate a secure secret:

# 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:

/**
 * 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:

/**
 * 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:

/**
 * 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
/**
 * 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

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

// 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

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:

// 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:

// 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:

// 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:

// 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(),
);