Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 167 additions & 31 deletions src/Phaseolies/Support/Encryption.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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()];
}
}
67 changes: 67 additions & 0 deletions tests/EncryptionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading