PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.11.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.11.0
2.11.0 2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 All 78 releases
← All changes | vendor/wpfluent/framework/src/WPFluent/Encryption/Encrypter.php +164 -14 1.0.912.11.0 View file →
@@ -23,8 +23,15 @@
23 23 */
24 24 protected $cipher;
25 25
26 26 /**
27 + * The plugin slug key.
28 + *
29 + * @var string
30 + */
31 + protected $slug;
32 +
33 + /**
27 34 * The supported cipher algorithms and their properties.
28 35 *
29 36 * @var array
30 37 */
@@ -49,9 +56,9 @@
49 56 $this->cipher = $cipher;
50 57
51 58 $this->slug = $this->getSlug();
52 59
53 - $key = $this->getKey();
60 + $key = $key ?: $this->getKey();
54 61
55 62 if (!static::supported($key, $this->cipher)) {
56 63 $ciphers = implode(', ', array_keys(self::$supportedCiphers));
57 64
@@ -83,14 +90,24 @@
83 90 * Create a new encryption key for the given cipher.
84 91 *
85 92 * @param string $cipher
86 93 * @return string
94 + *
95 + * @throws \RuntimeException If the cipher is not in the supported list.
87 96 */
88 97 public static function generateKey($cipher)
89 98 {
90 - return random_bytes(
91 - self::$supportedCiphers[strtolower($cipher)]['size'] ?? 32
92 - );
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']);
93 110 }
94 111
95 112 /**
96 113 * Encrypt the given value.
@@ -163,19 +180,19 @@
163 180 );
164 181
165 182 $foundValidMac = false;
166 183
167 - // Here we will decrypt the value. If we are able to successfully decrypt it
168 - // we will then unserialize it and return it out to the caller. If we are
169 - // 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.
170 188 foreach ($this->getAllKeys() as $key) {
171 - if (
172 - $this->shouldValidateMac() &&
173 - !($foundValidMac = $foundValidMac || $this->validMacForKey($payload, $key))
174 - ) {
189 + if ($this->shouldValidateMac() && !$this->validMacForKey($payload, $key)) {
175 190 continue;
176 191 }
177 192
193 + $foundValidMac = true;
194 +
178 195 $decrypted = \openssl_decrypt(
179 196 $payload['value'], strtolower($this->cipher), $key, 0, $iv, $tag ?? ''
180 197 );
181 198
@@ -326,13 +343,34 @@
326 343
327 344 /**
328 345 * Get the application encryption key.
329 346 *
347 + * Developers may override the database key name using the filter:
348 + * `{slug}.encryption.option_key`
349 + *
330 350 * @return string
331 351 */
332 352 public function getSlug()
333 353 {
334 - 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);
335 373 }
336 374
337 375 /**
338 376 * Get the encryption key that the encrypter is currently using.
@@ -355,11 +393,123 @@
355 393
356 394 /**
357 395 * Get the current encryption key and all previous encryption keys.
358 396 *
359 - * @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.
360 401 */
361 402 public function getAllKeys()
362 403 {
363 - 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 + );
364 514 }
365 515 }