You've already forked wc-licensed-product-client
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fc838ada7 | |||
| a11aa4260a | |||
| 8062e1be77 | |||
| 64d215cb26 | |||
| fa748d61d3 | |||
| 9f513a819e | |||
| c2cb1814de | |||
| a3a957914f | |||
| da84bbad43 |
28
CHANGELOG.md
28
CHANGELOG.md
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-01-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- SSRF protection with URL validation and private IP range blocking
|
||||||
|
- `allowInsecureHttp` constructor parameter for development environments
|
||||||
|
- Input validation in all DTO `fromArray()` methods
|
||||||
|
- DateTime exception handling in DTOs
|
||||||
|
- Recursive key sorting in `ResponseSignature` for nested objects
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Key derivation now uses RFC 5869 compliant `hash_hkdf()` instead of custom HMAC
|
||||||
|
- Exception messages sanitized to prevent information disclosure
|
||||||
|
- Header normalization treats empty values as null
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- JSON encoding error handling in `ResponseSignature::buildSignaturePayload()`
|
||||||
|
- Header normalization null risk in `SecureLicenseClient`
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Comprehensive security audit performed
|
||||||
|
- SSRF vulnerability mitigated
|
||||||
|
- Information disclosure in error messages fixed
|
||||||
|
- Improved cryptographic key derivation
|
||||||
|
|
||||||
## [0.1.0] - 2026-01-22
|
## [0.1.0] - 2026-01-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
70
CLAUDE.md
70
CLAUDE.md
@@ -29,6 +29,14 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
|
|||||||
|
|
||||||
No known bugs at the moment
|
No known bugs at the moment
|
||||||
|
|
||||||
|
### Version 0.2.1
|
||||||
|
|
||||||
|
No pending tasks at the moment.
|
||||||
|
|
||||||
|
### Version 0.3.0
|
||||||
|
|
||||||
|
No pending tasks at the moment.
|
||||||
|
|
||||||
## Technical Stack
|
## Technical Stack
|
||||||
|
|
||||||
- **Language:** PHP 8.3.x
|
- **Language:** PHP 8.3.x
|
||||||
@@ -152,3 +160,65 @@ When editing CLAUDE.md or other markdown files, follow these rules to avoid lint
|
|||||||
- IntegrityChecker normalizes line endings for cross-platform hash consistency
|
- IntegrityChecker normalizes line endings for cross-platform hash consistency
|
||||||
- StringEncoder uses XOR with expanded key for simple obfuscation (not encryption)
|
- StringEncoder uses XOR with expanded key for simple obfuscation (not encryption)
|
||||||
- PHPUnit 11 uses PHP 8 attributes (`#[Test]`, `#[CoversClass]`) instead of annotations
|
- PHPUnit 11 uses PHP 8 attributes (`#[Test]`, `#[CoversClass]`) instead of annotations
|
||||||
|
- OpenAPI spec (tmp/openapi.json) updated to v0.3.2 with signature header definitions
|
||||||
|
|
||||||
|
### 2026-01-23 - Client Documentation
|
||||||
|
|
||||||
|
**Completed:**
|
||||||
|
|
||||||
|
- Created comprehensive `docs/client-implementation.md` documentation
|
||||||
|
- Documented all classes: LicenseClient, SecureLicenseClient, DTOs, Exceptions, Security classes
|
||||||
|
- Added integration guides for: Basic PHP, WordPress plugins, Laravel, Symfony
|
||||||
|
- Documented constructor parameters, method signatures, and return types
|
||||||
|
- Added complete exception hierarchy reference with error codes
|
||||||
|
- Included best practices section for production use
|
||||||
|
- Added API reference with endpoints and request/response formats
|
||||||
|
- Added troubleshooting section for common issues
|
||||||
|
- Updated README.md with documentation links section
|
||||||
|
|
||||||
|
**Learnings:**
|
||||||
|
|
||||||
|
- Client documentation complements server documentation for complete integration guide
|
||||||
|
- Integration examples for major PHP frameworks help adoption
|
||||||
|
- Error code mapping to exception classes aids programmatic error handling
|
||||||
|
|
||||||
|
### 2026-01-24 - Security Audit and Fixes
|
||||||
|
|
||||||
|
**Completed:**
|
||||||
|
|
||||||
|
- Performed comprehensive security audit of entire codebase
|
||||||
|
- Fixed JSON encoding error handling in `ResponseSignature::buildSignaturePayload()`
|
||||||
|
- Sanitized exception messages in both client classes to prevent information disclosure
|
||||||
|
- Fixed header normalization to treat empty values as null in `SecureLicenseClient`
|
||||||
|
- Added SSRF protection with URL validation and private IP range blocking
|
||||||
|
- Replaced custom key derivation with RFC 5869 compliant `hash_hkdf()`
|
||||||
|
- Added input validation in all DTO `fromArray()` methods
|
||||||
|
- Added DateTime exception handling in DTOs to prevent uncaught exceptions
|
||||||
|
- Added new `allowInsecureHttp` constructor parameter for development environments
|
||||||
|
|
||||||
|
**Learnings:**
|
||||||
|
|
||||||
|
- Security audit identified 7 fixable issues across critical, high, and medium priority
|
||||||
|
- `hash_hkdf()` is PHP's native RFC 5869 implementation - prefer it over custom HKDF
|
||||||
|
- SSRF protection requires: URL scheme validation, private IP blocking, DNS resolution checks
|
||||||
|
- Exception messages should never expose internal details to end users
|
||||||
|
- DTO validation should check both existence (`isset`) and type (`is_int`, `is_bool`, etc.)
|
||||||
|
- Empty header values should be treated as missing (null) not empty strings
|
||||||
|
- Constructor parameters added: `allowInsecureHttp` for HTTP on non-localhost in dev mode
|
||||||
|
- Private IP ranges to block: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, 0.0.0.0/8
|
||||||
|
|
||||||
|
### 2026-01-26 - Server Implementation Alignment
|
||||||
|
|
||||||
|
**Completed:**
|
||||||
|
|
||||||
|
- Verified client implementation against server documentation
|
||||||
|
- Updated server docs to use RFC 5869 `hash_hkdf()` for key derivation (matching client)
|
||||||
|
- Added recursive key sorting (`sortKeysRecursive()`) to client `ResponseSignature`
|
||||||
|
- Client and server now use identical signature algorithms
|
||||||
|
|
||||||
|
**Learnings:**
|
||||||
|
|
||||||
|
- Server and client must use identical key derivation and JSON canonicalization
|
||||||
|
- Recursive key sorting is essential for nested objects like the `license` object in validate responses
|
||||||
|
- When updating cryptographic implementations, both client and server documentation must be aligned
|
||||||
|
- The remote server documentation URL was 404 - local `docs/server-implementation.md` is the source of truth
|
||||||
|
|||||||
@@ -138,6 +138,13 @@ try {
|
|||||||
- **Per-License Keys**: Each license has a unique verification key
|
- **Per-License Keys**: Each license has a unique verification key
|
||||||
- **Code Integrity**: Optional verification of source file integrity
|
- **Code Integrity**: Optional verification of source file integrity
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
For detailed implementation guides, see:
|
||||||
|
|
||||||
|
- [Client Implementation Guide](docs/client-implementation.md) - Complete guide for integrating this client into existing projects
|
||||||
|
- [Server Implementation Guide](docs/server-implementation.md) - How to set up response signing on the server
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Run the test suite with PHPUnit:
|
Run the test suite with PHPUnit:
|
||||||
|
|||||||
1018
docs/client-implementation.md
Normal file
1018
docs/client-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,28 @@
|
|||||||
# Server-Side Response Signing Implementation
|
# Server-Side Implementation Guide
|
||||||
|
|
||||||
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`.
|
This document describes how to implement the WooCommerce Licensed Product REST API with response signing. The API allows external applications to validate, check status, and activate software licenses.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The security model works as follows:
|
The license server provides:
|
||||||
|
|
||||||
1. Server generates a unique signature for each response using HMAC-SHA256
|
1. **REST API endpoints** for license operations
|
||||||
2. Signature includes a timestamp to prevent replay attacks
|
2. **Response signing** using HMAC-SHA256 for tamper protection
|
||||||
3. Client verifies the signature using a shared secret
|
3. **Rate limiting** to prevent abuse
|
||||||
4. Invalid signatures cause the client to reject the response
|
4. **Comprehensive error responses** for programmatic handling
|
||||||
|
|
||||||
This prevents attackers from:
|
### Security Model
|
||||||
|
|
||||||
|
The signature system prevents attackers from:
|
||||||
|
|
||||||
- Faking valid license responses
|
- Faking valid license responses
|
||||||
- Replaying old responses
|
- Replaying old responses (timestamp validation)
|
||||||
- Tampering with response data
|
- Tampering with response data in transit
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- PHP 7.4+ (8.0+ recommended)
|
- PHP 8.3 or higher (to match client requirements)
|
||||||
|
- WordPress 6.0+ with WooCommerce
|
||||||
- A server secret stored securely (not in version control)
|
- A server secret stored securely (not in version control)
|
||||||
|
|
||||||
## Server Configuration
|
## Server Configuration
|
||||||
@@ -29,7 +32,7 @@ This prevents attackers from:
|
|||||||
Add a secret key to your WordPress configuration:
|
Add a secret key to your WordPress configuration:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// wp-config.php or secure configuration file
|
// wp-config.php
|
||||||
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
|
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -45,26 +48,193 @@ php -r "echo bin2hex(random_bytes(32));"
|
|||||||
|
|
||||||
**IMPORTANT:** Never commit this secret to version control!
|
**IMPORTANT:** Never commit this secret to version control!
|
||||||
|
|
||||||
## Implementation
|
### 2. Configure Rate Limiting (Optional)
|
||||||
|
|
||||||
|
```php
|
||||||
|
// wp-config.php
|
||||||
|
define('WC_LICENSE_RATE_LIMIT', 30); // Requests per window
|
||||||
|
define('WC_LICENSE_RATE_WINDOW', 60); // Window in seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
Base URL: `{site_url}/wp-json/wc-licensed-product/v1`
|
||||||
|
|
||||||
|
### POST /validate
|
||||||
|
|
||||||
|
Validate a license key for a specific domain.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"license_key": "ABCD-1234-EFGH-5678",
|
||||||
|
"domain": "example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Response (200):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"valid": true,
|
||||||
|
"license": {
|
||||||
|
"product_id": 123,
|
||||||
|
"expires_at": "2027-01-21",
|
||||||
|
"version_id": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Response (403):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"valid": false,
|
||||||
|
"error": "license_expired",
|
||||||
|
"message": "This license has expired."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /status
|
||||||
|
|
||||||
|
Get detailed license status information.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"license_key": "ABCD-1234-EFGH-5678"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Response (200):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"valid": true,
|
||||||
|
"status": "active",
|
||||||
|
"domain": "example.com",
|
||||||
|
"expires_at": "2027-01-21",
|
||||||
|
"activations_count": 1,
|
||||||
|
"max_activations": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /activate
|
||||||
|
|
||||||
|
Activate a license on a specific domain.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"license_key": "ABCD-1234-EFGH-5678",
|
||||||
|
"domain": "newdomain.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Response (200):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "License activated successfully."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Response (403):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "max_activations_reached",
|
||||||
|
"message": "Maximum number of activations reached."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Codes
|
||||||
|
|
||||||
|
The API uses consistent error codes for programmatic handling:
|
||||||
|
|
||||||
|
| Error Code | HTTP Status | Description |
|
||||||
|
| ---------- | ----------- | ----------- |
|
||||||
|
| `license_not_found` | 404 | License key doesn't exist |
|
||||||
|
| `license_expired` | 403 | License past expiration date |
|
||||||
|
| `license_revoked` | 403 | License manually revoked by admin |
|
||||||
|
| `license_inactive` | 403 | License not yet activated |
|
||||||
|
| `license_invalid` | 403 | License invalid for requested operation |
|
||||||
|
| `domain_mismatch` | 403 | License not authorized for this domain |
|
||||||
|
| `max_activations_reached` | 403 | Maximum activations limit exceeded |
|
||||||
|
| `activation_failed` | 500 | Server error during activation |
|
||||||
|
| `rate_limit_exceeded` | 429 | Too many requests |
|
||||||
|
|
||||||
|
### Rate Limit Response
|
||||||
|
|
||||||
|
When rate limit is exceeded, include `retry_after`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "rate_limit_exceeded",
|
||||||
|
"message": "Too many requests. Please try again later.",
|
||||||
|
"retry_after": 45
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also include the `Retry-After` HTTP header.
|
||||||
|
|
||||||
|
## Response Signing Implementation
|
||||||
|
|
||||||
### Key Derivation
|
### Key Derivation
|
||||||
|
|
||||||
Each license key gets a unique signing key derived from the server secret:
|
Each license key gets a unique signing key derived from the server secret using RFC 5869 HKDF:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
/**
|
/**
|
||||||
* Derive a unique signing key for a license.
|
* Derive a unique signing key for a license.
|
||||||
*
|
*
|
||||||
|
* Uses RFC 5869 HKDF for secure key derivation.
|
||||||
|
*
|
||||||
* @param string $licenseKey The license key
|
* @param string $licenseKey The license key
|
||||||
* @param string $serverSecret The server's master secret
|
* @param string $serverSecret The server's master secret
|
||||||
* @return string The derived key (hex encoded)
|
* @return string The derived key (hex encoded, 64 chars)
|
||||||
*/
|
*/
|
||||||
function derive_signing_key(string $licenseKey, string $serverSecret): string
|
function derive_signing_key(string $licenseKey, string $serverSecret): string
|
||||||
{
|
{
|
||||||
// HKDF-like key derivation
|
// Use PHP's native HKDF implementation (RFC 5869)
|
||||||
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
|
// - IKM (input keying material): server secret
|
||||||
|
// - Length: 32 bytes (256 bits for SHA-256)
|
||||||
|
// - Info: license key (context-specific info)
|
||||||
|
// - Salt: empty (uses hash-length zero bytes as per RFC 5869)
|
||||||
|
$binaryKey = hash_hkdf('sha256', $serverSecret, 32, $licenseKey);
|
||||||
|
|
||||||
return hash_hmac('sha256', $prk . "\x01", $serverSecret);
|
return bin2hex($binaryKey);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recursive Key Sorting
|
||||||
|
|
||||||
|
**IMPORTANT:** Response data must have keys sorted recursively for consistent signatures:
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Recursively sort array keys for consistent JSON output.
|
||||||
|
*
|
||||||
|
* @param array $data The data to sort
|
||||||
|
* @return array Sorted data
|
||||||
|
*/
|
||||||
|
function sort_keys_recursive(array $data): array
|
||||||
|
{
|
||||||
|
ksort($data);
|
||||||
|
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
if (is_array($value)) {
|
||||||
|
$data[$key] = sort_keys_recursive($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -86,11 +256,11 @@ function sign_response(array $responseData, string $licenseKey, string $serverSe
|
|||||||
$timestamp = time();
|
$timestamp = time();
|
||||||
$signingKey = derive_signing_key($licenseKey, $serverSecret);
|
$signingKey = derive_signing_key($licenseKey, $serverSecret);
|
||||||
|
|
||||||
// Sort keys for consistent ordering
|
// Sort keys recursively for consistent ordering
|
||||||
ksort($responseData);
|
$sortedData = sort_keys_recursive($responseData);
|
||||||
|
|
||||||
// Build signature payload
|
// Build signature payload
|
||||||
$jsonBody = json_encode($responseData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
$jsonBody = json_encode($sortedData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
$payload = $timestamp . ':' . $jsonBody;
|
$payload = $timestamp . ':' . $jsonBody;
|
||||||
|
|
||||||
// Generate HMAC signature
|
// Generate HMAC signature
|
||||||
@@ -103,81 +273,356 @@ function sign_response(array $responseData, string $licenseKey, string $serverSe
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### WordPress REST API Integration
|
### Signature Algorithm
|
||||||
|
|
||||||
Example integration with WooCommerce REST API:
|
```text
|
||||||
|
signature = HMAC-SHA256(
|
||||||
```php
|
key = derive_signing_key(license_key, server_secret),
|
||||||
/**
|
message = timestamp + ":" + canonical_json(response_body)
|
||||||
* 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
|
Where:
|
||||||
|
|
||||||
|
- `derive_signing_key` uses RFC 5869 HKDF via `hash_hkdf()`
|
||||||
|
- `canonical_json` sorts keys recursively, uses `JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE`
|
||||||
|
- Result is hex-encoded (64 characters)
|
||||||
|
|
||||||
|
### Response 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` |
|
||||||
|
|
||||||
|
## Complete WordPress Plugin Implementation
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Plugin Name: WC Licensed Product Signature
|
* Plugin Name: WC Licensed Product API
|
||||||
* Description: Adds response signing to WC Licensed Product API
|
* Description: License validation API with response signing
|
||||||
* Version: 1.0.0
|
* Version: 1.0.0
|
||||||
|
* Requires PHP: 8.3
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace WcLicensedProduct\Security;
|
declare(strict_types=1);
|
||||||
|
|
||||||
class ResponseSigner
|
namespace WcLicensedProduct;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* License API with response signing and rate limiting.
|
||||||
|
*/
|
||||||
|
final class LicenseApi
|
||||||
{
|
{
|
||||||
|
private const API_NAMESPACE = 'wc-licensed-product/v1';
|
||||||
|
|
||||||
private string $serverSecret;
|
private string $serverSecret;
|
||||||
|
private int $rateLimit;
|
||||||
|
private int $rateWindow;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->serverSecret = defined('WC_LICENSE_SERVER_SECRET')
|
$this->serverSecret = defined('WC_LICENSE_SERVER_SECRET')
|
||||||
? WC_LICENSE_SERVER_SECRET
|
? WC_LICENSE_SERVER_SECRET
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
$this->rateLimit = defined('WC_LICENSE_RATE_LIMIT')
|
||||||
|
? (int) WC_LICENSE_RATE_LIMIT
|
||||||
|
: 30;
|
||||||
|
|
||||||
|
$this->rateWindow = defined('WC_LICENSE_RATE_WINDOW')
|
||||||
|
? (int) WC_LICENSE_RATE_WINDOW
|
||||||
|
: 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
|
add_action('rest_api_init', [$this, 'registerRoutes']);
|
||||||
add_filter('rest_post_dispatch', [$this, 'signResponse'], 10, 3);
|
add_filter('rest_post_dispatch', [$this, 'signResponse'], 10, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function signResponse($response, $server, $request)
|
public function registerRoutes(): void
|
||||||
{
|
{
|
||||||
|
register_rest_route(self::API_NAMESPACE, '/validate', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [$this, 'handleValidate'],
|
||||||
|
'permission_callback' => [$this, 'checkRateLimit'],
|
||||||
|
'args' => $this->getValidateArgs(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route(self::API_NAMESPACE, '/status', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [$this, 'handleStatus'],
|
||||||
|
'permission_callback' => [$this, 'checkRateLimit'],
|
||||||
|
'args' => $this->getStatusArgs(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route(self::API_NAMESPACE, '/activate', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [$this, 'handleActivate'],
|
||||||
|
'permission_callback' => [$this, 'checkRateLimit'],
|
||||||
|
'args' => $this->getActivateArgs(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Request Validation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private function getValidateArgs(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'license_key' => [
|
||||||
|
'required' => true,
|
||||||
|
'type' => 'string',
|
||||||
|
'sanitize_callback' => 'sanitize_text_field',
|
||||||
|
'validate_callback' => [$this, 'validateLicenseKeyFormat'],
|
||||||
|
],
|
||||||
|
'domain' => [
|
||||||
|
'required' => true,
|
||||||
|
'type' => 'string',
|
||||||
|
'sanitize_callback' => 'sanitize_text_field',
|
||||||
|
'validate_callback' => [$this, 'validateDomainFormat'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getStatusArgs(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'license_key' => [
|
||||||
|
'required' => true,
|
||||||
|
'type' => 'string',
|
||||||
|
'sanitize_callback' => 'sanitize_text_field',
|
||||||
|
'validate_callback' => [$this, 'validateLicenseKeyFormat'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getActivateArgs(): array
|
||||||
|
{
|
||||||
|
return $this->getValidateArgs(); // Same as validate
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateLicenseKeyFormat($value): bool
|
||||||
|
{
|
||||||
|
return is_string($value) && strlen($value) <= 64 && strlen($value) >= 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateDomainFormat($value): bool
|
||||||
|
{
|
||||||
|
return is_string($value) && strlen($value) <= 255 && strlen($value) >= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Rate Limiting
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function checkRateLimit(\WP_REST_Request $request): bool|\WP_Error
|
||||||
|
{
|
||||||
|
$ip = $this->getClientIp();
|
||||||
|
$key = 'license_rate_' . md5($ip);
|
||||||
|
|
||||||
|
$data = get_transient($key);
|
||||||
|
|
||||||
|
if ($data === false) {
|
||||||
|
// First request in window
|
||||||
|
set_transient($key, ['count' => 1, 'start' => time()], $this->rateWindow);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data['count'] >= $this->rateLimit) {
|
||||||
|
$retryAfter = $this->rateWindow - (time() - $data['start']);
|
||||||
|
|
||||||
|
return new \WP_Error(
|
||||||
|
'rate_limit_exceeded',
|
||||||
|
'Too many requests. Please try again later.',
|
||||||
|
[
|
||||||
|
'status' => 429,
|
||||||
|
'retry_after' => max(1, $retryAfter),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment counter
|
||||||
|
$data['count']++;
|
||||||
|
set_transient($key, $data, $this->rateWindow - (time() - $data['start']));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getClientIp(): string
|
||||||
|
{
|
||||||
|
$headers = [
|
||||||
|
'HTTP_CF_CONNECTING_IP', // Cloudflare
|
||||||
|
'HTTP_X_FORWARDED_FOR', // Proxy
|
||||||
|
'HTTP_X_REAL_IP', // Nginx
|
||||||
|
'REMOTE_ADDR', // Direct
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($headers as $header) {
|
||||||
|
if (!empty($_SERVER[$header])) {
|
||||||
|
$ip = $_SERVER[$header];
|
||||||
|
// Handle comma-separated list (X-Forwarded-For)
|
||||||
|
if (str_contains($ip, ',')) {
|
||||||
|
$ip = trim(explode(',', $ip)[0]);
|
||||||
|
}
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||||
|
return $ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '0.0.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// API Handlers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function handleValidate(\WP_REST_Request $request): \WP_REST_Response|\WP_Error
|
||||||
|
{
|
||||||
|
$licenseKey = $request->get_param('license_key');
|
||||||
|
$domain = $request->get_param('domain');
|
||||||
|
|
||||||
|
// TODO: Replace with your license lookup logic
|
||||||
|
$license = $this->findLicense($licenseKey);
|
||||||
|
|
||||||
|
if ($license === null) {
|
||||||
|
return $this->errorResponse('license_not_found', 'License key not found.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($license['status'] === 'revoked') {
|
||||||
|
return $this->errorResponse('license_revoked', 'This license has been revoked.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($license['status'] === 'expired' || $this->isExpired($license)) {
|
||||||
|
return $this->errorResponse('license_expired', 'This license has expired.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($license['status'] === 'inactive') {
|
||||||
|
return $this->errorResponse('license_inactive', 'This license is inactive.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($license['domain']) && $license['domain'] !== $domain) {
|
||||||
|
return $this->errorResponse('domain_mismatch', 'This license is not valid for this domain.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \WP_REST_Response([
|
||||||
|
'valid' => true,
|
||||||
|
'license' => [
|
||||||
|
'product_id' => $license['product_id'],
|
||||||
|
'expires_at' => $license['expires_at'],
|
||||||
|
'version_id' => $license['version_id'] ?? null,
|
||||||
|
],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handleStatus(\WP_REST_Request $request): \WP_REST_Response|\WP_Error
|
||||||
|
{
|
||||||
|
$licenseKey = $request->get_param('license_key');
|
||||||
|
|
||||||
|
$license = $this->findLicense($licenseKey);
|
||||||
|
|
||||||
|
if ($license === null) {
|
||||||
|
return $this->errorResponse('license_not_found', 'License key not found.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $license['status'];
|
||||||
|
if ($status === 'active' && $this->isExpired($license)) {
|
||||||
|
$status = 'expired';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \WP_REST_Response([
|
||||||
|
'valid' => $status === 'active',
|
||||||
|
'status' => $status,
|
||||||
|
'domain' => $license['domain'] ?? '',
|
||||||
|
'expires_at' => $license['expires_at'],
|
||||||
|
'activations_count' => $license['activations_count'] ?? 0,
|
||||||
|
'max_activations' => $license['max_activations'] ?? 1,
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handleActivate(\WP_REST_Request $request): \WP_REST_Response|\WP_Error
|
||||||
|
{
|
||||||
|
$licenseKey = $request->get_param('license_key');
|
||||||
|
$domain = $request->get_param('domain');
|
||||||
|
|
||||||
|
$license = $this->findLicense($licenseKey);
|
||||||
|
|
||||||
|
if ($license === null) {
|
||||||
|
return $this->errorResponse('license_not_found', 'License key not found.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($license['status'] === 'revoked') {
|
||||||
|
return $this->errorResponse('license_invalid', 'This license is not valid.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isExpired($license)) {
|
||||||
|
return $this->errorResponse('license_invalid', 'This license has expired.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already activated on this domain
|
||||||
|
if ($license['domain'] === $domain) {
|
||||||
|
return new \WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'License is already activated for this domain.',
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check activation limit
|
||||||
|
$activations = $license['activations_count'] ?? 0;
|
||||||
|
$maxActivations = $license['max_activations'] ?? 1;
|
||||||
|
|
||||||
|
if ($activations >= $maxActivations && $license['domain'] !== $domain) {
|
||||||
|
return $this->errorResponse(
|
||||||
|
'max_activations_reached',
|
||||||
|
'Maximum number of activations reached.',
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Replace with your activation logic
|
||||||
|
$activated = $this->activateLicense($licenseKey, $domain);
|
||||||
|
|
||||||
|
if (!$activated) {
|
||||||
|
return $this->errorResponse('activation_failed', 'Failed to activate license.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \WP_REST_Response([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'License activated successfully.',
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function errorResponse(string $code, string $message, int $status): \WP_REST_Response
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'valid' => false,
|
||||||
|
'success' => false,
|
||||||
|
'error' => $code,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($code === 'rate_limit_exceeded') {
|
||||||
|
$data['retry_after'] = $this->rateWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new \WP_REST_Response($data, $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Response Signing
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function signResponse(
|
||||||
|
\WP_REST_Response $response,
|
||||||
|
\WP_REST_Server $server,
|
||||||
|
\WP_REST_Request $request
|
||||||
|
): \WP_REST_Response {
|
||||||
if (!$this->shouldSign($request)) {
|
if (!$this->shouldSign($request)) {
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
@@ -198,13 +643,11 @@ class ResponseSigner
|
|||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function shouldSign($request): bool
|
private function shouldSign(\WP_REST_Request $request): bool
|
||||||
{
|
{
|
||||||
$route = $request->get_route();
|
$route = $request->get_route();
|
||||||
|
|
||||||
return str_starts_with($route, '/wc-licensed-product/v1/validate')
|
return str_starts_with($route, '/' . self::API_NAMESPACE . '/');
|
||||||
|| 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
|
private function createSignatureHeaders(array $data, string $licenseKey): array
|
||||||
@@ -212,9 +655,11 @@ class ResponseSigner
|
|||||||
$timestamp = time();
|
$timestamp = time();
|
||||||
$signingKey = $this->deriveKey($licenseKey);
|
$signingKey = $this->deriveKey($licenseKey);
|
||||||
|
|
||||||
ksort($data);
|
// Sort keys recursively for consistent ordering
|
||||||
|
$sortedData = $this->sortKeysRecursive($data);
|
||||||
|
|
||||||
$payload = $timestamp . ':' . json_encode(
|
$payload = $timestamp . ':' . json_encode(
|
||||||
$data,
|
$sortedData,
|
||||||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -224,46 +669,85 @@ class ResponseSigner
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function sortKeysRecursive(array $data): array
|
||||||
|
{
|
||||||
|
ksort($data);
|
||||||
|
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
if (is_array($value)) {
|
||||||
|
$data[$key] = $this->sortKeysRecursive($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
private function deriveKey(string $licenseKey): string
|
private function deriveKey(string $licenseKey): string
|
||||||
{
|
{
|
||||||
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true);
|
// RFC 5869 HKDF key derivation
|
||||||
|
$binaryKey = hash_hkdf('sha256', $this->serverSecret, 32, $licenseKey);
|
||||||
|
|
||||||
return hash_hmac('sha256', $prk . "\x01", $this->serverSecret);
|
return bin2hex($binaryKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// License Storage (Replace with your implementation)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a license by key.
|
||||||
|
*
|
||||||
|
* TODO: Replace with your database/WooCommerce lookup
|
||||||
|
*
|
||||||
|
* @return array|null License data or null if not found
|
||||||
|
*/
|
||||||
|
private function findLicense(string $licenseKey): ?array
|
||||||
|
{
|
||||||
|
// Example structure - replace with actual database lookup
|
||||||
|
// return [
|
||||||
|
// 'license_key' => 'ABCD-1234-EFGH-5678',
|
||||||
|
// 'product_id' => 123,
|
||||||
|
// 'status' => 'active', // active, inactive, expired, revoked
|
||||||
|
// 'domain' => 'example.com',
|
||||||
|
// 'expires_at' => '2027-01-21', // or null for lifetime
|
||||||
|
// 'version_id' => null,
|
||||||
|
// 'activations_count' => 1,
|
||||||
|
// 'max_activations' => 3,
|
||||||
|
// ];
|
||||||
|
|
||||||
|
return null; // Replace with actual implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate a license on a domain.
|
||||||
|
*
|
||||||
|
* TODO: Replace with your database update logic
|
||||||
|
*/
|
||||||
|
private function activateLicense(string $licenseKey, string $domain): bool
|
||||||
|
{
|
||||||
|
// Update license record with new domain
|
||||||
|
// Increment activations_count
|
||||||
|
// Set status to 'active'
|
||||||
|
|
||||||
|
return false; // Replace with actual implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isExpired(?array $license): bool
|
||||||
|
{
|
||||||
|
if ($license === null || empty($license['expires_at'])) {
|
||||||
|
return false; // Lifetime license
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtotime($license['expires_at']) < time();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize
|
// Initialize plugin
|
||||||
add_action('init', function() {
|
add_action('plugins_loaded', function () {
|
||||||
(new ResponseSigner())->register();
|
(new LicenseApi())->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
|
## Testing
|
||||||
|
|
||||||
### Verify Signing Works
|
### Verify Signing Works
|
||||||
@@ -291,6 +775,7 @@ echo "X-License-Timestamp: " . $headers['X-License-Timestamp'] . "\n";
|
|||||||
|
|
||||||
```php
|
```php
|
||||||
use Magdev\WcLicensedProductClient\SecureLicenseClient;
|
use Magdev\WcLicensedProductClient\SecureLicenseClient;
|
||||||
|
use Magdev\WcLicensedProductClient\Security\SignatureException;
|
||||||
use Symfony\Component\HttpClient\HttpClient;
|
use Symfony\Component\HttpClient\HttpClient;
|
||||||
|
|
||||||
$client = new SecureLicenseClient(
|
$client = new SecureLicenseClient(
|
||||||
@@ -307,6 +792,18 @@ try {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Verify Signature Manually
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using curl to test the API
|
||||||
|
curl -X POST https://your-site.com/wp-json/wc-licensed-product/v1/validate \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"license_key":"ABCD-1234-EFGH-5678","domain":"example.com"}' \
|
||||||
|
-i
|
||||||
|
|
||||||
|
# Check for X-License-Signature and X-License-Timestamp headers
|
||||||
|
```
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
### Timestamp Tolerance
|
### Timestamp Tolerance
|
||||||
@@ -320,6 +817,8 @@ Adjust if needed:
|
|||||||
|
|
||||||
```php
|
```php
|
||||||
// Client-side: custom tolerance
|
// Client-side: custom tolerance
|
||||||
|
use Magdev\WcLicensedProductClient\Security\ResponseSignature;
|
||||||
|
|
||||||
$signature = new ResponseSignature($key, timestampTolerance: 600); // 10 minutes
|
$signature = new ResponseSignature($key, timestampTolerance: 600); // 10 minutes
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -344,19 +843,42 @@ $secrets = [
|
|||||||
$response->header('X-License-Signature-Version', 'v2');
|
$response->header('X-License-Signature-Version', 'v2');
|
||||||
```
|
```
|
||||||
|
|
||||||
### Error Responses
|
### Sign Error Responses
|
||||||
|
|
||||||
Sign error responses too! Otherwise attackers could craft fake error messages:
|
Sign error responses too! Otherwise attackers could craft fake error messages:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// Sign both success and error responses
|
// Both success and error responses are signed by the filter
|
||||||
$errorData = [
|
// No additional code needed - the signResponse filter handles all responses
|
||||||
'valid' => false,
|
```
|
||||||
'error' => 'license_expired',
|
|
||||||
'message' => 'This license has expired.',
|
|
||||||
];
|
|
||||||
|
|
||||||
$headers = sign_response($errorData, $licenseKey, $serverSecret);
|
### HTTPS Required
|
||||||
|
|
||||||
|
Always serve the API over HTTPS:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// In your plugin, enforce HTTPS
|
||||||
|
add_action('rest_api_init', function() {
|
||||||
|
if (!is_ssl() && !defined('WP_DEBUG') || !WP_DEBUG) {
|
||||||
|
wp_die('License API requires HTTPS');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Input Sanitization
|
||||||
|
|
||||||
|
Always sanitize and validate input:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// The WordPress REST API args handle this automatically
|
||||||
|
// But add additional validation as needed
|
||||||
|
$licenseKey = sanitize_text_field($request->get_param('license_key'));
|
||||||
|
$domain = sanitize_text_field($request->get_param('domain'));
|
||||||
|
|
||||||
|
// Validate format
|
||||||
|
if (!preg_match('/^[A-Z0-9\-]{8,64}$/i', $licenseKey)) {
|
||||||
|
return new WP_Error('invalid_license_key', 'Invalid license key format');
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -372,22 +894,69 @@ $headers = sign_response($errorData, $licenseKey, $serverSecret);
|
|||||||
- Different secrets on server/client
|
- Different secrets on server/client
|
||||||
- Clock skew > 5 minutes
|
- Clock skew > 5 minutes
|
||||||
- Response body modified after signing (e.g., by caching plugin)
|
- Response body modified after signing (e.g., by caching plugin)
|
||||||
- JSON encoding differences (check `ksort` and flags)
|
- JSON encoding differences (check recursive `ksort` and flags)
|
||||||
|
- Nested objects not sorted consistently
|
||||||
|
|
||||||
### Debugging
|
### Debugging
|
||||||
|
|
||||||
Enable detailed logging:
|
Enable detailed logging:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// Server-side
|
// Server-side debugging
|
||||||
error_log('Signing response for: ' . $licenseKey);
|
add_filter('rest_post_dispatch', function($response, $server, $request) {
|
||||||
error_log('Timestamp: ' . $timestamp);
|
if (!str_starts_with($request->get_route(), '/wc-licensed-product/v1/')) {
|
||||||
error_log('Payload: ' . $payload);
|
return $response;
|
||||||
error_log('Signature: ' . $signature);
|
}
|
||||||
|
|
||||||
|
$data = $response->get_data();
|
||||||
|
$sortedData = sort_keys_recursive($data);
|
||||||
|
$json = json_encode($sortedData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
error_log('License API Debug:');
|
||||||
|
error_log('Route: ' . $request->get_route());
|
||||||
|
error_log('License Key: ' . $request->get_param('license_key'));
|
||||||
|
error_log('Sorted JSON: ' . $json);
|
||||||
|
error_log('Timestamp: ' . time());
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}, 5, 3);
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
// Client-side: use a PSR-3 logger
|
// Client-side: use a PSR-3 logger
|
||||||
$client = new SecureLicenseClient(
|
$client = new SecureLicenseClient(
|
||||||
// ...
|
// ...
|
||||||
logger: new YourDebugLogger(),
|
logger: new YourDebugLogger(),
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Rate Limit Issues
|
||||||
|
|
||||||
|
If legitimate users hit rate limits:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Increase limits in wp-config.php
|
||||||
|
define('WC_LICENSE_RATE_LIMIT', 60); // 60 requests
|
||||||
|
define('WC_LICENSE_RATE_WINDOW', 60); // per minute
|
||||||
|
|
||||||
|
// Or implement per-license key rate limiting instead of per-IP
|
||||||
|
$key = 'license_rate_' . md5($licenseKey);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caching Conflicts
|
||||||
|
|
||||||
|
If a caching plugin modifies responses:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Exclude license API from caching
|
||||||
|
add_action('rest_api_init', function() {
|
||||||
|
if (str_contains($_SERVER['REQUEST_URI'], '/wc-licensed-product/')) {
|
||||||
|
// Disable page caching
|
||||||
|
define('DONOTCACHEPAGE', true);
|
||||||
|
|
||||||
|
// Set no-cache headers
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ final readonly class ActivationResult
|
|||||||
|
|
||||||
public static function fromArray(array $data): self
|
public static function fromArray(array $data): self
|
||||||
{
|
{
|
||||||
|
if (!isset($data['success']) || !is_bool($data['success'])) {
|
||||||
|
throw new \InvalidArgumentException('Invalid response: missing or invalid success field');
|
||||||
|
}
|
||||||
|
if (!isset($data['message']) || !is_string($data['message'])) {
|
||||||
|
throw new \InvalidArgumentException('Invalid response: missing or invalid message field');
|
||||||
|
}
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
success: $data['success'],
|
success: $data['success'],
|
||||||
message: $data['message'],
|
message: $data['message'],
|
||||||
|
|||||||
@@ -15,9 +15,21 @@ final readonly class LicenseInfo
|
|||||||
|
|
||||||
public static function fromArray(array $data): self
|
public static function fromArray(array $data): self
|
||||||
{
|
{
|
||||||
|
if (!isset($data['product_id']) || !is_int($data['product_id'])) {
|
||||||
|
throw new \InvalidArgumentException('Invalid response: missing or invalid product_id');
|
||||||
|
}
|
||||||
|
|
||||||
$expiresAt = null;
|
$expiresAt = null;
|
||||||
if (isset($data['expires_at']) && $data['expires_at'] !== null) {
|
if (isset($data['expires_at']) && $data['expires_at'] !== null) {
|
||||||
$expiresAt = new \DateTimeImmutable($data['expires_at']);
|
try {
|
||||||
|
$expiresAt = new \DateTimeImmutable($data['expires_at']);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
'Invalid response: invalid date format for expires_at',
|
||||||
|
0,
|
||||||
|
$e
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
|
|||||||
@@ -26,14 +26,49 @@ final readonly class LicenseStatus
|
|||||||
|
|
||||||
public static function fromArray(array $data): self
|
public static function fromArray(array $data): self
|
||||||
{
|
{
|
||||||
|
// Validate required fields
|
||||||
|
if (!isset($data['valid']) || !is_bool($data['valid'])) {
|
||||||
|
throw new \InvalidArgumentException('Invalid response: missing or invalid valid field');
|
||||||
|
}
|
||||||
|
if (!isset($data['status']) || !is_string($data['status'])) {
|
||||||
|
throw new \InvalidArgumentException('Invalid response: missing or invalid status field');
|
||||||
|
}
|
||||||
|
if (!isset($data['domain']) || !is_string($data['domain'])) {
|
||||||
|
throw new \InvalidArgumentException('Invalid response: missing or invalid domain field');
|
||||||
|
}
|
||||||
|
if (!isset($data['activations_count']) || !is_int($data['activations_count'])) {
|
||||||
|
throw new \InvalidArgumentException('Invalid response: missing or invalid activations_count field');
|
||||||
|
}
|
||||||
|
if (!isset($data['max_activations']) || !is_int($data['max_activations'])) {
|
||||||
|
throw new \InvalidArgumentException('Invalid response: missing or invalid max_activations field');
|
||||||
|
}
|
||||||
|
|
||||||
$expiresAt = null;
|
$expiresAt = null;
|
||||||
if (isset($data['expires_at']) && $data['expires_at'] !== null) {
|
if (isset($data['expires_at']) && $data['expires_at'] !== null) {
|
||||||
$expiresAt = new \DateTimeImmutable($data['expires_at']);
|
try {
|
||||||
|
$expiresAt = new \DateTimeImmutable($data['expires_at']);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
'Invalid response: invalid date format for expires_at',
|
||||||
|
0,
|
||||||
|
$e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$status = LicenseState::from($data['status']);
|
||||||
|
} catch (\ValueError $e) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
'Invalid response: unknown license status value',
|
||||||
|
0,
|
||||||
|
$e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
valid: $data['valid'],
|
valid: $data['valid'],
|
||||||
status: LicenseState::from($data['status']),
|
status: $status,
|
||||||
domain: $data['domain'],
|
domain: $data['domain'],
|
||||||
expiresAt: $expiresAt,
|
expiresAt: $expiresAt,
|
||||||
activationsCount: $data['activations_count'],
|
activationsCount: $data['activations_count'],
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ final class LicenseClient implements LicenseClientInterface
|
|||||||
private const API_PATH = '/wp-json/wc-licensed-product/v1';
|
private const API_PATH = '/wp-json/wc-licensed-product/v1';
|
||||||
private const CACHE_TTL = 300; // 5 minutes
|
private const CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
|
/** @var string[] Private IPv4 ranges (CIDR notation) */
|
||||||
|
private const PRIVATE_IP_RANGES = [
|
||||||
|
'10.0.0.0/8',
|
||||||
|
'172.16.0.0/12',
|
||||||
|
'192.168.0.0/16',
|
||||||
|
'127.0.0.0/8',
|
||||||
|
'169.254.0.0/16',
|
||||||
|
'0.0.0.0/8',
|
||||||
|
];
|
||||||
|
|
||||||
private readonly LoggerInterface $logger;
|
private readonly LoggerInterface $logger;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -26,8 +36,10 @@ final class LicenseClient implements LicenseClientInterface
|
|||||||
?LoggerInterface $logger = null,
|
?LoggerInterface $logger = null,
|
||||||
private readonly ?CacheItemPoolInterface $cache = null,
|
private readonly ?CacheItemPoolInterface $cache = null,
|
||||||
private readonly int $cacheTtl = self::CACHE_TTL,
|
private readonly int $cacheTtl = self::CACHE_TTL,
|
||||||
|
bool $allowInsecureHttp = false,
|
||||||
) {
|
) {
|
||||||
$this->logger = $logger ?? new NullLogger();
|
$this->logger = $logger ?? new NullLogger();
|
||||||
|
$this->validateBaseUrl($baseUrl, $allowInsecureHttp);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function validate(string $licenseKey, string $domain): LicenseInfo
|
public function validate(string $licenseKey, string $domain): LicenseInfo
|
||||||
@@ -165,13 +177,15 @@ final class LicenseClient implements LicenseClientInterface
|
|||||||
} catch (LicenseException $e) {
|
} catch (LicenseException $e) {
|
||||||
throw $e;
|
throw $e;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
// Log full error for debugging but sanitize for user-facing message
|
||||||
$this->logger->error('License API request failed', [
|
$this->logger->error('License API request failed', [
|
||||||
'endpoint' => $endpoint,
|
'endpoint' => $endpoint,
|
||||||
'error' => $e->getMessage(),
|
'exception_class' => $e::class,
|
||||||
|
'error_code' => $e->getCode(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
throw new LicenseException(
|
throw new LicenseException(
|
||||||
'Failed to communicate with license server: ' . $e->getMessage(),
|
'Failed to communicate with license server',
|
||||||
null,
|
null,
|
||||||
0,
|
0,
|
||||||
$e
|
$e
|
||||||
@@ -187,4 +201,69 @@ final class LicenseClient implements LicenseClientInterface
|
|||||||
}
|
}
|
||||||
return $key;
|
return $key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the base URL to prevent SSRF attacks.
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException If the URL is invalid or potentially dangerous
|
||||||
|
*/
|
||||||
|
private function validateBaseUrl(string $url, bool $allowInsecureHttp): void
|
||||||
|
{
|
||||||
|
if ($url === '') {
|
||||||
|
throw new \InvalidArgumentException('Base URL cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = parse_url($url);
|
||||||
|
if ($parsed === false || !isset($parsed['scheme'], $parsed['host'])) {
|
||||||
|
throw new \InvalidArgumentException('Invalid base URL format');
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = strtolower($parsed['scheme']);
|
||||||
|
$host = strtolower($parsed['host']);
|
||||||
|
|
||||||
|
// Require HTTPS unless explicitly allowed for localhost
|
||||||
|
if ($scheme !== 'https') {
|
||||||
|
if ($scheme !== 'http') {
|
||||||
|
throw new \InvalidArgumentException('Base URL must use HTTP or HTTPS scheme');
|
||||||
|
}
|
||||||
|
$isLocalhost = $host === 'localhost' || $host === '127.0.0.1';
|
||||||
|
if (!$allowInsecureHttp && !$isLocalhost) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
'Base URL must use HTTPS for non-localhost hosts. ' .
|
||||||
|
'Set allowInsecureHttp=true to allow HTTP (not recommended for production).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve hostname and check for private IPs
|
||||||
|
$ip = gethostbyname($host);
|
||||||
|
if ($ip !== $host && $this->isPrivateIp($ip)) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
'Base URL resolves to a private IP address, which is not allowed'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an IP address is in a private range.
|
||||||
|
*/
|
||||||
|
private function isPrivateIp(string $ip): bool
|
||||||
|
{
|
||||||
|
$ipLong = ip2long($ip);
|
||||||
|
if ($ipLong === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::PRIVATE_IP_RANGES as $range) {
|
||||||
|
[$subnet, $bits] = explode('/', $range);
|
||||||
|
$subnetLong = ip2long($subnet);
|
||||||
|
$mask = -1 << (32 - (int) $bits);
|
||||||
|
|
||||||
|
if (($ipLong & $mask) === ($subnetLong & $mask)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ use Psr\Cache\CacheItemPoolInterface;
|
|||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Secure license client with response signature verification.
|
* Secure license client with response signature verification.
|
||||||
@@ -34,6 +33,16 @@ final class SecureLicenseClient implements LicenseClientInterface
|
|||||||
{
|
{
|
||||||
private const CACHE_TTL = 300;
|
private const CACHE_TTL = 300;
|
||||||
|
|
||||||
|
/** @var string[] Private IPv4 ranges (CIDR notation) */
|
||||||
|
private const PRIVATE_IP_RANGES = [
|
||||||
|
'10.0.0.0/8',
|
||||||
|
'172.16.0.0/12',
|
||||||
|
'192.168.0.0/16',
|
||||||
|
'127.0.0.0/8',
|
||||||
|
'169.254.0.0/16',
|
||||||
|
'0.0.0.0/8',
|
||||||
|
];
|
||||||
|
|
||||||
private readonly LoggerInterface $logger;
|
private readonly LoggerInterface $logger;
|
||||||
private readonly StringEncoder $encoder;
|
private readonly StringEncoder $encoder;
|
||||||
private readonly string $apiPath;
|
private readonly string $apiPath;
|
||||||
@@ -53,10 +62,12 @@ final class SecureLicenseClient implements LicenseClientInterface
|
|||||||
private readonly int $cacheTtl = self::CACHE_TTL,
|
private readonly int $cacheTtl = self::CACHE_TTL,
|
||||||
private readonly bool $verifyIntegrity = false,
|
private readonly bool $verifyIntegrity = false,
|
||||||
?StringEncoder $encoder = null,
|
?StringEncoder $encoder = null,
|
||||||
|
bool $allowInsecureHttp = false,
|
||||||
) {
|
) {
|
||||||
$this->logger = $logger ?? new NullLogger();
|
$this->logger = $logger ?? new NullLogger();
|
||||||
$this->encoder = $encoder ?? new StringEncoder();
|
$this->encoder = $encoder ?? new StringEncoder();
|
||||||
$this->apiPath = $this->encoder->decode(self::ENCODED_API_PATH);
|
$this->apiPath = $this->encoder->decode(self::ENCODED_API_PATH);
|
||||||
|
$this->validateBaseUrl($baseUrl, $allowInsecureHttp);
|
||||||
|
|
||||||
if ($this->verifyIntegrity) {
|
if ($this->verifyIntegrity) {
|
||||||
$this->checkIntegrity();
|
$this->checkIntegrity();
|
||||||
@@ -200,13 +211,15 @@ final class SecureLicenseClient implements LicenseClientInterface
|
|||||||
} catch (LicenseException | SignatureException $e) {
|
} catch (LicenseException | SignatureException $e) {
|
||||||
throw $e;
|
throw $e;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
// Log full error for debugging but sanitize for user-facing message
|
||||||
$this->logger->error('License API request failed', [
|
$this->logger->error('License API request failed', [
|
||||||
'endpoint' => $endpoint,
|
'endpoint' => $endpoint,
|
||||||
'error' => $e->getMessage(),
|
'exception_class' => $e::class,
|
||||||
|
'error_code' => $e->getCode(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
throw new LicenseException(
|
throw new LicenseException(
|
||||||
'Failed to communicate with license server: ' . $e->getMessage(),
|
'Failed to communicate with license server',
|
||||||
null,
|
null,
|
||||||
0,
|
0,
|
||||||
$e
|
$e
|
||||||
@@ -240,7 +253,13 @@ final class SecureLicenseClient implements LicenseClientInterface
|
|||||||
|
|
||||||
foreach ($headers as $name => $values) {
|
foreach ($headers as $name => $values) {
|
||||||
// HTTP client returns arrays of values; take the first one
|
// HTTP client returns arrays of values; take the first one
|
||||||
$normalized[$name] = is_array($values) ? ($values[0] ?? '') : $values;
|
// Empty arrays or empty strings should be treated as missing (null)
|
||||||
|
if (is_array($values)) {
|
||||||
|
$value = $values[0] ?? null;
|
||||||
|
$normalized[$name] = ($value !== '' && $value !== null) ? $value : null;
|
||||||
|
} else {
|
||||||
|
$normalized[$name] = ($values !== '' && $values !== null) ? $values : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $normalized;
|
return $normalized;
|
||||||
@@ -299,4 +318,69 @@ final class SecureLicenseClient implements LicenseClientInterface
|
|||||||
}
|
}
|
||||||
return $key;
|
return $key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the base URL to prevent SSRF attacks.
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException If the URL is invalid or potentially dangerous
|
||||||
|
*/
|
||||||
|
private function validateBaseUrl(string $url, bool $allowInsecureHttp): void
|
||||||
|
{
|
||||||
|
if ($url === '') {
|
||||||
|
throw new \InvalidArgumentException('Base URL cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = parse_url($url);
|
||||||
|
if ($parsed === false || !isset($parsed['scheme'], $parsed['host'])) {
|
||||||
|
throw new \InvalidArgumentException('Invalid base URL format');
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = strtolower($parsed['scheme']);
|
||||||
|
$host = strtolower($parsed['host']);
|
||||||
|
|
||||||
|
// Require HTTPS unless explicitly allowed for localhost
|
||||||
|
if ($scheme !== 'https') {
|
||||||
|
if ($scheme !== 'http') {
|
||||||
|
throw new \InvalidArgumentException('Base URL must use HTTP or HTTPS scheme');
|
||||||
|
}
|
||||||
|
$isLocalhost = $host === 'localhost' || $host === '127.0.0.1';
|
||||||
|
if (!$allowInsecureHttp && !$isLocalhost) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
'Base URL must use HTTPS for non-localhost hosts. ' .
|
||||||
|
'Set allowInsecureHttp=true to allow HTTP (not recommended for production).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve hostname and check for private IPs
|
||||||
|
$ip = gethostbyname($host);
|
||||||
|
if ($ip !== $host && $this->isPrivateIp($ip)) {
|
||||||
|
throw new \InvalidArgumentException(
|
||||||
|
'Base URL resolves to a private IP address, which is not allowed'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an IP address is in a private range.
|
||||||
|
*/
|
||||||
|
private function isPrivateIp(string $ip): bool
|
||||||
|
{
|
||||||
|
$ipLong = ip2long($ip);
|
||||||
|
if ($ipLong === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::PRIVATE_IP_RANGES as $range) {
|
||||||
|
[$subnet, $bits] = explode('/', $range);
|
||||||
|
$subnetLong = ip2long($subnet);
|
||||||
|
$mask = -1 << (32 - (int) $bits);
|
||||||
|
|
||||||
|
if (($ipLong & $mask) === ($subnetLong & $mask)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Magdev\WcLicensedProductClient\Security;
|
namespace Magdev\WcLicensedProductClient\Security;
|
||||||
|
|
||||||
use Magdev\WcLicensedProductClient\Exception\LicenseException;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies the integrity of critical source files.
|
* Verifies the integrity of critical source files.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -112,28 +112,55 @@ final class ResponseSignature
|
|||||||
/**
|
/**
|
||||||
* Derive a unique key from the license key and server secret.
|
* Derive a unique key from the license key and server secret.
|
||||||
*
|
*
|
||||||
* Uses HKDF-like key derivation to create a unique key per license.
|
* Uses RFC 5869 HKDF to create a unique key per license.
|
||||||
*/
|
*/
|
||||||
public static function deriveKey(string $licenseKey, string $serverSecret): string
|
public static function deriveKey(string $licenseKey, string $serverSecret): string
|
||||||
{
|
{
|
||||||
// Use HKDF expansion with license key as info
|
// Use PHP's native HKDF implementation (RFC 5869)
|
||||||
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
|
// - IKM (input keying material): server secret
|
||||||
|
// - Length: 32 bytes (256 bits for SHA-256)
|
||||||
|
// - Info: license key (context-specific info)
|
||||||
|
// - Salt: empty (uses hash-length zero bytes as per RFC 5869)
|
||||||
|
$binaryKey = hash_hkdf('sha256', $serverSecret, 32, $licenseKey);
|
||||||
|
|
||||||
return hash_hmac('sha256', $prk . "\x01", $serverSecret);
|
// Convert to hex for consistent string handling
|
||||||
|
return bin2hex($binaryKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildSignaturePayload(array $responseData, int $timestamp): string
|
private function buildSignaturePayload(array $responseData, int $timestamp): string
|
||||||
{
|
{
|
||||||
// Sort keys for consistent ordering
|
// Sort keys recursively for consistent ordering (matches server implementation)
|
||||||
ksort($responseData);
|
$sortedData = $this->sortKeysRecursive($responseData);
|
||||||
|
|
||||||
// Create deterministic JSON representation
|
// Create deterministic JSON representation
|
||||||
$jsonBody = json_encode($responseData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
$jsonBody = json_encode($sortedData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
if ($jsonBody === false) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Failed to encode response data for signature verification: ' . json_last_error_msg()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Combine timestamp and body for signature
|
// Combine timestamp and body for signature
|
||||||
return $timestamp . ':' . $jsonBody;
|
return $timestamp . ':' . $jsonBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively sort array keys for consistent JSON output.
|
||||||
|
*/
|
||||||
|
private function sortKeysRecursive(array $data): array
|
||||||
|
{
|
||||||
|
ksort($data);
|
||||||
|
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
if (is_array($value)) {
|
||||||
|
$data[$key] = $this->sortKeysRecursive($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
private function isTimestampValid(int $timestamp): bool
|
private function isTimestampValid(int $timestamp): bool
|
||||||
{
|
{
|
||||||
$now = time();
|
$now = time();
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "WooCommerce Licensed Product API",
|
"title": "WooCommerce Licensed Product API",
|
||||||
"description": "REST API for validating and managing software licenses bound to domains. This API allows external applications to validate license keys, check license status, and activate licenses on specific domains.",
|
"description": "REST API for validating and managing software licenses bound to domains. This API allows external applications to validate license keys, check license status, and activate licenses on specific domains.\n\n## Response Signing (Optional)\n\nWhen the server is configured with `WC_LICENSE_SERVER_SECRET`, all API responses include cryptographic signatures for tamper protection:\n\n- `X-License-Signature`: HMAC-SHA256 signature of the response\n- `X-License-Timestamp`: Unix timestamp when the response was generated\n\nSignature verification prevents man-in-the-middle attacks and ensures response integrity. Use the `magdev/wc-licensed-product-client` library's `SecureLicenseClient` class to automatically verify signatures.",
|
||||||
"version": "0.0.7",
|
"version": "0.3.2",
|
||||||
"contact": {
|
"contact": {
|
||||||
"name": "Marco Graetsch",
|
"name": "Marco Graetsch",
|
||||||
"url": "https://src.bundespruefstelle.ch/magdev",
|
"url": "https://src.bundespruefstelle.ch/magdev",
|
||||||
@@ -55,6 +55,14 @@
|
|||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "License is valid for the specified domain",
|
"description": "License is valid for the specified domain",
|
||||||
|
"headers": {
|
||||||
|
"X-License-Signature": {
|
||||||
|
"$ref": "#/components/headers/X-License-Signature"
|
||||||
|
},
|
||||||
|
"X-License-Timestamp": {
|
||||||
|
"$ref": "#/components/headers/X-License-Timestamp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -156,6 +164,14 @@
|
|||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "License status retrieved successfully",
|
"description": "License status retrieved successfully",
|
||||||
|
"headers": {
|
||||||
|
"X-License-Signature": {
|
||||||
|
"$ref": "#/components/headers/X-License-Signature"
|
||||||
|
},
|
||||||
|
"X-License-Timestamp": {
|
||||||
|
"$ref": "#/components/headers/X-License-Timestamp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -221,6 +237,14 @@
|
|||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "License activated successfully or already activated",
|
"description": "License activated successfully or already activated",
|
||||||
|
"headers": {
|
||||||
|
"X-License-Signature": {
|
||||||
|
"$ref": "#/components/headers/X-License-Signature"
|
||||||
|
},
|
||||||
|
"X-License-Timestamp": {
|
||||||
|
"$ref": "#/components/headers/X-License-Timestamp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -519,6 +543,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"headers": {
|
||||||
|
"X-License-Signature": {
|
||||||
|
"description": "HMAC-SHA256 signature of the response body for tamper protection. Only present when server is configured with WC_LICENSE_SERVER_SECRET. Signature format: hex-encoded HMAC-SHA256 of (timestamp + ':' + canonical_json_body) using a per-license derived key.",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-f0-9]{64}$",
|
||||||
|
"example": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"
|
||||||
|
},
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"X-License-Timestamp": {
|
||||||
|
"description": "Unix timestamp when the response was generated. Used together with X-License-Signature to prevent replay attacks. Only present when server is configured with WC_LICENSE_SERVER_SECRET.",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9]+$",
|
||||||
|
"example": "1737550000"
|
||||||
|
},
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
Reference in New Issue
Block a user