You've already forked wc-licensed-product-client
Security classes: - ResponseSignature: HMAC-SHA256 signing and verification - StringEncoder: XOR-based string obfuscation for source code - IntegrityChecker: Source file hash verification - SignatureException, IntegrityException for error handling SecureLicenseClient: - Verifies server response signatures - Prevents response tampering and replay attacks - Per-license derived signing keys - Optional code integrity checking Documentation: - docs/server-implementation.md with complete WordPress/WooCommerce integration guide for signing responses Tests: - 34 new security tests (66 total, all passing) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
182 lines
5.5 KiB
PHP
182 lines
5.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Magdev\WcLicensedProductClient\Tests\Security;
|
|
|
|
use Magdev\WcLicensedProductClient\Security\ResponseSignature;
|
|
use PHPUnit\Framework\Attributes\CoversClass;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
#[CoversClass(ResponseSignature::class)]
|
|
final class ResponseSignatureTest extends TestCase
|
|
{
|
|
private const SECRET_KEY = 'test-secret-key-for-unit-tests';
|
|
private const LICENSE_KEY = 'ABCD-1234-EFGH-5678';
|
|
private const SERVER_SECRET = 'server-master-secret';
|
|
|
|
#[Test]
|
|
public function itSignsAndVerifiesResponse(): void
|
|
{
|
|
$signature = new ResponseSignature(self::SECRET_KEY);
|
|
$timestamp = time();
|
|
$data = ['valid' => true, 'license' => ['product_id' => 123]];
|
|
|
|
$sig = $signature->sign($data, $timestamp);
|
|
|
|
self::assertTrue($signature->verify($data, $sig, $timestamp));
|
|
}
|
|
|
|
#[Test]
|
|
public function itRejectsInvalidSignature(): void
|
|
{
|
|
$signature = new ResponseSignature(self::SECRET_KEY);
|
|
$timestamp = time();
|
|
$data = ['valid' => true];
|
|
|
|
self::assertFalse($signature->verify($data, 'invalid-signature', $timestamp));
|
|
}
|
|
|
|
#[Test]
|
|
public function itRejectsTamperedData(): void
|
|
{
|
|
$signature = new ResponseSignature(self::SECRET_KEY);
|
|
$timestamp = time();
|
|
$originalData = ['valid' => true];
|
|
$tamperedData = ['valid' => false];
|
|
|
|
$sig = $signature->sign($originalData, $timestamp);
|
|
|
|
self::assertFalse($signature->verify($tamperedData, $sig, $timestamp));
|
|
}
|
|
|
|
#[Test]
|
|
public function itRejectsExpiredTimestamp(): void
|
|
{
|
|
$signature = new ResponseSignature(self::SECRET_KEY, timestampTolerance: 60);
|
|
$oldTimestamp = time() - 120; // 2 minutes ago
|
|
$data = ['valid' => true];
|
|
|
|
$sig = $signature->sign($data, $oldTimestamp);
|
|
|
|
self::assertFalse($signature->verify($data, $sig, $oldTimestamp));
|
|
}
|
|
|
|
#[Test]
|
|
public function itAcceptsTimestampWithinTolerance(): void
|
|
{
|
|
$signature = new ResponseSignature(self::SECRET_KEY, timestampTolerance: 300);
|
|
$recentTimestamp = time() - 60; // 1 minute ago
|
|
$data = ['valid' => true];
|
|
|
|
$sig = $signature->sign($data, $recentTimestamp);
|
|
|
|
self::assertTrue($signature->verify($data, $sig, $recentTimestamp));
|
|
}
|
|
|
|
#[Test]
|
|
public function itDerivesUniqueKeysPerLicense(): void
|
|
{
|
|
$key1 = ResponseSignature::deriveKey('LICENSE-001', self::SERVER_SECRET);
|
|
$key2 = ResponseSignature::deriveKey('LICENSE-002', self::SERVER_SECRET);
|
|
|
|
self::assertNotSame($key1, $key2);
|
|
self::assertSame(64, strlen($key1)); // SHA256 hex = 64 chars
|
|
}
|
|
|
|
#[Test]
|
|
public function itProducesDeterministicKeys(): void
|
|
{
|
|
$key1 = ResponseSignature::deriveKey(self::LICENSE_KEY, self::SERVER_SECRET);
|
|
$key2 = ResponseSignature::deriveKey(self::LICENSE_KEY, self::SERVER_SECRET);
|
|
|
|
self::assertSame($key1, $key2);
|
|
}
|
|
|
|
#[Test]
|
|
public function itCreatesFromLicenseKey(): void
|
|
{
|
|
$signature = ResponseSignature::fromLicenseKey(self::LICENSE_KEY, self::SERVER_SECRET);
|
|
$timestamp = time();
|
|
$data = ['valid' => true];
|
|
|
|
$sig = $signature->sign($data, $timestamp);
|
|
|
|
// Verify with same derived key
|
|
$signature2 = ResponseSignature::fromLicenseKey(self::LICENSE_KEY, self::SERVER_SECRET);
|
|
self::assertTrue($signature2->verify($data, $sig, $timestamp));
|
|
}
|
|
|
|
#[Test]
|
|
public function itExtractsSignatureFromHeaders(): void
|
|
{
|
|
$headers = [
|
|
'X-License-Signature' => 'abc123',
|
|
'Content-Type' => 'application/json',
|
|
];
|
|
|
|
self::assertSame('abc123', ResponseSignature::extractSignature($headers));
|
|
}
|
|
|
|
#[Test]
|
|
public function itExtractsTimestampFromHeaders(): void
|
|
{
|
|
$headers = [
|
|
'X-License-Timestamp' => '1706000000',
|
|
'Content-Type' => 'application/json',
|
|
];
|
|
|
|
self::assertSame(1706000000, ResponseSignature::extractTimestamp($headers));
|
|
}
|
|
|
|
#[Test]
|
|
public function itReturnsNullForMissingHeaders(): void
|
|
{
|
|
$headers = ['Content-Type' => 'application/json'];
|
|
|
|
self::assertNull(ResponseSignature::extractSignature($headers));
|
|
self::assertNull(ResponseSignature::extractTimestamp($headers));
|
|
}
|
|
|
|
#[Test]
|
|
public function itHandlesLowercaseHeaders(): void
|
|
{
|
|
$headers = [
|
|
'x-license-signature' => 'abc123',
|
|
'x-license-timestamp' => '1706000000',
|
|
];
|
|
|
|
self::assertSame('abc123', ResponseSignature::extractSignature($headers));
|
|
self::assertSame(1706000000, ResponseSignature::extractTimestamp($headers));
|
|
}
|
|
|
|
#[Test]
|
|
public function itProducesConsistentSignaturesForSameData(): void
|
|
{
|
|
$signature = new ResponseSignature(self::SECRET_KEY);
|
|
$timestamp = 1706000000;
|
|
$data = ['b' => 2, 'a' => 1]; // Unsorted
|
|
|
|
$sig1 = $signature->sign($data, $timestamp);
|
|
$sig2 = $signature->sign($data, $timestamp);
|
|
|
|
self::assertSame($sig1, $sig2);
|
|
}
|
|
|
|
#[Test]
|
|
public function itSortsKeysForConsistentSignatures(): void
|
|
{
|
|
$signature = new ResponseSignature(self::SECRET_KEY);
|
|
$timestamp = 1706000000;
|
|
|
|
$data1 = ['a' => 1, 'b' => 2];
|
|
$data2 = ['b' => 2, 'a' => 1];
|
|
|
|
$sig1 = $signature->sign($data1, $timestamp);
|
|
$sig2 = $signature->sign($data2, $timestamp);
|
|
|
|
self::assertSame($sig1, $sig2);
|
|
}
|
|
}
|