PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 2.5.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v2.5.0
2.5.0 2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 All 34 releases
← All changes | vendor/wpfluent/framework/src/WPFluent/Encryption/Encrypter.php +157 -14 1.7.1 → 2.5.0 View file →
@@ -56,9 +56,9 @@
56 56 $this->cipher = $cipher;
57 57
58 58 $this->slug = $this->getSlug();
59 59
60 - $key = $this->getKey();
60 + $key = $key ?: $this->getKey();
61 61
62 62 if (!static::supported($key, $this->cipher)) {
63 63 $ciphers = implode(', ', array_keys(self::$supportedCiphers));
64 64
@@ -90,14 +90,24 @@
90 90 * Create a new encryption key for the given cipher.
91 91 *
92 92 * @param string $cipher
93 93 * @return string
94 + *
95 + * @throws \RuntimeException If the cipher is not in the supported list.
94 96 */
95 97 public static function generateKey($cipher)
96 98 {
97 - return random_bytes(
98 - self::$supportedCiphers[strtolower($cipher)]['size'] ?? 32
99 - );
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']);
100 110 }
101 111
102 112 /**
103 113 * Encrypt the given value.
@@ -170,19 +180,19 @@
170 180 );
171 181
172 182 $foundValidMac = false;
173 183
174 - // Here we will decrypt the value. If we are able to successfully decrypt it
175 - // we will then unserialize it and return it out to the caller. If we are
176 - // unable to decrypt this value we will throw out an exception message.
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.
177 188 foreach ($this->getAllKeys() as $key) {
178 - if (
179 - $this->shouldValidateMac() &&
180 - !($foundValidMac = $foundValidMac || $this->validMacForKey($payload, $key))
181 - ) {
189 + if ($this->shouldValidateMac() && !$this->validMacForKey($payload, $key)) {
182 190 continue;
183 191 }
184 192
193 + $foundValidMac = true;
194 +
185 195 $decrypted = \openssl_decrypt(
186 196 $payload['value'], strtolower($this->cipher), $key, 0, $iv, $tag ?? ''
187 197 );
188 198
@@ -333,13 +343,34 @@
333 343
334 344 /**
335 345 * Get the application encryption key.
336 346 *
347 + * Developers may override the database key name using the filter:
348 + * `{slug}.encryption.option_key`
349 + *
337 350 * @return string
338 351 */
339 352 public function getSlug()
340 353 {
341 - return App::config()->get('app.slug') . '_enc_key';
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);
342 373 }
343 374
344 375 /**
345 376 * Get the encryption key that the encrypter is currently using.
@@ -362,11 +393,123 @@
362 393
363 394 /**
364 395 * Get the current encryption key and all previous encryption keys.
365 396 *
366 - * @return array
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.
367 401 */
368 402 public function getAllKeys()
369 403 {
370 - return [$this->key];
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 + );
371 514 }
372 515 }