3 Commits

Author SHA1 Message Date
41e46fc7b8 Bump version to 0.5.2 and update changelog
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:31:53 +01:00
549a58dc5d Add per-license customer secrets for API response verification
- Add static methods to ResponseSigner for deriving customer-specific secrets
- Display "API Verification Secret" in customer account licenses page
- Add collapsible secret section with copy button
- Update server-implementation.md with per-license secret documentation
- Update translations with new strings

Each customer now gets a unique verification secret derived from their
license key, eliminating the need to share the master server secret.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:29:57 +01:00
7d02105284 Update CLAUDE.md with v0.5.1 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:20:35 +01:00
13 changed files with 1360 additions and 1038 deletions

View File

@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.5.2] - 2026-01-26
### Added
- Per-license customer secrets for API response verification
- "API Verification Secret" section in customer account licenses page (collapsible)
- Copy button for customer secrets with clipboard support
- Documentation for per-license secret derivation and usage
### Security
- Customers no longer need the master server secret for signature verification
- Each license key has a unique derived secret using HKDF-like key derivation
- If one customer's secret is compromised, other customers remain unaffected
### Changed
- Updated `ResponseSigner` with static methods for secret derivation
- Updated `server-implementation.md` with per-license secret documentation
- Added new translation strings for secret-related UI
## [0.5.1] - 2026-01-26 ## [0.5.1] - 2026-01-26
### Fixed ### Fixed

View File

@@ -32,18 +32,14 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
**Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file. **Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file.
### Known Bugs ### Version 0.5.2
No known bugs at the moment. *No planned bugfixes yet.*
### Version 0.6.0 ### Version 0.6.0
*No planned features yet.* *No planned features yet.*
### Version 0.5.1
*No planned bugfixes yet.*
## Technical Stack ## Technical Stack
- **Language:** PHP 8.3.x - **Language:** PHP 8.3.x
@@ -1268,3 +1264,32 @@ Major feature release enabling customers to purchase multiple licenses for diffe
- Created release package: `releases/wc-licensed-product-0.5.0.zip` (863 KB) - Created release package: `releases/wc-licensed-product-0.5.0.zip` (863 KB)
- SHA256: `446804948e5f99d705b548061d5b78180856984c58458640a910ada8f27f5316` - SHA256: `446804948e5f99d705b548061d5b78180856984c58458640a910ada8f27f5316`
- Tagged as `v0.5.0` and pushed to `main` branch - Tagged as `v0.5.0` and pushed to `main` branch
### 2026-01-26 - Version 0.5.1 - Admin UI Fixes
**Overview:**
Bug fix release improving admin UI usability for version management and license overview.
**Bug Fixes:**
- Fixed: Product versions in admin now sort by version DESC when adding via AJAX
- Fixed: License actions in admin overview are now always visible (not just on hover)
**Modified files:**
- `assets/css/admin.css` - Added `!important` to `.licenses-table .row-actions` for permanent visibility
- `assets/js/versions.js` - Added `compareVersions()` function and sorted insertion for AJAX-added versions
**Technical notes:**
- Version sorting uses semantic version comparison (major.minor.patch)
- New versions are inserted in correct sorted position in the table instead of always appending
- CSS override uses `!important` to overcome WordPress default hover-only behavior for row actions
- `compareVersions()` function compares version strings numerically (1.10.0 > 1.9.0)
**Release v0.5.1:**
- Created release package: `releases/wc-licensed-product-0.5.1.zip` (863 KB)
- SHA256: `a489f0b8cfcd7d5d9b2021b7ff581b9f1a56468dfde87bbb06bb4555d11f7556`
- Tagged as `v0.5.1` and pushed to `main` branch

View File

@@ -863,3 +863,118 @@
color: #2271b1; color: #2271b1;
font-weight: 500; font-weight: 500;
} }
/* Customer Secret Section */
.license-row-secret {
margin-top: 0.75em;
padding-top: 0.75em;
border-top: 1px dashed #e5e5e5;
}
.secret-toggle {
display: inline-flex;
align-items: center;
gap: 0.35em;
padding: 0.4em 0.75em;
background: transparent;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.85em;
color: #666;
cursor: pointer;
transition: all 0.2s ease;
}
.secret-toggle:hover {
background: #f5f5f5;
border-color: #ccc;
color: #333;
}
.secret-toggle .dashicons {
font-size: 14px;
width: 14px;
height: 14px;
}
.secret-toggle .toggle-arrow {
transition: transform 0.2s ease;
}
.secret-toggle[aria-expanded="true"] .toggle-arrow {
transform: rotate(180deg);
}
.secret-content {
margin-top: 0.75em;
padding: 1em;
background: #f8f9fa;
border-radius: 4px;
border: 1px solid #e5e5e5;
}
.secret-description {
margin: 0 0 0.75em 0;
font-size: 0.85em;
color: #666;
}
.secret-value-wrapper {
display: flex;
align-items: center;
gap: 0.5em;
}
.secret-value {
font-family: 'SF Mono', Monaco, Consolas, monospace;
font-size: 0.75em;
background: #fff;
padding: 0.5em 0.75em;
border: 1px solid #ddd;
border-radius: 4px;
word-break: break-all;
flex: 1;
min-width: 0;
overflow-x: auto;
}
.copy-secret-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: #f0f0f0;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.copy-secret-btn:hover {
background: #e5e5e5;
border-color: #ccc;
}
.copy-secret-btn .dashicons {
font-size: 18px;
width: 18px;
height: 18px;
}
@media screen and (max-width: 768px) {
.secret-value-wrapper {
flex-direction: column;
align-items: stretch;
}
.secret-value {
font-size: 0.7em;
}
.copy-secret-btn {
align-self: flex-start;
}
}

View File

@@ -19,6 +19,7 @@
bindEvents: function() { bindEvents: function() {
$(document).on('click', '.copy-license-btn', this.copyLicenseKey); $(document).on('click', '.copy-license-btn', this.copyLicenseKey);
$(document).on('click', '.copy-secret-btn', this.copySecret);
// Transfer modal events // Transfer modal events
$(document).on('click', '.wclp-transfer-btn', this.openTransferModal.bind(this)); $(document).on('click', '.wclp-transfer-btn', this.openTransferModal.bind(this));
@@ -28,6 +29,9 @@
// Older versions toggle // Older versions toggle
$(document).on('click', '.older-versions-toggle', this.toggleOlderVersions); $(document).on('click', '.older-versions-toggle', this.toggleOlderVersions);
// Secret toggle
$(document).on('click', '.secret-toggle', this.toggleSecret);
// Close modal on escape key // Close modal on escape key
$(document).on('keyup', function(e) { $(document).on('keyup', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
@@ -50,6 +54,47 @@
$list.slideToggle(200); $list.slideToggle(200);
}, },
/**
* Toggle secret visibility
*/
toggleSecret: function(e) {
e.preventDefault();
var $btn = $(this);
var $content = $btn.siblings('.secret-content');
var isExpanded = $btn.attr('aria-expanded') === 'true';
$btn.attr('aria-expanded', !isExpanded);
$content.slideToggle(200);
},
/**
* Copy secret to clipboard
*/
copySecret: function(e) {
e.preventDefault();
var $btn = $(this);
var secret = $btn.data('secret');
if (!secret) {
return;
}
// Use modern clipboard API if available
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(secret)
.then(function() {
WCLicensedProductFrontend.showCopyFeedback($btn, true);
})
.catch(function() {
WCLicensedProductFrontend.fallbackCopy(secret, $btn);
});
} else {
WCLicensedProductFrontend.fallbackCopy(secret, $btn);
}
},
/** /**
* Copy license key to clipboard * Copy license key to clipboard
*/ */

12
composer.lock generated
View File

@@ -380,16 +380,16 @@
}, },
{ {
"name": "symfony/http-client", "name": "symfony/http-client",
"version": "v7.4.3", "version": "v7.4.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/http-client.git", "url": "https://github.com/symfony/http-client.git",
"reference": "d01dfac1e0dc99f18da48b18101c23ce57929616" "reference": "d63c23357d74715a589454c141c843f0172bec6c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/d01dfac1e0dc99f18da48b18101c23ce57929616", "url": "https://api.github.com/repos/symfony/http-client/zipball/d63c23357d74715a589454c141c843f0172bec6c",
"reference": "d01dfac1e0dc99f18da48b18101c23ce57929616", "reference": "d63c23357d74715a589454c141c843f0172bec6c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -457,7 +457,7 @@
"http" "http"
], ],
"support": { "support": {
"source": "https://github.com/symfony/http-client/tree/v7.4.3" "source": "https://github.com/symfony/http-client/tree/v7.4.4"
}, },
"funding": [ "funding": [
{ {
@@ -477,7 +477,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-12-23T14:50:43+00:00" "time": "2026-01-23T16:34:22+00:00"
}, },
{ {
"name": "symfony/http-client-contracts", "name": "symfony/http-client-contracts",

View File

@@ -8,14 +8,16 @@ The security model works as follows:
1. Server generates a unique signature for each response using HMAC-SHA256 1. Server generates a unique signature for each response using HMAC-SHA256
2. Signature includes a timestamp to prevent replay attacks 2. Signature includes a timestamp to prevent replay attacks
3. Client verifies the signature using a shared secret 3. Each license key has a unique derived secret (not the master secret)
4. Invalid signatures cause the client to reject the response 4. Client verifies the signature using their per-license secret
5. Invalid signatures cause the client to reject the response
This prevents attackers from: This prevents attackers from:
- Faking valid license responses - Faking valid license responses
- Replaying old responses - Replaying old responses
- Tampering with response data - Tampering with response data
- Using one customer's secret to verify another customer's responses
## Requirements ## Requirements
@@ -323,13 +325,49 @@ Adjust if needed:
$signature = new ResponseSignature($key, timestampTolerance: 600); // 10 minutes $signature = new ResponseSignature($key, timestampTolerance: 600); // 10 minutes
``` ```
### Per-License Secrets
Each customer receives a unique secret derived from their license key. This means:
- Customers only know their own secret, not the master server secret
- If one customer's secret is leaked, other customers are not affected
- The server uses HKDF-like derivation to create unique secrets
#### How Customers Get Their Secret
Customers can find their per-license verification secret in their account:
1. Log in to the store
2. Go to My Account > Licenses
3. Click "API Verification Secret" under any license
4. Copy the 64-character hex string
This secret is automatically derived from the customer's license key and the server's master secret.
#### Using the Customer Secret
```php
use Magdev\WcLicensedProductClient\SecureLicenseClient;
use Symfony\Component\HttpClient\HttpClient;
// Customer uses their per-license secret (from account page)
$client = new SecureLicenseClient(
httpClient: HttpClient::create(),
baseUrl: 'https://shop.example.com',
serverSecret: 'customer-secret-from-account-page', // 64 hex chars
);
$info = $client->validate('XXXX-XXXX-XXXX-XXXX', 'example.com');
```
### Secret Key Rotation ### Secret Key Rotation
To rotate the server secret: To rotate the server secret:
1. Deploy new secret to server 1. Deploy new secret to server
2. Update client configurations 2. All per-license secrets change automatically (they're derived)
3. Old signatures become invalid immediately 3. Customers must copy their new secret from their account page
4. Old signatures become invalid immediately
For zero-downtime rotation, implement versioned secrets: For zero-downtime rotation, implement versioned secrets:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -147,9 +147,52 @@ final class ResponseSigner
*/ */
private function deriveKey(string $licenseKey): string private function deriveKey(string $licenseKey): string
{ {
// HKDF-like key derivation return self::deriveCustomerSecret($licenseKey, $this->serverSecret);
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true); }
return hash_hmac('sha256', $prk . "\x01", $this->serverSecret); /**
* Derive a customer-specific secret from a license key
*
* This secret is unique per license and can be shared with the customer
* to verify signed API responses. Each customer gets their own secret
* derived from their license key.
*
* @param string $licenseKey The customer's license key
* @param string $serverSecret The server's master secret
* @return string The derived secret (64 hex characters)
*/
public static function deriveCustomerSecret(string $licenseKey, string $serverSecret): string
{
// HKDF-like key derivation
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
return hash_hmac('sha256', $prk . "\x01", $serverSecret);
}
/**
* Get the customer secret for a license key using the configured server secret
*
* @param string $licenseKey The customer's license key
* @return string|null The derived secret, or null if server secret is not configured
*/
public static function getCustomerSecretForLicense(string $licenseKey): ?string
{
$serverSecret = defined('WC_LICENSE_SERVER_SECRET') ? WC_LICENSE_SERVER_SECRET : '';
if (empty($serverSecret)) {
return null;
}
return self::deriveCustomerSecret($licenseKey, $serverSecret);
}
/**
* Check if response signing is enabled
*
* @return bool True if server secret is configured
*/
public static function isSigningEnabled(): bool
{
return defined('WC_LICENSE_SERVER_SECRET') && !empty(WC_LICENSE_SERVER_SECRET);
} }
} }

View File

@@ -9,6 +9,7 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Frontend; namespace Jeremias\WcLicensedProduct\Frontend;
use Jeremias\WcLicensedProduct\Api\ResponseSigner;
use Jeremias\WcLicensedProduct\License\LicenseManager; use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\Product\VersionManager; use Jeremias\WcLicensedProduct\Product\VersionManager;
use Twig\Environment; use Twig\Environment;
@@ -114,6 +115,7 @@ final class AccountController
echo $this->twig->render('frontend/licenses.html.twig', [ echo $this->twig->render('frontend/licenses.html.twig', [
'packages' => $packages, 'packages' => $packages,
'has_packages' => !empty($packages), 'has_packages' => !empty($packages),
'signing_enabled' => ResponseSigner::isSigningEnabled(),
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
// Fallback to PHP template if Twig fails // Fallback to PHP template if Twig fails
@@ -161,6 +163,7 @@ final class AccountController
'status' => $license->getStatus(), 'status' => $license->getStatus(),
'expires_at' => $license->getExpiresAt(), 'expires_at' => $license->getExpiresAt(),
'is_transferable' => in_array($license->getStatus(), ['active', 'inactive'], true), 'is_transferable' => in_array($license->getStatus(), ['active', 'inactive'], true),
'customer_secret' => ResponseSigner::getCustomerSecretForLicense($license->getLicenseKey()),
]; ];
// Track if package has at least one active license // Track if package has at least one active license

View File

@@ -65,6 +65,26 @@
{% endif %} {% endif %}
</span> </span>
</div> </div>
{% if signing_enabled and license.customer_secret %}
<div class="license-row-secret">
<button type="button" class="secret-toggle" aria-expanded="false">
<span class="dashicons dashicons-lock"></span>
{{ __('API Verification Secret') }}
<span class="dashicons dashicons-arrow-down-alt2 toggle-arrow"></span>
</button>
<div class="secret-content" style="display: none;">
<p class="secret-description">
{{ __('Use this secret to verify signed API responses. Keep it secure.') }}
</p>
<div class="secret-value-wrapper">
<code class="secret-value">{{ esc_html(license.customer_secret) }}</code>
<button type="button" class="copy-secret-btn" data-secret="{{ esc_attr(license.customer_secret) }}" title="{{ __('Copy to clipboard') }}">
<span class="dashicons dashicons-clipboard"></span>
</button>
</div>
</div>
</div>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

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