- Add comprehensive health check system with multiple endpoints - Add Prometheus metrics endpoint - Add production logging configurations (5 strategies) - Add complete deployment documentation suite: * QUICKSTART.md - 30-minute deployment guide * DEPLOYMENT_CHECKLIST.md - Printable verification checklist * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference * production-logging.md - Logging configuration guide * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation * README.md - Navigation hub * DEPLOYMENT_SUMMARY.md - Executive summary - Add deployment scripts and automation - Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment - Update README with production-ready features All production infrastructure is now complete and ready for deployment.
387 lines
14 KiB
PHP
387 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\LiveComponents\Serialization\EncryptedStateSerializer;
|
|
use App\Framework\LiveComponents\Serialization\StateEncryptor;
|
|
use App\Framework\LiveComponents\Exceptions\StateEncryptionException;
|
|
use App\Framework\StateManagement\SerializableState;
|
|
use App\Framework\Cryptography\CryptographicUtilities;
|
|
use App\Framework\Random\SecureRandomGenerator;
|
|
|
|
// Test State Value Object
|
|
final readonly class TestEncryptableState implements SerializableState
|
|
{
|
|
public function __construct(
|
|
public int $count,
|
|
public string $message,
|
|
public ?string $optional = null
|
|
) {
|
|
}
|
|
|
|
public function toArray(): array
|
|
{
|
|
return [
|
|
'count' => $this->count,
|
|
'message' => $this->message,
|
|
'optional' => $this->optional,
|
|
];
|
|
}
|
|
|
|
public static function fromArray(array $data): self
|
|
{
|
|
return new self(
|
|
count: $data['count'] ?? 0,
|
|
message: $data['message'] ?? '',
|
|
optional: $data['optional'] ?? null
|
|
);
|
|
}
|
|
}
|
|
|
|
describe('EncryptedStateSerializer', function () {
|
|
beforeEach(function () {
|
|
// Use framework's RandomGenerator for cryptographically secure key
|
|
$this->random = new SecureRandomGenerator();
|
|
$this->crypto = new CryptographicUtilities($this->random);
|
|
|
|
// Generate valid 32-byte encryption key using framework's RandomGenerator
|
|
$this->encryptionKey = $this->random->bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
|
|
|
|
$this->encryptor = new StateEncryptor(
|
|
$this->encryptionKey,
|
|
$this->crypto,
|
|
$this->random
|
|
);
|
|
|
|
$this->serializer = new EncryptedStateSerializer($this->encryptor);
|
|
|
|
$this->testState = new TestEncryptableState(
|
|
count: 42,
|
|
message: 'Hello, encrypted world!',
|
|
optional: 'optional data'
|
|
);
|
|
});
|
|
|
|
it('serializes and encrypts state to string', function () {
|
|
$encrypted = $this->serializer->serialize($this->testState);
|
|
|
|
expect($encrypted)->toBeString();
|
|
expect($encrypted)->not->toBeEmpty();
|
|
|
|
// Encrypted data should be base64-encoded
|
|
$decoded = base64_decode($encrypted, strict: true);
|
|
expect($decoded)->not->toBeFalse();
|
|
|
|
// Should be longer than original due to nonce and MAC
|
|
expect(strlen($encrypted))->toBeGreaterThan(50);
|
|
});
|
|
|
|
it('deserializes and decrypts state correctly', function () {
|
|
$encrypted = $this->serializer->serialize($this->testState);
|
|
|
|
$decrypted = $this->serializer->deserialize($encrypted, TestEncryptableState::class);
|
|
|
|
expect($decrypted)->toBeInstanceOf(TestEncryptableState::class);
|
|
expect($decrypted->count)->toBe(42);
|
|
expect($decrypted->message)->toBe('Hello, encrypted world!');
|
|
expect($decrypted->optional)->toBe('optional data');
|
|
});
|
|
|
|
it('produces different ciphertext for same state due to unique nonces', function () {
|
|
$encrypted1 = $this->serializer->serialize($this->testState);
|
|
$encrypted2 = $this->serializer->serialize($this->testState);
|
|
|
|
// Same plaintext should produce different ciphertext (unique nonces)
|
|
expect($encrypted1)->not->toBe($encrypted2);
|
|
|
|
// But both should decrypt to same state
|
|
$decrypted1 = $this->serializer->deserialize($encrypted1, TestEncryptableState::class);
|
|
$decrypted2 = $this->serializer->deserialize($encrypted2, TestEncryptableState::class);
|
|
|
|
expect($decrypted1->count)->toBe($decrypted2->count);
|
|
expect($decrypted1->message)->toBe($decrypted2->message);
|
|
});
|
|
|
|
it('handles state with null optional field', function () {
|
|
$state = new TestEncryptableState(
|
|
count: 10,
|
|
message: 'test',
|
|
optional: null
|
|
);
|
|
|
|
$encrypted = $this->serializer->serialize($state);
|
|
$decrypted = $this->serializer->deserialize($encrypted, TestEncryptableState::class);
|
|
|
|
expect($decrypted->optional)->toBeNull();
|
|
});
|
|
|
|
it('handles state with special characters', function () {
|
|
$state = new TestEncryptableState(
|
|
count: 123,
|
|
message: "Special chars: 🔐 <script>alert('xss')</script> '; DROP TABLE users;--",
|
|
optional: "Unicode: 你好世界 مرحبا العالم"
|
|
);
|
|
|
|
$encrypted = $this->serializer->serialize($state);
|
|
$decrypted = $this->serializer->deserialize($encrypted, TestEncryptableState::class);
|
|
|
|
expect($decrypted->message)->toBe($state->message);
|
|
expect($decrypted->optional)->toBe($state->optional);
|
|
});
|
|
|
|
it('throws exception for invalid encrypted data', function () {
|
|
$this->serializer->deserialize('invalid-base64-data', TestEncryptableState::class);
|
|
})->throws(StateEncryptionException::class);
|
|
|
|
it('throws exception for tampered ciphertext', function () {
|
|
$encrypted = $this->serializer->serialize($this->testState);
|
|
|
|
// Tamper with encrypted data
|
|
$tamperedEncrypted = substr($encrypted, 0, -5) . 'XXXXX';
|
|
|
|
$this->serializer->deserialize($tamperedEncrypted, TestEncryptableState::class);
|
|
})->throws(StateEncryptionException::class);
|
|
|
|
it('throws exception for invalid state class', function () {
|
|
$encrypted = $this->serializer->serialize($this->testState);
|
|
|
|
// Try to deserialize to wrong class
|
|
$this->serializer->deserialize($encrypted, \stdClass::class);
|
|
})->throws(StateEncryptionException::class, 'must implement fromArray');
|
|
|
|
it('throws exception for state without toArray method', function () {
|
|
$invalidState = new class {
|
|
public int $value = 42;
|
|
};
|
|
|
|
$this->serializer->serialize($invalidState);
|
|
})->throws(StateEncryptionException::class, 'must implement toArray');
|
|
|
|
it('throws exception for corrupted JSON in ciphertext', function () {
|
|
// Create manually corrupted encrypted data with valid structure but invalid JSON
|
|
$nonce = random_bytes(24);
|
|
$invalidJson = '{invalid json}';
|
|
$ciphertext = sodium_crypto_secretbox($invalidJson, $nonce, $this->encryptionKey);
|
|
|
|
$versionByte = chr(1);
|
|
$encrypted = base64_encode($versionByte . $nonce . $ciphertext);
|
|
|
|
$this->serializer->deserialize($encrypted, TestEncryptableState::class);
|
|
})->throws(StateEncryptionException::class);
|
|
|
|
it('preserves data types through encryption cycle', function () {
|
|
$state = new TestEncryptableState(
|
|
count: 0, // Zero value
|
|
message: '', // Empty string
|
|
optional: null
|
|
);
|
|
|
|
$encrypted = $this->serializer->serialize($state);
|
|
$decrypted = $this->serializer->deserialize($encrypted, TestEncryptableState::class);
|
|
|
|
expect($decrypted->count)->toBe(0);
|
|
expect($decrypted->count)->toBeInt();
|
|
expect($decrypted->message)->toBe('');
|
|
expect($decrypted->message)->toBeString();
|
|
expect($decrypted->optional)->toBeNull();
|
|
});
|
|
|
|
it('handles large state objects', function () {
|
|
$largeMessage = str_repeat('A', 10000); // 10KB string
|
|
|
|
$state = new TestEncryptableState(
|
|
count: 999999,
|
|
message: $largeMessage,
|
|
optional: str_repeat('B', 5000)
|
|
);
|
|
|
|
$encrypted = $this->serializer->serialize($state);
|
|
$decrypted = $this->serializer->deserialize($encrypted, TestEncryptableState::class);
|
|
|
|
expect($decrypted->message)->toBe($largeMessage);
|
|
expect(strlen($decrypted->message))->toBe(10000);
|
|
});
|
|
});
|
|
|
|
describe('StateEncryptor', function () {
|
|
beforeEach(function () {
|
|
$this->random = new SecureRandomGenerator();
|
|
$this->crypto = new CryptographicUtilities($this->random);
|
|
$this->encryptionKey = $this->random->bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
|
|
|
|
$this->encryptor = new StateEncryptor(
|
|
$this->encryptionKey,
|
|
$this->crypto,
|
|
$this->random
|
|
);
|
|
});
|
|
|
|
it('encrypts and decrypts plaintext correctly', function () {
|
|
$plaintext = 'Secret message';
|
|
|
|
$encrypted = $this->encryptor->encrypt($plaintext);
|
|
$decrypted = $this->encryptor->decrypt($encrypted);
|
|
|
|
expect($decrypted)->toBe($plaintext);
|
|
});
|
|
|
|
it('produces different ciphertext for same plaintext', function () {
|
|
$plaintext = 'Same message';
|
|
|
|
$encrypted1 = $this->encryptor->encrypt($plaintext);
|
|
$encrypted2 = $this->encryptor->encrypt($plaintext);
|
|
|
|
expect($encrypted1)->not->toBe($encrypted2);
|
|
|
|
expect($this->encryptor->decrypt($encrypted1))->toBe($plaintext);
|
|
expect($this->encryptor->decrypt($encrypted2))->toBe($plaintext);
|
|
});
|
|
|
|
it('throws exception for invalid encryption key length', function () {
|
|
new StateEncryptor(
|
|
'too-short-key', // Invalid key length
|
|
$this->crypto,
|
|
$this->random
|
|
);
|
|
})->throws(\InvalidArgumentException::class, 'Encryption key must be');
|
|
|
|
it('throws exception for weak encryption key', function () {
|
|
$random = new SecureRandomGenerator();
|
|
$crypto = new CryptographicUtilities($random);
|
|
|
|
// All zeros - weak key (insufficient entropy)
|
|
$weakKey = str_repeat("\0", SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
|
|
|
|
new StateEncryptor(
|
|
$weakKey,
|
|
$crypto,
|
|
$random
|
|
);
|
|
})->throws(\InvalidArgumentException::class, 'insufficient entropy');
|
|
|
|
it('detects MAC tampering', function () {
|
|
$encrypted = $this->encryptor->encrypt('original message');
|
|
|
|
// Tamper with MAC by modifying last bytes
|
|
$decoded = base64_decode($encrypted, strict: true);
|
|
$tampered = substr($decoded, 0, -5) . 'XXXXX';
|
|
$tamperedEncrypted = base64_encode($tampered);
|
|
|
|
$this->encryptor->decrypt($tamperedEncrypted);
|
|
})->throws(StateEncryptionException::class, 'MAC verification failed');
|
|
|
|
it('throws exception for corrupted base64', function () {
|
|
$this->encryptor->decrypt('!!!invalid-base64!!!');
|
|
})->throws(StateEncryptionException::class);
|
|
|
|
it('throws exception for invalid encryption version', function () {
|
|
// Create encrypted data with invalid version byte
|
|
$nonce = random_bytes(24);
|
|
$plaintext = 'test';
|
|
$ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $this->encryptionKey);
|
|
|
|
$invalidVersion = chr(99); // Invalid version
|
|
$encrypted = base64_encode($invalidVersion . $nonce . $ciphertext);
|
|
|
|
$this->encryptor->decrypt($encrypted);
|
|
})->throws(StateEncryptionException::class, 'Unsupported encryption version');
|
|
|
|
it('handles empty plaintext', function () {
|
|
$encrypted = $this->encryptor->encrypt('');
|
|
$decrypted = $this->encryptor->decrypt($encrypted);
|
|
|
|
expect($decrypted)->toBe('');
|
|
});
|
|
|
|
it('handles unicode plaintext', function () {
|
|
$plaintext = '🔐 Encrypted Unicode: 你好世界 مرحبا العالم';
|
|
|
|
$encrypted = $this->encryptor->encrypt($plaintext);
|
|
$decrypted = $this->encryptor->decrypt($encrypted);
|
|
|
|
expect($decrypted)->toBe($plaintext);
|
|
});
|
|
|
|
it('validates encrypted data format', function () {
|
|
$encrypted = $this->encryptor->encrypt('test');
|
|
|
|
expect($this->encryptor->isEncrypted($encrypted))->toBeTrue();
|
|
expect($this->encryptor->isEncrypted('not-encrypted'))->toBeFalse();
|
|
expect($this->encryptor->isEncrypted(''))->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('StateEncryptor Security', function () {
|
|
beforeEach(function () {
|
|
$this->random = new SecureRandomGenerator();
|
|
$this->crypto = new CryptographicUtilities($this->random);
|
|
$this->encryptionKey = $this->random->bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
|
|
|
|
$this->encryptor = new StateEncryptor(
|
|
$this->encryptionKey,
|
|
$this->crypto,
|
|
$this->random
|
|
);
|
|
});
|
|
|
|
it('uses different nonce for each encryption', function () {
|
|
$plaintext = 'test';
|
|
|
|
$nonces = [];
|
|
for ($i = 0; $i < 100; $i++) {
|
|
$encrypted = $this->encryptor->encrypt($plaintext);
|
|
$decoded = base64_decode($encrypted, strict: true);
|
|
|
|
// Minimum length check: 1 (version) + 24 (nonce) + 16 (min ciphertext from sodium_crypto_secretbox)
|
|
expect(strlen($decoded))->toBeGreaterThanOrEqual(41);
|
|
|
|
// Extract nonce (after version byte, 24 bytes)
|
|
$nonce = substr($decoded, 1, 24);
|
|
|
|
expect(strlen($nonce))->toBe(24);
|
|
expect($nonces)->not->toContain($nonce);
|
|
|
|
$nonces[] = $nonce;
|
|
}
|
|
|
|
// All nonces should be unique
|
|
expect(count($nonces))->toBe(100);
|
|
expect(count(array_unique($nonces, SORT_REGULAR)))->toBe(100);
|
|
});
|
|
|
|
it('prevents key reuse across different encryptors', function () {
|
|
$plaintext = 'secret';
|
|
|
|
$random = new SecureRandomGenerator();
|
|
$crypto = new CryptographicUtilities($random);
|
|
|
|
$encryptor1 = new StateEncryptor(
|
|
$this->encryptionKey,
|
|
$crypto,
|
|
$random
|
|
);
|
|
|
|
// Different key using framework's RandomGenerator
|
|
$differentKey = $random->bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
|
|
$encryptor2 = new StateEncryptor(
|
|
$differentKey,
|
|
$crypto,
|
|
$random
|
|
);
|
|
|
|
$encrypted1 = $encryptor1->encrypt($plaintext);
|
|
|
|
// Should fail with wrong key
|
|
$encryptor2->decrypt($encrypted1);
|
|
})->throws(StateEncryptionException::class);
|
|
|
|
it('includes version byte for future compatibility', function () {
|
|
$encrypted = $this->encryptor->encrypt('test');
|
|
$decoded = base64_decode($encrypted, strict: true);
|
|
|
|
// First byte should be version byte (value 1)
|
|
$version = ord($decoded[0]);
|
|
expect($version)->toBe(1);
|
|
});
|
|
});
|