From 23bbc24c5fe99f9da4d8ba948948cf594d812be7 Mon Sep 17 00:00:00 2001 From: magdev Date: Thu, 22 Jan 2026 16:57:54 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 46 ++- CLAUDE.md | 50 ++++ assets/js/versions.js | 19 +- docs/server-implementation.md | 393 +++++++++++++++++++++++++ languages/wc-licensed-product-de_CH.mo | Bin 24662 -> 26754 bytes languages/wc-licensed-product-de_CH.po | 88 +++--- languages/wc-licensed-product.pot | 24 +- src/Admin/VersionAdminController.php | 28 +- src/Api/ResponseSigner.php | 128 ++++++++ src/Installer.php | 1 + src/Plugin.php | 6 + src/Product/ProductVersion.php | 15 +- src/Product/VersionManager.php | 62 +++- wc-licensed-product.php | 4 +- 14 files changed, 789 insertions(+), 75 deletions(-) create mode 100644 docs/server-implementation.md create mode 100644 src/Api/ResponseSigner.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b2aa417..e0d8403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 ### Added @@ -297,7 +340,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - WordPress REST API integration - 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.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 diff --git a/CLAUDE.md b/CLAUDE.md index 474ca5f..8d98ed6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,8 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w No known bugs at the moment +No planned features at this time. See Session History for completed work. + ## Technical Stack - **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) - 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'); +``` diff --git a/assets/js/versions.js b/assets/js/versions.js index 9500747..92205d6 100644 --- a/assets/js/versions.js +++ b/assets/js/versions.js @@ -78,14 +78,14 @@ $('#selected_file_name').text(attachment.filename); $('#remove-version-file-btn').show(); + // Show SHA256 hash field + $('#sha256-hash-row').show(); + // Try to extract version from filename var extractedVersion = self.extractVersionFromFilename(attachment.filename); if (extractedVersion && !$('#new_version').val().trim()) { $('#new_version').val(extractedVersion); } - - // Clear external URL when file is selected - $('#new_download_url').val(''); }); this.mediaFrame.open(); @@ -100,6 +100,10 @@ $('#new_attachment_id').val(''); $('#selected_file_name').text(''); $('#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 productId = $btn.data('product-id'); var version = $('#new_version').val().trim(); - var downloadUrl = $('#new_download_url').val().trim(); var releaseNotes = $('#new_release_notes').val().trim(); var attachmentId = $('#new_attachment_id').val(); + var fileHash = $('#new_file_hash').val().trim(); // Validate version if (!version) { @@ -160,9 +164,9 @@ nonce: wcLicensedProductVersions.nonce, product_id: productId, version: version, - download_url: downloadUrl, release_notes: releaseNotes, - attachment_id: attachmentId + attachment_id: attachmentId, + file_hash: fileHash }, success: function(response) { if (response.success) { @@ -174,11 +178,12 @@ // Clear form $('#new_version').val(''); - $('#new_download_url').val(''); $('#new_release_notes').val(''); $('#new_attachment_id').val(''); $('#selected_file_name').text(''); $('#remove-version-file-btn').hide(); + $('#sha256-hash-row').hide(); + $('#new_file_hash').val(''); } else { alert(response.data.message || wcLicensedProductVersions.strings.error); } diff --git a/docs/server-implementation.md b/docs/server-implementation.md new file mode 100644 index 0000000..f7fde97 --- /dev/null +++ b/docs/server-implementation.md @@ -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 +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(), +); +``` diff --git a/languages/wc-licensed-product-de_CH.mo b/languages/wc-licensed-product-de_CH.mo index 44faa48a83ba2ab883b17ab53e0cbf359a75f3fe..cb9618d93ecae6b135f2e2f60d2c1d2a6917edc4 100644 GIT binary patch delta 7922 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 Veya2P_^bZT#4=xi6F6;&{~rwl#3TR! diff --git a/languages/wc-licensed-product-de_CH.po b/languages/wc-licensed-product-de_CH.po index efd2903..2d4d852 100644 --- a/languages/wc-licensed-product-de_CH.po +++ b/languages/wc-licensed-product-de_CH.po @@ -3,7 +3,7 @@ # This file is distributed under the GPL-2.0-or-later. msgid "" 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" "POT-Creation-Date: 2026-01-22 11:52+0100\n" "PO-Revision-Date: 2026-01-21T00:00:00+00:00\n" @@ -44,18 +44,16 @@ msgid "Overview" msgstr "Übersicht" #: src/Admin/AdminController.php:138 -#, fuzzy msgid "No licenses found" -msgstr "Keine Lizenzen gefunden." +msgstr "Keine Lizenzen gefunden" #: src/Admin/AdminController.php:139 msgid "Searching..." msgstr "Suche..." #: src/Admin/AdminController.php:140 -#, fuzzy msgid "Search failed" -msgstr "Speichern fehlgeschlagen" +msgstr "Suche fehlgeschlagen" #: src/Admin/AdminController.php:141 src/Admin/OrderLicenseController.php:285 msgid "Saving..." @@ -249,11 +247,11 @@ msgid "Attention:" msgstr "Achtung:" #: src/Admin/AdminController.php:910 -#, fuzzy, php-format +#, php-format msgid "%d license is 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[1] "läuft innerhalb der nächsten 30 Tage ab." +msgstr[0] "%d Lizenz 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 msgid "View Licenses" @@ -657,25 +655,21 @@ msgid "No domain specified" msgstr "Keine Domain angegeben" #: src/Admin/OrderLicenseController.php:56 -#, fuzzy msgid "Product Licenses" -msgstr "Top-Produkte nach Lizenzen" +msgstr "Produktlizenzen" #: src/Admin/OrderLicenseController.php:77 #: src/Admin/OrderLicenseController.php:313 -#, fuzzy msgid "Order not found." -msgstr "Version nicht gefunden." +msgstr "Bestellung nicht gefunden." #: src/Admin/OrderLicenseController.php:92 -#, fuzzy 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 -#, fuzzy msgid "Order Domain" -msgstr "Neue Domain" +msgstr "Bestellungs-Domain" #: src/Admin/OrderLicenseController.php:108 msgid "" @@ -706,22 +700,25 @@ msgstr "" #: src/Admin/OrderLicenseController.php:137 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 msgid "Edit domain" msgstr "Domain bearbeiten" #: src/Admin/OrderLicenseController.php:208 -#, fuzzy msgid "View in Licenses" -msgstr "Lizenzen anzeigen" +msgstr "In Lizenzen anzeigen" #. translators: %s: Link to licenses page #: src/Admin/OrderLicenseController.php:221 #, php-format 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 msgid "Saved!" @@ -738,20 +735,17 @@ msgid "Please enter a valid domain." msgstr "Bitte geben Sie eine gültige Domain ein." #: src/Admin/OrderLicenseController.php:308 -#, fuzzy msgid "Invalid order ID." -msgstr "Ungültige Lizenz-ID." +msgstr "Ungültige Bestellungs-ID." #: src/Admin/OrderLicenseController.php:319 #: src/Admin/OrderLicenseController.php:357 -#, fuzzy msgid "Invalid domain format." -msgstr "Ungültiges Datumsformat." +msgstr "Ungültiges Domain-Format." #: src/Admin/OrderLicenseController.php:327 -#, fuzzy msgid "Order domain updated." -msgstr "%d aktualisiert." +msgstr "Bestellungs-Domain aktualisiert." #: src/Admin/OrderLicenseController.php:363 #: src/Frontend/AccountController.php:351 @@ -760,9 +754,8 @@ msgid "License not found." msgstr "Lizenz nicht gefunden." #: src/Admin/OrderLicenseController.php:371 -#, fuzzy msgid "License domain updated." -msgstr "Lizenz-Domain" +msgstr "Lizenz-Domain aktualisiert." #: src/Admin/OrderLicenseController.php:375 msgid "Failed to update license domain." @@ -973,14 +966,12 @@ msgid "This version already exists." msgstr "Diese Version existiert bereits." #: src/Admin/VersionAdminController.php:266 -#, fuzzy msgid "Product not found." -msgstr "Version nicht gefunden." +msgstr "Produkt nicht gefunden." #: src/Admin/VersionAdminController.php:270 -#, fuzzy 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 msgid "Failed to create version." @@ -1077,12 +1068,10 @@ msgid "License Domain:" msgstr "Lizenz-Domain:" #: src/Checkout/CheckoutBlocksIntegration.php:105 -#, fuzzy 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 -#, fuzzy msgid "Domain for license activation" 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." #: src/Frontend/AccountController.php:381 -#, fuzzy 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:89 @@ -1468,6 +1456,32 @@ msgstr "Ja" msgid "No" 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" #~ msgstr "Maximale Anzahl der Domain-Aktivierungen pro Lizenz. Standard: 1" diff --git a/languages/wc-licensed-product.pot b/languages/wc-licensed-product.pot index 307ecbb..d82df74 100644 --- a/languages/wc-licensed-product.pot +++ b/languages/wc-licensed-product.pot @@ -6,7 +6,7 @@ #, fuzzy msgid "" 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" "POT-Creation-Date: 2026-01-22 11:52+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" @@ -1408,3 +1408,25 @@ msgstr "" #: src/Product/LicensedProductType.php:162 msgid "No" 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 "" diff --git a/src/Admin/VersionAdminController.php b/src/Admin/VersionAdminController.php index 7fa7881..c65b659 100644 --- a/src/Admin/VersionAdminController.php +++ b/src/Admin/VersionAdminController.php @@ -98,11 +98,11 @@ final class VersionAdminController

- - + + - -

+ +

@@ -242,9 +242,9 @@ final class VersionAdminController $productId = absint($_POST['product_id'] ?? 0); $version = sanitize_text_field($_POST['version'] ?? ''); - $downloadUrl = esc_url_raw($_POST['download_url'] ?? ''); $releaseNotes = sanitize_textarea_field($_POST['release_notes'] ?? ''); $attachmentId = absint($_POST['attachment_id'] ?? 0); + $fileHash = sanitize_text_field($_POST['file_hash'] ?? ''); if (!$productId || !$version) { 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')]); } - $newVersion = $this->versionManager->createVersion( - $productId, - $version, - $releaseNotes ?: null, - $downloadUrl ?: null, - $attachmentId ?: null - ); + try { + $newVersion = $this->versionManager->createVersion( + $productId, + $version, + $releaseNotes ?: null, + $attachmentId ?: null, + $fileHash ?: null + ); + } catch (\InvalidArgumentException $e) { + wp_send_json_error(['message' => $e->getMessage()]); + } if (!$newVersion) { global $wpdb; diff --git a/src/Api/ResponseSigner.php b/src/Api/ResponseSigner.php new file mode 100644 index 0000000..d1e085e --- /dev/null +++ b/src/Api/ResponseSigner.php @@ -0,0 +1,128 @@ +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); + } +} diff --git a/src/Installer.php b/src/Installer.php index ee7ff80..57824b4 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -102,6 +102,7 @@ final class Installer release_notes TEXT DEFAULT NULL, download_url VARCHAR(512) DEFAULT NULL, attachment_id BIGINT UNSIGNED DEFAULT NULL, + file_hash VARCHAR(64) DEFAULT NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, released_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/src/Plugin.php b/src/Plugin.php index 6a6d4ce..d429b8c 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -13,6 +13,7 @@ use Jeremias\WcLicensedProduct\Admin\AdminController; use Jeremias\WcLicensedProduct\Admin\OrderLicenseController; use Jeremias\WcLicensedProduct\Admin\SettingsController; use Jeremias\WcLicensedProduct\Admin\VersionAdminController; +use Jeremias\WcLicensedProduct\Api\ResponseSigner; use Jeremias\WcLicensedProduct\Api\RestApiController; use Jeremias\WcLicensedProduct\Checkout\CheckoutBlocksIntegration; use Jeremias\WcLicensedProduct\Checkout\CheckoutController; @@ -128,6 +129,11 @@ final class Plugin new RestApiController($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()) { new AdminController($this->twig, $this->licenseManager); new VersionAdminController($this->versionManager); diff --git a/src/Product/ProductVersion.php b/src/Product/ProductVersion.php index 52dadc5..0fda75c 100644 --- a/src/Product/ProductVersion.php +++ b/src/Product/ProductVersion.php @@ -23,6 +23,7 @@ class ProductVersion private ?string $releaseNotes; private ?string $downloadUrl; private ?int $attachmentId; + private ?string $fileHash; private bool $isActive; private \DateTimeInterface $releasedAt; private \DateTimeInterface $createdAt; @@ -42,6 +43,7 @@ class ProductVersion $version->releaseNotes = $data['release_notes'] ?: null; $version->downloadUrl = $data['download_url'] ?: 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->releasedAt = new \DateTimeImmutable($data['released_at']); $version->createdAt = new \DateTimeImmutable($data['created_at']); @@ -137,15 +139,20 @@ class ProductVersion 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 { if ($this->attachmentId) { return wp_get_attachment_url($this->attachmentId) ?: null; } - return $this->downloadUrl; + return null; } /** @@ -156,9 +163,6 @@ class ProductVersion if ($this->attachmentId) { return wp_basename(get_attached_file($this->attachmentId) ?: ''); } - if ($this->downloadUrl) { - return wp_basename($this->downloadUrl); - } return null; } @@ -192,6 +196,7 @@ class ProductVersion 'release_notes' => $this->releaseNotes, 'download_url' => $this->downloadUrl, 'attachment_id' => $this->attachmentId, + 'file_hash' => $this->fileHash, 'is_active' => $this->isActive, 'released_at' => $this->releasedAt->format('Y-m-d H:i:s'), 'created_at' => $this->createdAt->format('Y-m-d H:i:s'), diff --git a/src/Product/VersionManager.php b/src/Product/VersionManager.php index cb851b4..065dbf6 100644 --- a/src/Product/VersionManager.php +++ b/src/Product/VersionManager.php @@ -92,16 +92,27 @@ class VersionManager /** * Create a new version + * + * @throws \InvalidArgumentException If file hash validation fails */ public function createVersion( int $productId, string $version, ?string $releaseNotes = null, - ?string $downloadUrl = null, - ?int $attachmentId = null + ?int $attachmentId = null, + ?string $fileHash = null ): ?ProductVersion { 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); $tableName = Installer::getVersionsTable(); @@ -114,10 +125,9 @@ class VersionManager 'minor_version' => $parsed['minor'], 'patch_version' => $parsed['patch'], 'release_notes' => $releaseNotes, - 'download_url' => $downloadUrl, '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 if ($attachmentId !== null && $attachmentId > 0) { @@ -125,6 +135,12 @@ class VersionManager $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); if ($result === false) { @@ -136,13 +152,44 @@ class VersionManager 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 */ public function updateVersion( int $versionId, ?string $releaseNotes = null, - ?string $downloadUrl = null, ?bool $isActive = null, ?int $attachmentId = null ): bool { @@ -156,11 +203,6 @@ class VersionManager $formats[] = '%s'; } - if ($downloadUrl !== null) { - $data['download_url'] = $downloadUrl; - $formats[] = '%s'; - } - if ($isActive !== null) { $data['is_active'] = $isActive ? 1 : 0; $formats[] = '%d'; diff --git a/wc-licensed-product.php b/wc-licensed-product.php index 9bdb942..99db79b 100644 --- a/wc-licensed-product.php +++ b/wc-licensed-product.php @@ -3,7 +3,7 @@ * Plugin Name: WooCommerce 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. - * Version: 0.1.0 + * Version: 0.2.0 * Author: Marco Graetsch * Author URI: https://src.bundespruefstelle.ch/magdev * License: GPL-2.0-or-later @@ -28,7 +28,7 @@ if (!defined('ABSPATH')) { } // 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_DIR', plugin_dir_path(__FILE__)); define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));