From d7abced039f2d1d987ef3e9315b7e737e544e019 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Fri, 30 Oct 2026 10:59:11 -0700 Subject: [PATCH 1/5] Secrets API: Require `WP_SECRETS_KEY`. Without `WP_SECRETS_KEY`, the default keyring derived its key from `LOGGED_IN_KEY` and `LOGGED_IN_SALT`. Rotating the salts, which is routine advice, then made every stored secret unreadable. The constants were also read directly rather than through `wp_salt()`, so a translated placeholder from a localized `wp-config-sample.php`, or a value shared by several of the salts, became the source of the key. There is no fallback any more. Without a usable `WP_SECRETS_KEY`, every call that needs the key returns `secret_key_unavailable`. A key equal to the `wp-config-sample.php` placeholder is not usable, and neither is one equal to any of the authentication keys and salts. An unfilled sample gives all of them the same phrase, so the second check rejects the placeholder in any language. The Site Health key source test now reports a missing or unusable key as recommended, with a newly generated line to add to `wp-config.php`. Follow-up to [64337]. See #66187, #66399. --- src/wp-admin/includes/secrets-site-health.php | 20 ++- .../class-wp-secrets-config-key-provider.php | 168 +++++++++++------- tests/phpunit/includes/bootstrap.php | 8 +- tests/phpunit/tests/secrets/siteHealth.php | 28 ++- .../tests/secrets/threeStateContract.php | 12 +- .../secrets/wpSecretsConfigKeyProvider.php | 111 ++++++++---- 6 files changed, 228 insertions(+), 119 deletions(-) diff --git a/src/wp-admin/includes/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php index d6a19b4920d82..f42b06c7fbff0 100644 --- a/src/wp-admin/includes/secrets-site-health.php +++ b/src/wp-admin/includes/secrets-site-health.php @@ -74,7 +74,7 @@ function wp_secrets_site_health_result( string $test, string $label, string $sta } /** - * Site Health test: is the site key a dedicated constant, or a weaker fallback? + * Site Health test: is the site key defined, and in the recommended form? * * @since 7.2.0 * @@ -109,14 +109,24 @@ function wp_secrets_site_health_test_key_source(): array { ); } - if ( str_contains( $key_source, 'LOGGED_IN_KEY' ) ) { + if ( str_contains( $key_source, 'not defined' ) || str_contains( $key_source, 'not usable' ) ) { + $is_defined = str_contains( $key_source, 'not usable' ); + $example = sprintf( "define( 'WP_SECRETS_KEY', '%s' );", base64_encode( random_bytes( 32 ) ) ); + return wp_secrets_site_health_result( 'secrets_api_key_source', - __( 'Secrets API is using a fallback key' ), + __( 'Secrets API has no encryption key' ), 'recommended', sprintf( - '

%s

', - esc_html__( 'No dedicated WP_SECRETS_KEY constant is defined. Secrets are encrypted using a key derived from existing salts, which works but is not the recommended configuration. Define WP_SECRETS_KEY with a value from `wp secret generate-key`.' ) + '

%s

%s

%s

%s

', + $is_defined + ? esc_html__( 'WP_SECRETS_KEY is defined in wp-config.php, but its value cannot be used: it is empty, still the placeholder from wp-config-sample.php, or the same as one of the authentication keys and salts. Plugins cannot store API keys and other credentials with the Secrets API until it is fixed.' ) + : esc_html__( 'WP_SECRETS_KEY is not defined in wp-config.php. Plugins cannot store API keys and other credentials with the Secrets API until it is.' ), + $is_defined + ? esc_html__( 'Replace the WP_SECRETS_KEY line in wp-config.php with this one:' ) + : esc_html__( 'Add this line to wp-config.php, above the line reading "That\'s all, stop editing!":' ), + esc_html( $example ), + esc_html__( 'A new value is generated each time this page loads. Keep a copy of the one you use with your backups: secrets stored with it cannot be read without it.' ) ) ); } 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 c99adc925ecd0..6a541372388b7 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 @@ -13,21 +13,27 @@ * Derives a site key from wp-config.php and uses it to wrap and unwrap the root key * with authenticated encryption. * - * Site key derivation, in priority order: + * Site key derivation: * - * 1. WP_SECRETS_KEY is defined and base64-decodes (strictly) to exactly 32 bytes: - * those decoded bytes are used raw. This is the documented, recommended form; - * `wp secret generate-key` emits it. - * 2. WP_SECRETS_KEY is defined in any other shape: the literal constant string is - * hashed with a keyed BLAKE2b to 32 bytes. This is the legacy interpretation – - * sites arriving from a prior plugin with a constant of arbitrary shape are not - * locked out of their own credentials by a hard failure here. - * 3. WP_SECRETS_KEY is undefined: LOGGED_IN_KEY . LOGGED_IN_SALT is hashed the same - * way. Deliberately byte-identical to the legacy interpretation's hashing, not a - * coincidence – it is what makes salt-fallback sites migrate with zero - * credential re-entry. + * 1. WP_SECRETS_KEY base64-decodes (strictly) to exactly 32 bytes: those decoded + * bytes are used raw. This is the recommended form, such as the output of + * base64_encode( random_bytes( 32 ) ). + * 2. WP_SECRETS_KEY is any other string: the literal constant string is hashed + * with BLAKE2b to 32 bytes. This is the legacy interpretation – sites arriving + * from a prior plugin with a constant of arbitrary shape are not locked out of + * their own credentials by a hard failure here. * - * WP_SECRETS_KEY_PREVIOUS follows the same three rules and exists only so a site-key + * There is no fallback. Without a usable WP_SECRETS_KEY, every Secrets API call + * that needs the key returns WP_Error. Deriving a key from the authentication keys + * and salts would make every stored secret unreadable whenever the salts are + * rotated, which is routine advice for them. + * + * The wp-config-sample.php placeholder is not usable, and neither is a value that + * one of the authentication keys and salts also has. A sample that was never + * filled in gives every constant the same phrase, translated in localized samples, + * so the second check rejects the placeholder in any language. + * + * WP_SECRETS_KEY_PREVIOUS follows the same rules and exists only so a site-key * rotation can unwrap under the old key before wrapping under the new one. * * @since 7.2.0 @@ -45,7 +51,7 @@ final class WP_Secrets_Config_Key_Provider implements WP_Secrets_Keyring { const AAD = 'wp-secrets-root-key-v1'; /** - * The exact text wp-config-sample.php ships for every auth and salt constant. + * The exact text wp-config-sample.php ships for every key and salt constant. * * A site running with this literal value never ran the secret-key generator. * Treating it as usable would derive a key that any attacker can also derive. @@ -55,6 +61,23 @@ final class WP_Secrets_Config_Key_Provider implements WP_Secrets_Keyring { */ const KNOWN_PLACEHOLDER = 'put your unique phrase here'; + /** + * The authentication keys and salts that wp-config-sample.php defines. + * + * @since 7.2.0 + * @var string[] + */ + const AUTH_CONSTANTS = array( + 'AUTH_KEY', + 'SECURE_AUTH_KEY', + 'LOGGED_IN_KEY', + 'NONCE_KEY', + 'AUTH_SALT', + 'SECURE_AUTH_SALT', + 'LOGGED_IN_SALT', + 'NONCE_SALT', + ); + /** * Whether to derive from WP_SECRETS_KEY_PREVIOUS instead of WP_SECRETS_KEY. * @@ -177,17 +200,21 @@ public function unwrap( string $wrapped ) { * @return string */ public function get_key_source(): string { - if ( defined( 'WP_SECRETS_KEY' ) ) { - return $this->is_canonical_base64_32( WP_SECRETS_KEY ) - ? 'WP_SECRETS_KEY (base64-encoded 32 bytes)' - : 'WP_SECRETS_KEY (legacy interpretation, hashed as an opaque string)'; + if ( ! defined( 'WP_SECRETS_KEY' ) ) { + return 'WP_SECRETS_KEY is not defined'; } - return 'derived from LOGGED_IN_KEY and LOGGED_IN_SALT'; + if ( ! $this->is_usable_key_value( WP_SECRETS_KEY, $this->get_auth_constant_values() ) ) { + return 'WP_SECRETS_KEY is defined but not usable'; + } + + return $this->is_canonical_base64_32( WP_SECRETS_KEY ) + ? 'WP_SECRETS_KEY (base64-encoded 32 bytes)' + : 'WP_SECRETS_KEY (legacy interpretation, hashed as an opaque string)'; } /** - * Derives the 32-byte site key per the priority rules documented on this class. + * Derives the 32-byte site key per the rules documented on this class. * * @since 7.2.0 * @@ -196,75 +223,82 @@ public function get_key_source(): string { private function get_site_key() { $constant_name = $this->use_previous_key ? 'WP_SECRETS_KEY_PREVIOUS' : 'WP_SECRETS_KEY'; - if ( defined( $constant_name ) ) { - $raw_constant = constant( $constant_name ); - - if ( ! is_string( $raw_constant ) || '' === $raw_constant ) { - return new WP_Error( - WP_SECRETS_ERROR_KEY_UNAVAILABLE, - sprintf( - /* translators: %s: PHP constant name. */ - __( '%s is defined but is not a usable string.' ), - $constant_name - ) - ); - } - - $decoded = $this->decode_canonical_base64_32( $raw_constant ); - - if ( null !== $decoded ) { - return $decoded; - } - - return sodium_crypto_generichash( $raw_constant, '', SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES ); - } - - if ( $this->use_previous_key ) { + if ( ! defined( $constant_name ) ) { return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, - __( 'WP_SECRETS_KEY_PREVIOUS is not defined.' ) + sprintf( + /* translators: %s: PHP constant name. */ + __( '%s is not defined.' ), + $constant_name + ) ); } - $logged_in_key = defined( 'LOGGED_IN_KEY' ) ? LOGGED_IN_KEY : null; - $logged_in_salt = defined( 'LOGGED_IN_SALT' ) ? LOGGED_IN_SALT : null; + $raw_constant = constant( $constant_name ); - if ( ! $this->are_usable_salt_values( $logged_in_key, $logged_in_salt ) ) { + if ( ! $this->is_usable_key_value( $raw_constant, $this->get_auth_constant_values() ) ) { return new WP_Error( WP_SECRETS_ERROR_KEY_UNAVAILABLE, - __( 'WP_SECRETS_KEY is not defined, and LOGGED_IN_KEY/LOGGED_IN_SALT are not usable (undefined, empty, or left at the wp-config-sample.php placeholder).' ) + sprintf( + /* translators: %s: PHP constant name. */ + __( '%s is defined but is not usable: it must be a non-empty string, not the wp-config-sample.php placeholder, and not the value of one of the authentication keys and salts.' ), + $constant_name + ) ); } - return sodium_crypto_generichash( $logged_in_key . $logged_in_salt, '', SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES ); + $decoded = $this->decode_canonical_base64_32( $raw_constant ); + + if ( null !== $decoded ) { + return $decoded; + } + + return sodium_crypto_generichash( $raw_constant, '', SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES ); } /** - * Whether a pair of candidate salt values are usable as key material. + * Whether a candidate key constant value is usable as key material. * - * Both must be non-empty strings, and neither may be the literal - * wp-config-sample.php placeholder – which every unconfigured install shares, - * making it a publicly known value rather than a secret. + * It must be a non-empty string, must not be the wp-config-sample.php + * placeholder, and must not equal any of the authentication keys and salts. * - * Takes explicit arguments rather than reading LOGGED_IN_KEY/LOGGED_IN_SALT - * directly so the decision logic is testable without touching real constants, - * which – once WordPress has bootstrapped once in a process – can never be - * redefined to a different value for a test. + * Takes explicit arguments rather than reading the constants directly so the + * decision logic is testable without touching real constants, which – once + * WordPress has bootstrapped once in a process – can never be redefined to a + * different value for a test. * * @since 7.2.0 * - * @param mixed $logged_in_key Candidate value of LOGGED_IN_KEY, or null if undefined. - * @param mixed $logged_in_salt Candidate value of LOGGED_IN_SALT, or null if undefined. + * @param mixed $value Candidate value of WP_SECRETS_KEY or WP_SECRETS_KEY_PREVIOUS. + * @param string[] $auth_values Values of the authentication keys and salts that are defined. * @return bool * - * @phpstan-assert-if-true non-empty-string $logged_in_key - * @phpstan-assert-if-true non-empty-string $logged_in_salt + * @phpstan-assert-if-true non-empty-string $value */ - private function are_usable_salt_values( $logged_in_key, $logged_in_salt ): bool { - return array_all( - array( $logged_in_key, $logged_in_salt ), - fn( $value ) => is_string( $value ) && '' !== $value && self::KNOWN_PLACEHOLDER !== $value - ); + private function is_usable_key_value( $value, array $auth_values ): bool { + return is_string( $value ) + && '' !== $value + && self::KNOWN_PLACEHOLDER !== $value + && ! in_array( $value, $auth_values, true ); + } + + /** + * Returns the values of the authentication keys and salts that are defined. + * + * @since 7.2.0 + * + * @return string[] + */ + private function get_auth_constant_values(): array { + $values = array(); + + foreach ( self::AUTH_CONSTANTS as $constant_name ) { + if ( defined( $constant_name ) && is_string( constant( $constant_name ) ) ) { + $values[] = constant( $constant_name ); + } + } + + return $values; } /** diff --git a/tests/phpunit/includes/bootstrap.php b/tests/phpunit/includes/bootstrap.php index 37ae4b2384b58..56c796492a23e 100644 --- a/tests/phpunit/includes/bootstrap.php +++ b/tests/phpunit/includes/bootstrap.php @@ -30,17 +30,15 @@ require_once $config_file_path; /* - * The Secrets API will not derive a key from the placeholder salts that - * wp-tests-config-sample.php ships with, so give the suite a fixed test key. + * The Secrets API needs WP_SECRETS_KEY, which wp-tests-config-sample.php does not + * define, so give the suite a fixed test key. * * Not in a process-isolated test: PHPUnit sets $GLOBALS['__PHPUNIT_BOOTSTRAP'] * while it loads this file in a child process, and the Secrets API tests that * run isolated do so precisely to define WP_SECRETS_KEY themselves, or to * leave it undefined. */ -if ( ! defined( 'WP_SECRETS_KEY' ) && ! isset( $GLOBALS['__PHPUNIT_BOOTSTRAP'] ) - && defined( 'LOGGED_IN_KEY' ) && 'put your unique phrase here' === LOGGED_IN_KEY -) { +if ( ! defined( 'WP_SECRETS_KEY' ) && ! isset( $GLOBALS['__PHPUNIT_BOOTSTRAP'] ) ) { define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'k', 32 ) ) ); } require_once __DIR__ . '/functions.php'; diff --git a/tests/phpunit/tests/secrets/siteHealth.php b/tests/phpunit/tests/secrets/siteHealth.php index d0eee49ceb421..3bf7d6822a2ae 100644 --- a/tests/phpunit/tests/secrets/siteHealth.php +++ b/tests/phpunit/tests/secrets/siteHealth.php @@ -50,15 +50,33 @@ public function test_preserves_existing_tests(): void { $this->assertArrayHasKey( 'existing_test', $tests['direct'] ); } - public function test_key_source_test_recommends_a_dedicated_key_by_default(): void { - if ( 'put your unique phrase here' === LOGGED_IN_KEY || 'put your unique phrase here' === LOGGED_IN_SALT ) { - $this->markTestSkipped( 'Needs real LOGGED_IN_KEY and LOGGED_IN_SALT values; wp-tests-config-sample.php ships placeholders.' ); - } + /** + * @ticket 66187 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_key_source_test_offers_a_generated_key_when_none_is_defined(): void { + $result = wp_secrets_site_health_test_key_source(); + + $this->assertSame( 'recommended', $result['status'] ); + $this->assertStringContainsString( 'Add this line', $result['description'] ); + $this->assertMatchesRegularExpression( '/define\( 'WP_SECRETS_KEY', '([A-Za-z0-9+\/]{43}=)' \);/', $result['description'] ); + } + + /** + * @ticket 66187 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_key_source_test_offers_a_replacement_for_the_placeholder(): void { + define( 'WP_SECRETS_KEY', 'put your unique phrase here' ); $result = wp_secrets_site_health_test_key_source(); $this->assertSame( 'recommended', $result['status'] ); - $this->assertStringContainsString( 'WP_SECRETS_KEY', $result['description'] ); + $this->assertStringContainsString( 'Replace the WP_SECRETS_KEY line', $result['description'] ); } /** diff --git a/tests/phpunit/tests/secrets/threeStateContract.php b/tests/phpunit/tests/secrets/threeStateContract.php index acf2666ecb54f..0ee6821d79466 100644 --- a/tests/phpunit/tests/secrets/threeStateContract.php +++ b/tests/phpunit/tests/secrets/threeStateContract.php @@ -95,20 +95,20 @@ public function test_aad_mismatch_from_a_copied_record_is_wp_error_not_null(): v * wp_set_secret() so it bypasses the static _wp_secrets_get_key_manager(), whose * request-scoped root-key cache (ADR 0009) would otherwise keep serving the * already-unwrapped key and mask the misconfiguration this test defines below. + * Its keyring reads WP_SECRETS_KEY_PREVIOUS, because WP_SECRETS_KEY can only be + * defined once in a process. * * @runInSeparateProcess * @preserveGlobalState disabled */ public function test_key_unavailable_is_wp_error_not_null(): void { - if ( 'put your unique phrase here' === LOGGED_IN_KEY || 'put your unique phrase here' === LOGGED_IN_SALT ) { - $this->markTestSkipped( 'Needs real LOGGED_IN_KEY and LOGGED_IN_SALT values; wp-tests-config-sample.php ships placeholders.' ); - } + define( 'WP_SECRETS_KEY_PREVIOUS', base64_encode( str_repeat( 'P', 32 ) ) ); $provider = new WP_Secrets_Libsodium_Provider( new WP_Secrets_Option_Store(), - new WP_Secrets_Key_Manager( new WP_Secrets_Config_Key_Provider() ) + new WP_Secrets_Key_Manager( new WP_Secrets_Config_Key_Provider( true ) ) ); - $provider->set( 'myplugin/api-key', 'value' ); + $this->assertTrue( $provider->set( 'myplugin/api-key', 'value' ) ); define( 'WP_SECRETS_KEY', 424242 ); @@ -120,7 +120,7 @@ public function test_key_unavailable_is_wp_error_not_null(): void { } /** - * Written under the ambient salt-fallback key, then read back after the stored + * Written under the suite's key, then read back after the stored * wrapped root key has been corrupted – simulating the option row being * damaged after secrets already exist. get_master_key() must fail before * decryption is ever attempted, since a usable root key was never obtained. diff --git a/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php b/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php index b14d19549cb0e..a1714b5a57ff8 100644 --- a/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php +++ b/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php @@ -3,11 +3,9 @@ * Tests for WP_Secrets_Config_Key_Provider. * * Constants (WP_SECRETS_KEY, WP_SECRETS_KEY_PREVIOUS) that this class reads can only - * be defined once per PHP process, so any test that needs a specific value for them - * runs @runInSeparateProcess. LOGGED_IN_KEY/LOGGED_IN_SALT are already defined by the - * WordPress test suite's own bootstrap before any test of ours can run, in every - * process including an isolated one, so the salt-fallback path is exercised against - * whatever the ambient harness defines them as rather than a value this suite picks. + * be defined once per PHP process, so any test that needs a specific value for them, + * or needs them undefined, runs @runInSeparateProcess. The test bootstrap defines + * WP_SECRETS_KEY only outside isolated processes. * * @group secrets */ @@ -106,14 +104,31 @@ public function test_unwrap_rejects_a_tampered_ciphertext(): void { $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); } - public function test_get_key_source_reports_the_salt_fallback_when_wp_secrets_key_is_unset(): void { - if ( 'put your unique phrase here' === LOGGED_IN_KEY || 'put your unique phrase here' === LOGGED_IN_SALT ) { - $this->markTestSkipped( 'Needs real LOGGED_IN_KEY and LOGGED_IN_SALT values; wp-tests-config-sample.php ships placeholders.' ); - } + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_get_key_source_reports_an_undefined_key(): void { + $provider = new WP_Secrets_Config_Key_Provider(); + $this->assertStringContainsString( 'not defined', $provider->get_key_source() ); + } + + /** + * Without WP_SECRETS_KEY there is no key, whatever the salts are. + * + * @ticket 66187 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_an_undefined_wp_secrets_key_is_unavailable(): void { $provider = new WP_Secrets_Config_Key_Provider(); + $result = $this->invoke_private( $provider, 'get_site_key' ); - $this->assertStringContainsString( 'LOGGED_IN_KEY', $provider->get_key_source() ); + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); + $this->assertStringContainsString( 'WP_SECRETS_KEY', $result->get_error_message() ); } /** @@ -144,32 +159,70 @@ public static function data_canonical_base64_32(): array { } /** - * @dataProvider data_usable_salt_values + * @ticket 66187 + * + * @dataProvider data_usable_key_values * - * @param string|int|null $key Candidate LOGGED_IN_KEY value. - * @param string $salt Candidate LOGGED_IN_SALT value. - * @param bool $expected Whether the pair is usable as key material. + * @param string|int|null $value Candidate WP_SECRETS_KEY value. + * @param string[] $auth_values Values of the authentication keys and salts. + * @param bool $expected Whether the value is usable as key material. */ - public function test_are_usable_salt_values( $key, $salt, $expected ): void { + public function test_is_usable_key_value( $value, array $auth_values, bool $expected ): void { $provider = new WP_Secrets_Config_Key_Provider(); - $this->assertSame( $expected, $this->invoke_private( $provider, 'are_usable_salt_values', array( $key, $salt ) ) ); + $this->assertSame( $expected, $this->invoke_private( $provider, 'is_usable_key_value', array( $value, $auth_values ) ) ); } /** - * @return array + * @return array */ - public static function data_usable_salt_values(): array { + public static function data_usable_key_values(): array { + $translated = 'mettez une phrase unique ici'; + return array( - 'both usable' => array( 'a real key', 'a real salt', true ), - 'key is the placeholder' => array( 'put your unique phrase here', 'a real salt', false ), - 'salt is the placeholder' => array( 'a real key', 'put your unique phrase here', false ), - 'key is null (undefined)' => array( null, 'a real salt', false ), - 'salt is empty' => array( 'a real key', '', false ), - 'key is not a string' => array( 12345, 'a real salt', false ), + 'a real key' => array( 'a real key', array( 'a real salt', 'another salt' ), true ), + 'the placeholder' => array( 'put your unique phrase here', array( 'a real salt' ), false ), + 'the value of a key or salt' => array( 'a real salt', array( 'a real salt', 'another salt' ), false ), + 'a translated, unfilled sample' => array( $translated, array_fill( 0, 8, $translated ), false ), + 'empty' => array( '', array(), false ), + 'null (undefined)' => array( null, array(), false ), + 'not a string' => array( 12345, array(), false ), ); } + /** + * @ticket 66187 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_the_placeholder_is_unavailable(): void { + define( 'WP_SECRETS_KEY', 'put your unique phrase here' ); + + $provider = new WP_Secrets_Config_Key_Provider(); + $result = $this->invoke_private( $provider, 'get_site_key' ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); + $this->assertStringContainsString( 'not usable', $provider->get_key_source() ); + } + + /** + * @ticket 66187 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_the_value_of_a_salt_is_unavailable(): void { + define( 'WP_SECRETS_KEY', LOGGED_IN_SALT ); + + $provider = new WP_Secrets_Config_Key_Provider(); + $result = $this->invoke_private( $provider, 'get_site_key' ); + + $this->assertWPError( $result ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code() ); + } + /** * @runInSeparateProcess * @preserveGlobalState disabled @@ -278,23 +331,19 @@ public function test_use_previous_key_reads_the_previous_constant(): void { /** * Defining only the previous-key constant must not affect the current-key - * provider: it should still fall through to the salt fallback. + * provider: without WP_SECRETS_KEY it has no key. * * @runInSeparateProcess * @preserveGlobalState disabled */ public function test_previous_key_constant_does_not_leak_into_the_current_key_path(): void { - if ( 'put your unique phrase here' === LOGGED_IN_KEY || 'put your unique phrase here' === LOGGED_IN_SALT ) { - $this->markTestSkipped( 'Needs real LOGGED_IN_KEY and LOGGED_IN_SALT values; wp-tests-config-sample.php ships placeholders.' ); - } - define( 'WP_SECRETS_KEY_PREVIOUS', base64_encode( str_repeat( 'C', 32 ) ) ); $provider = new WP_Secrets_Config_Key_Provider( false ); $key = $this->invoke_private( $provider, 'get_site_key' ); - $this->assertNotWPError( $key ); - $this->assertNotSame( str_repeat( 'C', 32 ), $key ); + $this->assertWPError( $key ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $key->get_error_code() ); } /** From 78107b267a212344177d8d4df3e08209e28c14fd Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Fri, 6 Nov 2026 08:46:47 -0800 Subject: [PATCH 2/5] Secrets API: Translate the key source, and stop Site Health parsing it. `WP_Secrets_Config_Key_Provider::get_key_source()` returned English text that Site Health showed as is and also searched for "not defined", "not usable" and "legacy interpretation" to choose its result. The text could not be translated without breaking those checks. The provider now reports the state of `WP_SECRETS_KEY` through `get_key_status()`, which returns one of four `KEY_STATUS_*` constants. Site Health uses that, and `get_key_source()` returns translated text. --- src/wp-admin/includes/secrets-site-health.php | 10 ++- .../class-wp-secrets-config-key-provider.php | 76 +++++++++++++++++-- tests/phpunit/tests/secrets/siteHealth.php | 26 +++++++ .../secrets/wpSecretsConfigKeyProvider.php | 44 +++++++++-- 4 files changed, 137 insertions(+), 19 deletions(-) diff --git a/src/wp-admin/includes/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php index 42475bafa104a..1ef72fc63b5a9 100644 --- a/src/wp-admin/includes/secrets-site-health.php +++ b/src/wp-admin/includes/secrets-site-health.php @@ -82,7 +82,9 @@ function wp_secrets_site_health_result( string $test, string $label, string $sta * @phpstan-return array{test: string, label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string} */ function wp_secrets_site_health_test_key_source(): array { - $key_source = _wp_secrets_get_key_manager()->get_keyring()->get_key_source(); + $keyring = _wp_secrets_get_key_manager()->get_keyring(); + $key_source = $keyring->get_key_source(); + $key_status = $keyring instanceof WP_Secrets_Config_Key_Provider ? $keyring->get_key_status() : null; if ( str_contains( $key_source, 'broken' ) ) { return wp_secrets_site_health_result( @@ -96,7 +98,7 @@ function wp_secrets_site_health_test_key_source(): array { ); } - if ( str_contains( $key_source, 'legacy interpretation' ) ) { + if ( WP_Secrets_Config_Key_Provider::KEY_STATUS_LEGACY === $key_status ) { return wp_secrets_site_health_result( 'secrets_api_key_source', __( 'Secrets API key should be regenerated' ), @@ -108,8 +110,8 @@ function wp_secrets_site_health_test_key_source(): array { ); } - if ( str_contains( $key_source, 'not defined' ) || str_contains( $key_source, 'not usable' ) ) { - $is_defined = str_contains( $key_source, 'not usable' ); + if ( WP_Secrets_Config_Key_Provider::KEY_STATUS_UNDEFINED === $key_status || WP_Secrets_Config_Key_Provider::KEY_STATUS_UNUSABLE === $key_status ) { + $is_defined = WP_Secrets_Config_Key_Provider::KEY_STATUS_UNUSABLE === $key_status; $example = sprintf( "define( 'WP_SECRETS_KEY', '%s' );", base64_encode( random_bytes( 32 ) ) ); return wp_secrets_site_health_result( 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 6a541372388b7..babe5eb7f1505 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 @@ -61,6 +61,38 @@ final class WP_Secrets_Config_Key_Provider implements WP_Secrets_Keyring { */ const KNOWN_PLACEHOLDER = 'put your unique phrase here'; + /** + * WP_SECRETS_KEY is not defined. + * + * @since 7.2.0 + * @var string + */ + const KEY_STATUS_UNDEFINED = 'undefined'; + + /** + * WP_SECRETS_KEY is defined, but empty, the placeholder, or equal to a key or salt. + * + * @since 7.2.0 + * @var string + */ + const KEY_STATUS_UNUSABLE = 'unusable'; + + /** + * WP_SECRETS_KEY is the base64 encoding of 32 bytes, the recommended form. + * + * @since 7.2.0 + * @var string + */ + const KEY_STATUS_BASE64 = 'base64'; + + /** + * WP_SECRETS_KEY is usable but in another form, so it is hashed to 32 bytes. + * + * @since 7.2.0 + * @var string + */ + const KEY_STATUS_LEGACY = 'legacy'; + /** * The authentication keys and salts that wp-config-sample.php defines. * @@ -193,24 +225,52 @@ public function unwrap( string $wrapped ) { } /** - * Describes the active key source, for Site Health. + * Reports the state of WP_SECRETS_KEY. + * + * Site Health and wp_is_secrets_api_available() use this rather than the text of + * get_key_source(), which is translated. * * @since 7.2.0 * - * @return string + * @return string One of the KEY_STATUS_* constants. */ - public function get_key_source(): string { + public function get_key_status(): string { if ( ! defined( 'WP_SECRETS_KEY' ) ) { - return 'WP_SECRETS_KEY is not defined'; + return self::KEY_STATUS_UNDEFINED; } if ( ! $this->is_usable_key_value( WP_SECRETS_KEY, $this->get_auth_constant_values() ) ) { - return 'WP_SECRETS_KEY is defined but not usable'; + return self::KEY_STATUS_UNUSABLE; } - return $this->is_canonical_base64_32( WP_SECRETS_KEY ) - ? 'WP_SECRETS_KEY (base64-encoded 32 bytes)' - : 'WP_SECRETS_KEY (legacy interpretation, hashed as an opaque string)'; + return $this->is_canonical_base64_32( WP_SECRETS_KEY ) ? self::KEY_STATUS_BASE64 : self::KEY_STATUS_LEGACY; + } + + /** + * Describes the active key source, for Site Health. + * + * @since 7.2.0 + * + * @return string + */ + public function get_key_source(): string { + switch ( $this->get_key_status() ) { + case self::KEY_STATUS_UNDEFINED: + /* translators: %s: WP_SECRETS_KEY */ + return sprintf( __( '%s is not defined' ), 'WP_SECRETS_KEY' ); + + case self::KEY_STATUS_UNUSABLE: + /* translators: %s: WP_SECRETS_KEY */ + return sprintf( __( '%s is defined but not usable' ), 'WP_SECRETS_KEY' ); + + case self::KEY_STATUS_BASE64: + /* translators: %s: WP_SECRETS_KEY */ + return sprintf( __( '%s (base64-encoded 32 bytes)' ), 'WP_SECRETS_KEY' ); + + default: + /* translators: %s: WP_SECRETS_KEY */ + return sprintf( __( '%s (legacy interpretation, hashed as an opaque string)' ), 'WP_SECRETS_KEY' ); + } } /** diff --git a/tests/phpunit/tests/secrets/siteHealth.php b/tests/phpunit/tests/secrets/siteHealth.php index a99559a779098..d15a0a509585d 100644 --- a/tests/phpunit/tests/secrets/siteHealth.php +++ b/tests/phpunit/tests/secrets/siteHealth.php @@ -101,6 +101,32 @@ public function test_key_source_test_offers_a_replacement_for_the_placeholder(): $this->assertStringContainsString( 'Replace the WP_SECRETS_KEY line', $result['description'], 'The description should ask for the line to be replaced.' ); } + /** + * Ensure that the key source check still detects a missing key on a translated site. + * + * The key source text is translated, so the check must not depend on it. + * + * @covers ::wp_secrets_site_health_test_key_source + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_key_source_test_offers_a_generated_key_on_a_translated_site(): void { + add_filter( + 'gettext', + static function ( $translation, $text ) { + return '%s is not defined' === $text ? '%s ist nicht definiert' : $translation; + }, + 10, + 2 + ); + + $result = wp_secrets_site_health_test_key_source(); + + $this->assertSame( 'recommended', $result['status'], 'The key source status should be recommended without WP_SECRETS_KEY on a translated site.' ); + $this->assertStringContainsString( 'Add this line', $result['description'], 'The description should ask for the line to be added on a translated site.' ); + } + /** * Ensure that the key source check recommends regenerating a WP_SECRETS_KEY not in base64 form. * diff --git a/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php b/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php index ed40bf8281134..03baf0a70fd02 100644 --- a/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php +++ b/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php @@ -148,19 +148,44 @@ public function test_unwrap_rejects_a_tampered_ciphertext(): void { } /** - * Ensure that the key source reports an undefined WP_SECRETS_KEY. + * Ensure that the key status and source report an undefined WP_SECRETS_KEY. * + * @covers WP_Secrets_Config_Key_Provider::get_key_status * @covers WP_Secrets_Config_Key_Provider::get_key_source * * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_get_key_source_reports_an_undefined_key(): void { + public function test_get_key_status_reports_an_undefined_key(): void { $provider = new WP_Secrets_Config_Key_Provider(); + $this->assertSame( WP_Secrets_Config_Key_Provider::KEY_STATUS_UNDEFINED, $provider->get_key_status(), 'The key status should be undefined without WP_SECRETS_KEY.' ); $this->assertStringContainsString( 'not defined', $provider->get_key_source(), 'The key source should say that WP_SECRETS_KEY is not defined.' ); } + /** + * Ensure that the key source is translated. + * + * @covers WP_Secrets_Config_Key_Provider::get_key_source + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_get_key_source_is_translated(): void { + add_filter( + 'gettext', + static function ( $translation, $text ) { + return '%s is not defined' === $text ? '%s ist nicht definiert' : $translation; + }, + 10, + 2 + ); + + $provider = new WP_Secrets_Config_Key_Provider(); + + $this->assertSame( 'WP_SECRETS_KEY ist nicht definiert', $provider->get_key_source(), 'The key source should use the translated text.' ); + } + /** * Ensure that there is no site key without WP_SECRETS_KEY, whatever the salts are. * @@ -247,6 +272,7 @@ public static function data_is_usable_key_value(): array { * Ensure that the wp-config-sample.php placeholder is not usable as WP_SECRETS_KEY. * * @covers WP_Secrets_Config_Key_Provider::get_site_key + * @covers WP_Secrets_Config_Key_Provider::get_key_status * * @runInSeparateProcess * @preserveGlobalState disabled @@ -259,7 +285,7 @@ public function test_the_placeholder_is_unavailable(): void { $this->assertWPError( $result, 'The placeholder should return a WP_Error.' ); $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code(), 'The placeholder should return the key unavailable error code.' ); - $this->assertStringContainsString( 'not usable', $provider->get_key_source(), 'The key source should say that the placeholder is not usable.' ); + $this->assertSame( WP_Secrets_Config_Key_Provider::KEY_STATUS_UNUSABLE, $provider->get_key_status(), 'The key status should be unusable for the placeholder.' ); } /** @@ -365,34 +391,38 @@ public function test_non_string_wp_secrets_key_is_unavailable(): void { } /** - * Ensure that the key source identifies a canonical base64 WP_SECRETS_KEY. + * Ensure that the key status and source identify a canonical base64 WP_SECRETS_KEY. * + * @covers WP_Secrets_Config_Key_Provider::get_key_status * @covers WP_Secrets_Config_Key_Provider::get_key_source * * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_get_key_source_reports_base64_form(): void { + public function test_get_key_status_reports_base64_form(): void { define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'A', 32 ) ) ); $provider = new WP_Secrets_Config_Key_Provider(); + $this->assertSame( WP_Secrets_Config_Key_Provider::KEY_STATUS_BASE64, $provider->get_key_status(), 'The key status should identify a canonical base64 WP_SECRETS_KEY.' ); $this->assertStringContainsString( 'base64', $provider->get_key_source(), 'The key source should identify a canonical base64 WP_SECRETS_KEY.' ); } /** - * Ensure that the key source identifies a WP_SECRETS_KEY using the legacy interpretation. + * Ensure that the key status and source identify a WP_SECRETS_KEY using the legacy interpretation. * + * @covers WP_Secrets_Config_Key_Provider::get_key_status * @covers WP_Secrets_Config_Key_Provider::get_key_source * * @runInSeparateProcess * @preserveGlobalState disabled */ - public function test_get_key_source_reports_legacy_form(): void { + public function test_get_key_status_reports_legacy_form(): void { define( 'WP_SECRETS_KEY', 'not-base64-32!!' ); $provider = new WP_Secrets_Config_Key_Provider(); + $this->assertSame( WP_Secrets_Config_Key_Provider::KEY_STATUS_LEGACY, $provider->get_key_status(), 'The key status should identify a legacy WP_SECRETS_KEY.' ); $this->assertStringContainsString( 'legacy', $provider->get_key_source(), 'The key source should identify a legacy WP_SECRETS_KEY.' ); } From 6af3177d0a2123cc5ffbda50fb9552a885dc3249 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Fri, 6 Nov 2026 08:47:58 -0800 Subject: [PATCH 3/5] Secrets API: Add `wp_is_secrets_api_available()`. Now that `WP_SECRETS_KEY` is required, a plugin needs to know whether the Secrets API can be used before moving a credential into it. This returns false when the secrets.php drop-in failed to load, or when the default keyring has no usable key. A provider or keyring from a working drop-in manages its own keys, so it is reported as available. Suggested by peterwilsoncc in review. --- src/wp-includes/secrets.php | 52 ++++++++ .../tests/secrets/wpIsSecretsApiAvailable.php | 116 ++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 tests/phpunit/tests/secrets/wpIsSecretsApiAvailable.php diff --git a/src/wp-includes/secrets.php b/src/wp-includes/secrets.php index 826eb41b883b5..6abf2c34852cb 100644 --- a/src/wp-includes/secrets.php +++ b/src/wp-includes/secrets.php @@ -685,6 +685,58 @@ function wp_secrets_provider_is_writable(): bool { return _wp_secrets_get_provider()->is_writable(); } +/** + * Whether the Secrets API can store and read secrets on this site. + * + * A plugin can check this before it moves a credential into the Secrets API, and + * keep its existing storage while it returns false: + * + * if ( wp_is_secrets_api_available() ) { + * // Move the credential, then use wp_set_secret() and wp_get_secret(). + * } else { + * // Keep using the existing storage. + * } + * + * Returns false when the secrets.php drop-in failed to load, or when the default + * keyring has no usable WP_SECRETS_KEY. A provider or keyring from a working drop-in + * manages its own keys, so it is reported as available. Its backend can still be + * unreachable on a given request, which each call reports as a WP_Error. + * + * Whether the provider accepts writes is a separate question. See + * wp_secrets_provider_is_writable(). + * + * @since 7.2.0 + * + * @global WP_Secrets_Provider $wp_secrets_provider Optional. Provider override set by the drop-in. + * + * @return bool + */ +function wp_is_secrets_api_available(): bool { + if ( _wp_secrets_get_provider() instanceof WP_Secrets_Broken_Provider ) { + return false; + } + + if ( isset( $GLOBALS['wp_secrets_provider'] ) ) { + return true; + } + + $keyring = _wp_secrets_get_key_manager()->get_keyring(); + + if ( $keyring instanceof WP_Secrets_Broken_Keyring ) { + return false; + } + + if ( $keyring instanceof WP_Secrets_Config_Key_Provider ) { + return in_array( + $keyring->get_key_status(), + array( WP_Secrets_Config_Key_Provider::KEY_STATUS_BASE64, WP_Secrets_Config_Key_Provider::KEY_STATUS_LEGACY ), + true + ); + } + + return true; +} + /** * A human-readable description of what is protecting this site's secrets. * diff --git a/tests/phpunit/tests/secrets/wpIsSecretsApiAvailable.php b/tests/phpunit/tests/secrets/wpIsSecretsApiAvailable.php new file mode 100644 index 0000000000000..2509ba6a39fb0 --- /dev/null +++ b/tests/phpunit/tests/secrets/wpIsSecretsApiAvailable.php @@ -0,0 +1,116 @@ +assertTrue( wp_is_secrets_api_available(), 'The API should be available with a base64-encoded 32-byte key.' ); + } + + /** + * Ensure that the API is available with a usable WP_SECRETS_KEY in the legacy form. + * + * @covers ::wp_is_secrets_api_available + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_available_with_a_legacy_key(): void { + define( 'WP_SECRETS_KEY', 'a long legacy key that is not base64' ); + + $this->assertTrue( wp_is_secrets_api_available(), 'The API should be available with a usable legacy-form key.' ); + } + + /** + * Ensure that the API is unavailable when WP_SECRETS_KEY is not defined. + * + * @covers ::wp_is_secrets_api_available + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_unavailable_without_a_key(): void { + $this->assertFalse( wp_is_secrets_api_available(), 'The API should be unavailable without WP_SECRETS_KEY.' ); + $this->assertWPError( wp_set_secret( 'myplugin/api-key', 'value' ), 'A write should fail when the API reports itself unavailable.' ); + } + + /** + * Ensure that the API is unavailable when WP_SECRETS_KEY is the wp-config-sample.php placeholder. + * + * @covers ::wp_is_secrets_api_available + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_unavailable_with_the_placeholder(): void { + define( 'WP_SECRETS_KEY', 'put your unique phrase here' ); + + $this->assertFalse( wp_is_secrets_api_available(), 'The API should be unavailable when WP_SECRETS_KEY is the placeholder.' ); + } + + /** + * Ensure that the API is unavailable when the secrets.php drop-in failed to load. + * + * @covers ::wp_is_secrets_api_available + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_unavailable_with_a_broken_dropin(): void { + define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'k', 32 ) ) ); + + $GLOBALS['wp_secrets_dropin_broken'] = true; + + $this->assertFalse( wp_is_secrets_api_available(), 'The API should be unavailable when the drop-in failed to load.' ); + } + + /** + * Ensure that a keyring from a drop-in makes the API available without WP_SECRETS_KEY. + * + * @covers ::wp_is_secrets_api_available + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_available_with_a_dropin_keyring_and_no_key(): void { + $GLOBALS['wp_secrets_keyring'] = new Mock_Keyring(); + + $this->assertTrue( wp_is_secrets_api_available(), 'The API should be available with a drop-in keyring.' ); + } + + /** + * Ensure that a provider from a drop-in makes the API available without WP_SECRETS_KEY. + * + * @covers ::wp_is_secrets_api_available + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_available_with_a_dropin_provider_and_no_key(): void { + $GLOBALS['wp_secrets_provider'] = new WP_Secrets_Libsodium_Provider( + new Mock_Store(), + new WP_Secrets_Key_Manager( new Mock_Keyring() ) + ); + + $this->assertTrue( wp_is_secrets_api_available(), 'The API should be available with a drop-in provider.' ); + } +} From 8f2d25a78414e3e56d2e12686b2745c0dbca02ab Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Tue, 10 Nov 2026 06:20:46 -0800 Subject: [PATCH 4/5] Secrets API: Reject a whitespace-only key and mark up constants in Site Health. A WP_SECRETS_KEY of only whitespace was accepted as key material. It is now treated like an empty key. Site Health wraps WP_SECRETS_KEY, wp-config.php and wp-config-sample.php in , as the other Site Health results do, and quotes the translated "stop editing" line the way network.php does. From review on PR 15258 by peterwilsoncc. --- src/wp-admin/includes/secrets-site-health.php | 29 ++++++++++++++++--- .../class-wp-secrets-config-key-provider.php | 7 +++-- tests/phpunit/tests/secrets/siteHealth.php | 2 +- .../secrets/wpSecretsConfigKeyProvider.php | 1 + 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/wp-admin/includes/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php index 1ef72fc63b5a9..b2a4c097f392f 100644 --- a/src/wp-admin/includes/secrets-site-health.php +++ b/src/wp-admin/includes/secrets-site-health.php @@ -121,11 +121,32 @@ function wp_secrets_site_health_test_key_source(): array { sprintf( '

%s

%s

%s

%s

', $is_defined - ? esc_html__( 'WP_SECRETS_KEY is defined in wp-config.php, but its value cannot be used: it is empty, still the placeholder from wp-config-sample.php, or the same as one of the authentication keys and salts. Plugins cannot store API keys and other credentials with the Secrets API until it is fixed.' ) - : esc_html__( 'WP_SECRETS_KEY is not defined in wp-config.php. Plugins cannot store API keys and other credentials with the Secrets API until it is.' ), + ? sprintf( + /* translators: 1: WP_SECRETS_KEY, 2: wp-config.php, 3: wp-config-sample.php */ + __( '%1$s is defined in %2$s, but its value cannot be used: it is empty, still the placeholder from %3$s, or the same as one of the authentication keys and salts. Plugins cannot store API keys and other credentials with the Secrets API until it is fixed.' ), + 'WP_SECRETS_KEY', + 'wp-config.php', + 'wp-config-sample.php' + ) + : sprintf( + /* translators: 1: WP_SECRETS_KEY, 2: wp-config.php */ + __( '%1$s is not defined in %2$s. Plugins cannot store API keys and other credentials with the Secrets API until it is.' ), + 'WP_SECRETS_KEY', + 'wp-config.php' + ), $is_defined - ? esc_html__( 'Replace the WP_SECRETS_KEY line in wp-config.php with this one:' ) - : esc_html__( 'Add this line to wp-config.php, above the line reading "That\'s all, stop editing!":' ), + ? sprintf( + /* translators: 1: WP_SECRETS_KEY, 2: wp-config.php */ + __( 'Replace the %1$s line in %2$s with this one:' ), + 'WP_SECRETS_KEY', + 'wp-config.php' + ) + : sprintf( + /* translators: 1: wp-config.php, 2: Translated version of "That's all, stop editing! Happy publishing." */ + __( 'Add this line to %1$s, above the line reading %2$s:' ), + 'wp-config.php', + '/* ' . __( 'That’s all, stop editing! Happy publishing.' ) . ' */' + ), esc_html( $example ), esc_html__( 'A new value is generated each time this page loads. Keep a copy of the one you use with your backups: secrets stored with it cannot be read without it.' ) ) 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 20c3cfd12174d..64f253fd7304d 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 @@ -322,8 +322,9 @@ private function get_site_key() { /** * Whether a candidate key constant value is usable as key material. * - * It must be a non-empty string, must not be the wp-config-sample.php - * placeholder, and must not equal any of the authentication keys and salts. + * It must be a string that is not empty or only whitespace, must not be the + * wp-config-sample.php placeholder, and must not equal any of the authentication + * keys and salts. * * Takes explicit arguments rather than reading the constants directly so the * decision logic is testable without touching real constants, which – once @@ -340,7 +341,7 @@ private function get_site_key() { */ private function is_usable_key_value( $value, array $auth_values ): bool { return is_string( $value ) - && '' !== $value + && '' !== trim( $value ) && self::KNOWN_PLACEHOLDER !== $value && ! in_array( $value, $auth_values, true ); } diff --git a/tests/phpunit/tests/secrets/siteHealth.php b/tests/phpunit/tests/secrets/siteHealth.php index d15a0a509585d..ace85df9982c2 100644 --- a/tests/phpunit/tests/secrets/siteHealth.php +++ b/tests/phpunit/tests/secrets/siteHealth.php @@ -98,7 +98,7 @@ public function test_key_source_test_offers_a_replacement_for_the_placeholder(): $result = wp_secrets_site_health_test_key_source(); $this->assertSame( 'recommended', $result['status'], 'The key source status should be recommended for the placeholder.' ); - $this->assertStringContainsString( 'Replace the WP_SECRETS_KEY line', $result['description'], 'The description should ask for the line to be replaced.' ); + $this->assertStringContainsString( 'Replace the WP_SECRETS_KEY line', $result['description'], 'The description should ask for the line to be replaced.' ); } /** diff --git a/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php b/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php index 03baf0a70fd02..ea8de58bada24 100644 --- a/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php +++ b/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php @@ -263,6 +263,7 @@ public static function data_is_usable_key_value(): array { 'the value of a key or salt' => array( 'a real salt', array( 'a real salt', 'another salt' ), false ), 'a translated, unfilled sample' => array( $translated, array_fill( 0, 8, $translated ), false ), 'empty' => array( '', array(), false ), + 'empty when trimmed' => array( ' ', array(), false ), 'null (undefined)' => array( null, array(), false ), 'not a string' => array( 12345, array(), false ), ); From 409a23a750ef65d4c76068a32bb24f77ffad160a Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Tue, 10 Nov 2026 06:20:46 -0800 Subject: [PATCH 5/5] Secrets API: Report the API as unavailable when the key cannot unwrap the root key. A site that stored secrets and then added or changed WP_SECRETS_KEY without re-wrapping the root key has a usable-looking key, but every read and write fails. wp_is_secrets_api_available() now checks the stored root key, so a caller that checks first keeps its existing storage instead of failing on the write. With no root key stored yet, the API is still available: the first write creates one under the key. See #66187 comment:56. --- src/wp-includes/secrets.php | 29 ++++++++++--- .../tests/secrets/wpIsSecretsApiAvailable.php | 42 +++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/secrets.php b/src/wp-includes/secrets.php index 134b94e8b17c0..0a8163c3e86b4 100644 --- a/src/wp-includes/secrets.php +++ b/src/wp-includes/secrets.php @@ -705,10 +705,14 @@ function wp_secrets_provider_is_writable(): bool { * // Keep using the existing storage. * } * - * Returns false when the secrets.php drop-in failed to load, or when the default - * keyring has no usable WP_SECRETS_KEY. A provider or keyring from a working drop-in - * manages its own keys, so it is reported as available. Its backend can still be - * unreachable on a given request, which each call reports as a WP_Error. + * Returns false when the secrets.php drop-in failed to load, when the default + * keyring has no usable WP_SECRETS_KEY, or when WP_SECRETS_KEY cannot unwrap the + * stored root key. The last happens when a site that already stored secrets adds + * or changes the key without re-wrapping the root key. + * + * A provider or keyring from a working drop-in manages its own keys, so it is + * reported as available. Its backend can still be unreachable on a given request, + * which each call reports as a WP_Error. * * Whether the provider accepts writes is a separate question. See * wp_secrets_provider_is_writable(). @@ -735,11 +739,26 @@ function wp_is_secrets_api_available(): bool { } if ( $keyring instanceof WP_Secrets_Config_Key_Provider ) { - return in_array( + $is_usable = in_array( $keyring->get_key_status(), array( WP_Secrets_Config_Key_Provider::KEY_STATUS_BASE64, WP_Secrets_Config_Key_Provider::KEY_STATUS_LEGACY ), true ); + + if ( ! $is_usable ) { + return false; + } + + $root_key = _wp_secrets_get_key_manager()->get_root_key( false ); + + if ( ! is_wp_error( $root_key ) ) { + wp_secrets_memzero( $root_key ); + + return true; + } + + // With no root key stored yet, the first write creates one under this key. + return false === get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ); } return true; diff --git a/tests/phpunit/tests/secrets/wpIsSecretsApiAvailable.php b/tests/phpunit/tests/secrets/wpIsSecretsApiAvailable.php index 2509ba6a39fb0..39b45e829fd54 100644 --- a/tests/phpunit/tests/secrets/wpIsSecretsApiAvailable.php +++ b/tests/phpunit/tests/secrets/wpIsSecretsApiAvailable.php @@ -67,6 +67,48 @@ public function test_unavailable_with_the_placeholder(): void { $this->assertFalse( wp_is_secrets_api_available(), 'The API should be unavailable when WP_SECRETS_KEY is the placeholder.' ); } + /** + * Ensure that the API is available when WP_SECRETS_KEY unwraps the stored root key. + * + * @covers ::wp_is_secrets_api_available + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_available_when_the_key_unwraps_the_root_key(): void { + define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'k', 32 ) ) ); + + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Storing a secret should succeed and create the root key.' ); + $this->assertNotFalse( get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ), 'A root key should be stored after the first write.' ); + $this->assertTrue( wp_is_secrets_api_available(), 'The API should be available when the key unwraps the stored root key.' ); + } + + /** + * Ensure that the API is unavailable when WP_SECRETS_KEY cannot unwrap the stored root key. + * + * This is a site that stored secrets under one key, then defined another without + * re-wrapping the root key. + * + * @covers ::wp_is_secrets_api_available + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_unavailable_when_the_key_cannot_unwrap_the_root_key(): void { + define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'k', 32 ) ) ); + define( 'WP_SECRETS_KEY_PREVIOUS', base64_encode( str_repeat( 'p', 32 ) ) ); + + $wrapped = ( new WP_Secrets_Config_Key_Provider( true ) )->wrap( random_bytes( 32 ) ); + + $this->assertIsString( $wrapped, 'Wrapping a root key under the previous key should succeed.' ); + + update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, $wrapped ); + + $this->assertFalse( wp_is_secrets_api_available(), 'The API should be unavailable when the key cannot unwrap the stored root key.' ); + $this->assertWPError( wp_set_secret( 'myplugin/api-key', 'value' ), 'A write should fail when the API reports itself unavailable.' ); + $this->assertSame( $wrapped, get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ), 'The stored root key should be left unchanged.' ); + } + /** * Ensure that the API is unavailable when the secrets.php drop-in failed to load. *