PluginProbe
CryptX / 4.0.1
CryptX v4.0.1
4.2.1 4.2.0 4.1.1 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.9 2.0 2.1 2.2 2.3 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 All 93 releases
← All changes | classes/SecureEncryption.php +19 -208 4.1.14.0.1 View file →
@@ -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,68 +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 - * Gets current cache statistics
195 - *
196 - * @return array
197 - */
198 - public static function getCacheStats(): array
199 - {
200 - return [
201 - 'cache_size' => count(self::$keyCache),
202 - 'max_cache_size' => self::$maxCacheSize,
203 - 'memory_usage_bytes' => memory_get_usage(),
204 - ];
205 - }
206 -
207 - /**
208 82 * Decrypts encrypted data using AES-256-GCM - JavaScript compatible
209 83 *
210 84 * @param string $encryptedData Base64 encoded encrypted data
211 85 * @param string $password
@@ -217,14 +91,8 @@
217 91 if (!function_exists('openssl_decrypt')) {
218 92 throw new \Exception('OpenSSL extension not available');
219 93 }
220 94
221 - // Without this the iteration count is whatever a previous encrypt()
222 - // happened to leave behind -- or the built-in default, if this request
223 - // only ever decrypts. With a configured count other than 10000 the key
224 - // derivation would then silently produce the wrong key.
225 - self::setIterations();
226 -
227 95 try {
228 96 $combined = base64_decode($encryptedData, true);
229 97 if ($combined === false) {
230 98 throw new \Exception('Invalid base64 encoding');
@@ -249,9 +117,9 @@
249 117 strlen($tag) !== 16) {
250 118 throw new \Exception('Invalid encrypted data format');
251 119 }
252 120
253 - // Derive key from password (now with caching)
121 + // Derive key from password
254 122 $key = self::deriveKey($password, $salt);
255 123
256 124 // Decrypt data
257 125 $decrypted = openssl_decrypt(
@@ -268,9 +136,9 @@
268 136 }
269 137
270 138 return $decrypted;
271 139 } catch (\Throwable $e) {
272 - throw new \Exception('Decryption failed: ' . esc_html($e->getMessage()));
140 + throw new \Exception('Decryption failed: ' . $e->getMessage());
273 141 }
274 142 }
275 143
276 144 /**
@@ -282,15 +150,10 @@
282 150 */
283 151 public static function debugEncryption(string $plaintext, string $password): array
284 152 {
285 153 try {
286 - $startTime = microtime(true);
287 154 $encrypted = self::encrypt($plaintext, $password);
288 - $encryptTime = microtime(true) - $startTime;
289 -
290 - $startTime = microtime(true);
291 155 $decrypted = self::decrypt($encrypted, $password);
292 - $decryptTime = microtime(true) - $startTime;
293 156
294 157 return [
295 158 'success' => true,
296 159 'plaintext' => $plaintext,
@@ -297,12 +160,9 @@
297 160 'encrypted' => $encrypted,
298 161 'decrypted' => $decrypted,
299 162 'match' => ($plaintext === $decrypted),
300 163 'encrypted_length' => strlen($encrypted),
301 - 'binary_length' => strlen(base64_decode($encrypted)),
302 - 'encrypt_time_ms' => round($encryptTime * 1000, 2),
303 - 'decrypt_time_ms' => round($decryptTime * 1000, 2),
304 - 'cache_stats' => self::getCacheStats()
164 + 'binary_length' => strlen(base64_decode($encrypted))
305 165 ];
306 166 } catch (\Exception $e) {
307 167 return [
308 168 'success' => false,
@@ -326,9 +186,9 @@
326 186 if (strlen($url) > $maxLength) {
327 187 return false;
328 188 }
329 189
330 - $parsedUrl = wp_parse_url($url);
190 + $parsedUrl = parse_url($url);
331 191 if (!$parsedUrl || !isset($parsedUrl['scheme'])) {
332 192 return false;
333 193 }
334 194
@@ -344,55 +204,6 @@
344 204 }
345 205 }
346 206
347 207 return true;
348 - }
349 -
350 - /**
351 - * Get the current PBKDF2 iterations for JavaScript compatibility
352 - *
353 - * @return int
354 - */
355 - public static function getIterations(): int
356 - {
357 - return self::$iterations;
358 - }
359 -
360 - /** Bounds for the PBKDF2 iteration count taken from the stored option. */
361 - private const MIN_ITERATIONS = 1000;
362 - private const MAX_ITERATIONS = 1000000;
363 -
364 - private static function setIterations(): void
365 - {
366 - $config = new Config(get_option('cryptX', []));
367 - $configured = $config->get('iterations', self::$iterations);
368 -
369 - // The option is not necessarily a sane integer: the settings page keeps
370 - // it as a string, and a hand-edited row can hold anything. A zero makes
371 - // hash_pbkdf2() throw a ValueError and a non-numeric string a TypeError
372 - // -- neither of which is an \Exception, so the fallback in
373 - // CryptX::encryptEmailAddressSecure() would not catch them and the
374 - // front end would fatal on every page carrying an address.
375 - if (!is_numeric($configured)) {
376 - return;
377 - }
378 -
379 - self::$iterations = max(self::MIN_ITERATIONS, min(self::MAX_ITERATIONS, (int) $configured));
380 - }
381 -
382 - /**
383 - * Get configuration for JavaScript
384 - *
385 - * @return array
386 - */
387 - public static function getJavaScriptConfig(): array
388 - {
389 - self::setIterations();
390 - return [
391 - 'iterations' => self::getIterations(),
392 - 'keyLength' => self::KEY_LENGTH,
393 - 'ivLength' => self::IV_LENGTH,
394 - 'saltLength' => self::SALT_LENGTH,
395 - 'cipher' => self::CIPHER
396 - ];
397 208 }
398 209 }