From da8bbc2e87d9fa4a3f04e5b1f247d4bab314a42e Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Tue, 10 Nov 2026 06:51:44 -0800 Subject: [PATCH 01/10] Keep the root key after a failed read Ports r65373 from core trunk. WP_Secrets_Key_Manager::get_root_key() generated a new root key whenever get_site_option() returned false, which also happens when the query fails. On a single site the new key then overwrote the stored one, and every existing secret became unreadable. A false result is now checked against the database before a key is generated, and a failed read returns WP_Error without writing anything. Reading a secret no longer creates a root key. The first root key is inserted with INSERT IGNORE on a single site; on multisite every request uses the oldest row, and a request that lost the race deletes its own. https://core.trac.wordpress.org/changeset/65373 https://core.trac.wordpress.org/ticket/66413 Props aryanjasala, dhrupo, ericmann. --- docs/reference/classes.md | 13 +- .../secrets/class-wp-secrets-key-manager.php | 196 +++++++++++++++++- .../class-wp-secrets-libsodium-provider.php | 3 +- tests/phpunit/test-wp-secrets-key-manager.php | 196 ++++++++++++++++++ 4 files changed, 394 insertions(+), 14 deletions(-) diff --git a/docs/reference/classes.md b/docs/reference/classes.md index ade3077..9344c3f 100644 --- a/docs/reference/classes.md +++ b/docs/reference/classes.md @@ -1141,13 +1141,14 @@ public function get_keyring(): WP_Secrets_Keyring Derives a scope's master key from the root key. ```php -public function get_master_key( string $scope, ?int $site_id = null ) +public function get_master_key( string $scope, ?int $site_id = null, bool $create_root_key = true ) ``` | Parameter | Type | Description | |---|---|---| | `$scope` | `string` | 'site' or 'network'. | | `$site_id` | `int\|null` | Blog ID for site scope. Defaults to the current blog. Ignored for network scope. | +| `$create_root_key` | `bool` | Optional. Whether to generate and store a root key if none exists. Pass false when only reading secrets. Default true. | **Returns:** `string|WP_Error` 32-byte master key on success. WP_Error on failure, including when a caller passes an invalid scope or site ID – see WP_Secrets_Cipher::validate_common() for why that is a WP_Error and not an exception. @@ -1157,10 +1158,18 @@ public function get_master_key( string $scope, ?int $site_id = null ) Returns the root key, generating and persisting one on first use. +get_site_option() also returns false when its query fails, so a false result is +checked against the database before a new root key is generated. Generating one +while another is stored would make every existing secret undecryptable. + ```php -public function get_root_key() +public function get_root_key( bool $create = true ) ``` +| Parameter | Type | Description | +|---|---|---| +| `$create` | `bool` | Optional. Whether to generate and store a root key if none exists. Default true. | + **Returns:** `string|WP_Error` 32 raw bytes on success. WP_Error on failure. **Since:** 7.2.0 diff --git a/src/wp-includes/secrets/class-wp-secrets-key-manager.php b/src/wp-includes/secrets/class-wp-secrets-key-manager.php index de2e730..096d34d 100644 --- a/src/wp-includes/secrets/class-wp-secrets-key-manager.php +++ b/src/wp-includes/secrets/class-wp-secrets-key-manager.php @@ -135,9 +135,12 @@ public function get_keyring(): WP_Secrets_Keyring { * * @since 7.2.0 * - * @param string $scope 'site' or 'network'. - * @param int|null $site_id Blog ID for site scope. Defaults to the current blog. - * Ignored for network scope. + * @param string $scope 'site' or 'network'. + * @param int|null $site_id Blog ID for site scope. Defaults to the current blog. + * Ignored for network scope. + * @param bool $create_root_key Optional. Whether to generate and store a root key + * if none exists. Pass false when only reading + * secrets. Default true. * @return string|WP_Error 32-byte master key on success. WP_Error on failure, * including when a caller passes an invalid scope or * site ID – see WP_Secrets_Cipher::validate_common() @@ -145,7 +148,7 @@ public function get_keyring(): WP_Secrets_Keyring { * * @phpstan-param 'site'|'network' $scope */ - public function get_master_key( string $scope, ?int $site_id = null ) { + public function get_master_key( string $scope, ?int $site_id = null, bool $create_root_key = true ) { if ( ! in_array( $scope, array( 'site', 'network' ), true ) ) { $message = __( 'The scope must be "site" or "network".' ); @@ -171,7 +174,7 @@ public function get_master_key( string $scope, ?int $site_id = null ) { $context = self::SITE_KDF_CONTEXT; } - $root_key = $this->get_root_key(); + $root_key = $this->get_root_key( $create_root_key ); if ( is_wp_error( $root_key ) ) { return $root_key; @@ -196,15 +199,36 @@ public function get_master_key( string $scope, ?int $site_id = null ) { /** * Returns the root key, generating and persisting one on first use. * + * get_site_option() also returns false when its query fails, so a false result is + * checked against the database before a new root key is generated. Generating one + * while another is stored would make every existing secret undecryptable. + * * @since 7.2.0 * + * @param bool $create Optional. Whether to generate and store a root key if none + * exists. Default true. * @return string|WP_Error 32 raw bytes on success. WP_Error on failure. */ - public function get_root_key() { + public function get_root_key( bool $create = true ) { $wrapped = $this->get_wrapped_root_key(); if ( false === $wrapped ) { - return $this->generate_root_key(); + $wrapped = $this->read_stored_root_key(); + + if ( is_wp_error( $wrapped ) ) { + return $wrapped; + } + + if ( false === $wrapped ) { + if ( ! $create ) { + return new WP_Error( + WP_SECRETS_ERROR_KEY_UNAVAILABLE, + __( 'No root key is stored.' ) + ); + } + + return $this->generate_root_key(); + } } if ( ! is_string( $wrapped ) ) { @@ -338,9 +362,13 @@ private function get_wrapped_root_key() { * * @since 7.2.0 * + * @global wpdb $wpdb WordPress database abstraction object. + * * @return string|WP_Error */ private function generate_root_key() { + global $wpdb; + $candidate = random_bytes( 32 ); $wrapped = $this->keyring->wrap( $candidate ); @@ -351,7 +379,12 @@ private function generate_root_key() { return $wrapped; } - if ( add_site_option( self::ROOT_KEY_OPTION, $wrapped ) ) { + $inserted_row_id = $this->insert_root_key( $wrapped ); + + // Use whichever root key is stored now: this one, or one another request stored first. + $existing = $this->read_stored_root_key(); + + if ( is_string( $existing ) && hash_equals( $existing, $wrapped ) ) { $this->cached_wrapped = $wrapped; $this->cached_root_key = $candidate; @@ -361,12 +394,18 @@ private function generate_root_key() { // Lost the race: another request created one first. Use theirs, not ours. wp_secrets_memzero( $candidate ); - $existing = get_site_option( self::ROOT_KEY_OPTION ); + if ( $inserted_row_id ) { + $wpdb->delete( $wpdb->sitemeta, array( 'meta_id' => $inserted_row_id ) ); + } - if ( ! is_string( $existing ) ) { + if ( is_wp_error( $existing ) ) { + return $existing; + } + + if ( false === $existing ) { return new WP_Error( WP_SECRETS_ERROR_STORE_UNAVAILABLE, - __( 'Could not read the root key after losing the creation race.' ) + __( 'Could not store the root key.' ) ); } @@ -379,4 +418,139 @@ private function generate_root_key() { return $root_key; } + + /** + * Stores a new wrapped root key unless one is already stored. + * + * This does not use add_site_option(). On a single site that is add_option(), which + * skips its existence check for an option cached as missing and then overwrites the + * stored row, so a failed read followed by add_option() would replace the root key. + * + * @since 7.2.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param string $wrapped The wrapped root key. + * @return int ID of the inserted wp_sitemeta row on multisite, 0 otherwise. + */ + private function insert_root_key( string $wrapped ): int { + global $wpdb; + + $value = maybe_serialize( $wrapped ); + + if ( ! is_multisite() ) { + // The unique key on option_name makes this a no-op when a root key exists. + $wpdb->query( + $wpdb->prepare( + "INSERT IGNORE INTO `$wpdb->options` (`option_name`, `option_value`, `autoload`) VALUES (%s, %s, 'off')", + self::ROOT_KEY_OPTION, + $value + ) + ); + + return 0; + } + + /* + * wp_sitemeta has no unique key on meta_key, so two requests can both insert a + * row. read_stored_root_key() returns the oldest row, so both end up with the + * same root key, and the request that lost deletes its own row. + */ + $inserted = $wpdb->insert( + $wpdb->sitemeta, + array( + 'site_id' => get_current_network_id(), + 'meta_key' => self::ROOT_KEY_OPTION, + 'meta_value' => $value, + ) + ); + + return $inserted ? (int) $wpdb->insert_id : 0; + } + + /** + * Reads the wrapped root key from the database, bypassing the options cache. + * + * get_site_option() returns false both when the root key does not exist and when + * its query failed, and caches the option as missing either way. This read tells + * the two apart. When the row exists, it also clears that cache entry. + * + * @since 7.2.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @return string|false|WP_Error The wrapped root key, false if none is stored, or + * WP_Error if the read failed or the row is malformed. + */ + private function read_stored_root_key() { + global $wpdb; + + if ( is_multisite() ) { + $value = $wpdb->get_var( + $wpdb->prepare( + "SELECT meta_value FROM $wpdb->sitemeta WHERE meta_key = %s AND site_id = %d ORDER BY meta_id ASC LIMIT 1", + self::ROOT_KEY_OPTION, + get_current_network_id() + ) + ); + } else { + $value = $wpdb->get_var( + $wpdb->prepare( + "SELECT option_value FROM $wpdb->options WHERE option_name = %s LIMIT 1", + self::ROOT_KEY_OPTION + ) + ); + } + + if ( '' !== $wpdb->last_error ) { + return new WP_Error( + WP_SECRETS_ERROR_STORE_UNAVAILABLE, + __( 'Could not read the root key.' ) + ); + } + + if ( null === $value ) { + return false; + } + + $this->forget_missing_root_key(); + + $value = maybe_unserialize( $value ); + + if ( ! is_string( $value ) ) { + return new WP_Error( + WP_SECRETS_ERROR_RECORD_MALFORMED, + __( 'The stored root key is not a string.' ) + ); + } + + return $value; + } + + /** + * Removes the root key from the cache of options that do not exist. + * + * @since 7.2.0 + */ + private function forget_missing_root_key(): void { + if ( is_multisite() ) { + $network_id = get_current_network_id(); + $group = 'site-options'; + $notoptions_key = "$network_id:notoptions"; + + wp_cache_delete( "$network_id:" . self::ROOT_KEY_OPTION, $group ); + } else { + $group = 'options'; + $notoptions_key = 'notoptions'; + + wp_cache_delete( self::ROOT_KEY_OPTION, $group ); + } + + $notoptions = wp_cache_get( $notoptions_key, $group ); + + if ( is_array( $notoptions ) && isset( $notoptions[ self::ROOT_KEY_OPTION ] ) ) { + unset( $notoptions[ self::ROOT_KEY_OPTION ] ); + wp_cache_set( $notoptions_key, $notoptions, $group ); + } + } } diff --git a/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php index 7fdf1e5..e19fa03 100644 --- a/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php +++ b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php @@ -495,7 +495,8 @@ public function get( string $name, string $version, bool $network = false ) { $scope = $network ? 'network' : 'site'; $site_id = $network ? 0 : get_current_blog_id(); - $master_key = $this->key_manager->get_master_key( $scope, $network ? null : $site_id ); + // Reading never creates a root key: without the stored one, nothing can be decrypted. + $master_key = $this->key_manager->get_master_key( $scope, $network ? null : $site_id, false ); if ( is_wp_error( $master_key ) ) { return $master_key; diff --git a/tests/phpunit/test-wp-secrets-key-manager.php b/tests/phpunit/test-wp-secrets-key-manager.php index b6bf4d0..93b76ec 100644 --- a/tests/phpunit/test-wp-secrets-key-manager.php +++ b/tests/phpunit/test-wp-secrets-key-manager.php @@ -606,4 +606,200 @@ public function test_the_returned_root_key_is_a_copy_the_caller_can_zero(): void $this->assertSame( $root, $manager->get_root_key(), 'Zeroing the returned copy should not wipe the cached root key.' ); $this->assertSame( 1, $mock->unwrap_call_count(), 'The cached root key should be served without another unwrap.' ); } + + /** + * Ensure that a failed database read of the root key does not replace the stored root key when reading a secret. + * + * @ticket 66413 + * + * @covers WP_Secrets_Key_Manager::get_root_key + * @covers WP_Secrets_Key_Manager::read_stored_root_key + */ + public function test_a_failed_root_key_read_does_not_replace_the_root_key(): void { + $this->assertTrue( wp_set_secret( 'test/key', 'value-one' ), 'Storing the first secret should succeed.' ); + + $stored = $this->get_stored_root_key(); + + $this->fail_root_key_selects( 1 ); + + $secret = $this->without_db_errors( 'wp_get_secret', 'test/key' ); + + $this->assertInstanceOf( WP_Secret::class, $secret, 'The secret should still be readable after one failed root key read.' ); + $this->assertSame( 'value-one', $secret->reveal(), 'The secret should decrypt to the value that was stored.' ); + $this->assertSame( $stored, $this->get_stored_root_key(), 'A failed root key read should not replace the stored root key.' ); + } + + /** + * Ensure that a failed database read of the root key does not replace the stored root key when writing a secret. + * + * @ticket 66413 + * + * @covers WP_Secrets_Key_Manager::get_root_key + * @covers WP_Secrets_Key_Manager::generate_root_key + */ + public function test_a_failed_root_key_read_during_a_write_does_not_replace_the_root_key(): void { + $this->assertTrue( wp_set_secret( 'test/key', 'value-one' ), 'Storing the first secret should succeed.' ); + + $stored = $this->get_stored_root_key(); + + $this->fail_root_key_selects( 1 ); + + $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.' ); + $this->assertSame( $stored, $this->get_stored_root_key(), 'A failed root key read during a write should not replace the stored root key.' ); + $this->assertSame( 'value-one', wp_get_secret( 'test/key' )->reveal(), 'The first secret should still decrypt after the write.' ); + $this->assertSame( 'value-two', wp_get_secret( 'test/other' )->reveal(), 'The second secret should decrypt with the same root key.' ); + } + + /** + * Ensure that a root key that cannot be read is reported as an unavailable store and not replaced. + * + * @ticket 66413 + * + * @covers WP_Secrets_Key_Manager::get_root_key + * @covers WP_Secrets_Key_Manager::read_stored_root_key + */ + public function test_an_unreadable_root_key_is_reported_and_not_replaced(): void { + $this->assertTrue( wp_set_secret( 'test/key', 'value-one' ), 'Storing the first secret should succeed.' ); + + $stored = $this->get_stored_root_key(); + + $filter = $this->fail_root_key_selects( 10 ); + + $read = $this->without_db_errors( 'wp_get_secret', 'test/key' ); + $write = $this->without_db_errors( 'wp_set_secret', 'test/other', 'value-two' ); + + remove_filter( 'query', $filter ); + + $this->assertWPError( $read, 'Reading a secret should fail while the root key cannot be read.' ); + $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $read->get_error_code(), 'The failed read should report an unavailable store.' ); + $this->assertWPError( $write, 'Writing a secret should fail while the root key cannot be read.' ); + $this->assertSame( WP_SECRETS_ERROR_STORE_UNAVAILABLE, $write->get_error_code(), 'The failed write should report an unavailable store.' ); + $this->assertSame( $stored, $this->get_stored_root_key(), 'An unreadable root key should not be replaced.' ); + } + + /** + * Ensure that reading a secret never generates a root key. + * + * @ticket 66413 + * + * @covers WP_Secrets_Key_Manager::get_master_key + * @covers WP_Secrets_Key_Manager::get_root_key + */ + public function test_reading_a_secret_does_not_create_a_root_key(): void { + $this->assertTrue( wp_set_secret( 'test/key', 'value-one' ), 'Storing the secret should succeed.' ); + $this->assertTrue( delete_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ), 'Deleting the root key should succeed.' ); + + $secret = wp_get_secret( 'test/key' ); + + $this->assertWPError( $secret, 'Reading a secret without a root key should fail.' ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $secret->get_error_code(), 'The failed read should report an unavailable key.' ); + $this->assertNull( $this->get_stored_root_key(), 'Reading a secret should not store a new root key.' ); + } + + /** + * Ensure that a root key another request stored after this one cached it as missing is used, not replaced. + * + * @ticket 66413 + * + * @covers WP_Secrets_Key_Manager::get_root_key + * @covers WP_Secrets_Key_Manager::read_stored_root_key + */ + public function test_a_root_key_stored_after_it_was_cached_as_missing_is_used_not_replaced(): void { + global $wpdb; + + $keyring = new WP_Secrets_Config_Key_Provider(); + $known = str_repeat( 'R', 32 ); + $wrapped = $keyring->wrap( $known ); + + $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.' ); + + if ( is_multisite() ) { + $inserted = $wpdb->insert( + $wpdb->sitemeta, + array( + 'site_id' => get_current_network_id(), + 'meta_key' => WP_Secrets_Key_Manager::ROOT_KEY_OPTION, + 'meta_value' => $wrapped, + ) + ); + } else { + $inserted = $wpdb->insert( + $wpdb->options, + array( + 'option_name' => WP_Secrets_Key_Manager::ROOT_KEY_OPTION, + 'option_value' => $wrapped, + 'autoload' => 'off', + ) + ); + } + + $this->assertSame( 1, $inserted, 'Storing the root key as another request would should insert one row.' ); + + $manager = new WP_Secrets_Key_Manager( $keyring ); + + $this->assertSame( $known, $manager->get_root_key(), 'get_root_key() should return the root key the other request stored.' ); + $this->assertSame( $wrapped, $this->get_stored_root_key(), 'The stored root key should not be replaced.' ); + $this->assertSame( $wrapped, get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ), 'The cached root key option should match the stored one.' ); + } + + /** + * Returns the stored wrapped root key, read straight from the database. + * + * @return string|null + */ + private function get_stored_root_key(): ?string { + global $wpdb; + + if ( is_multisite() ) { + 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 ) ); + } + + return $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM $wpdb->options WHERE option_name = %s", WP_Secrets_Key_Manager::ROOT_KEY_OPTION ) ); + } + + /** + * Makes the next database reads of the root key fail, as a killed query or a lost + * connection would, after removing it from the object cache so it is read again. + * + * @param int $count Number of reads to fail. + * @return callable The 'query' filter that was added. + */ + private function fail_root_key_selects( int $count ): callable { + if ( is_multisite() ) { + wp_cache_delete( get_current_network_id() . ':' . WP_Secrets_Key_Manager::ROOT_KEY_OPTION, 'site-options' ); + } else { + wp_cache_delete( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, 'options' ); + } + + $filter = static function ( $query ) use ( &$count ) { + if ( $count > 0 && str_starts_with( ltrim( $query ), 'SELECT' ) && str_contains( $query, "'" . WP_Secrets_Key_Manager::ROOT_KEY_OPTION . "'" ) ) { + --$count; + + return 'SELECT * FROM wptests_table_that_does_not_exist'; + } + + return $query; + }; + + add_filter( 'query', $filter ); + + return $filter; + } + + /** + * Calls a function with database errors suppressed. + * + * @param callable $callback Function to call. + * @param mixed ...$args Arguments to pass. + * @return mixed The function's return value. + */ + private function without_db_errors( callable $callback, ...$args ) { + global $wpdb; + + $suppress = $wpdb->suppress_errors( true ); + $result = $callback( ...$args ); + $wpdb->suppress_errors( $suppress ); + + return $result; + } } From 3413ccd95266e27e919cdf015e6596d9bd48e09f Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Tue, 10 Nov 2026 06:51:44 -0800 Subject: [PATCH 02/10] Harden secret names and sensitive parameters Ports r65375 from core trunk. wp_secrets_validate_name() accepted a name with a trailing newline, because $ also matches before a final newline; the pattern now ends with \z. The parameters that receive a secret value or key material are marked #[\SensitiveParameter], so they stay out of stack traces. Attributes are not inherited, so keyrings and providers in a drop-in need to add it themselves. The third fix in r65375 masks _wp_secrets_root_key on Settings > All Settings. It changes wp-admin/options.php, which the plugin cannot, so it is not ported. https://core.trac.wordpress.org/changeset/65375 https://core.trac.wordpress.org/ticket/66414 Props dhrupo, aryanjasala, ericmann. --- docs/reference/classes.md | 21 ++-- docs/reference/functions.md | 6 +- src/wp-includes/secrets.php | 23 +++- .../class-wp-secrets-broken-keyring.php | 5 +- .../class-wp-secrets-broken-provider.php | 9 +- .../secrets/class-wp-secrets-cipher.php | 28 ++++- .../class-wp-secrets-config-key-provider.php | 5 +- .../class-wp-secrets-libsodium-provider.php | 19 +++- .../secrets/interface-wp-secrets-keyring.php | 8 +- .../secrets/interface-wp-secrets-provider.php | 9 +- .../phpunit/test-wp-secrets-validate-name.php | 3 + .../test-wp-set-secret-and-wp-get-secret.php | 106 ++++++++++++++++++ 12 files changed, 216 insertions(+), 26 deletions(-) diff --git a/docs/reference/classes.md b/docs/reference/classes.md index 9344c3f..e791e4e 100644 --- a/docs/reference/classes.md +++ b/docs/reference/classes.md @@ -619,7 +619,7 @@ public function unwrap( string $wrapped ): WP_Error Always fails closed. ```php -public function wrap( string $key_material ): WP_Error +public function wrap( #[\SensitiveParameter] string $key_material ): WP_Error ``` | Parameter | Type | Description | @@ -765,7 +765,7 @@ public function retire_previous( string $name, bool $network = false ): WP_Error Always an error. ```php -public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ): WP_Error +public function set( string $name, #[\SensitiveParameter] $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ): WP_Error ``` | Parameter | Type | Description | @@ -904,7 +904,7 @@ slot's worth of material, encrypt or decrypt it correctly. Decrypts a single record slot back to its plaintext. ```php -public function decrypt_value( string $master_key, string $scope, int $site_id, string $name, string $slot, $record ) +public function decrypt_value( #[\SensitiveParameter] string $master_key, string $scope, int $site_id, string $name, string $slot, $record ) ``` | Parameter | Type | Description | @@ -925,7 +925,7 @@ public function decrypt_value( string $master_key, string $scope, int $site_id, Encrypts a plaintext into a single record slot. ```php -public function encrypt_value( string $master_key, string $scope, int $site_id, string $name, string $slot, $plaintext ) +public function encrypt_value( #[\SensitiveParameter] string $master_key, string $scope, int $site_id, string $name, string $slot, #[\SensitiveParameter] $plaintext ) ``` | Parameter | Type | Description | @@ -953,7 +953,7 @@ decrypted plaintext and compare – never trust a fingerprint read back from a record, which sits outside the AAD and is not authenticated. ```php -public function fingerprint( string $master_key, $plaintext ) +public function fingerprint( #[\SensitiveParameter] string $master_key, #[\SensitiveParameter] $plaintext ) ``` | Parameter | Type | Description | @@ -1051,7 +1051,7 @@ public function unwrap( string $wrapped ) Wraps raw key material under the derived site key. ```php -public function wrap( string $key_material ) +public function wrap( #[\SensitiveParameter] string $key_material ) ``` | Parameter | Type | Description | @@ -1252,8 +1252,11 @@ different values. WP_Secrets_Key_Manager::rotate_site_key() stores the re-wrapped value with update_site_option(), which reports an unchanged value as a failure, and WP_Secrets_Keyring_Conformance checks this. +Attributes are not inherited, so an implementation must mark $key_material +with #[\SensitiveParameter] itself to keep it out of stack traces. + ```php -public function wrap( string $key_material ) +public function wrap( #[\SensitiveParameter] string $key_material ) ``` | Parameter | Type | Description | @@ -1443,7 +1446,7 @@ Backs wp_set_secret(), wp_set_network_secret(), and wp_import_option_as_secret() which select their behavior through the last two parameters. ```php -public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ) +public function set( string $name, #[\SensitiveParameter] $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ) ``` | Parameter | Type | Description | @@ -1777,7 +1780,7 @@ but a provider that cannot tell the value is unchanged without an additional read may write it and report it. ```php -public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ) +public function set( string $name, #[\SensitiveParameter] $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ) ``` | Parameter | Type | Description | diff --git a/docs/reference/functions.md b/docs/reference/functions.md index bf2534f..01214cb 100644 --- a/docs/reference/functions.md +++ b/docs/reference/functions.md @@ -594,7 +594,7 @@ capabilities and separate storage prefixes, with no implicit fallback from one scope to the other. ```php -function wp_set_network_secret( string $name, $value ) +function wp_set_network_secret( string $name, #[\SensitiveParameter] $value ) ``` | Parameter | Type | Description | @@ -618,7 +618,7 @@ cron, REST, and front-end requests where no user is logged in. Enforce capabilities at the operator boundary (CLI, an admin screen) instead. ```php -function wp_set_secret( string $name, $value ) +function wp_set_secret( string $name, #[\SensitiveParameter] $value ) ``` | Parameter | Type | Description | @@ -812,7 +812,7 @@ function _wp_secrets_retire( string $name, bool $network ) Shared implementation behind wp_set_secret() and wp_set_network_secret(). ```php -function _wp_secrets_set( string $name, $value, bool $network, bool $needs_rotation = false, ?string $action_override = null ) +function _wp_secrets_set( string $name, #[\SensitiveParameter] $value, bool $network, bool $needs_rotation = false, ?string $action_override = null ) ``` | Parameter | Type | Description | diff --git a/src/wp-includes/secrets.php b/src/wp-includes/secrets.php index 826eb41..32b3a4b 100644 --- a/src/wp-includes/secrets.php +++ b/src/wp-includes/secrets.php @@ -253,7 +253,7 @@ function wp_secrets_validate_name( string $name ) { ); } - $segment_pattern = '/^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/'; + $segment_pattern = '/^[a-z0-9]([a-z0-9_-]*[a-z0-9])?\z/'; if ( 0 === $slashes ) { if ( ! preg_match( $segment_pattern, $name ) ) { @@ -417,7 +417,11 @@ function wp_using_secrets_dropin(): bool { * @param string $value The plaintext value to store. * @return true|WP_Error */ -function wp_set_secret( string $name, $value ) { +function wp_set_secret( + string $name, + #[\SensitiveParameter] + $value +) { return _wp_secrets_set( $name, $value, false ); } @@ -544,7 +548,11 @@ function wp_list_secrets( string $namespace = '' ) { // phpcs:ignore Universal.N * @param string $value The plaintext value to store. * @return true|WP_Error */ -function wp_set_network_secret( string $name, $value ) { +function wp_set_network_secret( + string $name, + #[\SensitiveParameter] + $value +) { return _wp_secrets_set( $name, $value, true ); } @@ -711,7 +719,14 @@ function wp_secrets_provider_label(): string { * wp_secret_changed. * @return true|WP_Error */ -function _wp_secrets_set( string $name, $value, bool $network, bool $needs_rotation = false, ?string $action_override = null ) { +function _wp_secrets_set( + string $name, + #[\SensitiveParameter] + $value, + bool $network, + bool $needs_rotation = false, + ?string $action_override = null +) { return _wp_secrets_get_provider()->set( $name, $value, $network, $needs_rotation, $action_override ); } diff --git a/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php b/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php index 85b51f9..1ad8733 100644 --- a/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php +++ b/src/wp-includes/secrets/class-wp-secrets-broken-keyring.php @@ -30,7 +30,10 @@ final class WP_Secrets_Broken_Keyring implements WP_Secrets_Keyring { * @param string $key_material Ignored. * @return WP_Error */ - public function wrap( string $key_material ): WP_Error { + public function wrap( + #[\SensitiveParameter] + string $key_material + ): WP_Error { return $this->error(); } diff --git a/src/wp-includes/secrets/class-wp-secrets-broken-provider.php b/src/wp-includes/secrets/class-wp-secrets-broken-provider.php index 8c429c5..8edeee9 100644 --- a/src/wp-includes/secrets/class-wp-secrets-broken-provider.php +++ b/src/wp-includes/secrets/class-wp-secrets-broken-provider.php @@ -66,7 +66,14 @@ public function get( string $name, string $version, bool $network = false ): WP_ * @param string|null $action Ignored. * @return WP_Error */ - public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ): WP_Error { + public function set( + string $name, + #[\SensitiveParameter] + $value, + bool $network = false, + bool $needs_rotation = false, + ?string $action = null + ): WP_Error { return $this->error(); } diff --git a/src/wp-includes/secrets/class-wp-secrets-cipher.php b/src/wp-includes/secrets/class-wp-secrets-cipher.php index 5cb1284..4880d02 100644 --- a/src/wp-includes/secrets/class-wp-secrets-cipher.php +++ b/src/wp-includes/secrets/class-wp-secrets-cipher.php @@ -69,7 +69,16 @@ final class WP_Secrets_Cipher { * @phpstan-param 'site'|'network' $scope * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $slot */ - public function encrypt_value( string $master_key, string $scope, int $site_id, string $name, string $slot, $plaintext ) { + public function encrypt_value( + #[\SensitiveParameter] + string $master_key, + string $scope, + int $site_id, + string $name, + string $slot, + #[\SensitiveParameter] + $plaintext + ) { $check = $this->validate_common( $master_key, $scope, $site_id, $name, $slot ); if ( is_wp_error( $check ) ) { @@ -142,7 +151,15 @@ public function encrypt_value( string $master_key, string $scope, int $site_id, * @phpstan-param 'site'|'network' $scope * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $slot */ - public function decrypt_value( string $master_key, string $scope, int $site_id, string $name, string $slot, $record ) { + public function decrypt_value( + #[\SensitiveParameter] + string $master_key, + string $scope, + int $site_id, + string $name, + string $slot, + $record + ) { $check = $this->validate_common( $master_key, $scope, $site_id, $name, $slot ); if ( is_wp_error( $check ) ) { @@ -211,7 +228,12 @@ public function decrypt_value( string $master_key, string $scope, int $site_id, * @param string $plaintext Value to fingerprint. * @return string|WP_Error 32-character hex string on success. WP_Error on failure. */ - public function fingerprint( string $master_key, $plaintext ) { + public function fingerprint( + #[\SensitiveParameter] + string $master_key, + #[\SensitiveParameter] + $plaintext + ) { if ( ! $this->is_valid_master_key( $master_key ) ) { return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, diff --git a/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php b/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php index c99adc9..819814f 100644 --- a/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php +++ b/src/wp-includes/secrets/class-wp-secrets-config-key-provider.php @@ -84,7 +84,10 @@ public function __construct( bool $use_previous_key = false ) { * @param string $key_material Raw key material to protect. * @return string|WP_Error */ - public function wrap( string $key_material ) { + public function wrap( + #[\SensitiveParameter] + string $key_material + ) { if ( '' === $key_material ) { return new WP_Error( WP_SECRETS_ERROR_INVALID_VALUE, diff --git a/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php index e19fa03..3481e28 100644 --- a/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php +++ b/src/wp-includes/secrets/class-wp-secrets-libsodium-provider.php @@ -245,7 +245,15 @@ private function slot_fingerprint( $slot ): string { * @phpstan-return array{dk: string, dk_nonce: string, ct: string, nonce: string, fingerprint: string, created: int, needs_rotation: bool}|WP_Error * @phpstan-param 'site'|'network' $scope */ - private function demote_slot( WP_Secrets_Cipher $cipher, string $master_key, string $scope, int $site_id, string $name, array $current_slot ) { + private function demote_slot( + WP_Secrets_Cipher $cipher, + #[\SensitiveParameter] + string $master_key, + string $scope, + int $site_id, + string $name, + array $current_slot + ) { $plaintext = $cipher->decrypt_value( $master_key, $scope, $site_id, $name, WP_Secret_Version::CURRENT, $current_slot ); if ( is_wp_error( $plaintext ) ) { @@ -289,7 +297,14 @@ private function demote_slot( WP_Secrets_Cipher $cipher, string $master_key, str * wp_import_option_as_secret() passes 'imported'. * @return true|WP_Error */ - public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ) { + public function set( + string $name, + #[\SensitiveParameter] + $value, + bool $network = false, + bool $needs_rotation = false, + ?string $action = null + ) { $name_check = wp_secrets_validate_name( $name ); if ( is_wp_error( $name_check ) ) { diff --git a/src/wp-includes/secrets/interface-wp-secrets-keyring.php b/src/wp-includes/secrets/interface-wp-secrets-keyring.php index d378062..044e827 100644 --- a/src/wp-includes/secrets/interface-wp-secrets-keyring.php +++ b/src/wp-includes/secrets/interface-wp-secrets-keyring.php @@ -30,12 +30,18 @@ interface WP_Secrets_Keyring { * re-wrapped value with update_site_option(), which reports an unchanged value * as a failure, and WP_Secrets_Keyring_Conformance checks this. * + * Attributes are not inherited, so an implementation must mark $key_material + * with #[\SensitiveParameter] itself to keep it out of stack traces. + * * @since 7.2.0 * * @param string $key_material Raw key material to protect. * @return string|WP_Error Opaque wrapped value on success, WP_Error on failure. */ - public function wrap( string $key_material ); + public function wrap( + #[\SensitiveParameter] + string $key_material + ); /** * Unwraps (decrypts) previously wrapped key material. diff --git a/src/wp-includes/secrets/interface-wp-secrets-provider.php b/src/wp-includes/secrets/interface-wp-secrets-provider.php index d1916aa..671855a 100644 --- a/src/wp-includes/secrets/interface-wp-secrets-provider.php +++ b/src/wp-includes/secrets/interface-wp-secrets-provider.php @@ -138,7 +138,14 @@ public function get( string $name, string $version, bool $network = false ); * 'updated'. * @return true|WP_Error */ - public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ); + public function set( + string $name, + #[\SensitiveParameter] + $value, + bool $network = false, + bool $needs_rotation = false, + ?string $action = null + ); /** * Deletes a secret. diff --git a/tests/phpunit/test-wp-secrets-validate-name.php b/tests/phpunit/test-wp-secrets-validate-name.php index 9440ed2..6daba01 100644 --- a/tests/phpunit/test-wp-secrets-validate-name.php +++ b/tests/phpunit/test-wp-secrets-validate-name.php @@ -72,6 +72,9 @@ public static function data_rejects_invalid_names(): array { 'dot in key' => array( 'plugin/key.name' ), 'non-ascii' => array( 'plügin/key' ), 'one character over the max' => array( str_repeat( 'a', 85 ) . '/' . str_repeat( 'b', 87 ) ), + 'newline after key' => array( "plugin/key\n" ), + 'newline after namespace' => array( "plugin\n/key" ), + 'newline, unnamespaced' => array( "apikey\n" ), ); } diff --git a/tests/phpunit/test-wp-set-secret-and-wp-get-secret.php b/tests/phpunit/test-wp-set-secret-and-wp-get-secret.php index f1ad1e5..b8df01b 100644 --- a/tests/phpunit/test-wp-set-secret-and-wp-get-secret.php +++ b/tests/phpunit/test-wp-set-secret-and-wp-get-secret.php @@ -301,4 +301,110 @@ function ( ...$args ) use ( &$captured_args ) { $this->assertStringNotContainsString( 'a-plaintext-value-that-must-not-leak', (string) $arg, 'No hook argument should contain the plaintext value.' ); } } + + /** + * The options API trims option names, so a name with a trailing newline + * would share a row with the name without it while encrypting for itself. + * + * @ticket 66414 + */ + public function test_a_trailing_newline_name_cannot_overwrite_the_secret_without_it(): void { + wp_set_secret( 'myplugin/api-key', 'value-plain' ); + + $result = wp_set_secret( "myplugin/api-key\n", 'value-newline' ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() ); + + $secret = wp_get_secret( 'myplugin/api-key' ); + + $this->assertInstanceOf( WP_Secret::class, $secret ); + $this->assertSame( 'value-plain', $secret->reveal() ); + } + + /** + * @ticket 66414 + * + * @requires PHP 8.2 + */ + public function test_an_exception_while_setting_does_not_expose_the_value_in_the_trace(): void { + if ( ini_get( 'zend.exception_ignore_args' ) ) { + $this->markTestSkipped( 'Exception traces do not include arguments with zend.exception_ignore_args enabled.' ); + } + + add_action( + 'wp_secret_changed', + static function () { + throw new RuntimeException( 'Listener failed.' ); + } + ); + + $exception = null; + + try { + wp_set_secret( 'myplugin/api-key', 'a-plaintext-value-that-must-not-leak' ); + } catch ( RuntimeException $e ) { + $exception = $e; + } + + $this->assertInstanceOf( RuntimeException::class, $exception ); + + $string_args = array(); + foreach ( $exception->getTrace() as $frame ) { + foreach ( $frame['args'] ?? array() as $arg ) { + if ( is_string( $arg ) ) { + $string_args[] = $arg; + } + } + } + + $this->assertContains( 'myplugin/api-key', $string_args, 'The trace should include the other arguments.' ); + $this->assertNotContains( 'a-plaintext-value-that-must-not-leak', $string_args ); + } + + /** + * @ticket 66414 + * + * @requires PHP 8.2 + * + * @dataProvider data_parameters_that_receive_a_secret_value + * + * @param callable-string|array{0: class-string, 1: string} $target The function or method. + * @param string $parameter The parameter name. + */ + public function test_parameters_that_receive_a_secret_value_are_sensitive( $target, string $parameter ): void { + $reflection = is_array( $target ) ? new ReflectionMethod( $target[0], $target[1] ) : new ReflectionFunction( $target ); + + foreach ( $reflection->getParameters() as $reflection_parameter ) { + if ( $parameter === $reflection_parameter->getName() ) { + $this->assertNotEmpty( $reflection_parameter->getAttributes( SensitiveParameter::class ) ); + return; + } + } + + $this->fail( "Parameter \${$parameter} not found." ); + } + + /** + * @return array + */ + public static function data_parameters_that_receive_a_secret_value(): array { + return array( + 'wp_set_secret()' => array( 'wp_set_secret', 'value' ), + 'wp_set_network_secret()' => array( 'wp_set_network_secret', 'value' ), + '_wp_secrets_set()' => array( '_wp_secrets_set', 'value' ), + 'WP_Secrets_Provider::set()' => array( array( WP_Secrets_Provider::class, 'set' ), 'value' ), + 'WP_Secrets_Libsodium_Provider::set()' => array( array( WP_Secrets_Libsodium_Provider::class, 'set' ), 'value' ), + 'WP_Secrets_Broken_Provider::set()' => array( array( WP_Secrets_Broken_Provider::class, 'set' ), 'value' ), + 'WP_Secrets_Cipher::encrypt_value()' => array( array( WP_Secrets_Cipher::class, 'encrypt_value' ), 'plaintext' ), + 'WP_Secrets_Cipher::fingerprint()' => array( array( WP_Secrets_Cipher::class, 'fingerprint' ), 'plaintext' ), + 'WP_Secrets_Cipher::encrypt_value() key' => array( array( WP_Secrets_Cipher::class, 'encrypt_value' ), 'master_key' ), + 'WP_Secrets_Cipher::decrypt_value() key' => array( array( WP_Secrets_Cipher::class, 'decrypt_value' ), 'master_key' ), + 'WP_Secrets_Cipher::fingerprint() key' => array( array( WP_Secrets_Cipher::class, 'fingerprint' ), 'master_key' ), + 'WP_Secrets_Keyring::wrap()' => array( array( WP_Secrets_Keyring::class, 'wrap' ), 'key_material' ), + 'WP_Secrets_Config_Key_Provider::wrap()' => array( array( WP_Secrets_Config_Key_Provider::class, 'wrap' ), 'key_material' ), + 'WP_Secrets_Broken_Keyring::wrap()' => array( array( WP_Secrets_Broken_Keyring::class, 'wrap' ), 'key_material' ), + 'WP_Secrets_Libsodium_Provider::demote_slot()' => array( array( WP_Secrets_Libsodium_Provider::class, 'demote_slot' ), 'master_key' ), + ); + } } From a61a32ccec01408e9b167ae8cd957b0ac66cd011 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Tue, 10 Nov 2026 06:51:44 -0800 Subject: [PATCH 03/10] Document the hardening tests Ports r65377 from core trunk, which gives the tests added in r65375 a summary, @covers and a message for each assertion, as the other Secrets API tests have since r65272. https://core.trac.wordpress.org/changeset/65377 https://core.trac.wordpress.org/ticket/66414 --- .../test-wp-set-secret-and-wp-get-secret.php | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/tests/phpunit/test-wp-set-secret-and-wp-get-secret.php b/tests/phpunit/test-wp-set-secret-and-wp-get-secret.php index b8df01b..2c441da 100644 --- a/tests/phpunit/test-wp-set-secret-and-wp-get-secret.php +++ b/tests/phpunit/test-wp-set-secret-and-wp-get-secret.php @@ -303,28 +303,37 @@ function ( ...$args ) use ( &$captured_args ) { } /** + * Ensure that a name with a trailing newline cannot overwrite the secret stored under the name without it. + * * The options API trims option names, so a name with a trailing newline * would share a row with the name without it while encrypting for itself. * * @ticket 66414 + * + * @covers ::wp_set_secret + * @covers ::wp_secrets_validate_name */ public function test_a_trailing_newline_name_cannot_overwrite_the_secret_without_it(): void { wp_set_secret( 'myplugin/api-key', 'value-plain' ); $result = wp_set_secret( "myplugin/api-key\n", 'value-newline' ); - $this->assertWPError( $result ); - $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code() ); + $this->assertWPError( $result, 'Setting a secret under a name with a trailing newline should fail.' ); + $this->assertSame( WP_SECRETS_ERROR_INVALID_NAME, $result->get_error_code(), 'The error should report an invalid name.' ); $secret = wp_get_secret( 'myplugin/api-key' ); - $this->assertInstanceOf( WP_Secret::class, $secret ); - $this->assertSame( 'value-plain', $secret->reveal() ); + $this->assertInstanceOf( WP_Secret::class, $secret, 'The secret stored under the name without the newline should still be readable.' ); + $this->assertSame( 'value-plain', $secret->reveal(), 'The secret should keep the value it was stored with.' ); } /** + * Ensure that an exception thrown while setting a secret does not expose the value in its stack trace. + * * @ticket 66414 * + * @covers ::wp_set_secret + * * @requires PHP 8.2 */ public function test_an_exception_while_setting_does_not_expose_the_value_in_the_trace(): void { @@ -347,7 +356,7 @@ static function () { $exception = $e; } - $this->assertInstanceOf( RuntimeException::class, $exception ); + $this->assertInstanceOf( RuntimeException::class, $exception, 'The exception thrown by the listener should reach the caller.' ); $string_args = array(); foreach ( $exception->getTrace() as $frame ) { @@ -359,12 +368,16 @@ static function () { } $this->assertContains( 'myplugin/api-key', $string_args, 'The trace should include the other arguments.' ); - $this->assertNotContains( 'a-plaintext-value-that-must-not-leak', $string_args ); + $this->assertNotContains( 'a-plaintext-value-that-must-not-leak', $string_args, 'The trace should not include the secret value.' ); } /** + * Ensure that parameters receiving a secret value or key material are marked with the SensitiveParameter attribute. + * * @ticket 66414 * + * @coversNothing + * * @requires PHP 8.2 * * @dataProvider data_parameters_that_receive_a_secret_value @@ -377,7 +390,7 @@ public function test_parameters_that_receive_a_secret_value_are_sensitive( $targ foreach ( $reflection->getParameters() as $reflection_parameter ) { if ( $parameter === $reflection_parameter->getName() ) { - $this->assertNotEmpty( $reflection_parameter->getAttributes( SensitiveParameter::class ) ); + $this->assertNotEmpty( $reflection_parameter->getAttributes( SensitiveParameter::class ), "Parameter \${$parameter} should have the SensitiveParameter attribute." ); return; } } @@ -386,6 +399,8 @@ public function test_parameters_that_receive_a_secret_value_are_sensitive( $targ } /** + * Data provider. + * * @return array */ public static function data_parameters_that_receive_a_secret_value(): array { From 44a8437597d9c3dd82c83dc0b75fe234707d208b Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Tue, 10 Nov 2026 06:52:09 -0800 Subject: [PATCH 04/10] Let the drop-in set its overrides as plain variables Ports r65378 from core trunk to the plugin's drop-in loader. The drop-in is required inside wp_secrets_api_load_dropin(), so a plain assignment such as $wp_secrets_keyring = new My_Keyring(); set a local variable, and the override was ignored without a word. The loader now declares the three overrides global before the require, as core's wp_load_secrets_dropin() does and as require_wp_db() does for db.php. Assigning through $GLOBALS, as the examples do, works as before. https://core.trac.wordpress.org/changeset/65378 https://core.trac.wordpress.org/ticket/66187 Props ericmann, jorbin. --- docs/reference/functions.md | 4 ++++ secrets-api.php | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/docs/reference/functions.md b/docs/reference/functions.md index 01214cb..7e51143 100644 --- a/docs/reference/functions.md +++ b/docs/reference/functions.md @@ -259,6 +259,10 @@ since _wp_secrets_get_provider(), _wp_secrets_get_store(), and _wp_secrets_get_key_manager() each cache their result for the request, so this only ever needs to run once regardless of how many times it is called. +The drop-in sets its overrides in the `$wp_secrets_provider`, `$wp_secrets_store` +and `$wp_secrets_keyring` globals. They are declared global before the require, so +a plain assignment such as `$wp_secrets_keyring = new My_Keyring();` works. + Sets $GLOBALS['wp_secrets_dropin_loaded'] as soon as the file is found, whether or not it then loads cleanly: wp_using_secrets_dropin() reports presence, not health. diff --git a/secrets-api.php b/secrets-api.php index 1b00b80..e6d1257 100644 --- a/secrets-api.php +++ b/secrets-api.php @@ -278,6 +278,10 @@ function wp_secrets_api_notice_conflict(): void { * _wp_secrets_get_key_manager() each cache their result for the request, so this * only ever needs to run once regardless of how many times it is called. * + * The drop-in sets its overrides in the `$wp_secrets_provider`, `$wp_secrets_store` + * and `$wp_secrets_keyring` globals. They are declared global before the require, so + * a plain assignment such as `$wp_secrets_keyring = new My_Keyring();` works. + * * Sets $GLOBALS['wp_secrets_dropin_loaded'] as soon as the file is found, whether * or not it then loads cleanly: wp_using_secrets_dropin() reports presence, not * health. @@ -326,6 +330,12 @@ function wp_secrets_api_load_dropin(): void { $GLOBALS['wp_secrets_dropin_loaded'] = true; + /* + * The drop-in is required inside this function. Declaring the overrides global + * lets it set them as plain variables, as db.php does with $wpdb. + */ + global $wp_secrets_provider, $wp_secrets_store, $wp_secrets_keyring; + try { require $dropin_path; } catch ( \Throwable $e ) { From 0ba368712142210fa6abf78a558a2a48fee84d6c Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Tue, 10 Nov 2026 06:55:13 -0800 Subject: [PATCH 05/10] Check r65373's code the way core does src/ stays byte for byte with core trunk, so two rules from this repository's stricter setup are relaxed for the code r65373 added, as for earlier ports: - PHPCS: WordPress-Docs rejects a docblock paragraph that opens with a function name. Core's ruleset does not include WordPress-Docs, so the sniff is excluded for src/. - PHPStan: the WordPress stubs type $wpdb->sitemeta as string|null and want a literal-string for wpdb::prepare(). The root key's direct writes are added to the ignores that already cover the option store. --- phpcs.xml.dist | 9 +++++++++ phpstan.neon.dist | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 4db6a1f..ef8e276 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -98,6 +98,15 @@ /src/* + + + /src/* + +