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>
This commit is contained in:
2026-01-26 15:29:57 +01:00
parent 7d02105284
commit 549a58dc5d
10 changed files with 1306 additions and 1030 deletions

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>