diff --git a/src/Phaseolies/Support/Encryption.php b/src/Phaseolies/Support/Encryption.php index 7501f22..5a944e1 100644 --- a/src/Phaseolies/Support/Encryption.php +++ b/src/Phaseolies/Support/Encryption.php @@ -7,7 +7,49 @@ class Encryption { /** - * Encrypt data using AES-256-CBC encryption. + * The cipher used for new encryption. AES-256-GCM is an AEAD + * cipher, so ciphertext integrity is verified on decrypt instead + * of trusting unauthenticated CBC output. + * + * @var string + */ + private const CIPHER = 'aes-256-gcm'; + + /** + * The cipher used by data encrypted before AEAD support was + * added. Kept only so previously-encrypted values (e.g. existing + * remember-me cookies, encrypted model columns) remain readable. + * + * @var string + */ + private const LEGACY_CIPHER = 'AES-256-CBC'; + + /** + * Marks a ciphertext as using the versioned AEAD envelope, so + * decrypt() can tell it apart from the legacy CBC format. + * + * @var string + */ + private const VERSION_PREFIX = 'v2:'; + + /** + * GCM authentication tag length in bytes. + * + * @var int + */ + private const TAG_LENGTH = 16; + + /** + * Fallback key used only when no APP_KEY is configured in the + * environment (this package's own test suite never loads a + * .env file). + * + * @var string + */ + private const TESTING_KEY = 'base64:ImGTGoQ6ZhM7yBlMvp41ejp0nt8juImy1aIbf6shQCI='; + + /** + * Encrypt data using authenticated AES-256-GCM encryption. * * @param mixed $data * @return string @@ -19,41 +61,50 @@ public function encrypt(mixed $data): string throw new RuntimeException('Cannot encrypt null value'); } - [$cipher, $key] = $this->getAppKeyAndChiper(); - if (is_array($data)) { $data = json_encode($data); } - $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length($cipher)); - - $encryptedData = openssl_encrypt($data, $cipher, $key, 0, $iv); - - if ($encryptedData === false) { + $key = $this->getAppKey(); + $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length(self::CIPHER)); + $tag = ''; + + $ciphertext = openssl_encrypt( + $data, + self::CIPHER, + $key, + OPENSSL_RAW_DATA, + $iv, + $tag, + '', + self::TAG_LENGTH + ); + + if ($ciphertext === false) { throw new RuntimeException('Encryption failed'); } - // Combine the encrypted data and IV, - // Then base64-encode the result for safe storage/transmission - return base64_encode($encryptedData . '::' . $iv); + // Pack IV + auth tag + ciphertext into one binary blob, then + // base64-encode once for safe storage/transmission. The + // version prefix is left unencoded so decrypt() can dispatch + // without decoding first. + return self::VERSION_PREFIX . base64_encode($iv . $tag . $ciphertext); } /** - * Decrypt data that was encrypted using AES-256-CBC. + * Decrypt data produced by encrypt(). Also accepts ciphertext + * produced by the pre-AEAD AES-256-CBC format so existing data + * keeps working after upgrading; that fallback will be removed + * in a future major version. * * @param string $encryptedData * @return mixed - * @throws RuntimeException */ public function decrypt(string $encryptedData): mixed { - [$cipher, $key] = $this->getAppKeyAndChiper(); - - $data = base64_decode($encryptedData); - - [$encryptedData, $iv] = explode('::', $data, 2); - - $decryptedData = openssl_decrypt($encryptedData, $cipher, $key, 0, $iv); + $decryptedData = str_starts_with($encryptedData, self::VERSION_PREFIX) + ? $this->decryptAead(substr($encryptedData, strlen(self::VERSION_PREFIX))) + : $this->decryptLegacy($encryptedData); if ($decryptedData === false) { return false; @@ -67,22 +118,107 @@ public function decrypt(string $encryptedData): mixed } /** - * Get the application chipper and app key + * Decrypt the current AES-256-GCM envelope. * - * @return array + * @param string $payload + * @return string|false */ - public function getAppKeyAndChiper(): array + private function decryptAead(string $payload): string|false { - // For UNIT Testing - if (PHP_SAPI === 'cli' || defined('STDIN')) { - $cipher = 'AES-256-CBC'; - $key = "base64:ImGTGoQ6ZhM7yBlMvp41ejp0nt8juImy1aIbf6shQCI="; - return [$cipher, $key]; + $raw = base64_decode($payload, true); + + if ($raw === false) { + return false; } - $cipher = config('app.cipher') ?? 'AES-256-CBC'; - $key = base64_decode(getenv('APP_KEY')); + $ivLength = openssl_cipher_iv_length(self::CIPHER); + $iv = substr($raw, 0, $ivLength); + $tag = substr($raw, $ivLength, self::TAG_LENGTH); + $ciphertext = substr($raw, $ivLength + self::TAG_LENGTH); + + return openssl_decrypt($ciphertext, self::CIPHER, $this->getAppKey(), OPENSSL_RAW_DATA, $iv, $tag); + } + + /** + * Decrypt the pre-AEAD AES-256-CBC format. Tries the correctly + * derived key first, then falls back to the key this class used + * to derive before the APP_KEY decoding bug was fixed, so data + * encrypted before that fix remains readable. + * + * @param string $encryptedData + * @return string|false + */ + private function decryptLegacy(string $encryptedData): string|false + { + $data = base64_decode($encryptedData, true); - return [$cipher, $key]; + if ($data === false || !str_contains($data, '::')) { + return false; + } + + [$ciphertext, $iv] = explode('::', $data, 2); + + foreach ([$this->getAppKey(), $this->getLegacyBuggyAppKey()] as $key) { + $decrypted = openssl_decrypt($ciphertext, self::LEGACY_CIPHER, $key, 0, $iv); + + if ($decrypted !== false) { + return $decrypted; + } + } + + return false; + } + + /** + * Get the application encryption key, correctly decoded. + * + * @return string + */ + public function getAppKey(): string + { + $raw = $this->getRawAppKey(); + + return str_starts_with($raw, 'base64:') + ? base64_decode(substr($raw, 7)) + : $raw; + } + + /** + * Reproduce the pre-fix (buggy) key derivation, which ran the + * whole "base64:..." string through base64_decode() instead of + * stripping the prefix first. Used only as a decrypt fallback for + * data encrypted before the fix, so it never observes the raw + * env value changing shape. + * + * @return string + */ + private function getLegacyBuggyAppKey(): string + { + return (string) base64_decode($this->getRawAppKey()); + } + + /** + * Get the raw, undecoded APP_KEY value from the environment, + * falling back to a fixed testing key only when no real key is + * configured at all. + * + * @return string + */ + private function getRawAppKey(): string + { + $key = getenv('APP_KEY'); + + return ($key !== false && $key !== '') ? $key : self::TESTING_KEY; + } + + /** + * Get the application cipher and key. + * + * @return array + * @deprecated Use getAppKey() instead. The cipher is fixed to AES-256-GCM for new encryption; this is kept only for backward compatibility with any code calling it directly. + */ + public function getAppKeyAndChiper(): array + { + return [self::CIPHER, $this->getAppKey()]; } } diff --git a/tests/EncryptionTest.php b/tests/EncryptionTest.php index 16b1e55..7728a43 100644 --- a/tests/EncryptionTest.php +++ b/tests/EncryptionTest.php @@ -57,4 +57,71 @@ public function test_encrypted_values_are_different_each_time() $this->assertNotEquals($encrypted1, $encrypted2); } + + // ==================== AEAD / KEY DERIVATION TESTS ==================== + + public function test_encrypted_output_uses_versioned_aead_envelope() + { + $encrypted = $this->encryption->encrypt('some payload'); + + $this->assertStringStartsWith('v2:', $encrypted); + } + + public function test_tampered_ciphertext_fails_to_decrypt() + { + $encrypted = $this->encryption->encrypt('do not tamper with me'); + + // Flip one character in the payload after the version prefix. + $index = strlen('v2:') + 5; + $encrypted[$index] = $encrypted[$index] === 'A' ? 'B' : 'A'; + + $this->assertFalse($this->encryption->decrypt($encrypted)); + } + + public function test_truncated_ciphertext_fails_to_decrypt() + { + $encrypted = $this->encryption->encrypt('do not truncate me'); + + $this->assertFalse($this->encryption->decrypt(substr($encrypted, 0, -4))); + } + + public function test_decrypts_legacy_pre_aead_cbc_ciphertext() + { + // Simulates data encrypted by the pre-fix code path: raw + // AES-256-CBC with no authentication tag, no version prefix. + $key = $this->encryption->getAppKey(); + $cipher = 'AES-256-CBC'; + $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length($cipher)); + $ciphertext = openssl_encrypt('legacy secret', $cipher, $key, 0, $iv); + $legacyBlob = base64_encode($ciphertext . '::' . $iv); + + $this->assertSame('legacy secret', $this->encryption->decrypt($legacyBlob)); + } + + public function test_decrypts_legacy_ciphertext_encrypted_with_pre_fix_buggy_key() + { + // Simulates data encrypted before the APP_KEY decode bug was + // fixed: the whole "base64:..." string was run through + // base64_decode() instead of stripping the prefix first. + $buggyKey = base64_decode(getenv('APP_KEY') ?: 'base64:ImGTGoQ6ZhM7yBlMvp41ejp0nt8juImy1aIbf6shQCI='); + $cipher = 'AES-256-CBC'; + $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length($cipher)); + $ciphertext = openssl_encrypt('pre-fix secret', $cipher, $buggyKey, 0, $iv); + $legacyBlob = base64_encode($ciphertext . '::' . $iv); + + $this->assertSame('pre-fix secret', $this->encryption->decrypt($legacyBlob)); + } + + public function test_get_app_key_strips_base64_prefix_correctly() + { + $rawEnv = 'base64:ImGTGoQ6ZhM7yBlMvp41ejp0nt8juImy1aIbf6shQCI='; + $expectedKey = base64_decode(substr($rawEnv, 7)); + $buggyKey = base64_decode($rawEnv); + + $key = $this->encryption->getAppKey(); + + $this->assertSame($expectedKey, $key); + $this->assertSame(32, strlen($key)); + $this->assertNotSame($buggyKey, $key); + } }