12 Commits

Author SHA1 Message Date
56abe8a97c Add update-check endpoint documentation (v0.2.2)
- Add /update-check endpoint documentation to server-implementation.md
- Add product_not_found error code to error codes table
- Add handleUpdateCheck() handler example in WordPress plugin
- Add findProduct() method stub for product lookups
- Verified client implementation aligns with server documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:56:47 +01:00
760e1e752a Add update-check endpoint support (v0.2.1)
Implement /update-check endpoint aligned with remote OpenAPI spec:
- Add checkForUpdates() method to LicenseClientInterface
- Add UpdateInfo DTO for update check responses
- Add ProductNotFoundException for product_not_found error
- Update local openapi.json to v0.4.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:52:12 +01:00
5e4b5a970f Update session history with v0.2.0 release notes
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 16:54:37 +01:00
7fc838ada7 Release version 0.2.0
Security improvements and server implementation alignment.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 16:36:29 +01:00
a11aa4260a Update session history with server alignment learnings
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 16:34:18 +01:00
8062e1be77 Align client and server signature implementation
- Update server docs to use RFC 5869 hash_hkdf() for key derivation
- Add recursive key sorting to client ResponseSignature
- Ensures client and server produce matching signatures for nested objects

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 16:33:44 +01:00
64d215cb26 Update session history with security audit learnings
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 14:32:11 +01:00
fa748d61d3 Fix security vulnerabilities identified in audit
- Add JSON encoding error handling in ResponseSignature to prevent silent failures
- Sanitize exception messages to prevent information disclosure
- Fix header normalization to treat empty values as null
- Add SSRF protection with URL validation and private IP blocking
- Replace custom key derivation with RFC 5869 compliant hash_hkdf()
- Add input validation in DTO fromArray() methods
- Add DateTime exception handling in DTOs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 14:31:13 +01:00
9f513a819e Update server implementation documentation
- Add complete API endpoints reference with request/response formats
- Add recursive key sorting for nested objects in signatures
- Add comprehensive error codes table with HTTP status codes
- Add rate limiting implementation with configurable limits
- Add complete WordPress plugin example with all handlers
- Add security sections: HTTPS, input sanitization, caching conflicts
- Update PHP version requirement to 8.3 for consistency
- Expand troubleshooting section with more scenarios

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:45:59 +01:00
c2cb1814de Add comprehensive client implementation documentation
- Create docs/client-implementation.md with full integration guide
- Document all classes: clients, DTOs, exceptions, security classes
- Add integration examples for WordPress, Laravel, Symfony
- Include best practices and troubleshooting sections
- Update README with documentation links

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:33:10 +01:00
a3a957914f Update session history with OpenAPI spec learnings
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 21:05:48 +01:00
da84bbad43 Update OpenAPI spec and clean up security classes
- Update OpenAPI spec to v0.3.2 with signature header documentation
- Add X-License-Signature and X-License-Timestamp header definitions
- Clean up unused imports in security classes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 21:04:53 +01:00
17 changed files with 2971 additions and 140 deletions

View File

@@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.2.2] - 2026-01-28
### Added
- `/update-check` endpoint documentation in server-implementation.md
- `product_not_found` error code to error codes table
- `handleUpdateCheck()` handler example in WordPress plugin implementation
- `findProduct()` method stub for product lookups
### Changed
- Verified client implementation aligns with updated server documentation
- All signature algorithms, key derivation, and JSON canonicalization match server
## [0.2.1] - 2026-01-27
### Added
- `checkForUpdates()` method for checking plugin updates
- `UpdateInfo` DTO for update check responses
- `ProductNotFoundException` for `product_not_found` error handling
- `/update-check` endpoint support aligned with remote OpenAPI spec (v0.4.0)
### Changed
- Updated local `openapi.json` to match remote specification (now v0.4.0)
- Added "Plugin Updates" tag to OpenAPI specification
## [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

125
CLAUDE.md
View File

@@ -29,6 +29,10 @@ 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.3.0
No pending tasks at the moment.
## Technical Stack ## Technical Stack
- **Language:** PHP 8.3.x - **Language:** PHP 8.3.x
@@ -152,3 +156,124 @@ 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
### 2026-01-26 - Version 0.2.0 Release
**Completed:**
- Released version 0.2.0 with security improvements and server alignment
- Created annotated git tag `v0.2.0`
- Updated CHANGELOG.md with all changes since 0.1.0
- Updated roadmap for versions 0.2.1 and 0.3.0
**Learnings:**
- Version tagging with `git tag -a` creates annotated tags with messages
- CHANGELOG.md follows Keep a Changelog format (MD024 duplicate headings are expected)
- Roadmap in CLAUDE.md should be updated after each release to reflect next versions
### 2026-01-27 - Version 0.2.1 (Update Check Endpoint)
**Completed:**
- Compared local `openapi.json` against remote server specification
- Discovered new `/update-check` endpoint in remote spec
- Added `UpdateInfo` DTO for update check responses
- Added `ProductNotFoundException` for `product_not_found` error code
- Implemented `checkForUpdates()` method in `LicenseClientInterface`
- Implemented `checkForUpdates()` in both `LicenseClient` and `SecureLicenseClient`
- Updated local `openapi.json` with `/update-check` endpoint (now v0.4.0)
- Updated `LicenseException::fromApiResponse()` to handle `product_not_found`
- Added encoded constant `ENCODED_UPDATE_CHECK` to `SecureLicenseClient`
- Updated documentation: README.md and docs/client-implementation.md
- All 66 tests pass
**Learnings:**
- Remote OpenAPI spec at server repo may have newer endpoints than local copy
- Update check endpoint returns WordPress-compatible plugin info (tested, requires, requires_php)
- `UpdateInfo` DTO supports both `download_url` and `package` fields (WordPress compatibility)
- Package hash format uses `sha256:hexstring` prefix for integrity verification
- StringEncoder can generate encoded constants for obfuscated endpoint names
### 2026-01-28 - Version 0.2.2 (Server Documentation Alignment)
**Completed:**
- Fetched and compared remote server-implementation.md against client implementation
- Verified client signature algorithm matches server exactly (HKDF, recursive key sorting, JSON flags)
- Added `/update-check` endpoint documentation to `docs/server-implementation.md`
- Added `product_not_found` error code to error codes table
- Added `handleUpdateCheck()` handler example in WordPress plugin implementation
- Added `findProduct()` method stub for product lookups
- Added `getUpdateCheckArgs()` for request validation
- All 66 tests pass
**Learnings:**
- Client implementation was already fully aligned with server documentation
- Server-implementation.md needed update-check endpoint documentation (added in v0.2.1 client but not server docs)
- Documentation alignment is as important as code alignment for maintainability
- Remote server docs fetched successfully via WebFetch tool

View File

@@ -23,6 +23,7 @@ composer require magdev/wc-licensed-product-client
- License validation against domains - License validation against domains
- License activation on domains - License activation on domains
- License status checking - License status checking
- Plugin update checking
- Comprehensive exception handling - Comprehensive exception handling
- Code integrity verification - Code integrity verification
- Built on Symfony HttpClient - Built on Symfony HttpClient
@@ -52,6 +53,12 @@ echo "Status: " . $status->status->value;
// Activate a license // Activate a license
$result = $client->activate('ABCD-1234-EFGH-5678', 'example.com'); $result = $client->activate('ABCD-1234-EFGH-5678', 'example.com');
echo "Activated: " . ($result->success ? 'Yes' : 'No'); echo "Activated: " . ($result->success ? 'Yes' : 'No');
// Check for updates
$updateInfo = $client->checkForUpdates('ABCD-1234-EFGH-5678', 'example.com', 'my-plugin', '1.0.0');
if ($updateInfo->updateAvailable) {
echo "New version available: " . $updateInfo->version;
}
``` ```
### With Logging ### With Logging
@@ -138,6 +145,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:
@@ -153,6 +167,7 @@ This client interacts with the following WooCommerce Licensed Product API endpoi
- **POST /validate** - Validate a license key for a specific domain - **POST /validate** - Validate a license key for a specific domain
- **POST /status** - Get detailed license status information - **POST /status** - Get detailed license status information
- **POST /activate** - Activate a license on a domain - **POST /activate** - Activate a license on a domain
- **POST /update-check** - Check for plugin updates
## License ## License

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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'],

View File

@@ -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) {
try {
$expiresAt = new \DateTimeImmutable($data['expires_at']); $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(

View File

@@ -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) {
try {
$expiresAt = new \DateTimeImmutable($data['expires_at']); $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'],

110
src/Dto/UpdateInfo.php Normal file
View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Dto;
/**
* Represents update information returned by the update-check endpoint.
*/
final readonly class UpdateInfo
{
/**
* @param bool $updateAvailable Whether an update is available
* @param string|null $version Latest available version
* @param string|null $slug Plugin slug for WordPress
* @param string|null $plugin Plugin basename (slug/slug.php)
* @param string|null $downloadUrl Secure download URL for the update package
* @param \DateTimeImmutable|null $lastUpdated Date of the latest release
* @param string|null $tested Highest WordPress version tested with
* @param string|null $requires Minimum required WordPress version
* @param string|null $requiresPhp Minimum required PHP version
* @param string|null $changelog Release notes/changelog for the update
* @param string|null $packageHash SHA256 hash of the package for integrity verification
* @param string|null $name Product name
* @param string|null $homepage Product homepage URL
* @param array<string, string>|null $icons Plugin icons for WordPress admin
* @param array<string, string>|null $sections Content sections for plugin info modal
*/
public function __construct(
public bool $updateAvailable,
public ?string $version = null,
public ?string $slug = null,
public ?string $plugin = null,
public ?string $downloadUrl = null,
public ?\DateTimeImmutable $lastUpdated = null,
public ?string $tested = null,
public ?string $requires = null,
public ?string $requiresPhp = null,
public ?string $changelog = null,
public ?string $packageHash = null,
public ?string $name = null,
public ?string $homepage = null,
public ?array $icons = null,
public ?array $sections = null,
) {
}
public static function fromArray(array $data): self
{
if (!isset($data['update_available']) || !is_bool($data['update_available'])) {
throw new \InvalidArgumentException('Invalid response: missing or invalid update_available');
}
$lastUpdated = null;
if (isset($data['last_updated']) && $data['last_updated'] !== null) {
try {
$lastUpdated = new \DateTimeImmutable($data['last_updated']);
} catch (\Exception $e) {
throw new \InvalidArgumentException(
'Invalid response: invalid date format for last_updated',
0,
$e
);
}
}
$icons = null;
if (isset($data['icons']) && is_array($data['icons'])) {
$icons = $data['icons'];
}
$sections = null;
if (isset($data['sections']) && is_array($data['sections'])) {
$sections = $data['sections'];
}
return new self(
updateAvailable: $data['update_available'],
version: isset($data['version']) && is_string($data['version']) ? $data['version'] : null,
slug: isset($data['slug']) && is_string($data['slug']) ? $data['slug'] : null,
plugin: isset($data['plugin']) && is_string($data['plugin']) ? $data['plugin'] : null,
downloadUrl: isset($data['download_url']) && is_string($data['download_url']) ? $data['download_url'] : (
isset($data['package']) && is_string($data['package']) ? $data['package'] : null
),
lastUpdated: $lastUpdated,
tested: isset($data['tested']) && is_string($data['tested']) ? $data['tested'] : null,
requires: isset($data['requires']) && is_string($data['requires']) ? $data['requires'] : null,
requiresPhp: isset($data['requires_php']) && is_string($data['requires_php']) ? $data['requires_php'] : null,
changelog: isset($data['changelog']) && is_string($data['changelog']) ? $data['changelog'] : null,
packageHash: isset($data['package_hash']) && is_string($data['package_hash']) ? $data['package_hash'] : null,
name: isset($data['name']) && is_string($data['name']) ? $data['name'] : null,
homepage: isset($data['homepage']) && is_string($data['homepage']) ? $data['homepage'] : null,
icons: $icons,
sections: $sections,
);
}
/**
* Check if the package hash is valid against the expected format.
*/
public function hasValidPackageHash(): bool
{
if ($this->packageHash === null) {
return false;
}
// Package hash format: "sha256:hexstring"
return preg_match('/^sha256:[a-f0-9]{64}$/i', $this->packageHash) === 1;
}
}

View File

@@ -29,6 +29,7 @@ class LicenseException extends \RuntimeException
'domain_mismatch' => new DomainMismatchException($message, $errorCode, $httpCode), 'domain_mismatch' => new DomainMismatchException($message, $errorCode, $httpCode),
'max_activations_reached' => new MaxActivationsReachedException($message, $errorCode, $httpCode), 'max_activations_reached' => new MaxActivationsReachedException($message, $errorCode, $httpCode),
'activation_failed' => new ActivationFailedException($message, $errorCode, $httpCode), 'activation_failed' => new ActivationFailedException($message, $errorCode, $httpCode),
'product_not_found' => new ProductNotFoundException($message, $errorCode, $httpCode),
'rate_limit_exceeded' => new RateLimitExceededException( 'rate_limit_exceeded' => new RateLimitExceededException(
$message, $message,
$errorCode, $errorCode,

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Magdev\WcLicensedProductClient\Exception;
final class ProductNotFoundException extends LicenseException
{
}

View File

@@ -7,6 +7,7 @@ namespace Magdev\WcLicensedProductClient;
use Magdev\WcLicensedProductClient\Dto\ActivationResult; use Magdev\WcLicensedProductClient\Dto\ActivationResult;
use Magdev\WcLicensedProductClient\Dto\LicenseInfo; use Magdev\WcLicensedProductClient\Dto\LicenseInfo;
use Magdev\WcLicensedProductClient\Dto\LicenseStatus; use Magdev\WcLicensedProductClient\Dto\LicenseStatus;
use Magdev\WcLicensedProductClient\Dto\UpdateInfo;
use Magdev\WcLicensedProductClient\Exception\LicenseException; use Magdev\WcLicensedProductClient\Exception\LicenseException;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -18,6 +19,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 +37,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
@@ -132,6 +145,57 @@ final class LicenseClient implements LicenseClientInterface
return $result; return $result;
} }
public function checkForUpdates(
string $licenseKey,
string $domain,
?string $pluginSlug = null,
?string $currentVersion = null,
): UpdateInfo {
$cacheKey = $this->buildCacheKey('update-check', $licenseKey, $domain);
if ($this->cache !== null) {
$item = $this->cache->getItem($cacheKey);
if ($item->isHit()) {
$this->logger->debug('Update check cache hit', ['domain' => $domain]);
return $item->get();
}
}
$this->logger->info('Checking for updates', ['domain' => $domain]);
$payload = [
'license_key' => $licenseKey,
'domain' => $domain,
];
if ($pluginSlug !== null) {
$payload['plugin_slug'] = $pluginSlug;
}
if ($currentVersion !== null) {
$payload['current_version'] = $currentVersion;
}
$response = $this->request('update-check', $payload);
$result = UpdateInfo::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('Update check completed', [
'domain' => $domain,
'update_available' => $result->updateAvailable,
'version' => $result->version,
]);
return $result;
}
/** /**
* @throws LicenseException * @throws LicenseException
*/ */
@@ -165,13 +229,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 +253,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;
}
} }

View File

@@ -7,6 +7,7 @@ namespace Magdev\WcLicensedProductClient;
use Magdev\WcLicensedProductClient\Dto\ActivationResult; use Magdev\WcLicensedProductClient\Dto\ActivationResult;
use Magdev\WcLicensedProductClient\Dto\LicenseInfo; use Magdev\WcLicensedProductClient\Dto\LicenseInfo;
use Magdev\WcLicensedProductClient\Dto\LicenseStatus; use Magdev\WcLicensedProductClient\Dto\LicenseStatus;
use Magdev\WcLicensedProductClient\Dto\UpdateInfo;
use Magdev\WcLicensedProductClient\Exception\LicenseException; use Magdev\WcLicensedProductClient\Exception\LicenseException;
interface LicenseClientInterface interface LicenseClientInterface
@@ -31,4 +32,21 @@ interface LicenseClientInterface
* @throws LicenseException When activation fails * @throws LicenseException When activation fails
*/ */
public function activate(string $licenseKey, string $domain): ActivationResult; public function activate(string $licenseKey, string $domain): ActivationResult;
/**
* Check for available plugin updates.
*
* @param string $licenseKey The license key
* @param string $domain The domain the plugin is installed on
* @param string|null $pluginSlug Optional plugin slug for identification
* @param string|null $currentVersion Currently installed version for comparison
*
* @throws LicenseException When update check fails
*/
public function checkForUpdates(
string $licenseKey,
string $domain,
?string $pluginSlug = null,
?string $currentVersion = null,
): UpdateInfo;
} }

View File

@@ -7,6 +7,7 @@ namespace Magdev\WcLicensedProductClient;
use Magdev\WcLicensedProductClient\Dto\ActivationResult; use Magdev\WcLicensedProductClient\Dto\ActivationResult;
use Magdev\WcLicensedProductClient\Dto\LicenseInfo; use Magdev\WcLicensedProductClient\Dto\LicenseInfo;
use Magdev\WcLicensedProductClient\Dto\LicenseStatus; use Magdev\WcLicensedProductClient\Dto\LicenseStatus;
use Magdev\WcLicensedProductClient\Dto\UpdateInfo;
use Magdev\WcLicensedProductClient\Exception\LicenseException; use Magdev\WcLicensedProductClient\Exception\LicenseException;
use Magdev\WcLicensedProductClient\Security\IntegrityChecker; use Magdev\WcLicensedProductClient\Security\IntegrityChecker;
use Magdev\WcLicensedProductClient\Security\IntegrityException; use Magdev\WcLicensedProductClient\Security\IntegrityException;
@@ -17,7 +18,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 +34,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;
@@ -43,6 +53,7 @@ final class SecureLicenseClient implements LicenseClientInterface
private const ENCODED_VALIDATE = 'Egs4Oz4HMgE='; // validate private const ENCODED_VALIDATE = 'Egs4Oz4HMgE='; // validate
private const ENCODED_STATUS = 'NwgqKAcZ'; // status private const ENCODED_STATUS = 'NwgqKAcZ'; // status
private const ENCODED_ACTIVATE = 'Jggxfg4MEws='; // activate private const ENCODED_ACTIVATE = 'Jggxfg4MEws='; // activate
private const ENCODED_UPDATE_CHECK = 'Z8E5+5jwTPWoY64b'; // update-check
public function __construct( public function __construct(
private readonly HttpClientInterface $httpClient, private readonly HttpClientInterface $httpClient,
@@ -53,10 +64,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();
@@ -162,6 +175,58 @@ final class SecureLicenseClient implements LicenseClientInterface
return $result; return $result;
} }
public function checkForUpdates(
string $licenseKey,
string $domain,
?string $pluginSlug = null,
?string $currentVersion = null,
): UpdateInfo {
$cacheKey = $this->buildCacheKey('update-check', $licenseKey, $domain);
if ($this->cache !== null) {
$item = $this->cache->getItem($cacheKey);
if ($item->isHit()) {
$this->logger->debug('Update check cache hit', ['domain' => $domain]);
return $item->get();
}
}
$this->logger->info('Checking for updates', ['domain' => $domain]);
$payload = [
'license_key' => $licenseKey,
'domain' => $domain,
];
if ($pluginSlug !== null) {
$payload['plugin_slug'] = $pluginSlug;
}
if ($currentVersion !== null) {
$payload['current_version'] = $currentVersion;
}
$endpoint = $this->encoder->decode(self::ENCODED_UPDATE_CHECK);
$response = $this->secureRequest($endpoint, $payload, $licenseKey);
$result = UpdateInfo::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('Update check completed', [
'domain' => $domain,
'update_available' => $result->updateAvailable,
'version' => $result->version,
]);
return $result;
}
/** /**
* @throws LicenseException * @throws LicenseException
* @throws SignatureException * @throws SignatureException
@@ -200,13 +265,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 +307,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 +372,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;
}
} }

View File

@@ -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.
* *

View File

@@ -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();

View File

@@ -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.4.0",
"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": {
@@ -308,6 +332,148 @@
} }
} }
} }
},
"/update-check": {
"post": {
"operationId": "checkForUpdates",
"summary": "Check for plugin updates",
"description": "Checks if a newer version of the licensed product is available. Returns WordPress-compatible update information that can be used to integrate with WordPress's native plugin update system.",
"tags": ["Plugin Updates"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateCheckRequest"
},
"example": {
"license_key": "ABCD-1234-EFGH-5678",
"domain": "example.com",
"plugin_slug": "my-licensed-plugin",
"current_version": "1.0.0"
}
},
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/UpdateCheckRequest"
}
}
}
},
"responses": {
"200": {
"description": "Update check completed successfully",
"headers": {
"X-License-Signature": {
"$ref": "#/components/headers/X-License-Signature"
},
"X-License-Timestamp": {
"$ref": "#/components/headers/X-License-Timestamp"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateCheckResponse"
},
"examples": {
"update_available": {
"summary": "Update is available",
"value": {
"success": true,
"update_available": true,
"version": "1.2.0",
"slug": "my-licensed-plugin",
"plugin": "my-licensed-plugin/my-licensed-plugin.php",
"download_url": "https://example.com/license-download/123-456-abc123",
"package": "https://example.com/license-download/123-456-abc123",
"last_updated": "2026-01-27",
"tested": "6.7",
"requires": "6.0",
"requires_php": "8.3",
"changelog": "## 1.2.0\n- New feature added\n- Bug fixes",
"package_hash": "sha256:abc123def456...",
"name": "My Licensed Plugin",
"homepage": "https://example.com/product/my-plugin"
}
},
"no_update": {
"summary": "No update available",
"value": {
"success": true,
"update_available": false,
"version": "1.0.0"
}
}
}
}
}
},
"403": {
"description": "License validation failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"examples": {
"license_invalid": {
"summary": "License is not valid",
"value": {
"success": false,
"update_available": false,
"error": "license_invalid",
"message": "License validation failed."
}
},
"domain_mismatch": {
"summary": "Domain mismatch",
"value": {
"success": false,
"update_available": false,
"error": "domain_mismatch",
"message": "This license is not valid for this domain."
}
}
}
}
}
},
"404": {
"description": "License or product not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
},
"examples": {
"license_not_found": {
"summary": "License not found",
"value": {
"success": false,
"update_available": false,
"error": "license_not_found",
"message": "License not found."
}
},
"product_not_found": {
"summary": "Product not found",
"value": {
"success": false,
"update_available": false,
"error": "product_not_found",
"message": "Licensed product not found."
}
}
}
}
}
},
"429": {
"$ref": "#/components/responses/RateLimitExceeded"
}
}
}
} }
}, },
"components": { "components": {
@@ -449,6 +615,130 @@
} }
} }
}, },
"UpdateCheckRequest": {
"type": "object",
"required": ["license_key", "domain"],
"properties": {
"license_key": {
"type": "string",
"description": "The license key to validate (format: XXXX-XXXX-XXXX-XXXX)",
"maxLength": 64,
"example": "ABCD-1234-EFGH-5678"
},
"domain": {
"type": "string",
"description": "The domain the plugin is installed on",
"maxLength": 255,
"example": "example.com"
},
"plugin_slug": {
"type": "string",
"description": "The plugin slug (optional, for identification)",
"example": "my-licensed-plugin"
},
"current_version": {
"type": "string",
"description": "Currently installed version for comparison",
"example": "1.0.0"
}
}
},
"UpdateCheckResponse": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"description": "Whether the request was successful"
},
"update_available": {
"type": "boolean",
"description": "Whether an update is available"
},
"version": {
"type": "string",
"description": "Latest available version"
},
"slug": {
"type": "string",
"description": "Plugin slug for WordPress"
},
"plugin": {
"type": "string",
"description": "Plugin basename (slug/slug.php)"
},
"download_url": {
"type": "string",
"format": "uri",
"description": "Secure download URL for the update package"
},
"package": {
"type": "string",
"format": "uri",
"description": "Alias for download_url (WordPress compatibility)"
},
"last_updated": {
"type": "string",
"format": "date",
"description": "Date of the latest release"
},
"tested": {
"type": "string",
"description": "Highest WordPress version tested with"
},
"requires": {
"type": "string",
"description": "Minimum required WordPress version"
},
"requires_php": {
"type": "string",
"description": "Minimum required PHP version"
},
"changelog": {
"type": "string",
"description": "Release notes/changelog for the update"
},
"package_hash": {
"type": "string",
"description": "SHA256 hash of the package for integrity verification",
"example": "sha256:abc123..."
},
"name": {
"type": "string",
"description": "Product name"
},
"homepage": {
"type": "string",
"format": "uri",
"description": "Product homepage URL"
},
"icons": {
"type": "object",
"description": "Plugin icons for WordPress admin",
"properties": {
"1x": {
"type": "string",
"format": "uri"
},
"2x": {
"type": "string",
"format": "uri"
}
}
},
"sections": {
"type": "object",
"description": "Content sections for plugin info modal",
"properties": {
"description": {
"type": "string"
},
"changelog": {
"type": "string"
}
}
}
}
},
"ErrorResponse": { "ErrorResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -519,6 +809,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": [
@@ -533,6 +843,10 @@
{ {
"name": "License Activation", "name": "License Activation",
"description": "Activate licenses on domains" "description": "Activate licenses on domains"
},
{
"name": "Plugin Updates",
"description": "Check for plugin updates"
} }
] ]
} }