PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.9
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.9
6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 3.6.65 All 196 releases
fluentform / vendor / wpfluent / framework / src / WPFluent / Encryption / Encrypter.php

Encrypter.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder 6.2.9, at vendor/wpfluent/framework/src/WPFluent/Encryption/Encrypter.php

516 lines 13.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentForm\Framework\Encryption;
4
5 use RuntimeException;
6 use FluentForm\Framework\Foundation\App;
7 use FluentForm\Framework\Encryption\EncryptException;
8 use FluentForm\Framework\Encryption\DecryptException;
9
10 class Encrypter
11 {
12 /**
13 * The encryption key.
14 *
15 * @var string
16 */
17 protected $key;
18
19 /**
20 * The algorithm used for encryption.
21 *
22 * @var string
23 */
24 protected $cipher;
25
26 /**
27 * The plugin slug key.
28 *
29 * @var string
30 */
31 protected $slug;
32
33 /**
34 * The supported cipher algorithms and their properties.
35 *
36 * @var array
37 */
38 private static $supportedCiphers = [
39 'aes-128-cbc' => ['size' => 16, 'aead' => false],
40 'aes-256-cbc' => ['size' => 32, 'aead' => false],
41 'aes-128-gcm' => ['size' => 16, 'aead' => true],
42 'aes-256-gcm' => ['size' => 32, 'aead' => true],
43 ];
44
45 /**
46 * Create a new encrypter instance.
47 *
48 * @param string $key
49 * @param string $cipher
50 * @return void
51 *
52 * @throws \RuntimeException
53 */
54 public function __construct($key = null, $cipher = 'aes-128-cbc')
55 {
56 $this->cipher = $cipher;
57
58 $this->slug = $this->getSlug();
59
60 $key = $key ?: $this->getKey();
61
62 if (!static::supported($key, $this->cipher)) {
63 $ciphers = implode(', ', array_keys(self::$supportedCiphers));
64
65 throw new RuntimeException("Unsupported cipher or incorrect key length. Supported ciphers are: {$ciphers}.");
66 }
67
68 $this->key = $key;
69 }
70
71 /**
72 * Determine if the given key and cipher combination is valid.
73 *
74 * @param string $key
75 * @param string $cipher
76 * @return bool
77 */
78 public static function supported($key, $cipher)
79 {
80 if (!isset(self::$supportedCiphers[strtolower($cipher)])) {
81 return false;
82 }
83
84 return mb_strlen(
85 $key, '8bit'
86 ) === self::$supportedCiphers[strtolower($cipher)]['size'];
87 }
88
89 /**
90 * Create a new encryption key for the given cipher.
91 *
92 * @param string $cipher
93 * @return string
94 *
95 * @throws \RuntimeException If the cipher is not in the supported list.
96 */
97 public static function generateKey($cipher)
98 {
99 $cipher = strtolower($cipher);
100
101 if (!isset(self::$supportedCiphers[$cipher])) {
102 $ciphers = implode(', ', array_keys(self::$supportedCiphers));
103
104 throw new RuntimeException(
105 "Unsupported cipher '{$cipher}'. Supported ciphers are: {$ciphers}."
106 );
107 }
108
109 return random_bytes(self::$supportedCiphers[$cipher]['size']);
110 }
111
112 /**
113 * Encrypt the given value.
114 *
115 * @param mixed $value
116 * @param bool $serialize
117 * @return string
118 *
119 * @throws \FluentForm\Framework\Encryption\EncryptException
120 */
121 public function encrypt($value, $serialize = true)
122 {
123 $iv = random_bytes(openssl_cipher_iv_length(strtolower($this->cipher)));
124
125 $value = \openssl_encrypt(
126 $serialize ? serialize($value) : $value,
127 strtolower($this->cipher), $this->key, 0, $iv, $tag
128 );
129
130 if ($value === false) {
131 throw new EncryptException('Could not encrypt the data.');
132 }
133
134 $iv = base64_encode($iv);
135 $tag = base64_encode($tag ?? '');
136
137 $mac = self::$supportedCiphers[strtolower($this->cipher)]['aead']
138 ? '' // For AEAD-algorithms, the tag / MAC is returned by openssl_encrypt...
139 : $this->hash($iv, $value, $this->key);
140
141 $json = json_encode(compact('iv', 'value', 'mac', 'tag'), JSON_UNESCAPED_SLASHES);
142
143 if (json_last_error() !== JSON_ERROR_NONE) {
144 throw new EncryptException('Could not encrypt the data.');
145 }
146
147 return base64_encode($json);
148 }
149
150 /**
151 * Encrypt a string without serialization.
152 *
153 * @param string $value
154 * @return string
155 *
156 * @throws \FluentForm\Framework\Encryption\EncryptException
157 */
158 public function encryptString($value)
159 {
160 return $this->encrypt($value, false);
161 }
162
163 /**
164 * Decrypt the given value.
165 *
166 * @param string $payload
167 * @param bool $unserialize
168 * @return mixed
169 *
170 * @throws \FluentForm\Framework\Encryption\DecryptException
171 */
172 public function decrypt($payload, $unserialize = true)
173 {
174 $payload = $this->getJsonPayload($payload);
175
176 $iv = base64_decode($payload['iv']);
177
178 $this->ensureTagIsValid(
179 $tag = empty($payload['tag']) ? null : base64_decode($payload['tag'])
180 );
181
182 $foundValidMac = false;
183
184 // Try each key (current + rotated) in turn. For non-AEAD ciphers,
185 // the MAC must be validated against THIS specific key — not a
186 // previous iteration's. Once a key passes its MAC check, attempt
187 // decryption with the same key.
188 foreach ($this->getAllKeys() as $key) {
189 if ($this->shouldValidateMac() && !$this->validMacForKey($payload, $key)) {
190 continue;
191 }
192
193 $foundValidMac = true;
194
195 $decrypted = \openssl_decrypt(
196 $payload['value'], strtolower($this->cipher), $key, 0, $iv, $tag ?? ''
197 );
198
199 if ($decrypted !== false) {
200 break;
201 }
202 }
203
204 if ($this->shouldValidateMac() && !$foundValidMac) {
205 throw new DecryptException('The MAC is invalid.');
206 }
207
208 if (($decrypted ?? false) === false) {
209 throw new DecryptException('Could not decrypt the data.');
210 }
211
212 return $unserialize ? unserialize($decrypted) : $decrypted;
213 }
214
215 /**
216 * Decrypt the given string without unserialization.
217 *
218 * @param string $payload
219 * @return string
220 *
221 * @throws \FluentForm\Framework\Encryption\DecryptException
222 */
223 public function decryptString($payload)
224 {
225 return $this->decrypt($payload, false);
226 }
227
228 /**
229 * Create a MAC for the given value.
230 *
231 * @param string $iv
232 * @param mixed $value
233 * @param string $key
234 * @return string
235 */
236 protected function hash($iv, $value, $key)
237 {
238 return hash_hmac('sha256', $iv.$value, $key);
239 }
240
241 /**
242 * Get the JSON array from the given payload.
243 *
244 * @param string $payload
245 * @return array
246 *
247 * @throws \FluentForm\Framework\Encryption\DecryptException
248 */
249 protected function getJsonPayload($payload)
250 {
251 if (!is_string($payload)) {
252 throw new DecryptException('The payload is invalid.');
253 }
254
255 $payload = json_decode(base64_decode($payload), true);
256
257 // If the payload is not valid JSON or does not have the proper keys set we will
258 // assume it is invalid and bail out of the routine since we will not be able
259 // to decrypt the given value. We'll also check the MAC for this encryption.
260 if (!$this->validPayload($payload)) {
261 throw new DecryptException('The payload is invalid.');
262 }
263
264 return $payload;
265 }
266
267 /**
268 * Verify that the encryption payload is valid.
269 *
270 * @param mixed $payload
271 * @return bool
272 */
273 protected function validPayload($payload)
274 {
275 if (!is_array($payload)) {
276 return false;
277 }
278
279 foreach (['iv', 'value', 'mac'] as $item) {
280 if (!isset($payload[$item]) || !is_string($payload[$item])) {
281 return false;
282 }
283 }
284
285 if (isset($payload['tag']) && !is_string($payload['tag'])) {
286 return false;
287 }
288
289 return strlen(base64_decode($payload['iv'], true)) === openssl_cipher_iv_length(strtolower($this->cipher));
290 }
291
292 /**
293 * Determine if the MAC for the given payload is valid for the primary key.
294 *
295 * @param array $payload
296 * @return bool
297 */
298 protected function validMac(array $payload)
299 {
300 return $this->validMacForKey($payload, $this->key);
301 }
302
303 /**
304 * Determine if the MAC is valid for the given payload and key.
305 *
306 * @param array $payload
307 * @param string $key
308 * @return bool
309 */
310 protected function validMacForKey($payload, $key)
311 {
312 return hash_equals(
313 $this->hash($payload['iv'], $payload['value'], $key), $payload['mac']
314 );
315 }
316
317 /**
318 * Ensure the given tag is a valid tag given the selected cipher.
319 *
320 * @param string $tag
321 * @return void
322 */
323 protected function ensureTagIsValid($tag)
324 {
325 if (self::$supportedCiphers[strtolower($this->cipher)]['aead'] && strlen($tag) !== 16) {
326 throw new DecryptException('Could not decrypt the data.');
327 }
328
329 if (!self::$supportedCiphers[strtolower($this->cipher)]['aead'] && is_string($tag)) {
330 throw new DecryptException('Unable to use tag because the cipher algorithm does not support AEAD.');
331 }
332 }
333
334 /**
335 * Determine if we should validate the MAC while decrypting.
336 *
337 * @return bool
338 */
339 protected function shouldValidateMac()
340 {
341 return !self::$supportedCiphers[strtolower($this->cipher)]['aead'];
342 }
343
344 /**
345 * Get the application encryption key.
346 *
347 * Developers may override the database key name using the filter:
348 * `{slug}.encryption.option_key`
349 *
350 * @return string
351 */
352 public function getSlug()
353 {
354 // Defensive: framework not bootstrapped (bare PHP, plugin
355 // activation pre-init, early CLI). Fall back to a generic slug
356 // so the constructor can still build a working encrypter.
357 $app = App::getInstance();
358
359 if (!$app) {
360 return 'wpfluent_enc_key';
361 }
362
363 $slug = $app->config->get('app.slug');
364
365 $default = $slug . '_enc_key';
366
367 /**
368 * Allow developer to override the encryption key option name.
369 *
370 * @param string $default Default option key name.
371 */
372 return $app->applyFilters($slug . '.encryption.option_key', $default);
373 }
374
375 /**
376 * Get the encryption key that the encrypter is currently using.
377 *
378 * @return string
379 */
380 public function getKey()
381 {
382 if (!$key = get_option($this->slug)) {
383
384 add_option($this->slug, base64_encode(
385 $this->generateKey($this->cipher)
386 ));
387
388 $key = get_option($this->slug);
389 }
390
391 return base64_decode($key);
392 }
393
394 /**
395 * Get the current encryption key and all previous encryption keys.
396 *
397 * This is useful for key rotation, allowing decryption of data
398 * encrypted with older keys.
399 *
400 * @return array Array of binary encryption keys.
401 */
402 public function getAllKeys()
403 {
404 $keys = [$this->key];
405
406 $oldKeysOption = $this->oldKeysOptionName();
407
408 $oldKeys = get_option($oldKeysOption, []);
409
410 foreach ($oldKeys as $encodedKey) {
411 $decoded = base64_decode($encodedKey, true);
412
413 // Skip corrupted base64 and any key whose length doesn't match
414 // the active cipher (otherwise it'd silently fail in the
415 // decrypt loop, hiding the real cause).
416 if ($decoded === false || !static::supported($decoded, $this->cipher)) {
417 continue;
418 }
419
420 $keys[] = $decoded;
421 }
422
423 return $keys;
424 }
425
426 /**
427 * Resolve the option name that stores rotated old keys.
428 * Honors the {slug}.encryption.old_keys_option filter when the
429 * framework App is bootstrapped; otherwise uses the default.
430 *
431 * @return string
432 */
433 protected function oldKeysOptionName()
434 {
435 $default = $this->slug . '_old_enc_key';
436
437 $app = App::getInstance();
438
439 if (!$app) {
440 return $default;
441 }
442
443 return $app->applyFilters(
444 $this->slug . '.encryption.old_keys_option',
445 $default
446 );
447 }
448
449 /**
450 * Rotate the encryption key.
451 *
452 * This method archives the current key in the old keys list,
453 * generates a new encryption key, stores it in the database,
454 * and updates the encrypter instance with the new key.
455 *
456 * @return void
457 */
458 public function rotateKey()
459 {
460 $currentEncoded = base64_encode($this->key);
461
462 $oldKeysOption = $this->oldKeysOptionName();
463
464 $oldKeys = get_option($oldKeysOption, []);
465
466 // Add current key to old list if not already present
467 if (!in_array($currentEncoded, $oldKeys, true)) {
468 $oldKeys[] = $currentEncoded;
469 }
470
471 // Cap retention so the array doesn't grow unbounded across many
472 // rotations. Keeps the MOST RECENT $cap entries; trimmed-out keys
473 // mean any data still encrypted under them becomes undecryptable —
474 // that's the documented contract of any retention policy.
475 $cap = $this->oldKeysMax();
476 if (count($oldKeys) > $cap) {
477 $oldKeys = array_values(array_slice($oldKeys, -$cap));
478 }
479
480 update_option($oldKeysOption, $oldKeys);
481
482 // Generate and store new key
483 $newKey = base64_encode(static::generateKey($this->cipher));
484 update_option($this->slug, $newKey);
485
486 // Update instance property with decoded new key
487 $this->key = base64_decode($newKey);
488 }
489
490 /**
491 * Maximum number of rotated keys to retain.
492 *
493 * Honors the {slug}.encryption.old_keys_max filter when the framework
494 * App is bootstrapped. Default is 10, which covers ~10 weeks of weekly
495 * rotation or ~10 years of annual rotation; bump the filter to keep
496 * more historical keys around.
497 *
498 * @return int
499 */
500 protected function oldKeysMax()
501 {
502 $default = 10;
503
504 $app = App::getInstance();
505
506 if (!$app) {
507 return $default;
508 }
509
510 return (int) $app->applyFilters(
511 $this->slug . '.encryption.old_keys_max',
512 $default
513 );
514 }
515 }
516