PluginProbe
CryptX / 4.0.1
CryptX v4.0.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 2.4.5 All 92 releases
← All changes | classes/SecureEncryption.php +48 -196 trunk4.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,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 }