- Added GitHub spec-kit for development workflow - Standardized file signatures to Descomplicar® format - Updated development configuration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
402 lines
15 KiB
PHP
402 lines
15 KiB
PHP
/**
|
|
* Descomplicar® Crescimento Digital
|
|
* https://descomplicar.pt
|
|
*/
|
|
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace DeskMoloni\Tests\Security;
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Security Test: Encryption and Data Protection
|
|
*
|
|
* This test MUST FAIL initially as part of TDD methodology.
|
|
* Tests encryption implementation and security vulnerabilities.
|
|
*
|
|
* @group security
|
|
* @group encryption
|
|
*/
|
|
class EncryptionSecurityTest extends TestCase
|
|
{
|
|
private array $testConfig;
|
|
private \DeskMoloni\Encryption $encryption;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
global $testConfig;
|
|
$this->testConfig = $testConfig;
|
|
|
|
// This will fail initially until Encryption class is implemented
|
|
$this->encryption = new \DeskMoloni\Encryption($testConfig['encryption']);
|
|
}
|
|
|
|
/**
|
|
* Test OAuth token encryption and decryption
|
|
* This test will initially fail until encryption implementation exists
|
|
*/
|
|
public function testOAuthTokenEncryption(): void
|
|
{
|
|
$originalToken = 'oauth_access_token_example_12345';
|
|
|
|
// Test encryption
|
|
$encrypted = $this->encryption->encrypt($originalToken);
|
|
|
|
$this->assertIsString($encrypted);
|
|
$this->assertNotEquals($originalToken, $encrypted);
|
|
$this->assertGreaterThan(strlen($originalToken), strlen($encrypted));
|
|
|
|
// Test decryption
|
|
$decrypted = $this->encryption->decrypt($encrypted);
|
|
|
|
$this->assertEquals($originalToken, $decrypted);
|
|
}
|
|
|
|
/**
|
|
* Test encryption key rotation security
|
|
*/
|
|
public function testEncryptionKeyRotation(): void
|
|
{
|
|
$sensitiveData = 'client_secret_sensitive_data';
|
|
|
|
// Encrypt with current key
|
|
$encrypted1 = $this->encryption->encrypt($sensitiveData);
|
|
|
|
// Simulate key rotation
|
|
$newKey = bin2hex(random_bytes(32));
|
|
$this->encryption->rotateKey($newKey);
|
|
|
|
// Should still be able to decrypt old data
|
|
$decrypted1 = $this->encryption->decrypt($encrypted1);
|
|
$this->assertEquals($sensitiveData, $decrypted1);
|
|
|
|
// New encryptions should use new key
|
|
$encrypted2 = $this->encryption->encrypt($sensitiveData);
|
|
$this->assertNotEquals($encrypted1, $encrypted2);
|
|
|
|
// Both should decrypt to same value
|
|
$decrypted2 = $this->encryption->decrypt($encrypted2);
|
|
$this->assertEquals($sensitiveData, $decrypted2);
|
|
}
|
|
|
|
/**
|
|
* Test encryption algorithm security (AES-256-GCM)
|
|
*/
|
|
public function testEncryptionAlgorithmSecurity(): void
|
|
{
|
|
$testData = 'sensitive_api_credentials';
|
|
|
|
// Test multiple encryptions produce different results (due to IV)
|
|
$encrypted1 = $this->encryption->encrypt($testData);
|
|
$encrypted2 = $this->encryption->encrypt($testData);
|
|
|
|
$this->assertNotEquals($encrypted1, $encrypted2, 'Same plaintext should produce different ciphertext due to random IV');
|
|
|
|
// Both should decrypt to same value
|
|
$this->assertEquals($testData, $this->encryption->decrypt($encrypted1));
|
|
$this->assertEquals($testData, $this->encryption->decrypt($encrypted2));
|
|
|
|
// Test encryption metadata
|
|
$metadata = $this->encryption->getEncryptionMetadata($encrypted1);
|
|
|
|
$this->assertIsArray($metadata);
|
|
$this->assertArrayHasKey('algorithm', $metadata);
|
|
$this->assertArrayHasKey('iv_length', $metadata);
|
|
$this->assertArrayHasKey('tag_length', $metadata);
|
|
$this->assertEquals('AES-256-GCM', $metadata['algorithm']);
|
|
$this->assertEquals(12, $metadata['iv_length']); // GCM standard IV length
|
|
$this->assertEquals(16, $metadata['tag_length']); // GCM tag length
|
|
}
|
|
|
|
/**
|
|
* Test encryption key strength requirements
|
|
*/
|
|
public function testEncryptionKeyStrength(): void
|
|
{
|
|
// Test weak key rejection
|
|
$weakKeys = [
|
|
'weak',
|
|
'12345678',
|
|
str_repeat('a', 16),
|
|
'password123'
|
|
];
|
|
|
|
foreach ($weakKeys as $weakKey) {
|
|
$this->expectException(\InvalidArgumentException::class);
|
|
$this->expectExceptionMessage('Encryption key does not meet security requirements');
|
|
|
|
new \DeskMoloni\Encryption(['key' => $weakKey, 'cipher' => 'AES-256-GCM']);
|
|
}
|
|
|
|
// Test strong key acceptance
|
|
$strongKey = bin2hex(random_bytes(32)); // 256-bit key
|
|
$strongEncryption = new \DeskMoloni\Encryption(['key' => $strongKey, 'cipher' => 'AES-256-GCM']);
|
|
|
|
$this->assertInstanceOf(\DeskMoloni\Encryption::class, $strongEncryption);
|
|
}
|
|
|
|
/**
|
|
* Test encryption tampering detection
|
|
*/
|
|
public function testEncryptionTamperingDetection(): void
|
|
{
|
|
$originalData = 'tamper_test_data';
|
|
$encrypted = $this->encryption->encrypt($originalData);
|
|
|
|
// Test various tampering scenarios
|
|
$tamperedVersions = [
|
|
substr($encrypted, 0, -1), // Remove last character
|
|
$encrypted . 'x', // Add character
|
|
substr($encrypted, 1), // Remove first character
|
|
str_replace('A', 'B', $encrypted, 1) // Change one character
|
|
];
|
|
|
|
foreach ($tamperedVersions as $tampered) {
|
|
$this->expectException(\DeskMoloni\Exceptions\EncryptionException::class);
|
|
$this->expectExceptionMessage('Data integrity verification failed');
|
|
|
|
$this->encryption->decrypt($tampered);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test secure configuration storage
|
|
*/
|
|
public function testSecureConfigurationStorage(): void
|
|
{
|
|
$configManager = new \DeskMoloni\ConfigManager($this->encryption);
|
|
|
|
// Test storing sensitive configuration
|
|
$sensitiveConfig = [
|
|
'moloni_client_secret' => 'very_secret_value',
|
|
'oauth_refresh_token' => 'refresh_token_12345',
|
|
'webhook_secret' => 'webhook_secret_key'
|
|
];
|
|
|
|
foreach ($sensitiveConfig as $key => $value) {
|
|
$configManager->set($key, $value, true); // true = encrypt
|
|
}
|
|
|
|
// Verify data is encrypted in storage
|
|
$pdo = new \PDO(
|
|
"mysql:host={$this->testConfig['database']['hostname']};dbname={$this->testConfig['database']['database']}",
|
|
$this->testConfig['database']['username'],
|
|
$this->testConfig['database']['password'],
|
|
[\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]
|
|
);
|
|
|
|
foreach ($sensitiveConfig as $key => $originalValue) {
|
|
$stmt = $pdo->prepare("SELECT setting_value, encrypted FROM tbl_desk_moloni_config WHERE setting_key = ?");
|
|
$stmt->execute([$key]);
|
|
$stored = $stmt->fetch();
|
|
|
|
$this->assertNotFalse($stored, "Config {$key} should be stored");
|
|
$this->assertEquals(1, $stored['encrypted'], "Config {$key} should be marked as encrypted");
|
|
$this->assertNotEquals($originalValue, $stored['setting_value'], "Config {$key} should not be stored in plaintext");
|
|
|
|
// Verify retrieval works
|
|
$retrieved = $configManager->get($key);
|
|
$this->assertEquals($originalValue, $retrieved, "Config {$key} should decrypt correctly");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test password/token validation and security
|
|
*/
|
|
public function testPasswordTokenValidation(): void
|
|
{
|
|
$validator = new \DeskMoloni\SecurityValidator();
|
|
|
|
// Test OAuth token validation
|
|
$validTokens = [
|
|
'valid_oauth_token_123456789',
|
|
'Bearer_token_abcdef123456',
|
|
str_repeat('a', 64) // Long alphanumeric token
|
|
];
|
|
|
|
$invalidTokens = [
|
|
'', // Empty
|
|
'short', // Too short
|
|
'token with spaces', // Contains spaces
|
|
'token<script>', // Contains dangerous characters
|
|
str_repeat('a', 1000) // Too long
|
|
];
|
|
|
|
foreach ($validTokens as $token) {
|
|
$this->assertTrue($validator->isValidOAuthToken($token), "Token should be valid: {$token}");
|
|
}
|
|
|
|
foreach ($invalidTokens as $token) {
|
|
$this->assertFalse($validator->isValidOAuthToken($token), "Token should be invalid: {$token}");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test SQL injection prevention in encrypted data
|
|
*/
|
|
public function testSqlInjectionPrevention(): void
|
|
{
|
|
$maliciousInputs = [
|
|
"'; DROP TABLE tbl_desk_moloni_config; --",
|
|
"1' OR '1'='1",
|
|
"'; UPDATE tbl_desk_moloni_config SET setting_value='hacked'; --",
|
|
"<script>alert('xss')</script>",
|
|
"../../etc/passwd"
|
|
];
|
|
|
|
$configManager = new \DeskMoloni\ConfigManager($this->encryption);
|
|
|
|
foreach ($maliciousInputs as $maliciousInput) {
|
|
// Should be able to store and retrieve malicious input safely
|
|
$configManager->set('test_malicious', $maliciousInput, true);
|
|
$retrieved = $configManager->get('test_malicious');
|
|
|
|
$this->assertEquals($maliciousInput, $retrieved, 'Malicious input should be safely stored and retrieved');
|
|
|
|
// Verify no SQL injection occurred
|
|
$pdo = new \PDO(
|
|
"mysql:host={$this->testConfig['database']['hostname']};dbname={$this->testConfig['database']['database']}",
|
|
$this->testConfig['database']['username'],
|
|
$this->testConfig['database']['password'],
|
|
[\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]
|
|
);
|
|
|
|
$stmt = $pdo->query("SELECT COUNT(*) as count FROM tbl_desk_moloni_config");
|
|
$count = $stmt->fetch();
|
|
|
|
$this->assertGreaterThan(0, $count['count'], 'Table should not be dropped or corrupted');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test timing attack resistance
|
|
*/
|
|
public function testTimingAttackResistance(): void
|
|
{
|
|
$correctPassword = 'correct_password_123';
|
|
$incorrectPasswords = [
|
|
'wrong_password_123',
|
|
'correct_password_124', // Very similar
|
|
'x', // Very different length
|
|
str_repeat('x', strlen($correctPassword)) // Same length, different content
|
|
];
|
|
|
|
$validator = new \DeskMoloni\SecurityValidator();
|
|
|
|
// Measure timing for correct password
|
|
$startTime = microtime(true);
|
|
$validator->verifyPassword($correctPassword, password_hash($correctPassword, PASSWORD_ARGON2ID));
|
|
$correctTime = microtime(true) - $startTime;
|
|
|
|
// Measure timing for incorrect passwords
|
|
$incorrectTimes = [];
|
|
foreach ($incorrectPasswords as $incorrectPassword) {
|
|
$startTime = microtime(true);
|
|
$validator->verifyPassword($incorrectPassword, password_hash($correctPassword, PASSWORD_ARGON2ID));
|
|
$incorrectTimes[] = microtime(true) - $startTime;
|
|
}
|
|
|
|
// Calculate timing variance
|
|
$avgIncorrectTime = array_sum($incorrectTimes) / count($incorrectTimes);
|
|
$timingDifference = abs($correctTime - $avgIncorrectTime);
|
|
$maxAllowedDifference = 0.01; // 10ms tolerance
|
|
|
|
$this->assertLessThan(
|
|
$maxAllowedDifference,
|
|
$timingDifference,
|
|
'Password verification should be resistant to timing attacks'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Test secure random generation
|
|
*/
|
|
public function testSecureRandomGeneration(): void
|
|
{
|
|
$randomGenerator = new \DeskMoloni\SecureRandom();
|
|
|
|
// Test token generation
|
|
$tokens = [];
|
|
for ($i = 0; $i < 100; $i++) {
|
|
$token = $randomGenerator->generateToken(32);
|
|
|
|
$this->assertEquals(32, strlen($token));
|
|
$this->assertNotContains($token, $tokens, 'Generated tokens should be unique');
|
|
$this->assertMatchesRegularExpression('/^[a-zA-Z0-9]+$/', $token, 'Token should be alphanumeric');
|
|
|
|
$tokens[] = $token;
|
|
}
|
|
|
|
// Test cryptographic randomness quality
|
|
$randomBytes = $randomGenerator->generateBytes(1000);
|
|
$this->assertEquals(1000, strlen($randomBytes));
|
|
|
|
// Basic entropy test - should not have long runs of same byte
|
|
$maxRun = 0;
|
|
$currentRun = 1;
|
|
$prevByte = ord($randomBytes[0]);
|
|
|
|
for ($i = 1; $i < strlen($randomBytes); $i++) {
|
|
$currentByte = ord($randomBytes[$i]);
|
|
if ($currentByte === $prevByte) {
|
|
$currentRun++;
|
|
$maxRun = max($maxRun, $currentRun);
|
|
} else {
|
|
$currentRun = 1;
|
|
}
|
|
$prevByte = $currentByte;
|
|
}
|
|
|
|
$this->assertLessThan(10, $maxRun, 'Random data should not have long runs of identical bytes');
|
|
}
|
|
|
|
/**
|
|
* Test data sanitization and validation
|
|
*/
|
|
public function testDataSanitizationAndValidation(): void
|
|
{
|
|
$sanitizer = new \DeskMoloni\DataSanitizer();
|
|
|
|
$testCases = [
|
|
// [input, expected_output, description]
|
|
['<script>alert("xss")</script>', 'alert("xss")', 'Should remove script tags'],
|
|
['SELECT * FROM users', 'SELECT * FROM users', 'Should allow safe SQL in strings'],
|
|
['user@example.com', 'user@example.com', 'Should preserve valid email'],
|
|
['user@<script>evil</script>.com', 'user@evil.com', 'Should sanitize email with XSS'],
|
|
['+351910000000', '+351910000000', 'Should preserve valid phone'],
|
|
['javascript:alert(1)', 'alert(1)', 'Should remove javascript protocol'],
|
|
["'; DROP TABLE users; --", "'; DROP TABLE users; --", 'Should escape SQL injection attempts']
|
|
];
|
|
|
|
foreach ($testCases as [$input, $expectedOutput, $description]) {
|
|
$sanitized = $sanitizer->sanitizeString($input);
|
|
$this->assertEquals($expectedOutput, $sanitized, $description);
|
|
}
|
|
|
|
// Test specific field validation
|
|
$this->assertTrue($sanitizer->isValidEmail('test@example.com'));
|
|
$this->assertFalse($sanitizer->isValidEmail('invalid-email'));
|
|
|
|
$this->assertTrue($sanitizer->isValidPhone('+351910000000'));
|
|
$this->assertFalse($sanitizer->isValidPhone('not-a-phone'));
|
|
|
|
$this->assertTrue($sanitizer->isValidVAT('123456789'));
|
|
$this->assertFalse($sanitizer->isValidVAT('invalid-vat'));
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
// Clean up test configuration
|
|
$pdo = new \PDO(
|
|
"mysql:host={$this->testConfig['database']['hostname']};dbname={$this->testConfig['database']['database']}",
|
|
$this->testConfig['database']['username'],
|
|
$this->testConfig['database']['password'],
|
|
[\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]
|
|
);
|
|
|
|
$pdo->exec("DELETE FROM tbl_desk_moloni_config WHERE setting_key LIKE 'test_%' OR setting_key IN ('moloni_client_secret', 'oauth_refresh_token', 'webhook_secret')");
|
|
}
|
|
} |