Add security layer with response signature verification

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>
This commit is contained in:
2026-01-22 16:16:59 +01:00
parent af735df260
commit e87a60926b
12 changed files with 1717 additions and 0 deletions

302
src/SecureLicenseClient.php Normal file
View File

@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient;
use Magdev\WcLicensedProductClient\Dto\ActivationResult;
use Magdev\WcLicensedProductClient\Dto\LicenseInfo;
use Magdev\WcLicensedProductClient\Dto\LicenseStatus;
use Magdev\WcLicensedProductClient\Exception\LicenseException;
use Magdev\WcLicensedProductClient\Security\IntegrityChecker;
use Magdev\WcLicensedProductClient\Security\IntegrityException;
use Magdev\WcLicensedProductClient\Security\ResponseSignature;
use Magdev\WcLicensedProductClient\Security\SignatureException;
use Magdev\WcLicensedProductClient\Security\StringEncoder;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Secure license client with response signature verification.
*
* This client extends the basic LicenseClient with security features:
* - Response signature verification (HMAC)
* - Code integrity checking
* - Obfuscated API paths (optional)
*
* IMPORTANT: This client requires the server to sign responses.
* See docs/server-implementation.md for server setup instructions.
*/
final class SecureLicenseClient implements LicenseClientInterface
{
private const CACHE_TTL = 300;
private readonly LoggerInterface $logger;
private readonly StringEncoder $encoder;
private readonly string $apiPath;
// Encoded API path segments (decoded at runtime)
private const ENCODED_API_PATH = 'MR4xBi8rPDUcHhk2EQ0TICsvMBo9Mw0HJRE='; // /wp-json/wc-licensed-product/v1
private const ENCODED_VALIDATE = 'Egs4Oz4HMgE='; // validate
private const ENCODED_STATUS = 'NwgqKAcZ'; // status
private const ENCODED_ACTIVATE = 'Jggxfg4MEws='; // activate
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $baseUrl,
private readonly string $serverSecret,
?LoggerInterface $logger = null,
private readonly ?CacheItemPoolInterface $cache = null,
private readonly int $cacheTtl = self::CACHE_TTL,
private readonly bool $verifyIntegrity = false,
?StringEncoder $encoder = null,
) {
$this->logger = $logger ?? new NullLogger();
$this->encoder = $encoder ?? new StringEncoder();
$this->apiPath = $this->encoder->decode(self::ENCODED_API_PATH);
if ($this->verifyIntegrity) {
$this->checkIntegrity();
}
}
public function validate(string $licenseKey, string $domain): LicenseInfo
{
$cacheKey = $this->buildCacheKey('validate', $licenseKey, $domain);
if ($this->cache !== null) {
$item = $this->cache->getItem($cacheKey);
if ($item->isHit()) {
$this->logger->debug('License validation cache hit', ['domain' => $domain]);
return $item->get();
}
}
$this->logger->info('Validating license', ['domain' => $domain]);
$endpoint = $this->encoder->decode(self::ENCODED_VALIDATE);
$response = $this->secureRequest($endpoint, [
'license_key' => $licenseKey,
'domain' => $domain,
], $licenseKey);
$result = LicenseInfo::fromArray($response['license']);
if ($this->cache !== null) {
$item = $this->cache->getItem($cacheKey);
$item->set($result);
$item->expiresAfter($this->cacheTtl);
$this->cache->save($item);
}
$this->logger->info('License validated successfully', [
'domain' => $domain,
'product_id' => $result->productId,
]);
return $result;
}
public function status(string $licenseKey): LicenseStatus
{
$cacheKey = $this->buildCacheKey('status', $licenseKey);
if ($this->cache !== null) {
$item = $this->cache->getItem($cacheKey);
if ($item->isHit()) {
$this->logger->debug('License status cache hit');
return $item->get();
}
}
$this->logger->info('Checking license status');
$endpoint = $this->encoder->decode(self::ENCODED_STATUS);
$response = $this->secureRequest($endpoint, [
'license_key' => $licenseKey,
], $licenseKey);
$result = LicenseStatus::fromArray($response);
if ($this->cache !== null) {
$item = $this->cache->getItem($cacheKey);
$item->set($result);
$item->expiresAfter($this->cacheTtl);
$this->cache->save($item);
}
$this->logger->info('License status retrieved', [
'status' => $result->status->value,
'valid' => $result->valid,
]);
return $result;
}
public function activate(string $licenseKey, string $domain): ActivationResult
{
$this->logger->info('Activating license', ['domain' => $domain]);
$endpoint = $this->encoder->decode(self::ENCODED_ACTIVATE);
$response = $this->secureRequest($endpoint, [
'license_key' => $licenseKey,
'domain' => $domain,
], $licenseKey);
$result = ActivationResult::fromArray($response);
// Invalidate related cache entries after activation
if ($this->cache !== null) {
$this->cache->deleteItem($this->buildCacheKey('validate', $licenseKey, $domain));
$this->cache->deleteItem($this->buildCacheKey('status', $licenseKey));
}
$this->logger->info('License activation completed', [
'domain' => $domain,
'success' => $result->success,
]);
return $result;
}
/**
* @throws LicenseException
* @throws SignatureException
*/
private function secureRequest(string $endpoint, array $payload, string $licenseKey): array
{
$url = rtrim($this->baseUrl, '/') . $this->apiPath . '/' . $endpoint;
try {
$response = $this->httpClient->request('POST', $url, [
'json' => $payload,
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
]);
$statusCode = $response->getStatusCode();
$data = $response->toArray(false);
$headers = $this->normalizeHeaders($response->getHeaders(false));
// Verify response signature
$this->verifySignature($data, $headers, $licenseKey);
if ($statusCode >= 400) {
$this->logger->warning('License API error response', [
'endpoint' => $endpoint,
'status_code' => $statusCode,
'error' => $data['error'] ?? 'unknown',
]);
throw LicenseException::fromApiResponse($data, $statusCode);
}
return $data;
} catch (LicenseException | SignatureException $e) {
throw $e;
} catch (\Throwable $e) {
$this->logger->error('License API request failed', [
'endpoint' => $endpoint,
'error' => $e->getMessage(),
]);
throw new LicenseException(
'Failed to communicate with license server: ' . $e->getMessage(),
null,
0,
$e
);
}
}
private function verifySignature(array $data, array $headers, string $licenseKey): void
{
$signature = ResponseSignature::extractSignature($headers);
$timestamp = ResponseSignature::extractTimestamp($headers);
if ($signature === null || $timestamp === null) {
$this->logger->warning('Response missing signature headers');
throw new SignatureException('Response is not signed by the server');
}
$verifier = ResponseSignature::fromLicenseKey($licenseKey, $this->serverSecret);
if (!$verifier->verify($data, $signature, $timestamp)) {
$this->logger->warning('Response signature verification failed');
throw new SignatureException();
}
$this->logger->debug('Response signature verified');
}
private function normalizeHeaders(array $headers): array
{
$normalized = [];
foreach ($headers as $name => $values) {
// HTTP client returns arrays of values; take the first one
$normalized[$name] = is_array($values) ? ($values[0] ?? '') : $values;
}
return $normalized;
}
private function checkIntegrity(): void
{
$criticalFiles = [
'src/SecureLicenseClient.php',
'src/Security/ResponseSignature.php',
'src/Security/SignatureException.php',
];
// In production, these hashes would be embedded
// This is a placeholder showing the mechanism
$hashes = $this->getExpectedHashes();
if (empty($hashes)) {
$this->logger->debug('Integrity check skipped: no hashes configured');
return;
}
$basePath = dirname(__DIR__);
$checker = new IntegrityChecker($hashes, $basePath);
try {
$checker->verify();
$this->logger->debug('Integrity check passed');
} catch (IntegrityException $e) {
$this->logger->critical('Integrity check failed', [
'failures' => $e->failures,
]);
throw $e;
}
}
/**
* Get expected file hashes for integrity checking.
*
* In a production obfuscated build, these would be hardcoded.
* Override this method or inject hashes via configuration.
*
* @return array<string, string>
*/
protected function getExpectedHashes(): array
{
// Placeholder: in production, return actual hashes
return [];
}
private function buildCacheKey(string $operation, string $licenseKey, ?string $domain = null): string
{
$key = 'wc_license_secure_' . $operation . '_' . hash('sha256', $licenseKey);
if ($domain !== null) {
$key .= '_' . hash('sha256', $domain);
}
return $key;
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Security;
use Magdev\WcLicensedProductClient\Exception\LicenseException;
/**
* Verifies the integrity of critical source files.
*
* Detects if source code has been modified by comparing file hashes
* against known good values. This helps detect tampering attempts.
*
* Usage:
* 1. During build/release, generate hashes of critical files
* 2. Store hashes in a signed manifest or embedded in code
* 3. At runtime, verify files haven't been modified
*/
final class IntegrityChecker
{
private const ALGORITHM = 'sha256';
/**
* @param array<string, string> $expectedHashes Map of relative file paths to expected SHA256 hashes
* @param string $basePath Base path for resolving relative file paths
*/
public function __construct(
private readonly array $expectedHashes,
private readonly string $basePath,
) {
}
/**
* Verify all registered files have not been modified.
*
* @throws IntegrityException If any file has been modified or is missing
*/
public function verify(): void
{
$failures = [];
foreach ($this->expectedHashes as $relativePath => $expectedHash) {
$fullPath = $this->basePath . '/' . $relativePath;
if (!file_exists($fullPath)) {
$failures[] = "Missing file: {$relativePath}";
continue;
}
$actualHash = $this->hashFile($fullPath);
if (!hash_equals($expectedHash, $actualHash)) {
$failures[] = "Modified file: {$relativePath}";
}
}
if (!empty($failures)) {
throw new IntegrityException(
'Integrity check failed: ' . implode(', ', $failures),
$failures
);
}
}
/**
* Check if all files pass integrity verification.
*
* @return bool True if all files are intact
*/
public function isValid(): bool
{
try {
$this->verify();
return true;
} catch (IntegrityException) {
return false;
}
}
/**
* Generate hashes for a list of files.
*
* Use this during build/release to create the hash manifest.
*
* @param array<string> $relativePaths List of file paths relative to base path
* @return array<string, string> Map of relative paths to SHA256 hashes
*/
public function generateHashes(array $relativePaths): array
{
$hashes = [];
foreach ($relativePaths as $relativePath) {
$fullPath = $this->basePath . '/' . $relativePath;
if (!file_exists($fullPath)) {
throw new \InvalidArgumentException("File not found: {$relativePath}");
}
$hashes[$relativePath] = $this->hashFile($fullPath);
}
return $hashes;
}
/**
* Generate PHP code for embedding hashes in source.
*
* @param array<string> $relativePaths Files to hash
* @return string PHP array code that can be embedded in source
*/
public function generateHashesAsPhpCode(array $relativePaths): string
{
$hashes = $this->generateHashes($relativePaths);
$lines = ["["];
foreach ($hashes as $path => $hash) {
$lines[] = " '{$path}' => '{$hash}',";
}
$lines[] = "]";
return implode("\n", $lines);
}
private function hashFile(string $path): string
{
$content = file_get_contents($path);
if ($content === false) {
throw new \RuntimeException("Cannot read file: {$path}");
}
// Normalize line endings for consistent hashing across platforms
$content = str_replace(["\r\n", "\r"], "\n", $content);
return hash(self::ALGORITHM, $content);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Security;
use Magdev\WcLicensedProductClient\Exception\LicenseException;
/**
* Exception thrown when code integrity verification fails.
*/
final class IntegrityException extends LicenseException
{
/**
* @param string $message Error message
* @param array<string> $failures List of specific failures
*/
public function __construct(
string $message,
public readonly array $failures = [],
) {
parent::__construct($message, 'integrity_check_failed');
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Security;
/**
* Verifies HMAC signatures on API responses to prevent tampering.
*
* The server must sign responses using the same algorithm and key derivation.
* This ensures that even if an attacker intercepts or fakes a response,
* it cannot be accepted without a valid signature.
*
* @see docs/server-implementation.md for server-side implementation details
*/
final class ResponseSignature
{
private const ALGORITHM = 'sha256';
private const SIGNATURE_HEADER = 'X-License-Signature';
private const TIMESTAMP_HEADER = 'X-License-Timestamp';
private const TIMESTAMP_TOLERANCE = 300; // 5 minutes
public function __construct(
private readonly string $secretKey,
private readonly int $timestampTolerance = self::TIMESTAMP_TOLERANCE,
) {
}
/**
* Create a signature instance using a key derived from the license key.
*
* This ensures each license has a unique verification key, making it
* impossible to reuse signatures from one license for another.
*/
public static function fromLicenseKey(string $licenseKey, string $serverSecret): self
{
$derivedKey = self::deriveKey($licenseKey, $serverSecret);
return new self($derivedKey);
}
/**
* Verify a response signature.
*
* @param array $responseData The decoded JSON response body
* @param string $signature The signature from X-License-Signature header
* @param int $timestamp The timestamp from X-License-Timestamp header
* @return bool True if signature is valid
*/
public function verify(array $responseData, string $signature, int $timestamp): bool
{
// Check timestamp is within tolerance (prevents replay attacks)
if (!$this->isTimestampValid($timestamp)) {
return false;
}
$expectedSignature = $this->sign($responseData, $timestamp);
return hash_equals($expectedSignature, $signature);
}
/**
* Generate a signature for response data.
*
* Used by the server to sign responses. Included here for documentation
* and testing purposes.
*
* @param array $responseData The response body to sign
* @param int $timestamp Unix timestamp of the response
* @return string The HMAC signature (hex encoded)
*/
public function sign(array $responseData, int $timestamp): string
{
$payload = $this->buildSignaturePayload($responseData, $timestamp);
return hash_hmac(self::ALGORITHM, $payload, $this->secretKey);
}
/**
* Extract signature from response headers.
*/
public static function extractSignature(array $headers): ?string
{
return $headers[self::SIGNATURE_HEADER] ?? $headers[strtolower(self::SIGNATURE_HEADER)] ?? null;
}
/**
* Extract timestamp from response headers.
*/
public static function extractTimestamp(array $headers): ?int
{
$value = $headers[self::TIMESTAMP_HEADER] ?? $headers[strtolower(self::TIMESTAMP_HEADER)] ?? null;
return $value !== null ? (int) $value : null;
}
/**
* Get the header name for the signature.
*/
public static function getSignatureHeaderName(): string
{
return self::SIGNATURE_HEADER;
}
/**
* Get the header name for the timestamp.
*/
public static function getTimestampHeaderName(): string
{
return self::TIMESTAMP_HEADER;
}
/**
* Derive a unique key from the license key and server secret.
*
* Uses HKDF-like key derivation to create a unique key per license.
*/
public static function deriveKey(string $licenseKey, string $serverSecret): string
{
// Use HKDF expansion with license key as info
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
return hash_hmac('sha256', $prk . "\x01", $serverSecret);
}
private function buildSignaturePayload(array $responseData, int $timestamp): string
{
// Sort keys for consistent ordering
ksort($responseData);
// Create deterministic JSON representation
$jsonBody = json_encode($responseData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
// Combine timestamp and body for signature
return $timestamp . ':' . $jsonBody;
}
private function isTimestampValid(int $timestamp): bool
{
$now = time();
$diff = abs($now - $timestamp);
return $diff <= $this->timestampTolerance;
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Security;
use Magdev\WcLicensedProductClient\Exception\LicenseException;
/**
* Exception thrown when response signature verification fails.
*/
final class SignatureException extends LicenseException
{
public function __construct(string $message = 'Response signature verification failed')
{
parent::__construct($message, 'signature_invalid');
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Security;
/**
* Simple string encoding to obfuscate sensitive strings in source code.
*
* WARNING: This is NOT encryption. It only provides obfuscation to prevent
* casual inspection. A determined attacker can easily reverse this.
*
* Use this for:
* - API endpoint paths
* - Field names
* - Error messages
*
* Do NOT use this for:
* - Passwords or secrets
* - License keys
* - Any data requiring actual security
*
* The real security comes from ResponseSignature verification.
*/
final class StringEncoder
{
private const DEFAULT_KEY = 'wc_license_client_v1';
public function __construct(
private readonly string $key = self::DEFAULT_KEY,
) {
}
/**
* Encode a string for storage in source code.
*
* @param string $plaintext The string to encode
* @return string Base64-encoded obfuscated string
*/
public function encode(string $plaintext): string
{
$keyBytes = $this->expandKey(strlen($plaintext));
$encoded = '';
for ($i = 0; $i < strlen($plaintext); $i++) {
$encoded .= chr(ord($plaintext[$i]) ^ ord($keyBytes[$i]));
}
return base64_encode($encoded);
}
/**
* Decode an obfuscated string at runtime.
*
* @param string $encoded The base64-encoded obfuscated string
* @return string The original plaintext
*/
public function decode(string $encoded): string
{
$decoded = base64_decode($encoded, true);
if ($decoded === false) {
throw new \InvalidArgumentException('Invalid encoded string');
}
$keyBytes = $this->expandKey(strlen($decoded));
$plaintext = '';
for ($i = 0; $i < strlen($decoded); $i++) {
$plaintext .= chr(ord($decoded[$i]) ^ ord($keyBytes[$i]));
}
return $plaintext;
}
/**
* Generate encoded constants for use in source code.
*
* This is a helper method for developers to generate encoded strings
* that can be embedded in the obfuscated version of the client.
*
* @param array<string, string> $strings Map of constant names to values
* @return array<string, string> Map of constant names to encoded values
*/
public function generateEncodedConstants(array $strings): array
{
$result = [];
foreach ($strings as $name => $value) {
$result[$name] = $this->encode($value);
}
return $result;
}
/**
* Expand the key to match the required length using a simple KDF.
*/
private function expandKey(int $length): string
{
$expanded = '';
$counter = 0;
while (strlen($expanded) < $length) {
$expanded .= hash('sha256', $this->key . pack('N', $counter), true);
$counter++;
}
return substr($expanded, 0, $length);
}
}