2026-01-22 16:57:54 +01:00
|
|
|
<?php
|
|
|
|
|
/**
|
|
|
|
|
* Response Signer
|
|
|
|
|
*
|
|
|
|
|
* Signs REST API responses to prevent tampering and replay attacks.
|
|
|
|
|
*
|
|
|
|
|
* @package Jeremias\WcLicensedProduct\Api
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace Jeremias\WcLicensedProduct\Api;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Signs license API responses using HMAC-SHA256
|
|
|
|
|
*
|
|
|
|
|
* The security model:
|
|
|
|
|
* 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
|
|
|
|
|
*/
|
|
|
|
|
final class ResponseSigner
|
|
|
|
|
{
|
|
|
|
|
private string $serverSecret;
|
|
|
|
|
|
|
|
|
|
public function __construct()
|
|
|
|
|
{
|
2026-02-01 13:52:57 +01:00
|
|
|
$this->serverSecret = self::getServerSecret();
|
2026-01-22 16:57:54 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Register WordPress hooks
|
|
|
|
|
*/
|
|
|
|
|
public function register(): void
|
|
|
|
|
{
|
|
|
|
|
add_filter('rest_post_dispatch', [$this, 'signResponse'], 10, 3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sign REST API response
|
|
|
|
|
*
|
|
|
|
|
* @param \WP_REST_Response $response The response object
|
|
|
|
|
* @param \WP_REST_Server $server The REST server
|
|
|
|
|
* @param \WP_REST_Request $request The request object
|
|
|
|
|
* @return \WP_REST_Response
|
|
|
|
|
*/
|
|
|
|
|
public function signResponse($response, $server, $request)
|
|
|
|
|
{
|
|
|
|
|
// Only sign license API responses
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if request should be signed
|
|
|
|
|
*/
|
|
|
|
|
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')
|
2026-01-28 12:07:23 +01:00
|
|
|
|| str_starts_with($route, '/wc-licensed-product/v1/activate')
|
|
|
|
|
|| str_starts_with($route, '/wc-licensed-product/v1/update-check');
|
2026-01-22 16:57:54 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create signature headers for response
|
|
|
|
|
*
|
|
|
|
|
* @param array $data The response data
|
|
|
|
|
* @param string $licenseKey The license key from the request
|
|
|
|
|
* @return array Associative array of headers
|
|
|
|
|
*/
|
|
|
|
|
private function createSignatureHeaders(array $data, string $licenseKey): array
|
|
|
|
|
{
|
|
|
|
|
$timestamp = time();
|
|
|
|
|
$signingKey = $this->deriveKey($licenseKey);
|
|
|
|
|
|
2026-01-23 21:18:32 +01:00
|
|
|
// Recursively sort keys for consistent ordering (required by client implementation)
|
|
|
|
|
$data = $this->recursiveKeySort($data);
|
2026-01-22 16:57:54 +01:00
|
|
|
|
|
|
|
|
// Build signature payload
|
|
|
|
|
$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,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-23 21:18:32 +01:00
|
|
|
/**
|
|
|
|
|
* Recursively sort array keys alphabetically
|
|
|
|
|
*
|
|
|
|
|
* @param mixed $data The data to sort
|
|
|
|
|
* @return mixed The sorted data
|
|
|
|
|
*/
|
|
|
|
|
private function recursiveKeySort(mixed $data): mixed
|
|
|
|
|
{
|
|
|
|
|
if (!is_array($data)) {
|
|
|
|
|
return $data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if array is associative (has string keys)
|
|
|
|
|
$isAssociative = array_keys($data) !== range(0, count($data) - 1);
|
|
|
|
|
|
|
|
|
|
if ($isAssociative) {
|
|
|
|
|
ksort($data);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Recursively sort nested arrays
|
|
|
|
|
foreach ($data as $key => $value) {
|
|
|
|
|
$data[$key] = $this->recursiveKeySort($value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $data;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 16:57:54 +01:00
|
|
|
/**
|
|
|
|
|
* Derive a unique signing key for a license
|
|
|
|
|
*
|
|
|
|
|
* Uses HKDF-like key derivation to create a unique signing key
|
|
|
|
|
* for each license key, preventing cross-license signature attacks.
|
|
|
|
|
*
|
|
|
|
|
* @param string $licenseKey The license key
|
|
|
|
|
* @return string The derived signing key (hex encoded)
|
|
|
|
|
*/
|
|
|
|
|
private function deriveKey(string $licenseKey): string
|
2026-01-26 15:29:57 +01:00
|
|
|
{
|
|
|
|
|
return self::deriveCustomerSecret($licenseKey, $this->serverSecret);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Derive a customer-specific secret from a license key
|
|
|
|
|
*
|
|
|
|
|
* This secret is unique per license and can be shared with the customer
|
|
|
|
|
* to verify signed API responses. Each customer gets their own secret
|
|
|
|
|
* derived from their license key.
|
|
|
|
|
*
|
2026-01-26 17:06:18 +01:00
|
|
|
* Uses RFC 5869 HKDF via PHP's native hash_hkdf() function.
|
|
|
|
|
* Parameters match the client library (SecureLicenseClient):
|
|
|
|
|
* - IKM (input keying material): server_secret
|
|
|
|
|
* - Length: 32 bytes (256 bits for SHA-256)
|
|
|
|
|
* - Info: license_key (context-specific info)
|
|
|
|
|
*
|
2026-01-26 15:29:57 +01:00
|
|
|
* @param string $licenseKey The customer's license key
|
|
|
|
|
* @param string $serverSecret The server's master secret
|
|
|
|
|
* @return string The derived secret (64 hex characters)
|
|
|
|
|
*/
|
|
|
|
|
public static function deriveCustomerSecret(string $licenseKey, string $serverSecret): string
|
2026-01-22 16:57:54 +01:00
|
|
|
{
|
2026-01-26 17:06:18 +01:00
|
|
|
// RFC 5869 HKDF using PHP's native implementation
|
|
|
|
|
// Must match client's ResponseSignature::deriveKey() exactly
|
|
|
|
|
$binaryKey = hash_hkdf('sha256', $serverSecret, 32, $licenseKey);
|
2026-01-26 15:29:57 +01:00
|
|
|
|
2026-01-26 17:06:18 +01:00
|
|
|
return bin2hex($binaryKey);
|
2026-01-26 15:29:57 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the customer secret for a license key using the configured server secret
|
|
|
|
|
*
|
|
|
|
|
* @param string $licenseKey The customer's license key
|
|
|
|
|
* @return string|null The derived secret, or null if server secret is not configured
|
|
|
|
|
*/
|
|
|
|
|
public static function getCustomerSecretForLicense(string $licenseKey): ?string
|
|
|
|
|
{
|
2026-02-01 13:52:57 +01:00
|
|
|
$serverSecret = self::getServerSecret();
|
2026-01-22 16:57:54 +01:00
|
|
|
|
2026-01-26 15:29:57 +01:00
|
|
|
if (empty($serverSecret)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return self::deriveCustomerSecret($licenseKey, $serverSecret);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if response signing is enabled
|
|
|
|
|
*
|
|
|
|
|
* @return bool True if server secret is configured
|
|
|
|
|
*/
|
|
|
|
|
public static function isSigningEnabled(): bool
|
|
|
|
|
{
|
2026-02-01 13:52:57 +01:00
|
|
|
return !empty(self::getServerSecret());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the server secret from constant or environment variable
|
|
|
|
|
*
|
|
|
|
|
* Checks in order:
|
|
|
|
|
* 1. WC_LICENSE_SERVER_SECRET constant (preferred)
|
|
|
|
|
* 2. WC_LICENSE_SERVER_SECRET environment variable (Docker fallback)
|
|
|
|
|
*
|
|
|
|
|
* @return string The server secret, or empty string if not configured
|
|
|
|
|
*/
|
|
|
|
|
public static function getServerSecret(): string
|
|
|
|
|
{
|
|
|
|
|
// First check the constant (standard WordPress configuration)
|
|
|
|
|
if (defined('WC_LICENSE_SERVER_SECRET') && !empty(WC_LICENSE_SERVER_SECRET)) {
|
|
|
|
|
return WC_LICENSE_SERVER_SECRET;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback to environment variable (Docker environments)
|
|
|
|
|
$envSecret = getenv('WC_LICENSE_SERVER_SECRET');
|
|
|
|
|
if ($envSecret !== false && !empty($envSecret)) {
|
|
|
|
|
return $envSecret;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Also check $_ENV and $_SERVER (some PHP configurations)
|
|
|
|
|
if (!empty($_ENV['WC_LICENSE_SERVER_SECRET'])) {
|
|
|
|
|
return $_ENV['WC_LICENSE_SERVER_SECRET'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!empty($_SERVER['WC_LICENSE_SERVER_SECRET'])) {
|
|
|
|
|
return $_SERVER['WC_LICENSE_SERVER_SECRET'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return '';
|
2026-01-22 16:57:54 +01:00
|
|
|
}
|
|
|
|
|
}
|