Make WordPress Core

Changeset 65373


Ignore:
Timestamp:
11/10/2026 07:47:34 AM (14 hours ago)
Author:
whyisjake
Message:

Security: Keep the Secrets API root key after a failed read.

WP_Secrets_Key_Manager::get_root_key() generated a new root key whenever get_site_option() returned false. That also happens when the query fails, and on a single site the new key then overwrote the stored one, so every existing secret became unreadable. Two requests creating the first root key at the same time could overwrite each other in the same way.

A false result is now checked against the database before a key is generated. If that read also fails, a WP_Error is returned and nothing is written. Reading a secret no longer creates a root key: WP_Secrets_Libsodium_Provider::get() returns an error when none is stored. The first root key is inserted with INSERT IGNORE on a single site, so an existing key is never overwritten. On multisite, every request uses the oldest row, and a request that lost the race deletes its own.

Follow-up to r64337.

Props aryanjasala, dhrupo, ericmann.
Fixes #66413. See #66187.

Location:
trunk
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/secrets/class-wp-secrets-key-manager.php

    r65272 r65373  
    136136         * @since 7.2.0
    137137         *
    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.
    141144         * @return string|WP_Error 32-byte master key on success. WP_Error on failure,
    142145         *                         including when a caller passes an invalid scope or
    … …  
    146149         * @phpstan-param 'site'|'network' $scope
    147150         */
    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 ) {
    149152                if ( ! in_array( $scope, array( 'site', 'network' ), true ) ) {
    150153                        $message = __( 'The scope must be "site" or "network".' );
    … …  
    172175                }
    173176
    174                 $root_key = $this->get_root_key();
     177                $root_key = $this->get_root_key( $create_root_key );
    175178
    176179                if ( is_wp_error( $root_key ) ) {
    … …  
    197200         * Returns the root key, generating and persisting one on first use.
    198201         *
    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.
    201210         * @return string|WP_Error 32 raw bytes on success. WP_Error on failure.
    202211         */
    203         public function get_root_key() {
     212        public function get_root_key( bool $create = true ) {
    204213                $wrapped = $this->get_wrapped_root_key();
    205214
    206215                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                        }
    208232                }
    209233
    … …  
    339363         * @since 7.2.0
    340364         *
     365         * @global wpdb $wpdb WordPress database abstraction object.
     366         *
    341367         * @return string|WP_Error
    342368         */
    343369        private function generate_root_key() {
     370                global $wpdb;
     371
    344372                $candidate = random_bytes( 32 );
    345373
    … …  
    352380                }
    353381
    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 ) ) {
    355388                        $this->cached_wrapped  = $wrapped;
    356389                        $this->cached_root_key = $candidate;
    … …  
    362395                wp_secrets_memzero( $candidate );
    363396
    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 ) {
    367406                        return new WP_Error(
    368407                                WP_SECRETS_ERROR_STORE_UNAVAILABLE,
    369                                 __( 'Could not read the root key after losing the creation race.' )
     408                                __( 'Could not store the root key.' )
    370409                        );
    371410                }
    … …  
    380419                return $root_key;
    381420        }
     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        }
    382556}
  • trunk/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php

    r65272 r65373  
    496496                $site_id = $network ? 0 : get_current_blog_id();
    497497
    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 );
    499500
    500501                if ( is_wp_error( $master_key ) ) {
  • trunk/tests/phpunit/tests/secrets/wpSecretsKeyManager.php

    r65272 r65373  
    607607                $this->assertSame( 1, $mock->unwrap_call_count(), 'The cached root key should be served without another unwrap.' );
    608608        }
     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        }
    609805}
Note: See TracChangeset for help on using the changeset viewer.