You've already forked wc-licensed-product-client
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:
@@ -19,6 +19,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- PSR-6 caching support (optional)
|
||||
- PSR dependencies (`psr/log`, `psr/cache`, `psr/http-client`)
|
||||
- PHPUnit test suite with 32 tests covering DTOs, exceptions, and client
|
||||
- `SecureLicenseClient` with response signature verification (HMAC-SHA256)
|
||||
- `ResponseSignature` class for signing and verifying API responses
|
||||
- `StringEncoder` for basic string obfuscation in source code
|
||||
- `IntegrityChecker` for verifying source file integrity
|
||||
- `SignatureException` and `IntegrityException` for security errors
|
||||
- Server implementation documentation (`docs/server-implementation.md`)
|
||||
- Security test suite (34 additional tests)
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
33
README.md
33
README.md
@@ -16,6 +16,7 @@ composer require magdev/wc-licensed-product-client
|
||||
## Features
|
||||
|
||||
- Object-oriented client library
|
||||
- **Secure client with response signature verification (HMAC-SHA256)**
|
||||
- PSR-3 logging support
|
||||
- PSR-6 caching support
|
||||
- PSR-18 HTTP client compatible
|
||||
@@ -23,6 +24,7 @@ composer require magdev/wc-licensed-product-client
|
||||
- License activation on domains
|
||||
- License status checking
|
||||
- Comprehensive exception handling
|
||||
- Code integrity verification
|
||||
- Built on Symfony HttpClient
|
||||
|
||||
## Usage
|
||||
@@ -105,6 +107,37 @@ try {
|
||||
}
|
||||
```
|
||||
|
||||
## Secure Client
|
||||
|
||||
For enhanced security, use `SecureLicenseClient` which verifies response signatures:
|
||||
|
||||
```php
|
||||
use Magdev\WcLicensedProductClient\SecureLicenseClient;
|
||||
use Magdev\WcLicensedProductClient\Security\SignatureException;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
|
||||
$client = new SecureLicenseClient(
|
||||
httpClient: HttpClient::create(),
|
||||
baseUrl: 'https://your-wordpress-site.com',
|
||||
serverSecret: 'shared-secret-with-server', // Must match server configuration
|
||||
);
|
||||
|
||||
try {
|
||||
$licenseInfo = $client->validate('ABCD-1234-EFGH-5678', 'example.com');
|
||||
} catch (SignatureException $e) {
|
||||
// Response signature invalid - possible tampering!
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** The secure client requires the server to sign responses. See [docs/server-implementation.md](docs/server-implementation.md) for server setup instructions.
|
||||
|
||||
### Security Features
|
||||
|
||||
- **Response Signatures**: HMAC-SHA256 verification prevents response tampering
|
||||
- **Timestamp Validation**: Prevents replay attacks (5-minute tolerance)
|
||||
- **Per-License Keys**: Each license has a unique verification key
|
||||
- **Code Integrity**: Optional verification of source file integrity
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite with PHPUnit:
|
||||
|
||||
393
docs/server-implementation.md
Normal file
393
docs/server-implementation.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# 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:
|
||||
|
||||
```php
|
||||
// wp-config.php or secure configuration file
|
||||
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
|
||||
```
|
||||
|
||||
Generate a secure secret:
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```php
|
||||
/**
|
||||
* 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:
|
||||
|
||||
```php
|
||||
/**
|
||||
* 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:
|
||||
|
||||
```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);
|
||||
```
|
||||
|
||||
### Complete WordPress Plugin Example
|
||||
|
||||
```php
|
||||
<?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
|
||||
|
||||
```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
|
||||
|
||||
```php
|
||||
// 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
|
||||
|
||||
```php
|
||||
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:
|
||||
|
||||
```php
|
||||
// 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:
|
||||
|
||||
```php
|
||||
// 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:
|
||||
|
||||
```php
|
||||
// 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:
|
||||
|
||||
```php
|
||||
// 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(),
|
||||
);
|
||||
```
|
||||
302
src/SecureLicenseClient.php
Normal file
302
src/SecureLicenseClient.php
Normal 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;
|
||||
}
|
||||
}
|
||||
138
src/Security/IntegrityChecker.php
Normal file
138
src/Security/IntegrityChecker.php
Normal 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);
|
||||
}
|
||||
}
|
||||
24
src/Security/IntegrityException.php
Normal file
24
src/Security/IntegrityException.php
Normal 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');
|
||||
}
|
||||
}
|
||||
144
src/Security/ResponseSignature.php
Normal file
144
src/Security/ResponseSignature.php
Normal 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;
|
||||
}
|
||||
}
|
||||
18
src/Security/SignatureException.php
Normal file
18
src/Security/SignatureException.php
Normal 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');
|
||||
}
|
||||
}
|
||||
111
src/Security/StringEncoder.php
Normal file
111
src/Security/StringEncoder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
218
tests/Security/IntegrityCheckerTest.php
Normal file
218
tests/Security/IntegrityCheckerTest.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Magdev\WcLicensedProductClient\Tests\Security;
|
||||
|
||||
use Magdev\WcLicensedProductClient\Security\IntegrityChecker;
|
||||
use Magdev\WcLicensedProductClient\Security\IntegrityException;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(IntegrityChecker::class)]
|
||||
#[CoversClass(IntegrityException::class)]
|
||||
final class IntegrityCheckerTest extends TestCase
|
||||
{
|
||||
private string $tempDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tempDir = sys_get_temp_dir() . '/integrity_test_' . uniqid();
|
||||
mkdir($this->tempDir, 0755, true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->removeDirectory($this->tempDir);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPassesForUnmodifiedFiles(): void
|
||||
{
|
||||
$this->createFile('test.php', '<?php echo "hello";');
|
||||
|
||||
$checker = new IntegrityChecker([], $this->tempDir);
|
||||
$hashes = $checker->generateHashes(['test.php']);
|
||||
|
||||
$verifier = new IntegrityChecker($hashes, $this->tempDir);
|
||||
$verifier->verify();
|
||||
|
||||
self::assertTrue($verifier->isValid());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itFailsForModifiedFiles(): void
|
||||
{
|
||||
$this->createFile('test.php', '<?php echo "hello";');
|
||||
|
||||
$checker = new IntegrityChecker([], $this->tempDir);
|
||||
$hashes = $checker->generateHashes(['test.php']);
|
||||
|
||||
// Modify the file
|
||||
$this->createFile('test.php', '<?php echo "world";');
|
||||
|
||||
$verifier = new IntegrityChecker($hashes, $this->tempDir);
|
||||
|
||||
$this->expectException(IntegrityException::class);
|
||||
$this->expectExceptionMessage('Modified file: test.php');
|
||||
|
||||
$verifier->verify();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itFailsForMissingFiles(): void
|
||||
{
|
||||
$this->createFile('test.php', '<?php echo "hello";');
|
||||
|
||||
$checker = new IntegrityChecker([], $this->tempDir);
|
||||
$hashes = $checker->generateHashes(['test.php']);
|
||||
|
||||
// Delete the file
|
||||
unlink($this->tempDir . '/test.php');
|
||||
|
||||
$verifier = new IntegrityChecker($hashes, $this->tempDir);
|
||||
|
||||
$this->expectException(IntegrityException::class);
|
||||
$this->expectExceptionMessage('Missing file: test.php');
|
||||
|
||||
$verifier->verify();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsFailureDetailsInException(): void
|
||||
{
|
||||
$this->createFile('file1.php', 'content1');
|
||||
$this->createFile('file2.php', 'content2');
|
||||
|
||||
$checker = new IntegrityChecker([], $this->tempDir);
|
||||
$hashes = $checker->generateHashes(['file1.php', 'file2.php']);
|
||||
|
||||
// Modify both files
|
||||
$this->createFile('file1.php', 'modified1');
|
||||
$this->createFile('file2.php', 'modified2');
|
||||
|
||||
$verifier = new IntegrityChecker($hashes, $this->tempDir);
|
||||
|
||||
try {
|
||||
$verifier->verify();
|
||||
self::fail('Expected IntegrityException');
|
||||
} catch (IntegrityException $e) {
|
||||
self::assertCount(2, $e->failures);
|
||||
self::assertContains('Modified file: file1.php', $e->failures);
|
||||
self::assertContains('Modified file: file2.php', $e->failures);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIsValidReturnsFalseForModifiedFiles(): void
|
||||
{
|
||||
$this->createFile('test.php', 'original');
|
||||
|
||||
$checker = new IntegrityChecker([], $this->tempDir);
|
||||
$hashes = $checker->generateHashes(['test.php']);
|
||||
|
||||
$this->createFile('test.php', 'modified');
|
||||
|
||||
$verifier = new IntegrityChecker($hashes, $this->tempDir);
|
||||
|
||||
self::assertFalse($verifier->isValid());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGeneratesConsistentHashes(): void
|
||||
{
|
||||
$this->createFile('test.php', '<?php echo "hello";');
|
||||
|
||||
$checker1 = new IntegrityChecker([], $this->tempDir);
|
||||
$checker2 = new IntegrityChecker([], $this->tempDir);
|
||||
|
||||
$hash1 = $checker1->generateHashes(['test.php']);
|
||||
$hash2 = $checker2->generateHashes(['test.php']);
|
||||
|
||||
self::assertSame($hash1, $hash2);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itNormalizesLineEndings(): void
|
||||
{
|
||||
// Create file with Unix line endings
|
||||
$this->createFile('test.php', "line1\nline2\n");
|
||||
|
||||
$checker = new IntegrityChecker([], $this->tempDir);
|
||||
$hash1 = $checker->generateHashes(['test.php']);
|
||||
|
||||
// Create same file with Windows line endings
|
||||
$this->createFile('test.php', "line1\r\nline2\r\n");
|
||||
|
||||
$hash2 = $checker->generateHashes(['test.php']);
|
||||
|
||||
self::assertSame($hash1['test.php'], $hash2['test.php']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGeneratesPhpCode(): void
|
||||
{
|
||||
$this->createFile('test.php', 'content');
|
||||
|
||||
$checker = new IntegrityChecker([], $this->tempDir);
|
||||
$code = $checker->generateHashesAsPhpCode(['test.php']);
|
||||
|
||||
self::assertStringContainsString('[', $code);
|
||||
self::assertStringContainsString(']', $code);
|
||||
self::assertStringContainsString("'test.php'", $code);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsForNonExistentFileInGenerate(): void
|
||||
{
|
||||
$checker = new IntegrityChecker([], $this->tempDir);
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
$checker->generateHashes(['nonexistent.php']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesSubdirectories(): void
|
||||
{
|
||||
mkdir($this->tempDir . '/subdir', 0755, true);
|
||||
$this->createFile('subdir/test.php', 'content');
|
||||
|
||||
$checker = new IntegrityChecker([], $this->tempDir);
|
||||
$hashes = $checker->generateHashes(['subdir/test.php']);
|
||||
|
||||
self::assertArrayHasKey('subdir/test.php', $hashes);
|
||||
|
||||
$verifier = new IntegrityChecker($hashes, $this->tempDir);
|
||||
self::assertTrue($verifier->isValid());
|
||||
}
|
||||
|
||||
private function createFile(string $relativePath, string $content): void
|
||||
{
|
||||
$fullPath = $this->tempDir . '/' . $relativePath;
|
||||
$dir = dirname($fullPath);
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
file_put_contents($fullPath, $content);
|
||||
}
|
||||
|
||||
private function removeDirectory(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = array_diff(scandir($dir), ['.', '..']);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$path = $dir . '/' . $file;
|
||||
is_dir($path) ? $this->removeDirectory($path) : unlink($path);
|
||||
}
|
||||
|
||||
rmdir($dir);
|
||||
}
|
||||
}
|
||||
181
tests/Security/ResponseSignatureTest.php
Normal file
181
tests/Security/ResponseSignatureTest.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Magdev\WcLicensedProductClient\Tests\Security;
|
||||
|
||||
use Magdev\WcLicensedProductClient\Security\ResponseSignature;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(ResponseSignature::class)]
|
||||
final class ResponseSignatureTest extends TestCase
|
||||
{
|
||||
private const SECRET_KEY = 'test-secret-key-for-unit-tests';
|
||||
private const LICENSE_KEY = 'ABCD-1234-EFGH-5678';
|
||||
private const SERVER_SECRET = 'server-master-secret';
|
||||
|
||||
#[Test]
|
||||
public function itSignsAndVerifiesResponse(): void
|
||||
{
|
||||
$signature = new ResponseSignature(self::SECRET_KEY);
|
||||
$timestamp = time();
|
||||
$data = ['valid' => true, 'license' => ['product_id' => 123]];
|
||||
|
||||
$sig = $signature->sign($data, $timestamp);
|
||||
|
||||
self::assertTrue($signature->verify($data, $sig, $timestamp));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsInvalidSignature(): void
|
||||
{
|
||||
$signature = new ResponseSignature(self::SECRET_KEY);
|
||||
$timestamp = time();
|
||||
$data = ['valid' => true];
|
||||
|
||||
self::assertFalse($signature->verify($data, 'invalid-signature', $timestamp));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsTamperedData(): void
|
||||
{
|
||||
$signature = new ResponseSignature(self::SECRET_KEY);
|
||||
$timestamp = time();
|
||||
$originalData = ['valid' => true];
|
||||
$tamperedData = ['valid' => false];
|
||||
|
||||
$sig = $signature->sign($originalData, $timestamp);
|
||||
|
||||
self::assertFalse($signature->verify($tamperedData, $sig, $timestamp));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRejectsExpiredTimestamp(): void
|
||||
{
|
||||
$signature = new ResponseSignature(self::SECRET_KEY, timestampTolerance: 60);
|
||||
$oldTimestamp = time() - 120; // 2 minutes ago
|
||||
$data = ['valid' => true];
|
||||
|
||||
$sig = $signature->sign($data, $oldTimestamp);
|
||||
|
||||
self::assertFalse($signature->verify($data, $sig, $oldTimestamp));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAcceptsTimestampWithinTolerance(): void
|
||||
{
|
||||
$signature = new ResponseSignature(self::SECRET_KEY, timestampTolerance: 300);
|
||||
$recentTimestamp = time() - 60; // 1 minute ago
|
||||
$data = ['valid' => true];
|
||||
|
||||
$sig = $signature->sign($data, $recentTimestamp);
|
||||
|
||||
self::assertTrue($signature->verify($data, $sig, $recentTimestamp));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDerivesUniqueKeysPerLicense(): void
|
||||
{
|
||||
$key1 = ResponseSignature::deriveKey('LICENSE-001', self::SERVER_SECRET);
|
||||
$key2 = ResponseSignature::deriveKey('LICENSE-002', self::SERVER_SECRET);
|
||||
|
||||
self::assertNotSame($key1, $key2);
|
||||
self::assertSame(64, strlen($key1)); // SHA256 hex = 64 chars
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itProducesDeterministicKeys(): void
|
||||
{
|
||||
$key1 = ResponseSignature::deriveKey(self::LICENSE_KEY, self::SERVER_SECRET);
|
||||
$key2 = ResponseSignature::deriveKey(self::LICENSE_KEY, self::SERVER_SECRET);
|
||||
|
||||
self::assertSame($key1, $key2);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesFromLicenseKey(): void
|
||||
{
|
||||
$signature = ResponseSignature::fromLicenseKey(self::LICENSE_KEY, self::SERVER_SECRET);
|
||||
$timestamp = time();
|
||||
$data = ['valid' => true];
|
||||
|
||||
$sig = $signature->sign($data, $timestamp);
|
||||
|
||||
// Verify with same derived key
|
||||
$signature2 = ResponseSignature::fromLicenseKey(self::LICENSE_KEY, self::SERVER_SECRET);
|
||||
self::assertTrue($signature2->verify($data, $sig, $timestamp));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itExtractsSignatureFromHeaders(): void
|
||||
{
|
||||
$headers = [
|
||||
'X-License-Signature' => 'abc123',
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
|
||||
self::assertSame('abc123', ResponseSignature::extractSignature($headers));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itExtractsTimestampFromHeaders(): void
|
||||
{
|
||||
$headers = [
|
||||
'X-License-Timestamp' => '1706000000',
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
|
||||
self::assertSame(1706000000, ResponseSignature::extractTimestamp($headers));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsNullForMissingHeaders(): void
|
||||
{
|
||||
$headers = ['Content-Type' => 'application/json'];
|
||||
|
||||
self::assertNull(ResponseSignature::extractSignature($headers));
|
||||
self::assertNull(ResponseSignature::extractTimestamp($headers));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesLowercaseHeaders(): void
|
||||
{
|
||||
$headers = [
|
||||
'x-license-signature' => 'abc123',
|
||||
'x-license-timestamp' => '1706000000',
|
||||
];
|
||||
|
||||
self::assertSame('abc123', ResponseSignature::extractSignature($headers));
|
||||
self::assertSame(1706000000, ResponseSignature::extractTimestamp($headers));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itProducesConsistentSignaturesForSameData(): void
|
||||
{
|
||||
$signature = new ResponseSignature(self::SECRET_KEY);
|
||||
$timestamp = 1706000000;
|
||||
$data = ['b' => 2, 'a' => 1]; // Unsorted
|
||||
|
||||
$sig1 = $signature->sign($data, $timestamp);
|
||||
$sig2 = $signature->sign($data, $timestamp);
|
||||
|
||||
self::assertSame($sig1, $sig2);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSortsKeysForConsistentSignatures(): void
|
||||
{
|
||||
$signature = new ResponseSignature(self::SECRET_KEY);
|
||||
$timestamp = 1706000000;
|
||||
|
||||
$data1 = ['a' => 1, 'b' => 2];
|
||||
$data2 = ['b' => 2, 'a' => 1];
|
||||
|
||||
$sig1 = $signature->sign($data1, $timestamp);
|
||||
$sig2 = $signature->sign($data2, $timestamp);
|
||||
|
||||
self::assertSame($sig1, $sig2);
|
||||
}
|
||||
}
|
||||
148
tests/Security/StringEncoderTest.php
Normal file
148
tests/Security/StringEncoderTest.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Magdev\WcLicensedProductClient\Tests\Security;
|
||||
|
||||
use Magdev\WcLicensedProductClient\Security\StringEncoder;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(StringEncoder::class)]
|
||||
final class StringEncoderTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itEncodesAndDecodesStrings(): void
|
||||
{
|
||||
$encoder = new StringEncoder();
|
||||
$original = 'Hello, World!';
|
||||
|
||||
$encoded = $encoder->encode($original);
|
||||
$decoded = $encoder->decode($encoded);
|
||||
|
||||
self::assertSame($original, $decoded);
|
||||
self::assertNotSame($original, $encoded);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itEncodesApiPaths(): void
|
||||
{
|
||||
$encoder = new StringEncoder();
|
||||
$path = '/wp-json/wc-licensed-product/v1';
|
||||
|
||||
$encoded = $encoder->encode($path);
|
||||
$decoded = $encoder->decode($encoded);
|
||||
|
||||
self::assertSame($path, $decoded);
|
||||
self::assertStringNotContainsString('wp-json', $encoded);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itEncodesEndpointNames(): void
|
||||
{
|
||||
$encoder = new StringEncoder();
|
||||
$endpoints = ['validate', 'status', 'activate'];
|
||||
|
||||
foreach ($endpoints as $endpoint) {
|
||||
$encoded = $encoder->encode($endpoint);
|
||||
$decoded = $encoder->decode($encoded);
|
||||
|
||||
self::assertSame($endpoint, $decoded);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesEmptyString(): void
|
||||
{
|
||||
$encoder = new StringEncoder();
|
||||
|
||||
$encoded = $encoder->encode('');
|
||||
$decoded = $encoder->decode($encoded);
|
||||
|
||||
self::assertSame('', $decoded);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesUnicodeStrings(): void
|
||||
{
|
||||
$encoder = new StringEncoder();
|
||||
$original = 'Ümläut and émojis 🔐';
|
||||
|
||||
$encoded = $encoder->encode($original);
|
||||
$decoded = $encoder->decode($encoded);
|
||||
|
||||
self::assertSame($original, $decoded);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itProducesDifferentOutputWithDifferentKeys(): void
|
||||
{
|
||||
$encoder1 = new StringEncoder('key1');
|
||||
$encoder2 = new StringEncoder('key2');
|
||||
$original = 'test string';
|
||||
|
||||
$encoded1 = $encoder1->encode($original);
|
||||
$encoded2 = $encoder2->encode($original);
|
||||
|
||||
self::assertNotSame($encoded1, $encoded2);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRequiresSameKeyForDecoding(): void
|
||||
{
|
||||
$encoder1 = new StringEncoder('key1');
|
||||
$encoder2 = new StringEncoder('key2');
|
||||
$original = 'test string';
|
||||
|
||||
$encoded = $encoder1->encode($original);
|
||||
$decodedWithWrongKey = $encoder2->decode($encoded);
|
||||
|
||||
self::assertNotSame($original, $decodedWithWrongKey);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGeneratesEncodedConstants(): void
|
||||
{
|
||||
$encoder = new StringEncoder();
|
||||
$constants = [
|
||||
'API_PATH' => '/wp-json/wc-licensed-product/v1',
|
||||
'VALIDATE' => 'validate',
|
||||
'STATUS' => 'status',
|
||||
];
|
||||
|
||||
$encoded = $encoder->generateEncodedConstants($constants);
|
||||
|
||||
self::assertCount(3, $encoded);
|
||||
self::assertArrayHasKey('API_PATH', $encoded);
|
||||
self::assertArrayHasKey('VALIDATE', $encoded);
|
||||
self::assertArrayHasKey('STATUS', $encoded);
|
||||
|
||||
// Verify all can be decoded back
|
||||
foreach ($constants as $name => $value) {
|
||||
self::assertSame($value, $encoder->decode($encoded[$name]));
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsOnInvalidEncodedString(): void
|
||||
{
|
||||
$encoder = new StringEncoder();
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
$encoder->decode('not-valid-base64!!!');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesLongStrings(): void
|
||||
{
|
||||
$encoder = new StringEncoder();
|
||||
$original = str_repeat('A', 10000);
|
||||
|
||||
$encoded = $encoder->encode($original);
|
||||
$decoded = $encoder->decode($encoded);
|
||||
|
||||
self::assertSame($original, $decoded);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user