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>
This commit is contained in:
2026-01-22 16:57:54 +01:00
parent 8420734f37
commit 23bbc24c5f
14 changed files with 789 additions and 75 deletions

View File

@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [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 ## [0.1.0] - 2026-01-22
### Added ### Added
@@ -297,7 +340,8 @@ 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.1.0...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.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

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
@@ -667,3 +669,51 @@ Security practices verified:
- Created release package: `releases/wc-licensed-product-0.1.0.zip` (478 KB) - Created release package: `releases/wc-licensed-product-0.1.0.zip` (478 KB)
- SHA256: `62638e240315107098be4cb40faff8395e9e1b719d79b73d80e69d680b305e87` - SHA256: `62638e240315107098be4cb40faff8395e9e1b719d79b73d80e69d680b305e87`
- Tagged as `v0.1.0` and pushed to `main` branch - 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

@@ -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,10 @@
$('#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 SHA256 hash field
$('#sha256-hash-row').hide();
$('#new_file_hash').val('');
}, },
/** /**
@@ -134,9 +138,9 @@
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 fileHash = $('#new_file_hash').val().trim();
// Validate version // Validate version
if (!version) { if (!version) {
@@ -160,9 +164,9 @@
nonce: wcLicensedProductVersions.nonce, nonce: wcLicensedProductVersions.nonce,
product_id: productId, product_id: productId,
version: version, version: version,
download_url: downloadUrl,
release_notes: releaseNotes, release_notes: releaseNotes,
attachment_id: attachmentId attachment_id: attachmentId,
file_hash: fileHash
}, },
success: function(response) { success: function(response) {
if (response.success) { if (response.success) {
@@ -174,11 +178,12 @@
// 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();
$('#new_file_hash').val('');
} else { } else {
alert(response.data.message || wcLicensedProductVersions.strings.error); alert(response.data.message || wcLicensedProductVersions.strings.error);
} }

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(),
);
```

View File

@@ -3,7 +3,7 @@
# This file is distributed under the GPL-2.0-or-later. # This file is distributed under the GPL-2.0-or-later.
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: WC Licensed Product 0.1.0\n" "Project-Id-Version: WC Licensed Product 0.2.0\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-22 11:52+0100\n" "POT-Creation-Date: 2026-01-22 11:52+0100\n"
"PO-Revision-Date: 2026-01-21T00:00:00+00:00\n" "PO-Revision-Date: 2026-01-21T00:00:00+00:00\n"
@@ -44,18 +44,16 @@ msgid "Overview"
msgstr "Übersicht" msgstr "Übersicht"
#: src/Admin/AdminController.php:138 #: src/Admin/AdminController.php:138
#, fuzzy
msgid "No licenses found" msgid "No licenses found"
msgstr "Keine Lizenzen gefunden." msgstr "Keine Lizenzen gefunden"
#: src/Admin/AdminController.php:139 #: src/Admin/AdminController.php:139
msgid "Searching..." msgid "Searching..."
msgstr "Suche..." msgstr "Suche..."
#: src/Admin/AdminController.php:140 #: src/Admin/AdminController.php:140
#, fuzzy
msgid "Search failed" msgid "Search failed"
msgstr "Speichern fehlgeschlagen" msgstr "Suche fehlgeschlagen"
#: src/Admin/AdminController.php:141 src/Admin/OrderLicenseController.php:285 #: src/Admin/AdminController.php:141 src/Admin/OrderLicenseController.php:285
msgid "Saving..." msgid "Saving..."
@@ -249,11 +247,11 @@ msgid "Attention:"
msgstr "Achtung:" msgstr "Achtung:"
#: src/Admin/AdminController.php:910 #: src/Admin/AdminController.php:910
#, fuzzy, php-format #, php-format
msgid "%d license is expiring within the next 30 days." msgid "%d license is expiring within the next 30 days."
msgid_plural "%d licenses are expiring within the next 30 days." msgid_plural "%d licenses are expiring within the next 30 days."
msgstr[0] "läuft innerhalb der nächsten 30 Tage ab." msgstr[0] "%d Lizenz läuft innerhalb der nächsten 30 Tage ab."
msgstr[1] "läuft innerhalb der nächsten 30 Tage ab." msgstr[1] "%d Lizenzen laufen innerhalb der nächsten 30 Tage ab."
#: src/Admin/AdminController.php:918 #: src/Admin/AdminController.php:918
msgid "View Licenses" msgid "View Licenses"
@@ -657,25 +655,21 @@ msgid "No domain specified"
msgstr "Keine Domain angegeben" msgstr "Keine Domain angegeben"
#: src/Admin/OrderLicenseController.php:56 #: src/Admin/OrderLicenseController.php:56
#, fuzzy
msgid "Product Licenses" msgid "Product Licenses"
msgstr "Top-Produkte nach Lizenzen" msgstr "Produktlizenzen"
#: src/Admin/OrderLicenseController.php:77 #: src/Admin/OrderLicenseController.php:77
#: src/Admin/OrderLicenseController.php:313 #: src/Admin/OrderLicenseController.php:313
#, fuzzy
msgid "Order not found." msgid "Order not found."
msgstr "Version nicht gefunden." msgstr "Bestellung nicht gefunden."
#: src/Admin/OrderLicenseController.php:92 #: src/Admin/OrderLicenseController.php:92
#, fuzzy
msgid "This order does not contain licensed products." msgid "This order does not contain licensed products."
msgstr "Version stimmt nicht mit Ihrem lizensierten Produkt überein." msgstr "Diese Bestellung enthält keine lizensierten Produkte."
#: src/Admin/OrderLicenseController.php:106 #: src/Admin/OrderLicenseController.php:106
#, fuzzy
msgid "Order Domain" msgid "Order Domain"
msgstr "Neue Domain" msgstr "Bestellungs-Domain"
#: src/Admin/OrderLicenseController.php:108 #: src/Admin/OrderLicenseController.php:108
msgid "" msgid ""
@@ -706,22 +700,25 @@ msgstr ""
#: src/Admin/OrderLicenseController.php:137 #: src/Admin/OrderLicenseController.php:137
msgid "Licenses will be generated when the order is marked as paid/completed." msgid "Licenses will be generated when the order is marked as paid/completed."
msgstr "Lizenzen werden generiert, sobald die Bestellung als bezahlt/abgeschlossen markiert wird." msgstr ""
"Lizenzen werden generiert, sobald die Bestellung als bezahlt/abgeschlossen "
"markiert wird."
#: src/Admin/OrderLicenseController.php:178 #: src/Admin/OrderLicenseController.php:178
msgid "Edit domain" msgid "Edit domain"
msgstr "Domain bearbeiten" msgstr "Domain bearbeiten"
#: src/Admin/OrderLicenseController.php:208 #: src/Admin/OrderLicenseController.php:208
#, fuzzy
msgid "View in Licenses" msgid "View in Licenses"
msgstr "Lizenzen anzeigen" msgstr "In Lizenzen anzeigen"
#. translators: %s: Link to licenses page #. translators: %s: Link to licenses page
#: src/Admin/OrderLicenseController.php:221 #: src/Admin/OrderLicenseController.php:221
#, php-format #, php-format
msgid "For more actions (revoke, extend, delete), go to the %s page." msgid "For more actions (revoke, extend, delete), go to the %s page."
msgstr "Für weitere Aktionen (widerrufen, verlängern, löschen), gehen Sie zur Seite %s." msgstr ""
"Für weitere Aktionen (widerrufen, verlängern, löschen), gehen Sie zur Seite "
"%s."
#: src/Admin/OrderLicenseController.php:286 #: src/Admin/OrderLicenseController.php:286
msgid "Saved!" msgid "Saved!"
@@ -738,20 +735,17 @@ msgid "Please enter a valid domain."
msgstr "Bitte geben Sie eine gültige Domain ein." msgstr "Bitte geben Sie eine gültige Domain ein."
#: src/Admin/OrderLicenseController.php:308 #: src/Admin/OrderLicenseController.php:308
#, fuzzy
msgid "Invalid order ID." msgid "Invalid order ID."
msgstr "Ungültige Lizenz-ID." msgstr "Ungültige Bestellungs-ID."
#: src/Admin/OrderLicenseController.php:319 #: src/Admin/OrderLicenseController.php:319
#: src/Admin/OrderLicenseController.php:357 #: src/Admin/OrderLicenseController.php:357
#, fuzzy
msgid "Invalid domain format." msgid "Invalid domain format."
msgstr "Ungültiges Datumsformat." msgstr "Ungültiges Domain-Format."
#: src/Admin/OrderLicenseController.php:327 #: src/Admin/OrderLicenseController.php:327
#, fuzzy
msgid "Order domain updated." msgid "Order domain updated."
msgstr "%d aktualisiert." msgstr "Bestellungs-Domain aktualisiert."
#: src/Admin/OrderLicenseController.php:363 #: src/Admin/OrderLicenseController.php:363
#: src/Frontend/AccountController.php:351 #: src/Frontend/AccountController.php:351
@@ -760,9 +754,8 @@ msgid "License not found."
msgstr "Lizenz nicht gefunden." msgstr "Lizenz nicht gefunden."
#: src/Admin/OrderLicenseController.php:371 #: src/Admin/OrderLicenseController.php:371
#, fuzzy
msgid "License domain updated." msgid "License domain updated."
msgstr "Lizenz-Domain" msgstr "Lizenz-Domain aktualisiert."
#: src/Admin/OrderLicenseController.php:375 #: src/Admin/OrderLicenseController.php:375
msgid "Failed to update license domain." msgid "Failed to update license domain."
@@ -973,14 +966,12 @@ msgid "This version already exists."
msgstr "Diese Version existiert bereits." msgstr "Diese Version existiert bereits."
#: src/Admin/VersionAdminController.php:266 #: src/Admin/VersionAdminController.php:266
#, fuzzy
msgid "Product not found." msgid "Product not found."
msgstr "Version nicht gefunden." msgstr "Produkt nicht gefunden."
#: src/Admin/VersionAdminController.php:270 #: src/Admin/VersionAdminController.php:270
#, fuzzy
msgid "This product is not a licensed product." msgid "This product is not a licensed product."
msgstr "Version stimmt nicht mit Ihrem lizensierten Produkt überein." msgstr "Dieses Produkt ist kein lizensiertes Produkt."
#: src/Admin/VersionAdminController.php:283 #: src/Admin/VersionAdminController.php:283
msgid "Failed to create version." msgid "Failed to create version."
@@ -1077,12 +1068,10 @@ msgid "License Domain:"
msgstr "Lizenz-Domain:" msgstr "Lizenz-Domain:"
#: src/Checkout/CheckoutBlocksIntegration.php:105 #: src/Checkout/CheckoutBlocksIntegration.php:105
#, fuzzy
msgid "Please enter a valid domain for your license activation." msgid "Please enter a valid domain for your license activation."
msgstr "Bitte geben Sie eine Domain für Ihre Lizenz-Aktivierung ein." msgstr "Bitte geben Sie eine gültige Domain für Ihre Lizenz-Aktivierung ein."
#: src/Checkout/StoreApiExtension.php:85 #: src/Checkout/StoreApiExtension.php:85
#, fuzzy
msgid "Domain for license activation" msgid "Domain for license activation"
msgstr "Domain für Lizenz-Aktivierung" msgstr "Domain für Lizenz-Aktivierung"
@@ -1332,9 +1321,8 @@ msgid "The new domain is the same as the current domain."
msgstr "Die neue Domain ist dieselbe wie die aktuelle Domain." msgstr "Die neue Domain ist dieselbe wie die aktuelle Domain."
#: src/Frontend/AccountController.php:381 #: src/Frontend/AccountController.php:381
#, fuzzy
msgid "Failed to transfer license. Please try again." msgid "Failed to transfer license. Please try again."
msgstr "Übertragung fehlgeschlagen. Bitte versuchen Sie es erneut." msgstr "Lizenzübertragung fehlgeschlagen. Bitte versuchen Sie es erneut."
#: src/Frontend/DownloadController.php:65 #: src/Frontend/DownloadController.php:65
#: src/Frontend/DownloadController.php:89 #: src/Frontend/DownloadController.php:89
@@ -1468,6 +1456,32 @@ msgstr "Ja"
msgid "No" msgid "No"
msgstr "Nein" msgstr "Nein"
#: src/Admin/VersionAdminController.php:101
msgid "SHA256 Hash"
msgstr "SHA256 Prüfsumme"
#: src/Admin/VersionAdminController.php:103
msgid "Enter SHA256 checksum..."
msgstr "SHA256 Prüfsumme eingeben..."
#: src/Admin/VersionAdminController.php:104
msgid ""
"SHA256 checksum of the uploaded file (optional but recommended for integrity "
"verification)."
msgstr ""
"SHA256 Prüfsumme der hochgeladenen Datei (optional, aber empfohlen zur "
"Integritätsprüfung)."
#: src/Product/VersionManager.php:67
msgid "Attachment file not found."
msgstr "Anhangs-Datei nicht gefunden."
#. translators: 1: provided hash, 2: calculated hash
#: src/Product/VersionManager.php:73
#, php-format
msgid "File checksum does not match. Expected: %1$s, Got: %2$s"
msgstr "Datei-Prüfsumme stimmt nicht überein. Erwartet: %1$s, Erhalten: %2$s"
#~ msgid "Maximum number of domain activations per license. Default: 1" #~ msgid "Maximum number of domain activations per license. Default: 1"
#~ msgstr "Maximale Anzahl der Domain-Aktivierungen pro Lizenz. Standard: 1" #~ msgstr "Maximale Anzahl der Domain-Aktivierungen pro Lizenz. Standard: 1"

View File

@@ -6,7 +6,7 @@
#, fuzzy #, fuzzy
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: WooCommerce Licensed Product 0.1.0\n" "Project-Id-Version: WooCommerce Licensed Product 0.2.0\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-22 11:52+0100\n" "POT-Creation-Date: 2026-01-22 11:52+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
@@ -1408,3 +1408,25 @@ msgstr ""
#: src/Product/LicensedProductType.php:162 #: src/Product/LicensedProductType.php:162
msgid "No" msgid "No"
msgstr "" msgstr ""
#: src/Admin/VersionAdminController.php:101
msgid "SHA256 Hash"
msgstr ""
#: src/Admin/VersionAdminController.php:103
msgid "Enter SHA256 checksum..."
msgstr ""
#: src/Admin/VersionAdminController.php:104
msgid "SHA256 checksum of the uploaded file (optional but recommended for integrity verification)."
msgstr ""
#: src/Product/VersionManager.php:67
msgid "Attachment file not found."
msgstr ""
#. translators: 1: provided hash, 2: calculated hash
#: src/Product/VersionManager.php:73
#, php-format
msgid "File checksum does not match. Expected: %1$s, Got: %2$s"
msgstr ""

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_file_hash"><?php esc_html_e('SHA256 Hash', '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="text" id="new_file_hash" name="new_file_hash" class="large-text" placeholder="<?php esc_attr_e('Enter SHA256 checksum...', 'wc-licensed-product'); ?>" pattern="[a-fA-F0-9]{64}" />
<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('SHA256 checksum of the uploaded file (optional but recommended for integrity verification).', 'wc-licensed-product'); ?></p>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -242,9 +242,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 +270,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,11 +203,6 @@ 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';

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.1.0 * Version: 0.2.0
* 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.1.0'); define('WC_LICENSED_PRODUCT_VERSION', '0.2.0');
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__));