$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: 🔐 '; 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); }); });