PluginProbe
CryptX / trunk
CryptX vtrunk
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 trunk, at classes/SecureEncryption.php

357 lines 12.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 * Decrypts encrypted data using AES-256-GCM - JavaScript compatible
195 *
196 * @param string $encryptedData Base64 encoded encrypted data
197 * @param string $password
198 * @return string Decrypted plaintext
199 * @throws \Exception
200 */
201 public static function decrypt(string $encryptedData, string $password): string
202 {
203 if (!function_exists('openssl_decrypt')) {
204 throw new \Exception('OpenSSL extension not available');
205 }
206
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 try {
214 $combined = base64_decode($encryptedData, true);
215 if ($combined === false) {
216 throw new \Exception('Invalid base64 encoding');
217 }
218
219 $totalLength = strlen($combined);
220 $expectedMinLength = self::SALT_LENGTH + self::IV_LENGTH + 16; // +16 for tag
221
222 if ($totalLength < $expectedMinLength) {
223 throw new \Exception('Encrypted data too short');
224 }
225
226 // Extract components: salt(16) + iv(16) + encrypted_data + tag(16)
227 $salt = substr($combined, 0, self::SALT_LENGTH);
228 $iv = substr($combined, self::SALT_LENGTH, self::IV_LENGTH);
229 $encryptedDataLength = $totalLength - self::SALT_LENGTH - self::IV_LENGTH - 16;
230 $encrypted = substr($combined, self::SALT_LENGTH + self::IV_LENGTH, $encryptedDataLength);
231 $tag = substr($combined, -16); // Last 16 bytes
232
233 if (strlen($salt) !== self::SALT_LENGTH ||
234 strlen($iv) !== self::IV_LENGTH ||
235 strlen($tag) !== 16) {
236 throw new \Exception('Invalid encrypted data format');
237 }
238
239 // Derive key from password (now with caching)
240 $key = self::deriveKey($password, $salt);
241
242 // Decrypt data
243 $decrypted = openssl_decrypt(
244 $encrypted,
245 self::CIPHER,
246 $key,
247 OPENSSL_RAW_DATA,
248 $iv,
249 $tag
250 );
251
252 if ($decrypted === false) {
253 throw new \Exception('Decryption failed or data corrupted');
254 }
255
256 return $decrypted;
257 } catch (\Throwable $e) {
258 throw new \Exception('Decryption failed: ' . esc_html($e->getMessage()));
259 }
260 }
261
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.
273
274 /**
275 * Validates URL for security
276 *
277 * @param string $url
278 * @return bool
279 */
280 public static function validateUrl(string $url): bool
281 {
282 $allowedProtocols = ['http', 'https', 'mailto'];
283 $maxLength = 2048;
284
285 if (strlen($url) > $maxLength) {
286 return false;
287 }
288
289 $parsedUrl = wp_parse_url($url);
290 if (!$parsedUrl || !isset($parsedUrl['scheme'])) {
291 return false;
292 }
293
294 if (!in_array($parsedUrl['scheme'], $allowedProtocols)) {
295 return false;
296 }
297
298 // Additional validation for mailto URLs
299 if ($parsedUrl['scheme'] === 'mailto') {
300 $email = $parsedUrl['path'] ?? '';
301 if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
302 return false;
303 }
304 }
305
306 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 }
357 }