| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | /** |
| 3 | 3 | * @package FireBox |
| 4 | - * @version 3.1.8 | |
| 4 | + * @version 3.1.14 | |
| 5 | 5 | * |
| 6 | 6 | * @author FirePlugins <[email protected]> |
| 7 | 7 | * @link https://www.fireplugins.com |
| 8 | 8 | * @copyright Copyright © 2026 FirePlugins All Rights Reserved |
| @@ -15,23 +15,54 @@ | ||
| 15 | 15 | { |
| 16 | 16 | exit; // Exit if accessed directly. |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | +/** | |
| 20 | + * Encrypts integration credentials at rest. | |
| 21 | + * | |
| 22 | + * The key is derived from a fixed salt, which makes stored values portable: a site can be | |
| 23 | + * migrated, cloned or restored from a backup and its credentials remain readable. That is | |
| 24 | + * a deliberate trade — a key tied to the site (wp-config salts, say) would resist a | |
| 25 | + * database-only leak, but would silently invalidate every credential whenever those salts | |
| 26 | + * changed, which happens routinely during migrations. | |
| 27 | + * | |
| 28 | + * Be clear about what this does and does not buy: an attacker holding a database dump can | |
| 29 | + * derive the same key from the plugin source, so this is obfuscation at rest rather than | |
| 30 | + * a defence against disclosure. Treat the credentials themselves as the secret and rotate | |
| 31 | + * them if a dump ever leaks. | |
| 32 | + */ | |
| 19 | 33 | class Encryption |
| 20 | 34 | { |
| 21 | 35 | /** |
| 22 | - * Fixed salt for portable encryption across site exports/imports. | |
| 36 | + * Salt the encryption key is derived from. | |
| 23 | 37 | * |
| 38 | + * Fixed on purpose, so encrypted values survive a site move. See the class docblock. | |
| 39 | + * | |
| 24 | 40 | * @var string |
| 25 | 41 | */ |
| 26 | - private $fixed_salt = 'fbox_enc_3b7d50a8f9e84cb1a4627c8d6e1f90b3'; | |
| 42 | + private $salt = 'fbox_enc_3b7d50a8f9e84cb1a4627c8d6e1f90b3'; | |
| 27 | 43 | |
| 28 | 44 | /** |
| 45 | + * Cipher used for stored values. | |
| 46 | + * | |
| 47 | + * @var string | |
| 48 | + */ | |
| 49 | + const CIPHER = 'aes-256-cbc'; | |
| 50 | + | |
| 51 | + /** | |
| 52 | + * Prefix of payloads that carry an HMAC. Older values have no prefix and are | |
| 53 | + * still accepted on read so existing credentials keep working. | |
| 54 | + * | |
| 55 | + * @var string | |
| 56 | + */ | |
| 57 | + const AUTHENTICATED_PREFIX = 'fbx2:'; | |
| 58 | + | |
| 59 | + /** | |
| 29 | 60 | * Encrypt value for storage. |
| 30 | 61 | * |
| 31 | 62 | * @param string $value |
| 32 | 63 | * |
| 33 | - * @return string | |
| 64 | + * @return string|false Payload, '' for empty input, or false when encryption failed. | |
| 34 | 65 | */ |
| 35 | 66 | final public function encrypt($value = '') |
| 36 | 67 | { |
| 37 | 68 | $value = trim((string) $value); |
| @@ -42,18 +73,26 @@ | ||
| 42 | 73 | |
| 43 | 74 | $key = $this->getEncryptionKey(); |
| 44 | 75 | if ($key === '') |
| 45 | 76 | { |
| 46 | - return $value; | |
| 77 | + return false; | |
| 47 | 78 | } |
| 48 | 79 | |
| 49 | 80 | $encrypted_payload = $this->encryptWithOpenSSL($value, $key); |
| 50 | 81 | if ($encrypted_payload === false) |
| 51 | 82 | { |
| 52 | - return $value; | |
| 83 | + /** | |
| 84 | + * Report the failure rather than returning the plaintext. Storing a raw | |
| 85 | + * credential under a name that implies encryption would leave it in the | |
| 86 | + * database in the clear with no signal to anyone. | |
| 87 | + */ | |
| 88 | + return false; | |
| 53 | 89 | } |
| 54 | 90 | |
| 55 | - return base64_encode($encrypted_payload); | |
| 91 | + // Authenticate iv+ciphertext so a tampered value is rejected instead of decrypted to garbage. | |
| 92 | + $mac = hash_hmac('sha256', $encrypted_payload, $this->getMacKey($key), true); | |
| 93 | + | |
| 94 | + return self::AUTHENTICATED_PREFIX . base64_encode($encrypted_payload . $mac); | |
| 56 | 95 | } |
| 57 | 96 | |
| 58 | 97 | /** |
| 59 | 98 | * Decrypt value from storage. |
| @@ -69,11 +108,15 @@ | ||
| 69 | 108 | { |
| 70 | 109 | return ''; |
| 71 | 110 | } |
| 72 | 111 | |
| 73 | - $decoded = base64_decode($value, true); | |
| 112 | + $authenticated = strpos($value, self::AUTHENTICATED_PREFIX) === 0; | |
| 113 | + $encoded = $authenticated ? substr($value, strlen(self::AUTHENTICATED_PREFIX)) : $value; | |
| 114 | + | |
| 115 | + $decoded = base64_decode($encoded, true); | |
| 74 | 116 | if ($decoded === false || $decoded === '') |
| 75 | 117 | { |
| 118 | + // Not a payload we produced; hand back the value as stored. | |
| 76 | 119 | return $value; |
| 77 | 120 | } |
| 78 | 121 | |
| 79 | 122 | $key = $this->getEncryptionKey(); |
| @@ -81,11 +124,34 @@ | ||
| 81 | 124 | { |
| 82 | 125 | return $value; |
| 83 | 126 | } |
| 84 | 127 | |
| 128 | + if ($authenticated) | |
| 129 | + { | |
| 130 | + $mac_length = 32; | |
| 131 | + if (strlen($decoded) <= $mac_length) | |
| 132 | + { | |
| 133 | + return ''; | |
| 134 | + } | |
| 135 | + | |
| 136 | + $payload = substr($decoded, 0, -$mac_length); | |
| 137 | + $mac = substr($decoded, -$mac_length); | |
| 138 | + | |
| 139 | + if (!hash_equals(hash_hmac('sha256', $payload, $this->getMacKey($key), true), $mac)) | |
| 140 | + { | |
| 141 | + return ''; | |
| 142 | + } | |
| 143 | + | |
| 144 | + $decoded = $payload; | |
| 145 | + } | |
| 146 | + | |
| 85 | 147 | $decrypted = $this->decryptWithOpenSSL($decoded, $key); |
| 86 | 148 | if ($decrypted === '') |
| 87 | 149 | { |
| 150 | + /** | |
| 151 | + * A value that is valid base64 but was never encrypted (a plaintext key that | |
| 152 | + * happens to decode) must come back unchanged rather than as an empty string. | |
| 153 | + */ | |
| 88 | 154 | return $value; |
| 89 | 155 | } |
| 90 | 156 | |
| 91 | 157 | return trim((string) $decrypted); |
| @@ -90,11 +156,26 @@ | ||
| 90 | 156 | |
| 91 | 157 | return trim((string) $decrypted); |
| 92 | 158 | } |
| 93 | 159 | |
| 160 | + /** | |
| 161 | + * Returns whether this platform can encrypt. | |
| 162 | + * | |
| 163 | + * @return bool | |
| 164 | + */ | |
| 165 | + public static function isAvailable() | |
| 166 | + { | |
| 167 | + return function_exists('openssl_encrypt') && function_exists('openssl_cipher_iv_length'); | |
| 168 | + } | |
| 169 | + | |
| 170 | + /** | |
| 171 | + * Derives the encryption key. | |
| 172 | + * | |
| 173 | + * @return string Raw 32-byte key, or '' when the salt is unusable. | |
| 174 | + */ | |
| 94 | 175 | private function getEncryptionKey() |
| 95 | 176 | { |
| 96 | - $salt = trim((string) $this->fixed_salt); | |
| 177 | + $salt = trim((string) $this->salt); | |
| 97 | 178 | if ($salt === '') |
| 98 | 179 | { |
| 99 | 180 | return ''; |
| 100 | 181 | } |
| @@ -109,17 +190,28 @@ | ||
| 109 | 190 | * @param string $key |
| 110 | 191 | * |
| 111 | 192 | * @return string|false |
| 112 | 193 | */ |
| 194 | + /** | |
| 195 | + * Separate key for the MAC, derived from the encryption key. | |
| 196 | + * | |
| 197 | + * @param string $key | |
| 198 | + * | |
| 199 | + * @return string | |
| 200 | + */ | |
| 201 | + private function getMacKey($key) | |
| 202 | + { | |
| 203 | + return hash('sha256', 'mac|' . $key, true); | |
| 204 | + } | |
| 205 | + | |
| 113 | 206 | private function encryptWithOpenSSL($value = '', $key = '') |
| 114 | 207 | { |
| 115 | - if (!function_exists('openssl_encrypt') || !function_exists('openssl_cipher_iv_length')) | |
| 208 | + if (!self::isAvailable()) | |
| 116 | 209 | { |
| 117 | 210 | return false; |
| 118 | 211 | } |
| 119 | 212 | |
| 120 | - $cipher = 'aes-256-cbc'; | |
| 121 | - $iv_length = openssl_cipher_iv_length($cipher); | |
| 213 | + $iv_length = openssl_cipher_iv_length(self::CIPHER); | |
| 122 | 214 | if (!is_int($iv_length) || $iv_length <= 0) |
| 123 | 215 | { |
| 124 | 216 | return false; |
| 125 | 217 | } |
| @@ -133,9 +225,9 @@ | ||
| 133 | 225 | return false; |
| 134 | 226 | } |
| 135 | 227 | |
| 136 | 228 | $openssl_raw_data = defined('OPENSSL_RAW_DATA') ? OPENSSL_RAW_DATA : 1; |
| 137 | - $encrypted = openssl_encrypt($value, $cipher, $key, $openssl_raw_data, $iv); | |
| 229 | + $encrypted = openssl_encrypt($value, self::CIPHER, $key, $openssl_raw_data, $iv); | |
| 138 | 230 | if ($encrypted === false) |
| 139 | 231 | { |
| 140 | 232 | return false; |
| 141 | 233 | } |
| @@ -157,10 +249,9 @@ | ||
| 157 | 249 | { |
| 158 | 250 | return ''; |
| 159 | 251 | } |
| 160 | 252 | |
| 161 | - $cipher = 'aes-256-cbc'; | |
| 162 | - $iv_length = openssl_cipher_iv_length($cipher); | |
| 253 | + $iv_length = openssl_cipher_iv_length(self::CIPHER); | |
| 163 | 254 | if (!is_int($iv_length) || $iv_length <= 0 || strlen($payload) <= $iv_length) |
| 164 | 255 | { |
| 165 | 256 | return ''; |
| 166 | 257 | } |
| @@ -172,10 +263,9 @@ | ||
| 172 | 263 | return ''; |
| 173 | 264 | } |
| 174 | 265 | |
| 175 | 266 | $openssl_raw_data = defined('OPENSSL_RAW_DATA') ? OPENSSL_RAW_DATA : 1; |
| 176 | - $decrypted = openssl_decrypt($encrypted, $cipher, $key, $openssl_raw_data, $iv); | |
| 267 | + $decrypted = openssl_decrypt($encrypted, self::CIPHER, $key, $openssl_raw_data, $iv); | |
| 177 | 268 | |
| 178 | 269 | return $decrypted === false ? '' : (string) $decrypted; |
| 179 | 270 | } |
| 180 | - | |
| 181 | 271 | } |