Changeset 65373
Legend:
- Unmodified
- Added
- Removed
-
trunk/src/wp-includes/secrets/class-wp-secrets-key-manager.php
r65272 r65373 136 136 * @since 7.2.0 137 137 * 138 * @param string $scope 'site' or 'network'. 139 * @param int|null $site_id Blog ID for site scope. Defaults to the current blog. 140 * Ignored for network scope. 138 * @param string $scope 'site' or 'network'. 139 * @param int|null $site_id Blog ID for site scope. Defaults to the current blog. 140 * Ignored for network scope. 141 * @param bool $create_root_key Optional. Whether to generate and store a root key 142 * if none exists. Pass false when only reading 143 * secrets. Default true. 141 144 * @return string|WP_Error 32-byte master key on success. WP_Error on failure, 142 145 * including when a caller passes an invalid scope or … … 146 149 * @phpstan-param 'site'|'network' $scope 147 150 */ 148 public function get_master_key( string $scope, ?int $site_id = null ) {151 public function get_master_key( string $scope, ?int $site_id = null, bool $create_root_key = true ) { 149 152 if ( ! in_array( $scope, array( 'site', 'network' ), true ) ) { 150 153 $message = __( 'The scope must be "site" or "network".' ); … … 172 175 } 173 176 174 $root_key = $this->get_root_key( );177 $root_key = $this->get_root_key( $create_root_key ); 175 178 176 179 if ( is_wp_error( $root_key ) ) { … … 197 200 * Returns the root key, generating and persisting one on first use. 198 201 * 199 * @since 7.2.0 200 * 202 * get_site_option() also returns false when its query fails, so a false result is 203 * checked against the database before a new root key is generated. Generating one 204 * while another is stored would make every existing secret undecryptable. 205 * 206 * @since 7.2.0 207 * 208 * @param bool $create Optional. Whether to generate and store a root key if none 209 * exists. Default true. 201 210 * @return string|WP_Error 32 raw bytes on success. WP_Error on failure. 202 211 */ 203 public function get_root_key( ) {212 public function get_root_key( bool $create = true ) { 204 213 $wrapped = $this->get_wrapped_root_key(); 205 214 206 215 if ( false === $wrapped ) { 207 return $this->generate_root_key(); 216 $wrapped = $this->read_stored_root_key(); 217 218 if ( is_wp_error( $wrapped ) ) { 219 return $wrapped; 220 } 221 222 if ( false === $wrapped ) { 223 if ( ! $create ) { 224 return new WP_Error( 225 WP_SECRETS_ERROR_KEY_UNAVAILABLE, 226 __( 'No root key is stored.' ) 227 ); 228 } 229 230 return $this->generate_root_key(); 231 } 208 232 } 209 233 … … 339 363 * @since 7.2.0 340 364 * 365 * @global wpdb $wpdb WordPress database abstraction object. 366 * 341 367 * @return string|WP_Error 342 368 */ 343 369 private function generate_root_key() { 370 global $wpdb; 371 344 372 $candidate = random_bytes( 32 ); 345 373 … … 352 380 } 353 381 354 if ( add_site_option( self::ROOT_KEY_OPTION, $wrapped ) ) { 382 $inserted_row_id = $this->insert_root_key( $wrapped ); 383 384 // Use whichever root key is stored now: this one, or one another request stored first. 385 $existing = $this->read_stored_root_key(); 386 387 if ( is_string( $existing ) && hash_equals( $existing, $wrapped ) ) { 355 388 $this->cached_wrapped = $wrapped; 356 389 $this->cached_root_key = $candidate; … … 362 395 wp_secrets_memzero( $candidate ); 363 396 364 $existing = get_site_option( self::ROOT_KEY_OPTION ); 365 366 if ( ! is_string( $existing ) ) { 397 if ( $inserted_row_id ) { 398 $wpdb->delete( $wpdb->sitemeta, array( 'meta_id' => $inserted_row_id ) ); 399 } 400 401 if ( is_wp_error( $existing ) ) { 402 return $existing; 403 } 404 405 if ( false === $existing ) { 367 406 return new WP_Error( 368 407 WP_SECRETS_ERROR_STORE_UNAVAILABLE, 369 __( 'Could not read the root key after losing the creation race.' )408 __( 'Could not store the root key.' ) 370 409 ); 371 410 } … … 380 419 return $root_key; 381 420 } 421 422 /** 423 * Stores a new wrapped root key unless one is already stored. 424 * 425 * This does not use add_site_option(). On a single site that is add_option(), which 426 * skips its existence check for an option cached as missing and then overwrites the 427 * stored row, so a failed read followed by add_option() would replace the root key. 428 * 429 * @since 7.2.0 430 * 431 * @global wpdb $wpdb WordPress database abstraction object. 432 * 433 * @param string $wrapped The wrapped root key. 434 * @return int ID of the inserted wp_sitemeta row on multisite, 0 otherwise. 435 */ 436 private function insert_root_key( string $wrapped ): int { 437 global $wpdb; 438 439 $value = maybe_serialize( $wrapped ); 440 441 if ( ! is_multisite() ) { 442 // The unique key on option_name makes this a no-op when a root key exists. 443 $wpdb->query( 444 $wpdb->prepare( 445 "INSERT IGNORE INTO `$wpdb->options` (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, 'off')", 446 self::ROOT_KEY_OPTION, 447 $value 448 ) 449 ); 450 451 return 0; 452 } 453 454 /* 455 * wp_sitemeta has no unique key on meta_key, so two requests can both insert a 456 * row. read_stored_root_key() returns the oldest row, so both end up with the 457 * same root key, and the request that lost deletes its own row. 458 */ 459 $inserted = $wpdb->insert( 460 $wpdb->sitemeta, 461 array( 462 'site_id' => get_current_network_id(), 463 'meta_key' => self::ROOT_KEY_OPTION, 464 'meta_value' => $value, 465 ) 466 ); 467 468 return $inserted ? (int) $wpdb->insert_id : 0; 469 } 470 471 /** 472 * Reads the wrapped root key from the database, bypassing the options cache. 473 * 474 * get_site_option() returns false both when the root key does not exist and when 475 * its query failed, and caches the option as missing either way. This read tells 476 * the two apart. When the row exists, it also clears that cache entry. 477 * 478 * @since 7.2.0 479 * 480 * @global wpdb $wpdb WordPress database abstraction object. 481 * 482 * @return string|false|WP_Error The wrapped root key, false if none is stored, or 483 * WP_Error if the read failed or the row is malformed. 484 */ 485 private function read_stored_root_key() { 486 global $wpdb; 487 488 if ( is_multisite() ) { 489 $value = $wpdb->get_var( 490 $wpdb->prepare( 491 "SELECT meta_value FROM $wpdb->sitemeta WHERE meta_key = %s AND site_id = %d ORDER BY meta_id ASC LIMIT 1", 492 self::ROOT_KEY_OPTION, 493 get_current_network_id() 494 ) 495 ); 496 } else { 497 $value = $wpdb->get_var( 498 $wpdb->prepare( 499 "SELECT option_value FROM $wpdb->options WHERE option_name = %s LIMIT 1", 500 self::ROOT_KEY_OPTION 501 ) 502 ); 503 } 504 505 if ( '' !== $wpdb->last_error ) { 506 return new WP_Error( 507 WP_SECRETS_ERROR_STORE_UNAVAILABLE, 508 __( 'Could not read the root key.' ) 509 ); 510 } 511 512 if ( null === $value ) { 513 return false; 514 } 515 516 $this->forget_missing_root_key(); 517 518 $value = maybe_unserialize( $value ); 519 520 if ( ! is_string( $value ) ) { 521 return new WP_Error( 522 WP_SECRETS_ERROR_RECORD_MALFORMED, 523 __( 'The stored root key is not a string.' ) 524 ); 525 } 526 527 return $value; 528 } 529 530 /** 531 * Removes the root key from the cache of options that do not exist. 532 * 533 * @since 7.2.0 534 */ 535 private function forget_missing_root_key(): void { 536 if ( is_multisite() ) { 537 $network_id = get_current_network_id(); 538 $group = 'site-options'; 539 $notoptions_key = "$network_id:notoptions"; 540 541 wp_cache_delete( "$network_id:" . self::ROOT_KEY_OPTION, $group ); 542 } else { 543 $group = 'options'; 544 $notoptions_key = 'notoptions'; 545 546 wp_cache_delete( self::ROOT_KEY_OPTION, $group ); 547 } 548 549 $notoptions = wp_cache_get( $notoptions_key, $group ); 550 551 if ( is_array( $notoptions ) && isset( $notoptions[ self::ROOT_KEY_OPTION ] ) ) { 552 unset( $notoptions[ self::ROOT_KEY_OPTION ] ); 553 wp_cache_set( $notoptions_key, $notoptions, $group ); 554 } 555 } 382 556 } -
trunk/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php
r65272 r65373 496 496 $site_id = $network ? 0 : get_current_blog_id(); 497 497 498 $master_key = $this->key_manager->get_master_key( $scope, $network ? null : $site_id ); 498 // Reading never creates a root key: without the stored one, nothing can be decrypted. 499 $master_key = $this->key_manager->get_master_key( $scope, $network ? null : $site_id, false ); 499 500 500 501 if ( is_wp_error( $master_key ) ) { -
trunk/tests/phpunit/tests/secrets/wpSecretsKeyManager.php
r65272 r65373 607 607 $this->assertSame( 1, $mock->unwrap_call_count(), 'The cached root key should be served without another unwrap.' ); 608 608 } 609 610 /** 611 * Ensure that a failed database read of the root key does not replace the stored root key when reading a secret. 612 * 613 * @ticket 66413 614 * 615 * @covers WP_Secrets_Key_Manager::get_root_key 616 * @covers WP_Secrets_Key_Manager::read_stored_root_key 617 */ 618 public function test_a_failed_root_key_read_does_not_replace_the_root_key(): void { 619 $this->assertTrue( wp_set_secret( 'test/key', 'value-one' ), 'Storing the first secret should succeed.' ); 620 621 $stored = $this->get_stored_root_key(); 622 623 $this->fail_root_key_selects( 1 ); 624 625 $secret = $this->without_db_errors( 'wp_get_secret', 'test/key' ); 626 627 $this->assertInstanceOf( WP_Secret::class, $secret, 'The secret should still be readable after one failed root key read.' ); 628 $this->assertSame( 'value-one', $secret->reveal(), 'The secret should decrypt to the value that was stored.' ); 629 $this->assertSame( $stored, $this->get_stored_root_key(), 'A failed root key read should not replace the stored root key.' ); 630 } 631 632 /** 633 * Ensure that a failed database read of the root key does not replace the stored root key when writing a secret. 634 * 635 * @ticket 66413 636 * 637 * @covers WP_Secrets_Key_Manager::get_root_key 638 * @covers WP_Secrets_Key_Manager::generate_root_key 639 */ 640 public function test_a_failed_root_key_read_during_a_write_does_not_replace_the_root_key(): void { 641 $this->assertTrue( wp_set_secret( 'test/key', 'value-one' ), 'Storing the first secret should succeed.' ); 642 643 $stored = $this->get_stored_root_key(); 644 645 $this->fail_root_key_selects( 1 ); 646 647 $this->assertTrue( $this->without_db_errors( 'wp_set_secret', 'test/other', 'value-two' ), 'Storing a second secret should succeed after one failed root key read.' ); 648 $this->assertSame( $stored, $this->get_stored_root_key(), 'A failed root key read during a write should not replace the stored root key.' ); 649 $this->assertSame( 'value-one', wp_get_secret( 'test/key' )->reveal(), 'The first secret should still decrypt after the write.' ); 650 $this->assertSame( 'value-two', wp_get_secret( 'test/other' )->reveal(), 'The second secret should decrypt with the same root key.' ); 651 } 652 653 /** 654 * Ensure that a root key that cannot be read is reported as an unavailable store and not replaced. 655 * 656 * @ticket 66413 657 * 658 * @covers WP_Secrets_Key_Manager::get_root_key 659 * @covers WP_Secrets_Key_Manager::read_stored_root_key 660 */ 661 public function test_an_unreadable_root_key_is_reported_and_not_replaced(): void { 662 $this->assertTrue( wp_set_secret( 'test/key', 'value-one' ), 'Storing the first secret should succeed.' ); 663 664 $stored = $this->get_stored_root_key(); 665 666 $filter = $this->fail_root_key_selects( 10 ); 667 668 $read = $this->without_db_errors( 'wp_get_secret', 'test/key' ); 669 $write = $this->without_db_errors( 'wp_set_secret', 'test/other', 'value-two' ); 670 671 remove_filter( 'query', $filter ); 672 673 $this->assertWPError( $read, 'Reading a secret should fail while the root key cannot be read.' ); 674 $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $read->get_error_code(), 'The failed read should report an unavailable store.' ); 675 $this->assertWPError( $write, 'Writing a secret should fail while the root key cannot be read.' ); 676 $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $write->get_error_code(), 'The failed write should report an unavailable store.' ); 677 $this->assertSame( $stored, $this->get_stored_root_key(), 'An unreadable root key should not be replaced.' ); 678 } 679 680 /** 681 * Ensure that reading a secret never generates a root key. 682 * 683 * @ticket 66413 684 * 685 * @covers WP_Secrets_Key_Manager::get_master_key 686 * @covers WP_Secrets_Key_Manager::get_root_key 687 */ 688 public function test_reading_a_secret_does_not_create_a_root_key(): void { 689 $this->assertTrue( wp_set_secret( 'test/key', 'value-one' ), 'Storing the secret should succeed.' ); 690 $this->assertTrue( delete_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ), 'Deleting the root key should succeed.' ); 691 692 $secret = wp_get_secret( 'test/key' ); 693 694 $this->assertWPError( $secret, 'Reading a secret without a root key should fail.' ); 695 $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $secret->get_error_code(), 'The failed read should report an unavailable key.' ); 696 $this->assertNull( $this->get_stored_root_key(), 'Reading a secret should not store a new root key.' ); 697 } 698 699 /** 700 * Ensure that a root key another request stored after this one cached it as missing is used, not replaced. 701 * 702 * @ticket 66413 703 * 704 * @covers WP_Secrets_Key_Manager::get_root_key 705 * @covers WP_Secrets_Key_Manager::read_stored_root_key 706 */ 707 public function test_a_root_key_stored_after_it_was_cached_as_missing_is_used_not_replaced(): void { 708 global $wpdb; 709 710 $keyring = new WP_Secrets_Config_Key_Provider(); 711 $known = str_repeat( 'R', 32 ); 712 $wrapped = $keyring->wrap( $known ); 713 714 $this->assertFalse( get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ), 'No root key should be stored before the test, so it is cached as missing.' ); 715 716 if ( is_multisite() ) { 717 $inserted = $wpdb->insert( 718 $wpdb->sitemeta, 719 array( 720 'site_id' => get_current_network_id(), 721 'meta_key' => WP_Secrets_Key_Manager::ROOT_KEY_OPTION, 722 'meta_value' => $wrapped, 723 ) 724 ); 725 } else { 726 $inserted = $wpdb->insert( 727 $wpdb->options, 728 array( 729 'option_name' => WP_Secrets_Key_Manager::ROOT_KEY_OPTION, 730 'option_value' => $wrapped, 731 'autoload' => 'off', 732 ) 733 ); 734 } 735 736 $this->assertSame( 1, $inserted, 'Storing the root key as another request would should insert one row.' ); 737 738 $manager = new WP_Secrets_Key_Manager( $keyring ); 739 740 $this->assertSame( $known, $manager->get_root_key(), 'get_root_key() should return the root key the other request stored.' ); 741 $this->assertSame( $wrapped, $this->get_stored_root_key(), 'The stored root key should not be replaced.' ); 742 $this->assertSame( $wrapped, get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ), 'The cached root key option should match the stored one.' ); 743 } 744 745 /** 746 * Returns the stored wrapped root key, read straight from the database. 747 * 748 * @return string|null 749 */ 750 private function get_stored_root_key(): ?string { 751 global $wpdb; 752 753 if ( is_multisite() ) { 754 return $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM $wpdb->sitemeta WHERE meta_key = %s ORDER BY meta_id ASC LIMIT 1", WP_Secrets_Key_Manager::ROOT_KEY_OPTION ) ); 755 } 756 757 return $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM $wpdb->options WHERE option_name = %s", WP_Secrets_Key_Manager::ROOT_KEY_OPTION ) ); 758 } 759 760 /** 761 * Makes the next database reads of the root key fail, as a killed query or a lost 762 * connection would, after removing it from the object cache so it is read again. 763 * 764 * @param int $count Number of reads to fail. 765 * @return callable The 'query' filter that was added. 766 */ 767 private function fail_root_key_selects( int $count ): callable { 768 if ( is_multisite() ) { 769 wp_cache_delete( get_current_network_id() . ':' . WP_Secrets_Key_Manager::ROOT_KEY_OPTION, 'site-options' ); 770 } else { 771 wp_cache_delete( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, 'options' ); 772 } 773 774 $filter = static function ( $query ) use ( &$count ) { 775 if ( $count > 0 && str_starts_with( ltrim( $query ), 'SELECT' ) && str_contains( $query, "'" . WP_Secrets_Key_Manager::ROOT_KEY_OPTION . "'" ) ) { 776 --$count; 777 778 return 'SELECT * FROM wptests_table_that_does_not_exist'; 779 } 780 781 return $query; 782 }; 783 784 add_filter( 'query', $filter ); 785 786 return $filter; 787 } 788 789 /** 790 * Calls a function with database errors suppressed. 791 * 792 * @param callable $callback Function to call. 793 * @param mixed ...$args Arguments to pass. 794 * @return mixed The function's return value. 795 */ 796 private function without_db_errors( callable $callback, ...$args ) { 797 global $wpdb; 798 799 $suppress = $wpdb->suppress_errors( true ); 800 $result = $callback( ...$args ); 801 $wpdb->suppress_errors( $suppress ); 802 803 return $result; 804 } 609 805 }
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)