['size' => 16, 'aead' => false], 'aes-256-cbc' => ['size' => 32, 'aead' => false], 'aes-128-gcm' => ['size' => 16, 'aead' => true], 'aes-256-gcm' => ['size' => 32, 'aead' => true], ]; /** * Create a new encrypter instance. * * @param string $key * @param string $cipher * @return void * * @throws \RuntimeException */ public function __construct($key = null, $cipher = 'aes-128-cbc') { $this->cipher = $cipher; $this->slug = $this->getSlug(); $key = $key ?: $this->getKey(); if (!static::supported($key, $this->cipher)) { $ciphers = implode(', ', array_keys(self::$supportedCiphers)); throw new RuntimeException("Unsupported cipher or incorrect key length. Supported ciphers are: {$ciphers}."); } $this->key = $key; } /** * Determine if the given key and cipher combination is valid. * * @param string $key * @param string $cipher * @return bool */ public static function supported($key, $cipher) { if (!isset(self::$supportedCiphers[strtolower($cipher)])) { return false; } return mb_strlen( $key, '8bit' ) === self::$supportedCiphers[strtolower($cipher)]['size']; } /** * Create a new encryption key for the given cipher. * * @param string $cipher * @return string * * @throws \RuntimeException If the cipher is not in the supported list. */ public static function generateKey($cipher) { $cipher = strtolower($cipher); if (!isset(self::$supportedCiphers[$cipher])) { $ciphers = implode(', ', array_keys(self::$supportedCiphers)); throw new RuntimeException( "Unsupported cipher '{$cipher}'. Supported ciphers are: {$ciphers}." ); } return random_bytes(self::$supportedCiphers[$cipher]['size']); } /** * Encrypt the given value. * * @param mixed $value * @param bool $serialize * @return string * * @throws \FluentSupport\Framework\Encryption\EncryptException */ public function encrypt($value, $serialize = true) { $iv = random_bytes(openssl_cipher_iv_length(strtolower($this->cipher))); $value = \openssl_encrypt( $serialize ? serialize($value) : $value, strtolower($this->cipher), $this->key, 0, $iv, $tag ); if ($value === false) { throw new EncryptException('Could not encrypt the data.'); } $iv = base64_encode($iv); $tag = base64_encode($tag ?? ''); $mac = self::$supportedCiphers[strtolower($this->cipher)]['aead'] ? '' // For AEAD-algorithms, the tag / MAC is returned by openssl_encrypt... : $this->hash($iv, $value, $this->key); $json = json_encode(compact('iv', 'value', 'mac', 'tag'), JSON_UNESCAPED_SLASHES); if (json_last_error() !== JSON_ERROR_NONE) { throw new EncryptException('Could not encrypt the data.'); } return base64_encode($json); } /** * Encrypt a string without serialization. * * @param string $value * @return string * * @throws \FluentSupport\Framework\Encryption\EncryptException */ public function encryptString($value) { return $this->encrypt($value, false); } /** * Decrypt the given value. * * @param string $payload * @param bool $unserialize * @return mixed * * @throws \FluentSupport\Framework\Encryption\DecryptException */ public function decrypt($payload, $unserialize = true) { $payload = $this->getJsonPayload($payload); $iv = base64_decode($payload['iv']); $this->ensureTagIsValid( $tag = empty($payload['tag']) ? null : base64_decode($payload['tag']) ); $foundValidMac = false; // Try each key (current + rotated) in turn. For non-AEAD ciphers, // the MAC must be validated against THIS specific key — not a // previous iteration's. Once a key passes its MAC check, attempt // decryption with the same key. foreach ($this->getAllKeys() as $key) { if ($this->shouldValidateMac() && !$this->validMacForKey($payload, $key)) { continue; } $foundValidMac = true; $decrypted = \openssl_decrypt( $payload['value'], strtolower($this->cipher), $key, 0, $iv, $tag ?? '' ); if ($decrypted !== false) { break; } } if ($this->shouldValidateMac() && !$foundValidMac) { throw new DecryptException('The MAC is invalid.'); } if (($decrypted ?? false) === false) { throw new DecryptException('Could not decrypt the data.'); } return $unserialize ? unserialize($decrypted) : $decrypted; } /** * Decrypt the given string without unserialization. * * @param string $payload * @return string * * @throws \FluentSupport\Framework\Encryption\DecryptException */ public function decryptString($payload) { return $this->decrypt($payload, false); } /** * Create a MAC for the given value. * * @param string $iv * @param mixed $value * @param string $key * @return string */ protected function hash($iv, $value, $key) { return hash_hmac('sha256', $iv.$value, $key); } /** * Get the JSON array from the given payload. * * @param string $payload * @return array * * @throws \FluentSupport\Framework\Encryption\DecryptException */ protected function getJsonPayload($payload) { if (!is_string($payload)) { throw new DecryptException('The payload is invalid.'); } $payload = json_decode(base64_decode($payload), true); // If the payload is not valid JSON or does not have the proper keys set we will // assume it is invalid and bail out of the routine since we will not be able // to decrypt the given value. We'll also check the MAC for this encryption. if (!$this->validPayload($payload)) { throw new DecryptException('The payload is invalid.'); } return $payload; } /** * Verify that the encryption payload is valid. * * @param mixed $payload * @return bool */ protected function validPayload($payload) { if (!is_array($payload)) { return false; } foreach (['iv', 'value', 'mac'] as $item) { if (!isset($payload[$item]) || !is_string($payload[$item])) { return false; } } if (isset($payload['tag']) && !is_string($payload['tag'])) { return false; } return strlen(base64_decode($payload['iv'], true)) === openssl_cipher_iv_length(strtolower($this->cipher)); } /** * Determine if the MAC for the given payload is valid for the primary key. * * @param array $payload * @return bool */ protected function validMac(array $payload) { return $this->validMacForKey($payload, $this->key); } /** * Determine if the MAC is valid for the given payload and key. * * @param array $payload * @param string $key * @return bool */ protected function validMacForKey($payload, $key) { return hash_equals( $this->hash($payload['iv'], $payload['value'], $key), $payload['mac'] ); } /** * Ensure the given tag is a valid tag given the selected cipher. * * @param string $tag * @return void */ protected function ensureTagIsValid($tag) { if (self::$supportedCiphers[strtolower($this->cipher)]['aead'] && strlen($tag) !== 16) { throw new DecryptException('Could not decrypt the data.'); } if (!self::$supportedCiphers[strtolower($this->cipher)]['aead'] && is_string($tag)) { throw new DecryptException('Unable to use tag because the cipher algorithm does not support AEAD.'); } } /** * Determine if we should validate the MAC while decrypting. * * @return bool */ protected function shouldValidateMac() { return !self::$supportedCiphers[strtolower($this->cipher)]['aead']; } /** * Get the application encryption key. * * Developers may override the database key name using the filter: * `{slug}.encryption.option_key` * * @return string */ public function getSlug() { // Defensive: framework not bootstrapped (bare PHP, plugin // activation pre-init, early CLI). Fall back to a generic slug // so the constructor can still build a working encrypter. $app = App::getInstance(); if (!$app) { return 'wpfluent_enc_key'; } $slug = $app->config->get('app.slug'); $default = $slug . '_enc_key'; /** * Allow developer to override the encryption key option name. * * @param string $default Default option key name. */ return $app->applyFilters($slug . '.encryption.option_key', $default); } /** * Get the encryption key that the encrypter is currently using. * * @return string */ public function getKey() { if (!$key = get_option($this->slug)) { add_option($this->slug, base64_encode( $this->generateKey($this->cipher) )); $key = get_option($this->slug); } return base64_decode($key); } /** * Get the current encryption key and all previous encryption keys. * * This is useful for key rotation, allowing decryption of data * encrypted with older keys. * * @return array Array of binary encryption keys. */ public function getAllKeys() { $keys = [$this->key]; $oldKeysOption = $this->oldKeysOptionName(); $oldKeys = get_option($oldKeysOption, []); foreach ($oldKeys as $encodedKey) { $decoded = base64_decode($encodedKey, true); // Skip corrupted base64 and any key whose length doesn't match // the active cipher (otherwise it'd silently fail in the // decrypt loop, hiding the real cause). if ($decoded === false || !static::supported($decoded, $this->cipher)) { continue; } $keys[] = $decoded; } return $keys; } /** * Resolve the option name that stores rotated old keys. * Honors the {slug}.encryption.old_keys_option filter when the * framework App is bootstrapped; otherwise uses the default. * * @return string */ protected function oldKeysOptionName() { $default = $this->slug . '_old_enc_key'; $app = App::getInstance(); if (!$app) { return $default; } return $app->applyFilters( $this->slug . '.encryption.old_keys_option', $default ); } /** * Rotate the encryption key. * * This method archives the current key in the old keys list, * generates a new encryption key, stores it in the database, * and updates the encrypter instance with the new key. * * @return void */ public function rotateKey() { $currentEncoded = base64_encode($this->key); $oldKeysOption = $this->oldKeysOptionName(); $oldKeys = get_option($oldKeysOption, []); // Add current key to old list if not already present if (!in_array($currentEncoded, $oldKeys, true)) { $oldKeys[] = $currentEncoded; } // Cap retention so the array doesn't grow unbounded across many // rotations. Keeps the MOST RECENT $cap entries; trimmed-out keys // mean any data still encrypted under them becomes undecryptable — // that's the documented contract of any retention policy. $cap = $this->oldKeysMax(); if (count($oldKeys) > $cap) { $oldKeys = array_values(array_slice($oldKeys, -$cap)); } update_option($oldKeysOption, $oldKeys); // Generate and store new key $newKey = base64_encode(static::generateKey($this->cipher)); update_option($this->slug, $newKey); // Update instance property with decoded new key $this->key = base64_decode($newKey); } /** * Maximum number of rotated keys to retain. * * Honors the {slug}.encryption.old_keys_max filter when the framework * App is bootstrapped. Default is 10, which covers ~10 weeks of weekly * rotation or ~10 years of annual rotation; bump the filter to keep * more historical keys around. * * @return int */ protected function oldKeysMax() { $default = 10; $app = App::getInstance(); if (!$app) { return $default; } return (int) $app->applyFilters( $this->slug . '.encryption.old_keys_max', $default ); } }