| @@ -11,62 +11,13 @@ | ||
| 11 | 11 | private const CIPHER = 'aes-256-gcm'; |
| 12 | 12 | private const KEY_LENGTH = 32; // 256 bits |
| 13 | 13 | private const IV_LENGTH = 16; // 128 bits |
| 14 | 14 | private const SALT_LENGTH = 16; // 128 bits |
| 15 | - private static int $iterations = 10000; // PBKDF2 iterations | |
| 15 | + private const ITERATIONS = 100000; // PBKDF2 iterations | |
| 16 | 16 | |
| 17 | - // Performance optimization: Key cache | |
| 18 | - private static array $keyCache = []; | |
| 19 | - private static int $maxCacheSize = 10; // Limit cache size to prevent memory issues | |
| 20 | - | |
| 21 | 17 | /** |
| 22 | - * Performance optimization: one salt per request. | |
| 18 | + * Derives a key from password using PBKDF2 - compatible with JavaScript | |
| 23 | 19 | * |
| 24 | - * The salt is generated once per page load and reused for every encryption of that | |
| 25 | - * request, so the PBKDF2 key derivation runs once instead of once per address. | |
| 26 | - * This is safe: AES-GCM requires unique IVs, not unique salts - and the IV is still | |
| 27 | - * generated freshly for every single encrypt() call (see encrypt()). | |
| 28 | - */ | |
| 29 | - private static ?string $requestSalt = null; | |
| 30 | - | |
| 31 | - // Performance optimization: Pre-check cipher availability | |
| 32 | - private static ?bool $cipherAvailable = null; | |
| 33 | - | |
| 34 | - /** | |
| 35 | - * Pre-checks if the required cipher is available | |
| 36 | - * | |
| 37 | - * @return bool | |
| 38 | - */ | |
| 39 | - private static function isCipherAvailable(): bool | |
| 40 | - { | |
| 41 | - if (self::$cipherAvailable === null) { | |
| 42 | - self::$cipherAvailable = function_exists('openssl_encrypt') && | |
| 43 | - in_array(self::CIPHER, openssl_get_cipher_methods()); | |
| 44 | - } | |
| 45 | - return self::$cipherAvailable; | |
| 46 | - } | |
| 47 | - | |
| 48 | - /** | |
| 49 | - * Returns the salt for the current request, generating it on first use. | |
| 50 | - * | |
| 51 | - * Reusing the salt within one request is what makes the key cache effective: | |
| 52 | - * all addresses of a page share the same password anyway, so they may share the | |
| 53 | - * derived key. Uniqueness of the ciphertext is provided by the per-encryption IV. | |
| 54 | - * | |
| 55 | - * @return string | |
| 56 | - * @throws \Exception | |
| 57 | - */ | |
| 58 | - private static function getRequestSalt(): string | |
| 59 | - { | |
| 60 | - if (self::$requestSalt === null) { | |
| 61 | - self::$requestSalt = random_bytes(self::SALT_LENGTH); | |
| 62 | - } | |
| 63 | - return self::$requestSalt; | |
| 64 | - } | |
| 65 | - | |
| 66 | - /** | |
| 67 | - * Derives a key from password using PBKDF2 with caching - compatible with JavaScript | |
| 68 | - * | |
| 69 | 20 | * @param string $password |
| 70 | 21 | * @param string $salt |
| 71 | 22 | * @return string |
| 72 | 23 | * @throws \Exception |
| @@ -76,34 +27,13 @@ | ||
| 76 | 27 | if (!function_exists('hash_pbkdf2')) { |
| 77 | 28 | throw new \Exception('PBKDF2 not available'); |
| 78 | 29 | } |
| 79 | 30 | |
| 80 | - // Performance optimization: Cache derived keys. | |
| 81 | - // The iteration count is part of the cache key: setIterations() may change it | |
| 82 | - // within a single request, and the same password+salt yields a different key | |
| 83 | - // for a different iteration count. | |
| 84 | - $cacheKey = hash('sha256', self::getIterations() . '|' . $password . $salt); | |
| 85 | - | |
| 86 | - if (isset(self::$keyCache[$cacheKey])) { | |
| 87 | - return self::$keyCache[$cacheKey]; | |
| 88 | - } | |
| 89 | - | |
| 90 | - $derivedKey = hash_pbkdf2('sha256', $password, $salt, self::getIterations(), self::KEY_LENGTH, true); | |
| 91 | - | |
| 92 | - // Manage cache size to prevent memory issues | |
| 93 | - if (count(self::$keyCache) >= self::$maxCacheSize) { | |
| 94 | - // Remove oldest entry (FIFO) | |
| 95 | - $oldestKey = array_key_first(self::$keyCache); | |
| 96 | - unset(self::$keyCache[$oldestKey]); | |
| 97 | - } | |
| 98 | - | |
| 99 | - self::$keyCache[$cacheKey] = $derivedKey; | |
| 100 | - return $derivedKey; | |
| 31 | + return hash_pbkdf2('sha256', $password, $salt, self::ITERATIONS, self::KEY_LENGTH, true); | |
| 101 | 32 | } |
| 102 | 33 | |
| 103 | 34 | /** |
| 104 | 35 | * Encrypts plaintext using AES-256-GCM - JavaScript compatible format |
| 105 | - * Optimized for performance | |
| 106 | 36 | * |
| 107 | 37 | * @param string $plaintext |
| 108 | 38 | * @param string $password |
| 109 | 39 | * @return string Base64 encoded encrypted data |
| @@ -110,25 +40,21 @@ | ||
| 110 | 40 | * @throws \Exception |
| 111 | 41 | */ |
| 112 | 42 | public static function encrypt(string $plaintext, string $password): string |
| 113 | 43 | { |
| 114 | - // Performance optimization: Pre-check cipher availability | |
| 115 | - if (!self::isCipherAvailable()) { | |
| 116 | - throw new \Exception('OpenSSL extension or AES-256-GCM cipher not available'); | |
| 44 | + if (!function_exists('openssl_encrypt')) { | |
| 45 | + throw new \Exception('OpenSSL extension not available'); | |
| 117 | 46 | } |
| 118 | 47 | |
| 119 | - self::setIterations(); | |
| 48 | + if (!in_array(self::CIPHER, openssl_get_cipher_methods())) { | |
| 49 | + throw new \Exception('AES-256-GCM cipher not available'); | |
| 50 | + } | |
| 120 | 51 | |
| 121 | - // Performance optimization: the salt is generated once per request, which lets | |
| 122 | - // the key cache do its job (one PBKDF2 run per page instead of one per address). | |
| 123 | - $salt = self::getRequestSalt(); | |
| 124 | - | |
| 125 | - // SECURITY: the IV must NEVER be cached or reused. Reusing an IV with the same | |
| 126 | - // key breaks AES-GCM completely (keystream reuse, forgeable auth tag). | |
| 127 | - // Therefore random_bytes() runs on every single encrypt() call. | |
| 52 | + // Generate random salt and IV | |
| 53 | + $salt = random_bytes(self::SALT_LENGTH); | |
| 128 | 54 | $iv = random_bytes(self::IV_LENGTH); |
| 129 | 55 | |
| 130 | - // Derive key from password (now with caching) | |
| 56 | + // Derive key from password | |
| 131 | 57 | $key = self::deriveKey($password, $salt); |
| 132 | 58 | |
| 133 | 59 | // Encrypt data |
| 134 | 60 | $tag = ''; |
| @@ -144,54 +70,16 @@ | ||
| 144 | 70 | if ($encrypted === false) { |
| 145 | 71 | throw new \Exception('Encryption failed'); |
| 146 | 72 | } |
| 147 | 73 | |
| 148 | - // Performance optimization: Use direct concatenation instead of multiple operations | |
| 149 | - return base64_encode($salt . $iv . $encrypted . $tag); | |
| 150 | - } | |
| 74 | + // Format: salt(16) + iv(16) + encrypted_data + tag(16) | |
| 75 | + // This matches the JavaScript format expectation | |
| 76 | + $combined = $salt . $iv . $encrypted . $tag; | |
| 151 | 77 | |
| 152 | - /** | |
| 153 | - * Batch encrypt multiple plaintexts with same password for better performance | |
| 154 | - * | |
| 155 | - * @param array $plaintexts Array of strings to encrypt | |
| 156 | - * @param string $password | |
| 157 | - * @return array Array of encrypted strings | |
| 158 | - * @throws \Exception | |
| 159 | - */ | |
| 160 | - public static function encryptBatch(array $plaintexts, string $password): array | |
| 161 | - { | |
| 162 | - if (!self::isCipherAvailable()) { | |
| 163 | - throw new \Exception('OpenSSL extension or AES-256-GCM cipher not available'); | |
| 164 | - } | |
| 165 | - | |
| 166 | - $results = []; | |
| 167 | - | |
| 168 | - foreach ($plaintexts as $key => $plaintext) { | |
| 169 | - try { | |
| 170 | - $results[$key] = self::encrypt($plaintext, $password); | |
| 171 | - } catch (\Exception $e) { | |
| 172 | - $results[$key] = false; // Or handle error as needed | |
| 173 | - } | |
| 174 | - } | |
| 175 | - | |
| 176 | - return $results; | |
| 78 | + return base64_encode($combined); | |
| 177 | 79 | } |
| 178 | 80 | |
| 179 | 81 | /** |
| 180 | - * Clears the key cache - useful for memory management | |
| 181 | - * | |
| 182 | - * Also drops the remembered request salt, so the next encrypt() starts from a | |
| 183 | - * freshly generated salt and a genuinely empty cache. | |
| 184 | - * | |
| 185 | - * @return void | |
| 186 | - */ | |
| 187 | - public static function clearKeyCache(): void | |
| 188 | - { | |
| 189 | - self::$keyCache = []; | |
| 190 | - self::$requestSalt = null; | |
| 191 | - } | |
| 192 | - | |
| 193 | - /** | |
| 194 | 82 | * Decrypts encrypted data using AES-256-GCM - JavaScript compatible |
| 195 | 83 | * |
| 196 | 84 | * @param string $encryptedData Base64 encoded encrypted data |
| 197 | 85 | * @param string $password |
| @@ -203,14 +91,8 @@ | ||
| 203 | 91 | if (!function_exists('openssl_decrypt')) { |
| 204 | 92 | throw new \Exception('OpenSSL extension not available'); |
| 205 | 93 | } |
| 206 | 94 | |
| 207 | - // Without this the iteration count is whatever a previous encrypt() | |
| 208 | - // happened to leave behind -- or the built-in default, if this request | |
| 209 | - // only ever decrypts. With a configured count other than 10000 the key | |
| 210 | - // derivation would then silently produce the wrong key. | |
| 211 | - self::setIterations(); | |
| 212 | - | |
| 213 | 95 | try { |
| 214 | 96 | $combined = base64_decode($encryptedData, true); |
| 215 | 97 | if ($combined === false) { |
| 216 | 98 | throw new \Exception('Invalid base64 encoding'); |
| @@ -235,9 +117,9 @@ | ||
| 235 | 117 | strlen($tag) !== 16) { |
| 236 | 118 | throw new \Exception('Invalid encrypted data format'); |
| 237 | 119 | } |
| 238 | 120 | |
| 239 | - // Derive key from password (now with caching) | |
| 121 | + // Derive key from password | |
| 240 | 122 | $key = self::deriveKey($password, $salt); |
| 241 | 123 | |
| 242 | 124 | // Decrypt data |
| 243 | 125 | $decrypted = openssl_decrypt( |
| @@ -254,24 +136,43 @@ | ||
| 254 | 136 | } |
| 255 | 137 | |
| 256 | 138 | return $decrypted; |
| 257 | 139 | } catch (\Throwable $e) { |
| 258 | - throw new \Exception('Decryption failed: ' . esc_html($e->getMessage())); | |
| 140 | + throw new \Exception('Decryption failed: ' . $e->getMessage()); | |
| 259 | 141 | } |
| 260 | 142 | } |
| 261 | 143 | |
| 262 | - // debugEncryption() and getCacheStats() used to sit here. The second was | |
| 263 | - // only ever called by the first, so removing one left the other behind -- | |
| 264 | - // which is how dead code usually spreads. | |
| 265 | - // | |
| 266 | - // debugEncryption(): it encrypted a string, decrypted it | |
| 267 | - // again and returned both, plus timings and the key-cache statistics. It | |
| 268 | - // had no caller anywhere in the plugin and was shipped to every site all | |
| 269 | - // the same. Nothing was reachable through it -- it is a static method, not | |
| 270 | - // an endpoint -- but a method that hands back a plaintext next to its | |
| 271 | - // ciphertext is a poor thing to leave lying around for the next person who | |
| 272 | - // needs somewhere to hook a quick diagnosis. | |
| 144 | + /** | |
| 145 | + * Test encryption/decryption with debug output | |
| 146 | + * | |
| 147 | + * @param string $plaintext | |
| 148 | + * @param string $password | |
| 149 | + * @return array Debug information | |
| 150 | + */ | |
| 151 | + public static function debugEncryption(string $plaintext, string $password): array | |
| 152 | + { | |
| 153 | + try { | |
| 154 | + $encrypted = self::encrypt($plaintext, $password); | |
| 155 | + $decrypted = self::decrypt($encrypted, $password); | |
| 273 | 156 | |
| 157 | + return [ | |
| 158 | + 'success' => true, | |
| 159 | + 'plaintext' => $plaintext, | |
| 160 | + 'encrypted' => $encrypted, | |
| 161 | + 'decrypted' => $decrypted, | |
| 162 | + 'match' => ($plaintext === $decrypted), | |
| 163 | + 'encrypted_length' => strlen($encrypted), | |
| 164 | + 'binary_length' => strlen(base64_decode($encrypted)) | |
| 165 | + ]; | |
| 166 | + } catch (\Exception $e) { | |
| 167 | + return [ | |
| 168 | + 'success' => false, | |
| 169 | + 'error' => $e->getMessage(), | |
| 170 | + 'plaintext' => $plaintext | |
| 171 | + ]; | |
| 172 | + } | |
| 173 | + } | |
| 174 | + | |
| 274 | 175 | /** |
| 275 | 176 | * Validates URL for security |
| 276 | 177 | * |
| 277 | 178 | * @param string $url |
| @@ -285,9 +186,9 @@ | ||
| 285 | 186 | if (strlen($url) > $maxLength) { |
| 286 | 187 | return false; |
| 287 | 188 | } |
| 288 | 189 | |
| 289 | - $parsedUrl = wp_parse_url($url); | |
| 190 | + $parsedUrl = parse_url($url); | |
| 290 | 191 | if (!$parsedUrl || !isset($parsedUrl['scheme'])) { |
| 291 | 192 | return false; |
| 292 | 193 | } |
| 293 | 194 | |
| @@ -303,55 +204,6 @@ | ||
| 303 | 204 | } |
| 304 | 205 | } |
| 305 | 206 | |
| 306 | 207 | return true; |
| 307 | - } | |
| 308 | - | |
| 309 | - /** | |
| 310 | - * Get the current PBKDF2 iterations for JavaScript compatibility | |
| 311 | - * | |
| 312 | - * @return int | |
| 313 | - */ | |
| 314 | - public static function getIterations(): int | |
| 315 | - { | |
| 316 | - return self::$iterations; | |
| 317 | - } | |
| 318 | - | |
| 319 | - /** Bounds for the PBKDF2 iteration count taken from the stored option. */ | |
| 320 | - private const MIN_ITERATIONS = 1000; | |
| 321 | - private const MAX_ITERATIONS = 1000000; | |
| 322 | - | |
| 323 | - private static function setIterations(): void | |
| 324 | - { | |
| 325 | - $config = new Config(get_option('cryptX', [])); | |
| 326 | - $configured = $config->get('iterations', self::$iterations); | |
| 327 | - | |
| 328 | - // The option is not necessarily a sane integer: the settings page keeps | |
| 329 | - // it as a string, and a hand-edited row can hold anything. A zero makes | |
| 330 | - // hash_pbkdf2() throw a ValueError and a non-numeric string a TypeError | |
| 331 | - // -- neither of which is an \Exception, so the fallback in | |
| 332 | - // CryptX::encryptEmailAddressSecure() would not catch them and the | |
| 333 | - // front end would fatal on every page carrying an address. | |
| 334 | - if (!is_numeric($configured)) { | |
| 335 | - return; | |
| 336 | - } | |
| 337 | - | |
| 338 | - self::$iterations = max(self::MIN_ITERATIONS, min(self::MAX_ITERATIONS, (int) $configured)); | |
| 339 | - } | |
| 340 | - | |
| 341 | - /** | |
| 342 | - * Get configuration for JavaScript | |
| 343 | - * | |
| 344 | - * @return array | |
| 345 | - */ | |
| 346 | - public static function getJavaScriptConfig(): array | |
| 347 | - { | |
| 348 | - self::setIterations(); | |
| 349 | - return [ | |
| 350 | - 'iterations' => self::getIterations(), | |
| 351 | - 'keyLength' => self::KEY_LENGTH, | |
| 352 | - 'ivLength' => self::IV_LENGTH, | |
| 353 | - 'saltLength' => self::SALT_LENGTH, | |
| 354 | - 'cipher' => self::CIPHER | |
| 355 | - ]; | |
| 356 | 208 | } |
| 357 | 209 | } |