You've already forked wc-licensed-product-client
Add security layer with response signature verification
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>
This commit is contained in:
218
tests/Security/IntegrityCheckerTest.php
Normal file
218
tests/Security/IntegrityCheckerTest.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Magdev\WcLicensedProductClient\Tests\Security;
|
||||
|
||||
use Magdev\WcLicensedProductClient\Security\IntegrityChecker;
|
||||
use Magdev\WcLicensedProductClient\Security\IntegrityException;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(IntegrityChecker::class)]
|
||||
#[CoversClass(IntegrityException::class)]
|
||||
final class IntegrityCheckerTest extends TestCase
|
||||
{
|
||||
private string $tempDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tempDir = sys_get_temp_dir() . '/integrity_test_' . uniqid();
|
||||
mkdir($this->tempDir, 0755, true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->removeDirectory($this->tempDir);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itPassesForUnmodifiedFiles(): void
|
||||
{
|
||||
$this->createFile('test.php', '<?php echo "hello";');
|
||||
|
||||
$checker = new IntegrityChecker([], $this->tempDir);
|
||||
$hashes = $checker->generateHashes(['test.php']);
|
||||
|
||||
$verifier = new IntegrityChecker($hashes, $this->tempDir);
|
||||
$verifier->verify();
|
||||
|
||||
self::assertTrue($verifier->isValid());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itFailsForModifiedFiles(): void
|
||||
{
|
||||
$this->createFile('test.php', '<?php echo "hello";');
|
||||
|
||||
$checker = new IntegrityChecker([], $this->tempDir);
|
||||
$hashes = $checker->generateHashes(['test.php']);
|
||||
|
||||
// Modify the file
|
||||
$this->createFile('test.php', '<?php echo "world";');
|
||||
|
||||
$verifier = new IntegrityChecker($hashes, $this->tempDir);
|
||||
|
||||
$this->expectException(IntegrityException::class);
|
||||
$this->expectExceptionMessage('Modified file: test.php');
|
||||
|
||||
$verifier->verify();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itFailsForMissingFiles(): void
|
||||
{
|
||||
$this->createFile('test.php', '<?php echo "hello";');
|
||||
|
||||
$checker = new IntegrityChecker([], $this->tempDir);
|
||||
$hashes = $checker->generateHashes(['test.php']);
|
||||
|
||||
// Delete the file
|
||||
unlink($this->tempDir . '/test.php');
|
||||
|
||||
$verifier = new IntegrityChecker($hashes, $this->tempDir);
|
||||
|
||||
$this->expectException(IntegrityException::class);
|
||||
$this->expectExceptionMessage('Missing file: test.php');
|
||||
|
||||
$verifier->verify();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsFailureDetailsInException(): void
|
||||
{
|
||||
$this->createFile('file1.php', 'content1');
|
||||
$this->createFile('file2.php', 'content2');
|
||||
|
||||
$checker = new IntegrityChecker([], $this->tempDir);
|
||||
$hashes = $checker->generateHashes(['file1.php', 'file2.php']);
|
||||
|
||||
// Modify both files
|
||||
$this->createFile('file1.php', 'modified1');
|
||||
$this->createFile('file2.php', 'modified2');
|
||||
|
||||
$verifier = new IntegrityChecker($hashes, $this->tempDir);
|
||||
|
||||
try {
|
||||
$verifier->verify();
|
||||
self::fail('Expected IntegrityException');
|
||||
} catch (IntegrityException $e) {
|
||||
self::assertCount(2, $e->failures);
|
||||
self::assertContains('Modified file: file1.php', $e->failures);
|
||||
self::assertContains('Modified file: file2.php', $e->failures);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIsValidReturnsFalseForModifiedFiles(): void
|
||||
{
|
||||
$this->createFile('test.php', 'original');
|
||||
|
||||
$checker = new IntegrityChecker([], $this->tempDir);
|
||||
$hashes = $checker->generateHashes(['test.php']);
|
||||
|
||||
$this->createFile('test.php', 'modified');
|
||||
|
||||
$verifier = new IntegrityChecker($hashes, $this->tempDir);
|
||||
|
||||
self::assertFalse($verifier->isValid());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGeneratesConsistentHashes(): void
|
||||
{
|
||||
$this->createFile('test.php', '<?php echo "hello";');
|
||||
|
||||
$checker1 = new IntegrityChecker([], $this->tempDir);
|
||||
$checker2 = new IntegrityChecker([], $this->tempDir);
|
||||
|
||||
$hash1 = $checker1->generateHashes(['test.php']);
|
||||
$hash2 = $checker2->generateHashes(['test.php']);
|
||||
|
||||
self::assertSame($hash1, $hash2);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itNormalizesLineEndings(): void
|
||||
{
|
||||
// Create file with Unix line endings
|
||||
$this->createFile('test.php', "line1\nline2\n");
|
||||
|
||||
$checker = new IntegrityChecker([], $this->tempDir);
|
||||
$hash1 = $checker->generateHashes(['test.php']);
|
||||
|
||||
// Create same file with Windows line endings
|
||||
$this->createFile('test.php', "line1\r\nline2\r\n");
|
||||
|
||||
$hash2 = $checker->generateHashes(['test.php']);
|
||||
|
||||
self::assertSame($hash1['test.php'], $hash2['test.php']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGeneratesPhpCode(): void
|
||||
{
|
||||
$this->createFile('test.php', 'content');
|
||||
|
||||
$checker = new IntegrityChecker([], $this->tempDir);
|
||||
$code = $checker->generateHashesAsPhpCode(['test.php']);
|
||||
|
||||
self::assertStringContainsString('[', $code);
|
||||
self::assertStringContainsString(']', $code);
|
||||
self::assertStringContainsString("'test.php'", $code);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsForNonExistentFileInGenerate(): void
|
||||
{
|
||||
$checker = new IntegrityChecker([], $this->tempDir);
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
$checker->generateHashes(['nonexistent.php']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesSubdirectories(): void
|
||||
{
|
||||
mkdir($this->tempDir . '/subdir', 0755, true);
|
||||
$this->createFile('subdir/test.php', 'content');
|
||||
|
||||
$checker = new IntegrityChecker([], $this->tempDir);
|
||||
$hashes = $checker->generateHashes(['subdir/test.php']);
|
||||
|
||||
self::assertArrayHasKey('subdir/test.php', $hashes);
|
||||
|
||||
$verifier = new IntegrityChecker($hashes, $this->tempDir);
|
||||
self::assertTrue($verifier->isValid());
|
||||
}
|
||||
|
||||
private function createFile(string $relativePath, string $content): void
|
||||
{
|
||||
$fullPath = $this->tempDir . '/' . $relativePath;
|
||||
$dir = dirname($fullPath);
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
file_put_contents($fullPath, $content);
|
||||
}
|
||||
|
||||
private function removeDirectory(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = array_diff(scandir($dir), ['.', '..']);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$path = $dir . '/' . $file;
|
||||
is_dir($path) ? $this->removeDirectory($path) : unlink($path);
|
||||
}
|
||||
|
||||
rmdir($dir);
|
||||
}
|
||||
}
|
||||
181
tests/Security/ResponseSignatureTest.php
Normal file
181
tests/Security/ResponseSignatureTest.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
148
tests/Security/StringEncoderTest.php
Normal file
148
tests/Security/StringEncoderTest.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Magdev\WcLicensedProductClient\Tests\Security;
|
||||
|
||||
use Magdev\WcLicensedProductClient\Security\StringEncoder;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(StringEncoder::class)]
|
||||
final class StringEncoderTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function itEncodesAndDecodesStrings(): void
|
||||
{
|
||||
$encoder = new StringEncoder();
|
||||
$original = 'Hello, World!';
|
||||
|
||||
$encoded = $encoder->encode($original);
|
||||
$decoded = $encoder->decode($encoded);
|
||||
|
||||
self::assertSame($original, $decoded);
|
||||
self::assertNotSame($original, $encoded);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itEncodesApiPaths(): void
|
||||
{
|
||||
$encoder = new StringEncoder();
|
||||
$path = '/wp-json/wc-licensed-product/v1';
|
||||
|
||||
$encoded = $encoder->encode($path);
|
||||
$decoded = $encoder->decode($encoded);
|
||||
|
||||
self::assertSame($path, $decoded);
|
||||
self::assertStringNotContainsString('wp-json', $encoded);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itEncodesEndpointNames(): void
|
||||
{
|
||||
$encoder = new StringEncoder();
|
||||
$endpoints = ['validate', 'status', 'activate'];
|
||||
|
||||
foreach ($endpoints as $endpoint) {
|
||||
$encoded = $encoder->encode($endpoint);
|
||||
$decoded = $encoder->decode($encoded);
|
||||
|
||||
self::assertSame($endpoint, $decoded);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesEmptyString(): void
|
||||
{
|
||||
$encoder = new StringEncoder();
|
||||
|
||||
$encoded = $encoder->encode('');
|
||||
$decoded = $encoder->decode($encoded);
|
||||
|
||||
self::assertSame('', $decoded);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesUnicodeStrings(): void
|
||||
{
|
||||
$encoder = new StringEncoder();
|
||||
$original = 'Ümläut and émojis 🔐';
|
||||
|
||||
$encoded = $encoder->encode($original);
|
||||
$decoded = $encoder->decode($encoded);
|
||||
|
||||
self::assertSame($original, $decoded);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itProducesDifferentOutputWithDifferentKeys(): void
|
||||
{
|
||||
$encoder1 = new StringEncoder('key1');
|
||||
$encoder2 = new StringEncoder('key2');
|
||||
$original = 'test string';
|
||||
|
||||
$encoded1 = $encoder1->encode($original);
|
||||
$encoded2 = $encoder2->encode($original);
|
||||
|
||||
self::assertNotSame($encoded1, $encoded2);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itRequiresSameKeyForDecoding(): void
|
||||
{
|
||||
$encoder1 = new StringEncoder('key1');
|
||||
$encoder2 = new StringEncoder('key2');
|
||||
$original = 'test string';
|
||||
|
||||
$encoded = $encoder1->encode($original);
|
||||
$decodedWithWrongKey = $encoder2->decode($encoded);
|
||||
|
||||
self::assertNotSame($original, $decodedWithWrongKey);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itGeneratesEncodedConstants(): void
|
||||
{
|
||||
$encoder = new StringEncoder();
|
||||
$constants = [
|
||||
'API_PATH' => '/wp-json/wc-licensed-product/v1',
|
||||
'VALIDATE' => 'validate',
|
||||
'STATUS' => 'status',
|
||||
];
|
||||
|
||||
$encoded = $encoder->generateEncodedConstants($constants);
|
||||
|
||||
self::assertCount(3, $encoded);
|
||||
self::assertArrayHasKey('API_PATH', $encoded);
|
||||
self::assertArrayHasKey('VALIDATE', $encoded);
|
||||
self::assertArrayHasKey('STATUS', $encoded);
|
||||
|
||||
// Verify all can be decoded back
|
||||
foreach ($constants as $name => $value) {
|
||||
self::assertSame($value, $encoder->decode($encoded[$name]));
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsOnInvalidEncodedString(): void
|
||||
{
|
||||
$encoder = new StringEncoder();
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
$encoder->decode('not-valid-base64!!!');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesLongStrings(): void
|
||||
{
|
||||
$encoder = new StringEncoder();
|
||||
$original = str_repeat('A', 10000);
|
||||
|
||||
$encoded = $encoder->encode($original);
|
||||
$decoded = $encoder->decode($encoded);
|
||||
|
||||
self::assertSame($original, $decoded);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user