← All changes
|
vendor/wpfluent/framework/src/WPFluent/Encryption/Encrypter.php
+164
-14
1.0.96
→
2.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 | } |