Files
wc-licensed-product/src/Api/ResponseSigner.php

199 lines
5.8 KiB
PHP
Raw Normal View History

<?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()
{
$this->serverSecret = defined('WC_LICENSE_SERVER_SECRET')
? WC_LICENSE_SERVER_SECRET
: '';
}
/**
* 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')
|| str_starts_with($route, '/wc-licensed-product/v1/activate');
}
/**
* 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);
// Recursively sort keys for consistent ordering (required by client implementation)
$data = $this->recursiveKeySort($data);
// 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,
];
}
/**
* 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;
}
/**
* 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
{
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.
*
* @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
{
// HKDF-like key derivation
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
return hash_hmac('sha256', $prk . "\x01", $serverSecret);
}
/**
* 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
{
$serverSecret = defined('WC_LICENSE_SERVER_SECRET') ? WC_LICENSE_SERVER_SECRET : '';
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
{
return defined('WC_LICENSE_SERVER_SECRET') && !empty(WC_LICENSE_SERVER_SECRET);
}
}