PluginProbe
CryptX / 4.1.1
CryptX v4.1.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
cryptx / classes / SecureEncryption.php

SecureEncryption.php in CryptX 4.1.1, at classes/SecureEncryption.php

398 lines 13.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace CryptX;
4
5 /**
6 * Secure encryption class using modern cryptographic standards
7 * Compatible with JavaScript Web Crypto API
8 */
9 class SecureEncryption
10 {
11 private const CIPHER = 'aes-256-gcm';
12 private const KEY_LENGTH = 32; // 256 bits
13 private const IV_LENGTH = 16; // 128 bits
14 private const SALT_LENGTH = 16; // 128 bits
15 private static int $iterations = 10000; // PBKDF2 iterations
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 /**
22 * Performance optimization: one salt per request.
23 *
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 * @param string $password
70 * @param string $salt
71 * @return string
72 * @throws \Exception
73 */
74 private static function deriveKey(string $password, string $salt): string
75 {
76 if (!function_exists('hash_pbkdf2')) {
77 throw new \Exception('PBKDF2 not available');
78 }
79
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;
101 }
102
103 /**
104 * Encrypts plaintext using AES-256-GCM - JavaScript compatible format
105 * Optimized for performance
106 *
107 * @param string $plaintext
108 * @param string $password
109 * @return string Base64 encoded encrypted data
110 * @throws \Exception
111 */
112 public static function encrypt(string $plaintext, string $password): string
113 {
114 // Performance optimization: Pre-check cipher availability
115 if (!self::isCipherAvailable()) {
116 throw new \Exception('OpenSSL extension or AES-256-GCM cipher not available');
117 }
118
119 self::setIterations();
120
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.
128 $iv = random_bytes(self::IV_LENGTH);
129
130 // Derive key from password (now with caching)
131 $key = self::deriveKey($password, $salt);
132
133 // Encrypt data
134 $tag = '';
135 $encrypted = openssl_encrypt(
136 $plaintext,
137 self::CIPHER,
138 $key,
139 OPENSSL_RAW_DATA,
140 $iv,
141 $tag
142 );
143
144 if ($encrypted === false) {
145 throw new \Exception('Encryption failed');
146 }
147
148 // Performance optimization: Use direct concatenation instead of multiple operations
149 return base64_encode($salt . $iv . $encrypted . $tag);
150 }
151
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;
177 }
178
179 /**
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 * Decrypts encrypted data using AES-256-GCM - JavaScript compatible
209 *
210 * @param string $encryptedData Base64 encoded encrypted data
211 * @param string $password
212 * @return string Decrypted plaintext
213 * @throws \Exception
214 */
215 public static function decrypt(string $encryptedData, string $password): string
216 {
217 if (!function_exists('openssl_decrypt')) {
218 throw new \Exception('OpenSSL extension not available');
219 }
220
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 try {
228 $combined = base64_decode($encryptedData, true);
229 if ($combined === false) {
230 throw new \Exception('Invalid base64 encoding');
231 }
232
233 $totalLength = strlen($combined);
234 $expectedMinLength = self::SALT_LENGTH + self::IV_LENGTH + 16; // +16 for tag
235
236 if ($totalLength < $expectedMinLength) {
237 throw new \Exception('Encrypted data too short');
238 }
239
240 // Extract components: salt(16) + iv(16) + encrypted_data + tag(16)
241 $salt = substr($combined, 0, self::SALT_LENGTH);
242 $iv = substr($combined, self::SALT_LENGTH, self::IV_LENGTH);
243 $encryptedDataLength = $totalLength - self::SALT_LENGTH - self::IV_LENGTH - 16;
244 $encrypted = substr($combined, self::SALT_LENGTH + self::IV_LENGTH, $encryptedDataLength);
245 $tag = substr($combined, -16); // Last 16 bytes
246
247 if (strlen($salt) !== self::SALT_LENGTH ||
248 strlen($iv) !== self::IV_LENGTH ||
249 strlen($tag) !== 16) {
250 throw new \Exception('Invalid encrypted data format');
251 }
252
253 // Derive key from password (now with caching)
254 $key = self::deriveKey($password, $salt);
255
256 // Decrypt data
257 $decrypted = openssl_decrypt(
258 $encrypted,
259 self::CIPHER,
260 $key,
261 OPENSSL_RAW_DATA,
262 $iv,
263 $tag
264 );
265
266 if ($decrypted === false) {
267 throw new \Exception('Decryption failed or data corrupted');
268 }
269
270 return $decrypted;
271 } catch (\Throwable $e) {
272 throw new \Exception('Decryption failed: ' . esc_html($e->getMessage()));
273 }
274 }
275
276 /**
277 * Test encryption/decryption with debug output
278 *
279 * @param string $plaintext
280 * @param string $password
281 * @return array Debug information
282 */
283 public static function debugEncryption(string $plaintext, string $password): array
284 {
285 try {
286 $startTime = microtime(true);
287 $encrypted = self::encrypt($plaintext, $password);
288 $encryptTime = microtime(true) - $startTime;
289
290 $startTime = microtime(true);
291 $decrypted = self::decrypt($encrypted, $password);
292 $decryptTime = microtime(true) - $startTime;
293
294 return [
295 'success' => true,
296 'plaintext' => $plaintext,
297 'encrypted' => $encrypted,
298 'decrypted' => $decrypted,
299 'match' => ($plaintext === $decrypted),
300 '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()
305 ];
306 } catch (\Exception $e) {
307 return [
308 'success' => false,
309 'error' => $e->getMessage(),
310 'plaintext' => $plaintext
311 ];
312 }
313 }
314
315 /**
316 * Validates URL for security
317 *
318 * @param string $url
319 * @return bool
320 */
321 public static function validateUrl(string $url): bool
322 {
323 $allowedProtocols = ['http', 'https', 'mailto'];
324 $maxLength = 2048;
325
326 if (strlen($url) > $maxLength) {
327 return false;
328 }
329
330 $parsedUrl = wp_parse_url($url);
331 if (!$parsedUrl || !isset($parsedUrl['scheme'])) {
332 return false;
333 }
334
335 if (!in_array($parsedUrl['scheme'], $allowedProtocols)) {
336 return false;
337 }
338
339 // Additional validation for mailto URLs
340 if ($parsedUrl['scheme'] === 'mailto') {
341 $email = $parsedUrl['path'] ?? '';
342 if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
343 return false;
344 }
345 }
346
347 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 }
398 }