From 0b58de193eabb25f3e609b546334287c0cab9273 Mon Sep 17 00:00:00 2001 From: magdev Date: Mon, 26 Jan 2026 17:06:18 +0100 Subject: [PATCH] Fix critical signature compatibility with client library (v0.5.5) CRITICAL: Key derivation now uses native hash_hkdf() for RFC 5869 compliance. Previous custom implementation was incompatible with the magdev/wc-licensed-product-client library. Changes: - ResponseSigner::deriveCustomerSecret() now uses hash_hkdf() - Added missing domain validation to /activate endpoint - Customer secrets will change after upgrade (breaking change) The signature algorithm now matches the client's ResponseSignature::deriveKey(): - IKM: server_secret - Length: 32 bytes - Info: license_key Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 18 ++++++++++++++++++ CLAUDE.md | 31 +++++++++++++++++++++++++++++++ src/Api/ResponseSigner.php | 13 ++++++++++--- src/Api/RestApiController.php | 3 +++ wc-licensed-product.php | 4 ++-- 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a94bb66..de2a056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.5] - 2026-01-26 + +### Fixed + +- **CRITICAL:** Response signing key derivation now uses native `hash_hkdf()` for RFC 5869 compliance +- Key derivation now matches client library (`SecureLicenseClient`) exactly +- Added missing domain validation to `/activate` endpoint (1-255 characters) + +### Changed + +- `ResponseSigner::deriveCustomerSecret()` now uses `hash_hkdf('sha256', $serverSecret, 32, $licenseKey)` +- Previous custom HKDF-like implementation was incompatible with client library + +### Security + +- Signatures generated by server now verify correctly with `magdev/wc-licensed-product-client` +- All three API endpoints now have consistent parameter validation + ## [0.5.4] - 2026-01-26 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 8ed7ffe..167afc0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1406,3 +1406,34 @@ Bug fix release aligning server implementation with client documentation at `mag - New `getStatusCodeForResult()` method maps error codes to HTTP status codes - License key validation callback added to all three endpoints (validate, status, activate) - Uses PHP 8 match expression for status code mapping + +### 2026-01-26 - Version 0.5.5 - Critical Signature Fix + +**Overview:** + +Critical bug fix for response signing. The key derivation algorithm was incompatible with the client library, causing signature verification failures. + +**Critical Fix:** + +- Key derivation now uses PHP's native `hash_hkdf()` function per RFC 5869 +- Previous custom implementation produced different keys than the client library +- Signatures now verify correctly with `magdev/wc-licensed-product-client` + +**Additional Fix:** + +- Added missing domain validation to `/activate` endpoint (1-255 characters) + +**Modified files:** + +- `src/Api/ResponseSigner.php` - Fixed key derivation to use `hash_hkdf()` +- `src/Api/RestApiController.php` - Added domain validation to `/activate` endpoint + +**Technical notes:** + +- Old implementation: `hash_hmac('sha256', $prk . "\x01", $serverSecret)` - custom HKDF-like +- New implementation: `bin2hex(hash_hkdf('sha256', $serverSecret, 32, $licenseKey))` - RFC 5869 +- Parameters match client's `ResponseSignature::deriveKey()` exactly: + - IKM (input keying material): server_secret + - Length: 32 bytes (256 bits) + - Info: license_key (context-specific info) +- **Breaking change for existing signatures** - customer secrets will change after upgrade diff --git a/src/Api/ResponseSigner.php b/src/Api/ResponseSigner.php index 540066c..b174f1a 100644 --- a/src/Api/ResponseSigner.php +++ b/src/Api/ResponseSigner.php @@ -157,16 +157,23 @@ final class ResponseSigner * to verify signed API responses. Each customer gets their own secret * derived from their license key. * + * Uses RFC 5869 HKDF via PHP's native hash_hkdf() function. + * Parameters match the client library (SecureLicenseClient): + * - IKM (input keying material): server_secret + * - Length: 32 bytes (256 bits for SHA-256) + * - Info: license_key (context-specific info) + * * @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); + // RFC 5869 HKDF using PHP's native implementation + // Must match client's ResponseSignature::deriveKey() exactly + $binaryKey = hash_hkdf('sha256', $serverSecret, 32, $licenseKey); - return hash_hmac('sha256', $prk . "\x01", $serverSecret); + return bin2hex($binaryKey); } /** diff --git a/src/Api/RestApiController.php b/src/Api/RestApiController.php index 0ea92cf..9b6c1ff 100644 --- a/src/Api/RestApiController.php +++ b/src/Api/RestApiController.php @@ -331,6 +331,9 @@ final class RestApiController 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => function ($value): bool { + return !empty($value) && strlen($value) <= 255; + }, ], ], ]); diff --git a/wc-licensed-product.php b/wc-licensed-product.php index fdab926..ec709e2 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.5.4 + * Version: 0.5.5 * 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.5.4'); +define('WC_LICENSED_PRODUCT_VERSION', '0.5.5'); 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__));