8 Commits

Author SHA1 Message Date
fc2fe70576 v0.2.1: Change SHA256 input to file upload field
- Replace SHA256 text input with file upload field for checksum files
- Add readChecksumFile() JavaScript function using FileReader API
- Support .sha256 and .txt checksum file formats
- Add Promise-based async handling for file reading
- Add localized error messages for checksum file validation
- Update translations (de_CH) with new strings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 17:13:27 +01:00
f5a1e55710 Add v0.2.0 release package
- wc-licensed-product-0.2.0.zip (486 KB)
- SHA256: 20d90f61721b4579cb979cd19b0262f3286c3510dcb0345fe5e8da2703e3836f

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 16:59:56 +01:00
4aecba3272 Merge branch 'dev' 2026-01-22 16:57:58 +01:00
23bbc24c5f Release v0.2.0 - Security and integrity features
- Add REST API response signing using HMAC-SHA256
- Add SHA256 hash validation for version file uploads
- Add ResponseSigner class for automatic API response signing
- Add file_hash column to database schema
- Remove external URL support from version uploads
- Update translations with all fuzzy strings resolved

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 16:57:54 +01:00
8420734f37 Update CLAUDE.md with v0.1.0 session history
- Removed completed 0.1.0 roadmap items
- Added comprehensive session history for v0.1.0 release
- Documented code review findings and bug fixes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 12:00:01 +01:00
968cd6a18f Merge branch 'dev' for v0.1.0 release 2026-01-22 11:57:16 +01:00
5256f88815 Prepare v0.1.0 release - code review and documentation updates
- Conducted comprehensive security and best practices review
- Fixed VersionManager null format handling for attachment updates
- Improved input sanitization in AdminController for page context checks
- Updated README.md with complete feature documentation
- Updated CHANGELOG.md with 0.1.0 release notes
- Updated translations (.pot, .po, .mo files) to version 0.1.0
- Bumped version to 0.1.0 in plugin header and constant

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 11:57:05 +01:00
d0c0756412 Update CLAUDE.md with v0.0.11 session history
Corrected session history to reflect actual v0.0.11 release:
- Created date column added to license overview
- Removed references to AnalyticsController (was deleted due to issues)
- Added release package details and SHA256 checksum

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 00:26:46 +01:00
18 changed files with 3474 additions and 1566 deletions

View File

@@ -7,6 +7,85 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.2.1] - 2026-01-22
### Changed
- SHA256 hash input changed from text field to file upload field
- Checksum files (.sha256 or .txt) can now be uploaded directly
- Improved user experience for version integrity verification
### Technical Details
- Added `readChecksumFile()` JavaScript function using FileReader API with Promise support
- Checksum file format supports both "hash filename" and plain "hash" formats
- Added localized error messages for checksum file validation
## [0.2.0] - 2026-01-22
### Added
- Response signing for REST API using HMAC-SHA256
- SHA256 hash field for product version uploads with checksum validation
- File integrity verification before storing uploaded version files
- New `ResponseSigner` class for automatic API response signing
- Database column `file_hash` in versions table for storing checksums
### Changed
- Version uploads now require file attachments (external URL option removed)
- API responses now include `X-License-Signature` and `X-License-Timestamp` headers when `WC_LICENSE_SERVER_SECRET` is configured
### Removed
- External download URL field from product version form
- Direct URL support in version uploads (use Media Library uploads only)
### Security
- API response signing prevents tampering and replay attacks
- Per-license key derivation using HKDF-like approach
- SHA256 checksum validation ensures file integrity
### Technical Details
- New class: `ResponseSigner` for HMAC-SHA256 response signing
- VersionManager extended with `$fileHash` parameter and validation
- ProductVersion model extended with `fileHash` property
- Signature algorithm: `HMAC-SHA256(derived_key, timestamp + ':' + canonical_json)`
- Key derivation: `HMAC-SHA256(HMAC-SHA256(license_key, server_secret) + "\x01", server_secret)`
- Compatible with `magdev/wc-licensed-product-client` SecureLicenseClient
### Configuration
To enable response signing, add to `wp-config.php`:
```php
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
```
## [0.1.0] - 2026-01-22
### Added
- First stable minor release
- Comprehensive code review for WordPress/WooCommerce best practices
- Security audit completed
### Changed
- Improved input sanitization for admin page context checks
- Fixed VersionManager null format handling for attachment updates
### Technical Details
- All code reviewed for OWASP Top 10 security vulnerabilities
- Verified proper nonce verification, capability checks, and input sanitization
- SQL injection prevention confirmed using `$wpdb->prepare()` throughout
- XSS prevention confirmed with proper output escaping
- Rate limiting verified on REST API endpoints
- README.md updated with full feature documentation
## [0.0.11] - 2026-01-22 ## [0.0.11] - 2026-01-22
### Added ### Added
@@ -275,7 +354,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- WordPress REST API integration - WordPress REST API integration
- Custom WooCommerce product type extending WC_Product - Custom WooCommerce product type extending WC_Product
[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.11...HEAD [Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.2.0...HEAD
[0.2.0]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.1.0...v0.2.0
[0.1.0]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.11...v0.1.0
[0.0.11]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.10...v0.0.11 [0.0.11]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.10...v0.0.11
[0.0.10]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.9...v0.0.10 [0.0.10]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.9...v0.0.10
[0.0.9]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.8...v0.0.9 [0.0.9]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.8...v0.0.9

137
CLAUDE.md
View File

@@ -36,6 +36,8 @@ 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
No planned features at this time. See Session History for completed work.
## Technical Stack ## Technical Stack
- **Language:** PHP 8.3.x - **Language:** PHP 8.3.x
@@ -594,35 +596,124 @@ Full API documentation available in `openapi.json` (OpenAPI 3.1 specification).
- SHA256: `3f4a093f6d4d02389082c3a88c00542f477ab3ad4d4a0c65079e524ef0739620` - SHA256: `3f4a093f6d4d02389082c3a88c00542f477ab3ad4d4a0c65079e524ef0739620`
- Tagged as `v0.0.10` and pushed to `main` branch - Tagged as `v0.0.10` and pushed to `main` branch
### 2026-01-21 - Version 0.0.11 Features ### 2026-01-22 - Version 0.0.11 Features
**Implemented:** **Implemented:**
- Created date column added to admin license overview - Created date column added to admin license overview showing when each license was generated
- License Statistics page under WooCommerce menu (WooCommerce > License Statistics)
- REST API endpoints for analytics data with time-series support
- WooCommerce Analytics integration via submenu page
**New files:**
- `src/Admin/AnalyticsController.php` - WooCommerce Analytics integration
- `templates/admin/statistics.html.twig` - Statistics page template
**New REST API endpoints:**
- `GET /wp-json/wc-licensed-product/v1/analytics/stats` - License statistics with time-series data (supports day/week/month/quarter/year intervals)
- `GET /wp-json/wc-licensed-product/v1/analytics/products` - License counts by product
**Modified files:** **Modified files:**
- `templates/admin/licenses.html.twig` - Added "Created" column - `templates/admin/licenses.html.twig` - Added "Created" column to table header and data cells
- `src/Admin/AdminController.php` - Added "Created" column to fallback rendering - `src/Admin/AdminController.php` - Added "Created" column to PHP fallback rendering
- `src/Plugin.php` - Added AnalyticsController initialization and `getInstance()` alias - `src/Plugin.php` - Added `getInstance()` alias for singleton access
**Technical notes:** **Technical notes:**
- Statistics page accessible via WooCommerce > License Statistics submenu - New column displays license creation date in Y-m-d format
- REST API endpoints support date range filtering (`after`, `before` parameters) - Both Twig template and PHP fallback updated for consistency
- Time-series data aggregation supports multiple intervals (day, week, month, quarter, year) - WooCommerce Analytics integration was attempted but removed due to WordPress permission issues with submenu pages
- AnalyticsController registers REST routes and renders statistics page
- Page uses existing dashboard CSS styles for consistent appearance **Release v0.0.11:**
- Created release package: `releases/wc-licensed-product-0.0.11.zip` (473 KB)
- SHA256: `c3f66c4ac54741053f87ce1a63b4ddb49ad9707d5c194a271311bb95518ab13c`
- Tagged as `v0.0.11` and pushed to `main` branch
### 2026-01-22 - Version 0.1.0 - First Stable Minor Release
**Overview:**
First stable minor release after comprehensive code review for WordPress/WooCommerce best practices and security.
**Code Review Findings:**
Security practices verified:
- Input sanitization with `sanitize_text_field()`, `absint()`, `esc_attr()`, `esc_html()`, `esc_url()`
- Nonce verification on all forms and AJAX handlers
- Capability checks with `current_user_can('manage_woocommerce')`
- SQL injection prevention using `$wpdb->prepare()` throughout
- Secure download URLs with hash verification using `hash_equals()`
- Rate limiting on REST API (30 requests/minute)
- Cryptographically secure license key generation with `random_int()`
**Bug Fixes:**
- Fixed `VersionManager::updateVersion()` null format handling for attachment ID updates
- Improved input sanitization in `AdminController::enqueueAdminAssets()` for page context checks
**Documentation Updates:**
- Updated README.md with complete feature documentation
- Added new features: Live Search, Inline Editing, Order Integration, WooCommerce HPOS compatibility, Checkout Blocks support
- Removed outdated "Current Version" field from usage instructions
**Translation Updates:**
- Regenerated .pot template with all current strings
- Updated German (de_CH) translation with new strings
- Compiled .mo file for production use
**Modified files:**
- `src/Product/VersionManager.php` - Fixed null format handling in attachment update
- `src/Admin/AdminController.php` - Improved $_GET sanitization for page context
- `README.md` - Updated feature documentation
- `CHANGELOG.md` - Added 0.1.0 release notes
- `wc-licensed-product.php` - Version bump to 0.1.0
- `languages/*` - Updated all translation files
**Release v0.1.0:**
- Created release package: `releases/wc-licensed-product-0.1.0.zip` (478 KB)
- SHA256: `62638e240315107098be4cb40faff8395e9e1b719d79b73d80e69d680b305e87`
- Tagged as `v0.1.0` and pushed to `main` branch
### 2026-01-22 - Version 0.2.0 - Security & Integrity Features
**Overview:**
Added response signing for REST API security and SHA256 checksum validation for uploaded version files.
**Implemented:**
- REST API response signing using HMAC-SHA256 for tamper-proof responses
- SHA256 hash field for product version uploads with server-side validation
- Per-license key derivation using HKDF-like approach
- Automatic signature headers on license API endpoints
**Removed:**
- External download URL field from product version form
- Direct URL support in version uploads (Media Library only now)
**New files:**
- `src/Api/ResponseSigner.php` - HMAC-SHA256 response signing class
**Modified files:**
- `src/Installer.php` - Added `file_hash` column to versions table schema
- `src/Product/ProductVersion.php` - Added `fileHash` property and getter
- `src/Product/VersionManager.php` - Removed `$downloadUrl` parameter, added `$fileHash` with validation
- `src/Admin/VersionAdminController.php` - Removed URL field, added SHA256 hash field
- `assets/js/versions.js` - Updated form handling for hash field
- `src/Plugin.php` - Initialize ResponseSigner when server secret is configured
**Technical notes:**
- Response signing only activates when `WC_LICENSE_SERVER_SECRET` constant is defined
- Signature algorithm: `HMAC-SHA256(derived_key, timestamp + ':' + canonical_json)`
- Key derivation: `HMAC-SHA256(HMAC-SHA256(license_key, server_secret) + "\x01", server_secret)`
- Hash validation throws `InvalidArgumentException` on mismatch
- Compatible with `magdev/wc-licensed-product-client` SecureLicenseClient
- Database migration handled by WordPress `dbDelta()` function
**Configuration:**
To enable response signing, add to `wp-config.php`:
```php
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
```

View File

@@ -17,6 +17,7 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
- **Version Binding**: Optional binding to major software versions - **Version Binding**: Optional binding to major software versions
- **Expiration Support**: Set license validity periods or lifetime licenses - **Expiration Support**: Set license validity periods or lifetime licenses
- **Rate Limiting**: API endpoints protected with rate limiting (30 requests/minute) - **Rate Limiting**: API endpoints protected with rate limiting (30 requests/minute)
- **Checkout Blocks**: Full support for WooCommerce Checkout Blocks (default since WC 8.3+)
### Customer Features ### Customer Features
@@ -30,12 +31,16 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
- **License Management**: Full CRUD interface for license management - **License Management**: Full CRUD interface for license management
- **License Dashboard**: Statistics and analytics (WooCommerce > Reports > Licenses) - **License Dashboard**: Statistics and analytics (WooCommerce > Reports > Licenses)
- **Search & Filtering**: Search by license key, domain, status, or product - **Search & Filtering**: Search by license key, domain, status, or product
- **Live Search**: AJAX-powered instant search results
- **Inline Editing**: Edit license status, expiry, and domain directly in the list
- **Bulk Operations**: Activate, deactivate, revoke, extend, or delete multiple licenses - **Bulk Operations**: Activate, deactivate, revoke, extend, or delete multiple licenses
- **License Transfer**: Transfer licenses to new domains - **License Transfer**: Transfer licenses to new domains
- **CSV Export/Import**: Export and import licenses via CSV - **CSV Export/Import**: Export and import licenses via CSV
- **Order Integration**: View and manage licenses directly from order pages
- **Expiration Warnings**: Automatic email notifications before license expiration - **Expiration Warnings**: Automatic email notifications before license expiration
- **Version Management**: Manage multiple versions per product with file attachments - **Version Management**: Manage multiple versions per product with file attachments
- **Global Settings**: Default license settings via WooCommerce settings tab - **Global Settings**: Default license settings via WooCommerce settings tab
- **WooCommerce HPOS**: Compatible with High-Performance Order Storage
## Requirements ## Requirements
@@ -60,7 +65,6 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
- **Max Activations**: Number of domains allowed per license - **Max Activations**: Number of domains allowed per license
- **License Validity**: Days until expiration (empty = lifetime) - **License Validity**: Days until expiration (empty = lifetime)
- **Bind to Major Version**: Lock license to current major version - **Bind to Major Version**: Lock license to current major version
- **Current Version**: Your software's current version
### Managing Product Versions ### Managing Product Versions

View File

@@ -78,14 +78,14 @@
$('#selected_file_name').text(attachment.filename); $('#selected_file_name').text(attachment.filename);
$('#remove-version-file-btn').show(); $('#remove-version-file-btn').show();
// Show SHA256 hash field
$('#sha256-hash-row').show();
// Try to extract version from filename // Try to extract version from filename
var extractedVersion = self.extractVersionFromFilename(attachment.filename); var extractedVersion = self.extractVersionFromFilename(attachment.filename);
if (extractedVersion && !$('#new_version').val().trim()) { if (extractedVersion && !$('#new_version').val().trim()) {
$('#new_version').val(extractedVersion); $('#new_version').val(extractedVersion);
} }
// Clear external URL when file is selected
$('#new_download_url').val('');
}); });
this.mediaFrame.open(); this.mediaFrame.open();
@@ -100,6 +100,39 @@
$('#new_attachment_id').val(''); $('#new_attachment_id').val('');
$('#selected_file_name').text(''); $('#selected_file_name').text('');
$('#remove-version-file-btn').hide(); $('#remove-version-file-btn').hide();
// Hide and clear checksum file field
$('#sha256-hash-row').hide();
$('#new_checksum_file').val('');
},
/**
* Read checksum from uploaded file
* Supports formats: "hash filename" or just "hash"
*/
readChecksumFile: function(file) {
return new Promise(function(resolve, reject) {
if (!file) {
resolve('');
return;
}
var reader = new FileReader();
reader.onload = function(e) {
var content = e.target.result.trim();
// Extract hash from content (format: "hash filename" or just "hash")
var match = content.match(/^([a-fA-F0-9]{64})/);
if (match) {
resolve(match[1].toLowerCase());
} else {
reject(new Error(wcLicensedProductVersions.strings.invalidChecksumFile || 'Invalid checksum file format'));
}
};
reader.onerror = function() {
reject(new Error(wcLicensedProductVersions.strings.checksumReadError || 'Failed to read checksum file'));
};
reader.readAsText(file);
});
}, },
/** /**
@@ -130,13 +163,14 @@
addVersion: function(e) { addVersion: function(e) {
e.preventDefault(); e.preventDefault();
var self = WCLicensedProductVersions;
var $btn = $(this); var $btn = $(this);
var $spinner = $btn.siblings('.spinner'); var $spinner = $btn.siblings('.spinner');
var productId = $btn.data('product-id'); var productId = $btn.data('product-id');
var version = $('#new_version').val().trim(); var version = $('#new_version').val().trim();
var downloadUrl = $('#new_download_url').val().trim();
var releaseNotes = $('#new_release_notes').val().trim(); var releaseNotes = $('#new_release_notes').val().trim();
var attachmentId = $('#new_attachment_id').val(); var attachmentId = $('#new_attachment_id').val();
var checksumFile = $('#new_checksum_file')[0].files[0];
// Validate version // Validate version
if (!version) { if (!version) {
@@ -152,44 +186,52 @@
$btn.prop('disabled', true); $btn.prop('disabled', true);
$spinner.addClass('is-active'); $spinner.addClass('is-active');
$.ajax({ // Read checksum file if provided, then submit
url: wcLicensedProductVersions.ajaxUrl, self.readChecksumFile(checksumFile).then(function(fileHash) {
type: 'POST', $.ajax({
data: { url: wcLicensedProductVersions.ajaxUrl,
action: 'wc_licensed_product_add_version', type: 'POST',
nonce: wcLicensedProductVersions.nonce, data: {
product_id: productId, action: 'wc_licensed_product_add_version',
version: version, nonce: wcLicensedProductVersions.nonce,
download_url: downloadUrl, product_id: productId,
release_notes: releaseNotes, version: version,
attachment_id: attachmentId release_notes: releaseNotes,
}, attachment_id: attachmentId,
success: function(response) { file_hash: fileHash
if (response.success) { },
// Remove "no versions" row if present success: function(response) {
$('#versions-table tbody .no-versions').remove(); if (response.success) {
// Remove "no versions" row if present
$('#versions-table tbody .no-versions').remove();
// Add new row to table // Add new row to table
$('#versions-table tbody').prepend(response.data.html); $('#versions-table tbody').prepend(response.data.html);
// Clear form // Clear form
$('#new_version').val(''); $('#new_version').val('');
$('#new_download_url').val(''); $('#new_release_notes').val('');
$('#new_release_notes').val(''); $('#new_attachment_id').val('');
$('#new_attachment_id').val(''); $('#selected_file_name').text('');
$('#selected_file_name').text(''); $('#remove-version-file-btn').hide();
$('#remove-version-file-btn').hide(); $('#sha256-hash-row').hide();
} else { $('#new_checksum_file').val('');
alert(response.data.message || wcLicensedProductVersions.strings.error); } else {
alert(response.data.message || wcLicensedProductVersions.strings.error);
}
},
error: function() {
alert(wcLicensedProductVersions.strings.error);
},
complete: function() {
$btn.prop('disabled', false);
$spinner.removeClass('is-active');
} }
}, });
error: function() { }).catch(function(error) {
alert(wcLicensedProductVersions.strings.error); alert(error.message);
}, $btn.prop('disabled', false);
complete: function() { $spinner.removeClass('is-active');
$btn.prop('disabled', false);
$spinner.removeClass('is-active');
}
}); });
}, },

View File

@@ -0,0 +1,393 @@
# Server-Side Response Signing Implementation
This document describes how to implement response signing on the server side (e.g., in the WooCommerce Licensed Product plugin) to work with the `SecureLicenseClient`.
## Overview
The security model works as follows:
1. Server generates a unique signature for each response using HMAC-SHA256
2. Signature includes a timestamp to prevent replay attacks
3. Client verifies the signature using a shared secret
4. Invalid signatures cause the client to reject the response
This prevents attackers from:
- Faking valid license responses
- Replaying old responses
- Tampering with response data
## Requirements
- PHP 7.4+ (8.0+ recommended)
- A server secret stored securely (not in version control)
## Server Configuration
### 1. Store the Server Secret
Add a secret key to your WordPress configuration:
```php
// wp-config.php or secure configuration file
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
```
Generate a secure secret:
```bash
# Using OpenSSL
openssl rand -hex 32
# Or using PHP
php -r "echo bin2hex(random_bytes(32));"
```
**IMPORTANT:** Never commit this secret to version control!
## Implementation
### Key Derivation
Each license key gets a unique signing key derived from the server secret:
```php
/**
* Derive a unique signing key for a license.
*
* @param string $licenseKey The license key
* @param string $serverSecret The server's master secret
* @return string The derived key (hex encoded)
*/
function derive_signing_key(string $licenseKey, string $serverSecret): string
{
// HKDF-like key derivation
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
return hash_hmac('sha256', $prk . "\x01", $serverSecret);
}
```
### Response Signing
Sign every API response before sending:
```php
/**
* Sign an API response.
*
* @param array $responseData The response body (before JSON encoding)
* @param string $licenseKey The license key from the request
* @param string $serverSecret The server's master secret
* @return array Headers to add to the response
*/
function sign_response(array $responseData, string $licenseKey, string $serverSecret): array
{
$timestamp = time();
$signingKey = derive_signing_key($licenseKey, $serverSecret);
// Sort keys for consistent ordering
ksort($responseData);
// Build signature payload
$jsonBody = json_encode($responseData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$payload = $timestamp . ':' . $jsonBody;
// Generate HMAC signature
$signature = hash_hmac('sha256', $payload, $signingKey);
return [
'X-License-Signature' => $signature,
'X-License-Timestamp' => (string) $timestamp,
];
}
```
### WordPress REST API Integration
Example integration with WooCommerce REST API:
```php
/**
* Add signature headers to license API responses.
*/
add_filter('rest_post_dispatch', function($response, $server, $request) {
// Only sign license API responses
if (!str_starts_with($request->get_route(), '/wc-licensed-product/v1/')) {
return $response;
}
// Get the response data
$data = $response->get_data();
// Get the license key from the request
$licenseKey = $request->get_param('license_key');
if (empty($licenseKey) || !is_array($data)) {
return $response;
}
// Sign the response
$serverSecret = defined('WC_LICENSE_SERVER_SECRET')
? WC_LICENSE_SERVER_SECRET
: '';
if (empty($serverSecret)) {
// Log warning: server secret not configured
return $response;
}
$signatureHeaders = sign_response($data, $licenseKey, $serverSecret);
// Add headers to response
foreach ($signatureHeaders as $name => $value) {
$response->header($name, $value);
}
return $response;
}, 10, 3);
```
### Complete WordPress Plugin Example
```php
<?php
/**
* Plugin Name: WC Licensed Product Signature
* Description: Adds response signing to WC Licensed Product API
* Version: 1.0.0
*/
namespace WcLicensedProduct\Security;
class ResponseSigner
{
private string $serverSecret;
public function __construct()
{
$this->serverSecret = defined('WC_LICENSE_SERVER_SECRET')
? WC_LICENSE_SERVER_SECRET
: '';
}
public function register(): void
{
add_filter('rest_post_dispatch', [$this, 'signResponse'], 10, 3);
}
public function signResponse($response, $server, $request)
{
if (!$this->shouldSign($request)) {
return $response;
}
$data = $response->get_data();
$licenseKey = $request->get_param('license_key');
if (empty($licenseKey) || !is_array($data) || empty($this->serverSecret)) {
return $response;
}
$headers = $this->createSignatureHeaders($data, $licenseKey);
foreach ($headers as $name => $value) {
$response->header($name, $value);
}
return $response;
}
private function shouldSign($request): bool
{
$route = $request->get_route();
return str_starts_with($route, '/wc-licensed-product/v1/validate')
|| str_starts_with($route, '/wc-licensed-product/v1/status')
|| str_starts_with($route, '/wc-licensed-product/v1/activate');
}
private function createSignatureHeaders(array $data, string $licenseKey): array
{
$timestamp = time();
$signingKey = $this->deriveKey($licenseKey);
ksort($data);
$payload = $timestamp . ':' . json_encode(
$data,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
return [
'X-License-Signature' => hash_hmac('sha256', $payload, $signingKey),
'X-License-Timestamp' => (string) $timestamp,
];
}
private function deriveKey(string $licenseKey): string
{
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true);
return hash_hmac('sha256', $prk . "\x01", $this->serverSecret);
}
}
// Initialize
add_action('init', function() {
(new ResponseSigner())->register();
});
```
## Response Format
### Headers
Every signed response includes:
| Header | Description | Example |
| -------- | ------------- | --------- |
| `X-License-Signature` | HMAC-SHA256 signature (hex) | `a1b2c3d4...` (64 chars) |
| `X-License-Timestamp` | Unix timestamp when signed | `1706000000` |
### Signature Algorithm
```text
signature = HMAC-SHA256(
key = derive_signing_key(license_key, server_secret),
message = timestamp + ":" + canonical_json(response_body)
)
```
Where:
- `derive_signing_key` uses HKDF-like derivation (see above)
- `canonical_json` sorts keys alphabetically, no escaping of slashes/unicode
- Result is hex-encoded (64 characters)
## Testing
### Verify Signing Works
```php
// Test script
$serverSecret = 'test-secret-key-for-development-only';
$licenseKey = 'ABCD-1234-EFGH-5678';
$responseData = [
'valid' => true,
'license' => [
'product_id' => 123,
'expires_at' => '2027-01-21',
'version_id' => null,
],
];
$headers = sign_response($responseData, $licenseKey, $serverSecret);
echo "X-License-Signature: " . $headers['X-License-Signature'] . "\n";
echo "X-License-Timestamp: " . $headers['X-License-Timestamp'] . "\n";
```
### Test with Client
```php
use Magdev\WcLicensedProductClient\SecureLicenseClient;
use Symfony\Component\HttpClient\HttpClient;
$client = new SecureLicenseClient(
httpClient: HttpClient::create(),
baseUrl: 'https://your-site.com',
serverSecret: 'same-secret-as-server',
);
try {
$info = $client->validate('ABCD-1234-EFGH-5678', 'example.com');
echo "License valid! Product ID: " . $info->productId;
} catch (SignatureException $e) {
echo "Signature verification failed - possible tampering!";
}
```
## Security Considerations
### Timestamp Tolerance
The client allows a 5-minute window for timestamp verification. This:
- Prevents replay attacks (old responses rejected)
- Allows for reasonable clock skew between server and client
Adjust if needed:
```php
// Client-side: custom tolerance
$signature = new ResponseSignature($key, timestampTolerance: 600); // 10 minutes
```
### Secret Key Rotation
To rotate the server secret:
1. Deploy new secret to server
2. Update client configurations
3. Old signatures become invalid immediately
For zero-downtime rotation, implement versioned secrets:
```php
// Server supports both old and new secrets during transition
$secrets = [
'v2' => 'new-secret',
'v1' => 'old-secret',
];
// Add version to signature header
$response->header('X-License-Signature-Version', 'v2');
```
### Error Responses
Sign error responses too! Otherwise attackers could craft fake error messages:
```php
// Sign both success and error responses
$errorData = [
'valid' => false,
'error' => 'license_expired',
'message' => 'This license has expired.',
];
$headers = sign_response($errorData, $licenseKey, $serverSecret);
```
## Troubleshooting
### "Response is not signed by the server"
- Server not configured with `WC_LICENSE_SERVER_SECRET`
- Filter not registered (check plugin activation)
- Route mismatch (check `shouldSign()` paths)
### "Response signature verification failed"
- Different secrets on server/client
- Clock skew > 5 minutes
- Response body modified after signing (e.g., by caching plugin)
- JSON encoding differences (check `ksort` and flags)
### Debugging
Enable detailed logging:
```php
// Server-side
error_log('Signing response for: ' . $licenseKey);
error_log('Timestamp: ' . $timestamp);
error_log('Payload: ' . $payload);
error_log('Signature: ' . $signature);
// Client-side: use a PSR-3 logger
$client = new SecureLicenseClient(
// ...
logger: new YourDebugLogger(),
);
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
20d90f61721b4579cb979cd19b0262f3286c3510dcb0345fe5e8da2703e3836f wc-licensed-product-0.2.0.zip

Binary file not shown.

View File

@@ -105,7 +105,9 @@ final class AdminController
{ {
// Check for our pages and WooCommerce Reports page with licenses tab // Check for our pages and WooCommerce Reports page with licenses tab
$isLicensePage = in_array($hook, ['woocommerce_page_wc-licenses', 'woocommerce_page_wc-license-dashboard'], true); $isLicensePage = in_array($hook, ['woocommerce_page_wc-licenses', 'woocommerce_page_wc-license-dashboard'], true);
$isReportsPage = $hook === 'woocommerce_page_wc-reports' && isset($_GET['tab']) && $_GET['tab'] === 'licenses'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only checking current page context
$currentTab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : '';
$isReportsPage = $hook === 'woocommerce_page_wc-reports' && $currentTab === 'licenses';
if (!$isLicensePage && !$isReportsPage) { if (!$isLicensePage && !$isReportsPage) {
return; return;

View File

@@ -98,11 +98,11 @@ final class VersionAdminController
<p class="description"><?php esc_html_e('Upload or select a file from the media library. Version will be auto-detected from filename (e.g., plugin-v1.2.3.zip).', 'wc-licensed-product'); ?></p> <p class="description"><?php esc_html_e('Upload or select a file from the media library. Version will be auto-detected from filename (e.g., plugin-v1.2.3.zip).', 'wc-licensed-product'); ?></p>
</td> </td>
</tr> </tr>
<tr> <tr id="sha256-hash-row" style="display: none;">
<th><label for="new_download_url"><?php esc_html_e('Or External URL', 'wc-licensed-product'); ?></label></th> <th><label for="new_checksum_file"><?php esc_html_e('Checksum File', 'wc-licensed-product'); ?></label></th>
<td> <td>
<input type="url" id="new_download_url" name="new_download_url" class="large-text" placeholder="https://" /> <input type="file" id="new_checksum_file" name="new_checksum_file" accept=".sha256,.txt" />
<p class="description"><?php esc_html_e('Alternative: Enter an external download URL instead of uploading a file.', 'wc-licensed-product'); ?></p> <p class="description"><?php esc_html_e('Upload a SHA256 checksum file (.sha256 or .txt) to verify file integrity.', 'wc-licensed-product'); ?></p>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -218,6 +218,8 @@ final class VersionAdminController
'error' => __('An error occurred. Please try again.', 'wc-licensed-product'), 'error' => __('An error occurred. Please try again.', 'wc-licensed-product'),
'selectFile' => __('Select Download File', 'wc-licensed-product'), 'selectFile' => __('Select Download File', 'wc-licensed-product'),
'useThisFile' => __('Use this file', 'wc-licensed-product'), 'useThisFile' => __('Use this file', 'wc-licensed-product'),
'invalidChecksumFile' => __('Invalid checksum file format. File must contain a 64-character SHA256 hash.', 'wc-licensed-product'),
'checksumReadError' => __('Failed to read checksum file.', 'wc-licensed-product'),
], ],
]); ]);
@@ -242,9 +244,9 @@ final class VersionAdminController
$productId = absint($_POST['product_id'] ?? 0); $productId = absint($_POST['product_id'] ?? 0);
$version = sanitize_text_field($_POST['version'] ?? ''); $version = sanitize_text_field($_POST['version'] ?? '');
$downloadUrl = esc_url_raw($_POST['download_url'] ?? '');
$releaseNotes = sanitize_textarea_field($_POST['release_notes'] ?? ''); $releaseNotes = sanitize_textarea_field($_POST['release_notes'] ?? '');
$attachmentId = absint($_POST['attachment_id'] ?? 0); $attachmentId = absint($_POST['attachment_id'] ?? 0);
$fileHash = sanitize_text_field($_POST['file_hash'] ?? '');
if (!$productId || !$version) { if (!$productId || !$version) {
wp_send_json_error(['message' => __('Product ID and version are required.', 'wc-licensed-product')]); wp_send_json_error(['message' => __('Product ID and version are required.', 'wc-licensed-product')]);
@@ -270,13 +272,17 @@ final class VersionAdminController
wp_send_json_error(['message' => __('This product is not a licensed product.', 'wc-licensed-product')]); wp_send_json_error(['message' => __('This product is not a licensed product.', 'wc-licensed-product')]);
} }
$newVersion = $this->versionManager->createVersion( try {
$productId, $newVersion = $this->versionManager->createVersion(
$version, $productId,
$releaseNotes ?: null, $version,
$downloadUrl ?: null, $releaseNotes ?: null,
$attachmentId ?: null $attachmentId ?: null,
); $fileHash ?: null
);
} catch (\InvalidArgumentException $e) {
wp_send_json_error(['message' => $e->getMessage()]);
}
if (!$newVersion) { if (!$newVersion) {
global $wpdb; global $wpdb;

128
src/Api/ResponseSigner.php Normal file
View File

@@ -0,0 +1,128 @@
<?php
/**
* Response Signer
*
* Signs REST API responses to prevent tampering and replay attacks.
*
* @package Jeremias\WcLicensedProduct\Api
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Api;
/**
* Signs license API responses using HMAC-SHA256
*
* The security model:
* 1. Server generates a unique signature for each response using HMAC-SHA256
* 2. Signature includes a timestamp to prevent replay attacks
* 3. Client verifies the signature using a shared secret
* 4. Invalid signatures cause the client to reject the response
*/
final class ResponseSigner
{
private string $serverSecret;
public function __construct()
{
$this->serverSecret = defined('WC_LICENSE_SERVER_SECRET')
? WC_LICENSE_SERVER_SECRET
: '';
}
/**
* Register WordPress hooks
*/
public function register(): void
{
add_filter('rest_post_dispatch', [$this, 'signResponse'], 10, 3);
}
/**
* Sign REST API response
*
* @param \WP_REST_Response $response The response object
* @param \WP_REST_Server $server The REST server
* @param \WP_REST_Request $request The request object
* @return \WP_REST_Response
*/
public function signResponse($response, $server, $request)
{
// Only sign license API responses
if (!$this->shouldSign($request)) {
return $response;
}
$data = $response->get_data();
$licenseKey = $request->get_param('license_key');
if (empty($licenseKey) || !is_array($data) || empty($this->serverSecret)) {
return $response;
}
$headers = $this->createSignatureHeaders($data, $licenseKey);
foreach ($headers as $name => $value) {
$response->header($name, $value);
}
return $response;
}
/**
* Check if request should be signed
*/
private function shouldSign(\WP_REST_Request $request): bool
{
$route = $request->get_route();
return str_starts_with($route, '/wc-licensed-product/v1/validate')
|| str_starts_with($route, '/wc-licensed-product/v1/status')
|| str_starts_with($route, '/wc-licensed-product/v1/activate');
}
/**
* Create signature headers for response
*
* @param array $data The response data
* @param string $licenseKey The license key from the request
* @return array Associative array of headers
*/
private function createSignatureHeaders(array $data, string $licenseKey): array
{
$timestamp = time();
$signingKey = $this->deriveKey($licenseKey);
// Sort keys for consistent ordering
ksort($data);
// Build signature payload
$payload = $timestamp . ':' . json_encode(
$data,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
return [
'X-License-Signature' => hash_hmac('sha256', $payload, $signingKey),
'X-License-Timestamp' => (string) $timestamp,
];
}
/**
* Derive a unique signing key for a license
*
* Uses HKDF-like key derivation to create a unique signing key
* for each license key, preventing cross-license signature attacks.
*
* @param string $licenseKey The license key
* @return string The derived signing key (hex encoded)
*/
private function deriveKey(string $licenseKey): string
{
// HKDF-like key derivation
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true);
return hash_hmac('sha256', $prk . "\x01", $this->serverSecret);
}
}

View File

@@ -102,6 +102,7 @@ final class Installer
release_notes TEXT DEFAULT NULL, release_notes TEXT DEFAULT NULL,
download_url VARCHAR(512) DEFAULT NULL, download_url VARCHAR(512) DEFAULT NULL,
attachment_id BIGINT UNSIGNED DEFAULT NULL, attachment_id BIGINT UNSIGNED DEFAULT NULL,
file_hash VARCHAR(64) DEFAULT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1, is_active TINYINT(1) NOT NULL DEFAULT 1,
released_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, released_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,

View File

@@ -13,6 +13,7 @@ use Jeremias\WcLicensedProduct\Admin\AdminController;
use Jeremias\WcLicensedProduct\Admin\OrderLicenseController; use Jeremias\WcLicensedProduct\Admin\OrderLicenseController;
use Jeremias\WcLicensedProduct\Admin\SettingsController; use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\Admin\VersionAdminController; use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
use Jeremias\WcLicensedProduct\Api\ResponseSigner;
use Jeremias\WcLicensedProduct\Api\RestApiController; use Jeremias\WcLicensedProduct\Api\RestApiController;
use Jeremias\WcLicensedProduct\Checkout\CheckoutBlocksIntegration; use Jeremias\WcLicensedProduct\Checkout\CheckoutBlocksIntegration;
use Jeremias\WcLicensedProduct\Checkout\CheckoutController; use Jeremias\WcLicensedProduct\Checkout\CheckoutController;
@@ -128,6 +129,11 @@ final class Plugin
new RestApiController($this->licenseManager); new RestApiController($this->licenseManager);
new LicenseEmailController($this->licenseManager); new LicenseEmailController($this->licenseManager);
// Initialize response signing if server secret is configured
if (defined('WC_LICENSE_SERVER_SECRET') && WC_LICENSE_SERVER_SECRET !== '') {
(new ResponseSigner())->register();
}
if (is_admin()) { if (is_admin()) {
new AdminController($this->twig, $this->licenseManager); new AdminController($this->twig, $this->licenseManager);
new VersionAdminController($this->versionManager); new VersionAdminController($this->versionManager);

View File

@@ -23,6 +23,7 @@ class ProductVersion
private ?string $releaseNotes; private ?string $releaseNotes;
private ?string $downloadUrl; private ?string $downloadUrl;
private ?int $attachmentId; private ?int $attachmentId;
private ?string $fileHash;
private bool $isActive; private bool $isActive;
private \DateTimeInterface $releasedAt; private \DateTimeInterface $releasedAt;
private \DateTimeInterface $createdAt; private \DateTimeInterface $createdAt;
@@ -42,6 +43,7 @@ class ProductVersion
$version->releaseNotes = $data['release_notes'] ?: null; $version->releaseNotes = $data['release_notes'] ?: null;
$version->downloadUrl = $data['download_url'] ?: null; $version->downloadUrl = $data['download_url'] ?: null;
$version->attachmentId = !empty($data['attachment_id']) ? (int) $data['attachment_id'] : null; $version->attachmentId = !empty($data['attachment_id']) ? (int) $data['attachment_id'] : null;
$version->fileHash = $data['file_hash'] ?? null;
$version->isActive = (bool) $data['is_active']; $version->isActive = (bool) $data['is_active'];
$version->releasedAt = new \DateTimeImmutable($data['released_at']); $version->releasedAt = new \DateTimeImmutable($data['released_at']);
$version->createdAt = new \DateTimeImmutable($data['created_at']); $version->createdAt = new \DateTimeImmutable($data['created_at']);
@@ -137,15 +139,20 @@ class ProductVersion
return $this->attachmentId; return $this->attachmentId;
} }
public function getFileHash(): ?string
{
return $this->fileHash;
}
/** /**
* Get the effective download URL (from attachment or direct URL) * Get the download URL from attachment
*/ */
public function getEffectiveDownloadUrl(): ?string public function getEffectiveDownloadUrl(): ?string
{ {
if ($this->attachmentId) { if ($this->attachmentId) {
return wp_get_attachment_url($this->attachmentId) ?: null; return wp_get_attachment_url($this->attachmentId) ?: null;
} }
return $this->downloadUrl; return null;
} }
/** /**
@@ -156,9 +163,6 @@ class ProductVersion
if ($this->attachmentId) { if ($this->attachmentId) {
return wp_basename(get_attached_file($this->attachmentId) ?: ''); return wp_basename(get_attached_file($this->attachmentId) ?: '');
} }
if ($this->downloadUrl) {
return wp_basename($this->downloadUrl);
}
return null; return null;
} }
@@ -192,6 +196,7 @@ class ProductVersion
'release_notes' => $this->releaseNotes, 'release_notes' => $this->releaseNotes,
'download_url' => $this->downloadUrl, 'download_url' => $this->downloadUrl,
'attachment_id' => $this->attachmentId, 'attachment_id' => $this->attachmentId,
'file_hash' => $this->fileHash,
'is_active' => $this->isActive, 'is_active' => $this->isActive,
'released_at' => $this->releasedAt->format('Y-m-d H:i:s'), 'released_at' => $this->releasedAt->format('Y-m-d H:i:s'),
'created_at' => $this->createdAt->format('Y-m-d H:i:s'), 'created_at' => $this->createdAt->format('Y-m-d H:i:s'),

View File

@@ -92,16 +92,27 @@ class VersionManager
/** /**
* Create a new version * Create a new version
*
* @throws \InvalidArgumentException If file hash validation fails
*/ */
public function createVersion( public function createVersion(
int $productId, int $productId,
string $version, string $version,
?string $releaseNotes = null, ?string $releaseNotes = null,
?string $downloadUrl = null, ?int $attachmentId = null,
?int $attachmentId = null ?string $fileHash = null
): ?ProductVersion { ): ?ProductVersion {
global $wpdb; global $wpdb;
// Validate file hash if both attachment and hash are provided
if ($attachmentId !== null && $attachmentId > 0 && $fileHash !== null && $fileHash !== '') {
$validatedHash = $this->validateFileHash($attachmentId, $fileHash);
if ($validatedHash === false) {
return null;
}
$fileHash = $validatedHash;
}
$parsed = ProductVersion::parseVersion($version); $parsed = ProductVersion::parseVersion($version);
$tableName = Installer::getVersionsTable(); $tableName = Installer::getVersionsTable();
@@ -114,10 +125,9 @@ class VersionManager
'minor_version' => $parsed['minor'], 'minor_version' => $parsed['minor'],
'patch_version' => $parsed['patch'], 'patch_version' => $parsed['patch'],
'release_notes' => $releaseNotes, 'release_notes' => $releaseNotes,
'download_url' => $downloadUrl,
'is_active' => 1, 'is_active' => 1,
]; ];
$formats = ['%d', '%s', '%d', '%d', '%d', '%s', '%s', '%d']; $formats = ['%d', '%s', '%d', '%d', '%d', '%s', '%d'];
// Only include attachment_id if it's set // Only include attachment_id if it's set
if ($attachmentId !== null && $attachmentId > 0) { if ($attachmentId !== null && $attachmentId > 0) {
@@ -125,6 +135,12 @@ class VersionManager
$formats[] = '%d'; $formats[] = '%d';
} }
// Only include file_hash if it's set
if ($fileHash !== null && $fileHash !== '') {
$data['file_hash'] = $fileHash;
$formats[] = '%s';
}
$result = $wpdb->insert($tableName, $data, $formats); $result = $wpdb->insert($tableName, $data, $formats);
if ($result === false) { if ($result === false) {
@@ -136,13 +152,44 @@ class VersionManager
return $this->getVersionById((int) $wpdb->insert_id); return $this->getVersionById((int) $wpdb->insert_id);
} }
/**
* Validate file hash against attachment
*
* @return string|false The validated hash (lowercase) or false on mismatch
* @throws \InvalidArgumentException If hash doesn't match
*/
private function validateFileHash(int $attachmentId, string $providedHash): string|false
{
$filePath = get_attached_file($attachmentId);
if (!$filePath || !file_exists($filePath)) {
throw new \InvalidArgumentException(
__('Attachment file not found.', 'wc-licensed-product')
);
}
$calculatedHash = hash_file('sha256', $filePath);
$providedHash = strtolower(trim($providedHash));
if (!hash_equals($calculatedHash, $providedHash)) {
throw new \InvalidArgumentException(
sprintf(
/* translators: 1: provided hash, 2: calculated hash */
__('File checksum does not match. Expected: %1$s, Got: %2$s', 'wc-licensed-product'),
$providedHash,
$calculatedHash
)
);
}
return $calculatedHash;
}
/** /**
* Update a version * Update a version
*/ */
public function updateVersion( public function updateVersion(
int $versionId, int $versionId,
?string $releaseNotes = null, ?string $releaseNotes = null,
?string $downloadUrl = null,
?bool $isActive = null, ?bool $isActive = null,
?int $attachmentId = null ?int $attachmentId = null
): bool { ): bool {
@@ -156,19 +203,26 @@ class VersionManager
$formats[] = '%s'; $formats[] = '%s';
} }
if ($downloadUrl !== null) {
$data['download_url'] = $downloadUrl;
$formats[] = '%s';
}
if ($isActive !== null) { if ($isActive !== null) {
$data['is_active'] = $isActive ? 1 : 0; $data['is_active'] = $isActive ? 1 : 0;
$formats[] = '%d'; $formats[] = '%d';
} }
if ($attachmentId !== null) { if ($attachmentId !== null) {
$data['attachment_id'] = $attachmentId > 0 ? $attachmentId : null; if ($attachmentId > 0) {
$formats[] = $attachmentId > 0 ? '%d' : null; $data['attachment_id'] = $attachmentId;
$formats[] = '%d';
} else {
// Set to NULL using raw SQL instead of adding to $data
global $wpdb;
$tableName = Installer::getVersionsTable();
$wpdb->query(
$wpdb->prepare(
"UPDATE {$tableName} SET attachment_id = NULL WHERE id = %d",
$versionId
)
);
}
} }
if (empty($data)) { if (empty($data)) {

View File

@@ -3,7 +3,7 @@
* Plugin Name: WooCommerce Licensed Product * Plugin Name: WooCommerce Licensed Product
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product * Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product
* Description: WooCommerce plugin to sell software products using license keys with domain-based validation. * Description: WooCommerce plugin to sell software products using license keys with domain-based validation.
* Version: 0.0.11 * Version: 0.2.1
* Author: Marco Graetsch * Author: Marco Graetsch
* Author URI: https://src.bundespruefstelle.ch/magdev * Author URI: https://src.bundespruefstelle.ch/magdev
* License: GPL-2.0-or-later * License: GPL-2.0-or-later
@@ -28,7 +28,7 @@ if (!defined('ABSPATH')) {
} }
// Plugin constants // Plugin constants
define('WC_LICENSED_PRODUCT_VERSION', '0.0.11'); define('WC_LICENSED_PRODUCT_VERSION', '0.2.1');
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__); define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__)); define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));