From d7abced039f2d1d987ef3e9315b7e737e544e019 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Fri, 30 Oct 2026 10:59:11 -0700 Subject: [PATCH 01/11] 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 891a7227013a9a54b5b30846361c4ba078c2db8b Mon Sep 17 00:00:00 2001 From: mpeshev Date: Sun, 25 Oct 2026 13:08:41 +0200 Subject: [PATCH 02/11] Secrets API: Report a broken or unreachable secrets backend in Site Health. Two Site Health tests reported a backend whose secrets cannot be listed as healthy: `wp_secrets_site_health_find_undecryptable()` and `wp_secrets_site_health_count_needing_rotation()` turned the `WP_Error` from `wp_list_secrets()` into an empty result, which read as "All secrets can be decrypted" and "No secrets are pending rotation". The key source test also detected a broken `secrets.php` drop-in by looking for "broken" in the keyring's description, which is a translated string, so on translated sites a broken drop-in was reported as using a dedicated key. Return the `WP_Error` from both helpers and report it, and detect the broken keyring by its class. Follow-up to [64337]. Co-Authored-By: Claude Opus 5.5 --- src/wp-admin/includes/secrets-site-health.php | 56 ++++++++++++++----- tests/phpunit/tests/secrets/siteHealth.php | 47 ++++++++++++++++ 2 files changed, 89 insertions(+), 14 deletions(-) diff --git a/src/wp-admin/includes/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php index f42b06c7fbff0..c40abc53c1dfd 100644 --- a/src/wp-admin/includes/secrets-site-health.php +++ b/src/wp-admin/includes/secrets-site-health.php @@ -83,9 +83,11 @@ 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(); - if ( str_contains( $key_source, 'broken' ) ) { + // The broken keyring's description is translated, so check its class rather than its text. + if ( $keyring instanceof WP_Secrets_Broken_Keyring ) { return wp_secrets_site_health_result( 'secrets_api_key_source', __( 'Secrets API key source is broken' ), @@ -154,8 +156,23 @@ function wp_secrets_site_health_test_key_source(): array { function wp_secrets_site_health_test_undecryptable(): array { $broken = wp_secrets_site_health_find_undecryptable( false ); - if ( is_multisite() && is_super_admin() ) { - $broken = array_merge( $broken, wp_secrets_site_health_find_undecryptable( true ) ); + if ( ! is_wp_error( $broken ) && is_multisite() && is_super_admin() ) { + $network_broken = wp_secrets_site_health_find_undecryptable( true ); + $broken = is_wp_error( $network_broken ) ? $network_broken : array_merge( $broken, $network_broken ); + } + + // Secrets that cannot be listed cannot be checked, which must not read as all of them decrypting. + if ( is_wp_error( $broken ) ) { + return wp_secrets_site_health_result( + 'secrets_api_undecryptable', + __( 'Secrets could not be checked' ), + 'critical', + sprintf( + '

%s

%s

', + esc_html__( 'The stored secrets could not be listed, so none of them could be checked. Secrets probably cannot be read or written either until this is fixed.' ), + esc_html( $broken->get_error_message() ) + ) + ); } if ( empty( $broken ) ) { @@ -200,15 +217,16 @@ function wp_secrets_site_health_test_undecryptable(): array { * @since 7.2.0 * * @param bool $network Whether to check network-scope secrets. - * @return array List of array( 'name' => ..., 'fingerprint' => ... ). + * @return array|WP_Error List of array( 'name' => ..., 'fingerprint' => ... ), + * or WP_Error when the secrets cannot be listed. * - * @phpstan-return list + * @phpstan-return list|WP_Error */ -function wp_secrets_site_health_find_undecryptable( bool $network ): array { +function wp_secrets_site_health_find_undecryptable( bool $network ) { $entries = $network ? wp_list_network_secrets() : wp_list_secrets(); if ( is_wp_error( $entries ) ) { - return array(); + return $entries; } $broken = array(); @@ -239,8 +257,18 @@ function wp_secrets_site_health_find_undecryptable( bool $network ): array { function wp_secrets_site_health_test_needs_rotation(): array { $count = wp_secrets_site_health_count_needing_rotation( false ); - if ( is_multisite() && is_super_admin() ) { - $count += wp_secrets_site_health_count_needing_rotation( true ); + if ( ! is_wp_error( $count ) && is_multisite() && is_super_admin() ) { + $network_count = wp_secrets_site_health_count_needing_rotation( true ); + $count = is_wp_error( $network_count ) ? $network_count : $count + $network_count; + } + + if ( is_wp_error( $count ) ) { + return wp_secrets_site_health_result( + 'secrets_api_needs_rotation', + __( 'Secrets could not be checked for pending rotation' ), + 'recommended', + sprintf( '

%s

', esc_html( $count->get_error_message() ) ) + ); } if ( 0 === $count ) { @@ -279,15 +307,15 @@ function wp_secrets_site_health_test_needs_rotation(): array { * @since 7.2.0 * * @param bool $network Whether to check network-scope secrets. - * @return int + * @return int|WP_Error The number of secrets, or WP_Error when the secrets cannot be listed. * - * @phpstan-return int<0, max> + * @phpstan-return int<0, max>|WP_Error */ -function wp_secrets_site_health_count_needing_rotation( bool $network ): int { +function wp_secrets_site_health_count_needing_rotation( bool $network ) { $entries = $network ? wp_list_network_secrets() : wp_list_secrets(); if ( is_wp_error( $entries ) ) { - return 0; + return $entries; } $count = 0; diff --git a/tests/phpunit/tests/secrets/siteHealth.php b/tests/phpunit/tests/secrets/siteHealth.php index 3bf7d6822a2ae..1509865d6220a 100644 --- a/tests/phpunit/tests/secrets/siteHealth.php +++ b/tests/phpunit/tests/secrets/siteHealth.php @@ -116,6 +116,53 @@ public function test_key_source_test_is_critical_when_the_keyring_is_broken(): v $this->assertSame( 'critical', $result['status'] ); } + /** + * The broken keyring describes itself with a translated string. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_key_source_test_is_critical_when_the_keyring_is_broken_on_a_translated_site(): void { + $GLOBALS['wp_secrets_dropin_broken'] = true; + + add_filter( + 'gettext', + static function ( $translation, $text ) { + return 'broken secrets.php drop-in' === $text ? 'defektes secrets.php-Drop-in' : $translation; + }, + 10, + 2 + ); + + $result = wp_secrets_site_health_test_key_source(); + + $this->assertSame( 'critical', $result['status'] ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_undecryptable_test_is_critical_when_the_secrets_cannot_be_listed(): void { + $GLOBALS['wp_secrets_dropin_broken'] = true; + + $result = wp_secrets_site_health_test_undecryptable(); + + $this->assertSame( 'critical', $result['status'] ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_needs_rotation_test_is_recommended_when_the_secrets_cannot_be_listed(): void { + $GLOBALS['wp_secrets_dropin_broken'] = true; + + $result = wp_secrets_site_health_test_needs_rotation(); + + $this->assertSame( 'recommended', $result['status'] ); + } + public function test_undecryptable_test_is_good_when_nothing_is_broken(): void { wp_set_secret( 'myplugin/api-key', 'value' ); From 47bf4237e26a3c54832a4d10e5e50ad5aba9ff5a Mon Sep 17 00:00:00 2001 From: mpeshev Date: Sun, 25 Oct 2026 13:11:15 +0200 Subject: [PATCH 03/11] Secrets API: Do not recommend `WP_SECRETS_KEY` when a drop-in replaces the provider. A `secrets.php` drop-in can replace the whole secrets provider, as a host's KMS or vault integration does. Such a provider protects secrets with its own keys, but the key source Site Health test still described the unused default keyring, and recommended defining `WP_SECRETS_KEY`, which that provider never reads. Report the provider's label instead, with a good status. Follow-up to [64337]. Co-Authored-By: Claude Opus 5.5 --- src/wp-admin/includes/secrets-site-health.php | 24 +++++++++++++++++++ tests/phpunit/tests/secrets/siteHealth.php | 22 +++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/wp-admin/includes/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php index c40abc53c1dfd..487c07d1fdc39 100644 --- a/src/wp-admin/includes/secrets-site-health.php +++ b/src/wp-admin/includes/secrets-site-health.php @@ -83,6 +83,30 @@ 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 { + $provider = _wp_secrets_get_provider(); + + /* + * A secrets.php drop-in can replace the whole provider, which then protects secrets + * with its own keys. The default keyring, and WP_SECRETS_KEY, are not used at all. + */ + if ( ! $provider instanceof WP_Secrets_Libsodium_Provider && ! $provider instanceof WP_Secrets_Broken_Provider ) { + return wp_secrets_site_health_result( + 'secrets_api_key_source', + __( 'Secrets API keys are managed by the provider' ), + 'good', + sprintf( + '

%s

', + esc_html( + sprintf( + /* translators: %s: Name of the active secrets provider. */ + __( 'Secrets are protected by the provider of the secrets.php drop-in: %s. WP_SECRETS_KEY is not used.' ), + $provider->get_label() + ) + ) + ) + ); + } + $keyring = _wp_secrets_get_key_manager()->get_keyring(); $key_source = $keyring->get_key_source(); diff --git a/tests/phpunit/tests/secrets/siteHealth.php b/tests/phpunit/tests/secrets/siteHealth.php index 1509865d6220a..11a8ab93d4364 100644 --- a/tests/phpunit/tests/secrets/siteHealth.php +++ b/tests/phpunit/tests/secrets/siteHealth.php @@ -139,6 +139,28 @@ static function ( $translation, $text ) { $this->assertSame( 'critical', $result['status'] ); } + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + /** + * A provider from a secrets.php drop-in does not use the default keyring. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_key_source_test_is_good_when_a_dropin_replaces_the_provider(): void { + $provider = $this->createMock( WP_Secrets_Provider::class ); + $provider->method( 'get_label' )->willReturn( 'Example Vault' ); + + $GLOBALS['wp_secrets_provider'] = $provider; + + $result = wp_secrets_site_health_test_key_source(); + + $this->assertSame( 'good', $result['status'] ); + $this->assertStringContainsString( 'Example Vault', $result['description'] ); + } + /** * @runInSeparateProcess * @preserveGlobalState disabled From 4b1da48e8110363cd3bc47bc59898f6bdbcacb1e Mon Sep 17 00:00:00 2001 From: mpeshev Date: Sun, 25 Oct 2026 13:15:01 +0200 Subject: [PATCH 04/11] Secrets API: Stop Site Health advice that locks stored secrets. The key source Site Health test recommended defining `WP_SECRETS_KEY`, or regenerating it, "with a value from `wp secret generate-key`". That command is not part of core or WP-CLI. Following the advice after any secret was stored also makes every stored secret unreadable and blocks saving new ones: the root key stays wrapped with the previous site key, and core has no path that re-wraps it. The key source test then reported the new key as good. * Report a configured key that cannot unwrap the stored root key as critical, and say that restoring the previous value fixes it. * Recommend a dedicated key only before any secret is stored. Once secrets exist, warn that changing the key or the salts it derives from makes them unreadable. * Drop the reference to the WP-CLI command. * Say in the undecryptable secrets test that restoring the previous key makes the secrets readable again, instead of that there is no way to recover them. Follow-up to [64337]. Co-Authored-By: Claude Opus 5.5 --- src/wp-admin/includes/secrets-site-health.php | 35 ++++++++++++++++--- tests/phpunit/tests/secrets/siteHealth.php | 32 ++++++++++++++++- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/wp-admin/includes/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php index 487c07d1fdc39..f39c76d44c5c3 100644 --- a/src/wp-admin/includes/secrets-site-health.php +++ b/src/wp-admin/includes/secrets-site-health.php @@ -123,14 +123,41 @@ function wp_secrets_site_health_test_key_source(): array { ); } + /* + * Once a root key exists, it is wrapped with the current site key, and core has no way + * to re-wrap it under another one. Changing the key source then locks every stored + * secret, and blocks saving new ones, until the change is reverted. + */ + $has_root_key = false !== get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ); + + if ( $has_root_key ) { + $root_key = _wp_secrets_get_key_manager()->get_root_key(); + + if ( is_wp_error( $root_key ) ) { + return wp_secrets_site_health_result( + 'secrets_api_key_source', + __( 'Secrets API key does not match the stored secrets' ), + 'critical', + sprintf( + '

%s

', + esc_html__( 'The configured key cannot decrypt the root key that protects the stored secrets, so no secret can be read or saved. This happens when WP_SECRETS_KEY, LOGGED_IN_KEY, or LOGGED_IN_SALT changed after secrets were stored. Restoring the previous value fixes it.' ) + ) + ); + } + + wp_secrets_memzero( $root_key ); + } + if ( str_contains( $key_source, 'legacy interpretation' ) ) { return wp_secrets_site_health_result( 'secrets_api_key_source', - __( 'Secrets API key should be regenerated' ), + __( 'Secrets API key is not in the recommended form' ), 'recommended', sprintf( '

%s

', - esc_html__( 'WP_SECRETS_KEY is defined, but not in the recommended base64-encoded 32-byte form. Regenerate it with `wp secret generate-key`.' ) + $has_root_key + ? esc_html__( 'WP_SECRETS_KEY is defined, but not as a base64-encoded random 32-byte value. Keep its current value: secrets are already encrypted with it, and changing it makes them unreadable.' ) + : esc_html__( 'WP_SECRETS_KEY is defined, but not as a base64-encoded random 32-byte value. No secrets are stored yet, so it can still be replaced with such a value.' ) ) ); } @@ -228,9 +255,9 @@ function wp_secrets_site_health_test_undecryptable(): array { 'critical', sprintf( '

%s

    %s

%s

', - esc_html__( 'The following secrets exist but could not be decrypted with the current key. There is no way to recover the original value. The credential must be re-entered.' ), + esc_html__( 'The following secrets exist but could not be decrypted with the current key.' ), $items, - esc_html__( 'This can happen after losing WP_SECRETS_KEY, restoring a database backup without its matching key, or a failed key rotation.' ) + esc_html__( 'This can happen after WP_SECRETS_KEY, LOGGED_IN_KEY, or LOGGED_IN_SALT changed, after restoring a database backup without its matching key, or after a failed key rotation. Restoring the previous key makes the secrets readable again. Otherwise, each credential must be entered again.' ) ) ); } diff --git a/tests/phpunit/tests/secrets/siteHealth.php b/tests/phpunit/tests/secrets/siteHealth.php index 11a8ab93d4364..946a697794221 100644 --- a/tests/phpunit/tests/secrets/siteHealth.php +++ b/tests/phpunit/tests/secrets/siteHealth.php @@ -89,7 +89,37 @@ public function test_key_source_test_recommends_regeneration_for_a_legacy_key(): $result = wp_secrets_site_health_test_key_source(); $this->assertSame( 'recommended', $result['status'] ); - $this->assertStringContainsString( 'generate-key', $result['description'] ); + $this->assertStringContainsString( 'can still be replaced', $result['description'] ); + $this->assertStringNotContainsString( 'generate-key', $result['description'], 'There is no such WP-CLI command in core.' ); + } + + /** + * Replacing the key once secrets are stored would make them unreadable. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_key_source_test_warns_to_keep_a_legacy_key_once_secrets_are_stored(): void { + define( 'WP_SECRETS_KEY', 'not-base64-32!!' ); + + $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ) ); + + $result = wp_secrets_site_health_test_key_source(); + + $this->assertSame( 'recommended', $result['status'] ); + $this->assertStringContainsString( 'Keep its current value', $result['description'] ); + } + + public function test_key_source_test_is_critical_when_the_key_does_not_match_the_stored_secrets(): void { + wp_set_secret( 'myplugin/api-key', 'value' ); + + // Stands in for a root key that was wrapped with a different site key. + update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, base64_encode( random_bytes( 72 ) ) ); + + $result = wp_secrets_site_health_test_key_source(); + + $this->assertSame( 'critical', $result['status'] ); + $this->assertStringContainsString( 'Restoring the previous value', $result['description'] ); } /** From 1b7c9d1d8b63f031ff99f0337a59c23f357dc7e5 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Fri, 30 Oct 2026 11:40:46 -0700 Subject: [PATCH 05/11] Secrets API: Report a keyring's own error when it cannot unlock the stored secrets. Addresses review. Only the default keyring reads `wp-config.php`, so the critical key source result names `WP_SECRETS_KEY` only for it. A keyring from a `secrets.php` drop-in, such as a key service that is unreachable or was installed without re-wrapping the root key, has its own error shown instead. The label no longer says the key "does not match", which does not fit an outage. With `WP_SECRETS_KEY` now required, the key source and undecryptable results no longer mention `LOGGED_IN_KEY` and `LOGGED_IN_SALT`. When a drop-in replaces the provider, Site Health Info marks the keyring class and key source as not used, instead of describing a keyring that the site does not use. See #66380, #66187. --- src/wp-admin/includes/secrets-site-health.php | 52 +++++++++---- tests/phpunit/tests/secrets/siteHealth.php | 73 ++++++++++++++++++- 2 files changed, 108 insertions(+), 17 deletions(-) diff --git a/src/wp-admin/includes/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php index f39c76d44c5c3..8559111dfd8f1 100644 --- a/src/wp-admin/includes/secrets-site-health.php +++ b/src/wp-admin/includes/secrets-site-health.php @@ -73,6 +73,21 @@ function wp_secrets_site_health_result( string $test, string $label, string $sta ); } +/** + * Whether the active provider protects secrets with the keyring. + * + * A secrets.php drop-in can replace the whole provider, which then protects secrets + * with its own keys. The default keyring, and WP_SECRETS_KEY, are not used at all. + * + * @since 7.2.0 + * + * @param WP_Secrets_Provider $provider The active provider. + * @return bool + */ +function wp_secrets_site_health_uses_keyring( WP_Secrets_Provider $provider ): bool { + return $provider instanceof WP_Secrets_Libsodium_Provider || $provider instanceof WP_Secrets_Broken_Provider; +} + /** * Site Health test: is the site key defined, and in the recommended form? * @@ -85,11 +100,7 @@ function wp_secrets_site_health_result( string $test, string $label, string $sta function wp_secrets_site_health_test_key_source(): array { $provider = _wp_secrets_get_provider(); - /* - * A secrets.php drop-in can replace the whole provider, which then protects secrets - * with its own keys. The default keyring, and WP_SECRETS_KEY, are not used at all. - */ - if ( ! $provider instanceof WP_Secrets_Libsodium_Provider && ! $provider instanceof WP_Secrets_Broken_Provider ) { + if ( ! wp_secrets_site_health_uses_keyring( $provider ) ) { return wp_secrets_site_health_result( 'secrets_api_key_source', __( 'Secrets API keys are managed by the provider' ), @@ -134,13 +145,27 @@ function wp_secrets_site_health_test_key_source(): array { $root_key = _wp_secrets_get_key_manager()->get_root_key(); if ( is_wp_error( $root_key ) ) { + /* + * Only the default keyring reads wp-config.php. A keyring from a secrets.php + * drop-in fails for its own reasons, such as an unreachable key service, and + * its error is the useful part. + */ + $cause = $keyring instanceof WP_Secrets_Config_Key_Provider + ? __( 'This happens when WP_SECRETS_KEY was changed or removed after secrets were stored. Restoring the previous value fixes it.' ) + : sprintf( + /* translators: %s: Error message from the keyring. */ + __( 'The keyring of the secrets.php drop-in reported: %s' ), + $root_key->get_error_message() + ); + return wp_secrets_site_health_result( 'secrets_api_key_source', - __( 'Secrets API key does not match the stored secrets' ), + __( 'Secrets API cannot unlock the stored secrets' ), 'critical', sprintf( - '

%s

', - esc_html__( 'The configured key cannot decrypt the root key that protects the stored secrets, so no secret can be read or saved. This happens when WP_SECRETS_KEY, LOGGED_IN_KEY, or LOGGED_IN_SALT changed after secrets were stored. Restoring the previous value fixes it.' ) + '

%s

%s

', + esc_html__( 'The configured key cannot decrypt the root key that protects the stored secrets, so no secret can be read or saved.' ), + esc_html( $cause ) ) ); } @@ -257,7 +282,7 @@ function wp_secrets_site_health_test_undecryptable(): array { '

%s

    %s

%s

', esc_html__( 'The following secrets exist but could not be decrypted with the current key.' ), $items, - esc_html__( 'This can happen after WP_SECRETS_KEY, LOGGED_IN_KEY, or LOGGED_IN_SALT changed, after restoring a database backup without its matching key, or after a failed key rotation. Restoring the previous key makes the secrets readable again. Otherwise, each credential must be entered again.' ) + esc_html__( 'This can happen after WP_SECRETS_KEY was changed or removed, after restoring a database backup without its matching key, or after a failed key rotation. Restoring the previous key makes the secrets readable again. Otherwise, each credential must be entered again.' ) ) ); } @@ -392,8 +417,9 @@ function wp_secrets_site_health_count_needing_rotation( bool $network ) { * @return array> */ function wp_secrets_site_health_debug_info( $info ) { - $key_manager = _wp_secrets_get_key_manager(); - $provider = _wp_secrets_get_provider(); + $key_manager = _wp_secrets_get_key_manager(); + $provider = _wp_secrets_get_provider(); + $uses_keyring = wp_secrets_site_health_uses_keyring( $provider ); $fields = array( 'dropin_active' => array( @@ -420,11 +446,11 @@ function wp_secrets_site_health_debug_info( $info ) { ), 'keyring_class' => array( 'label' => __( 'Keyring class' ), - 'value' => get_class( $key_manager->get_keyring() ), + 'value' => $uses_keyring ? get_class( $key_manager->get_keyring() ) : __( 'Not used by the provider' ), ), 'key_source' => array( 'label' => __( 'Key source' ), - 'value' => $key_manager->get_keyring()->get_key_source(), + 'value' => $uses_keyring ? $key_manager->get_keyring()->get_key_source() : __( 'Not used by the provider' ), ), 'record_version' => array( 'label' => __( 'Record format version' ), diff --git a/tests/phpunit/tests/secrets/siteHealth.php b/tests/phpunit/tests/secrets/siteHealth.php index 946a697794221..5d229ca8981a2 100644 --- a/tests/phpunit/tests/secrets/siteHealth.php +++ b/tests/phpunit/tests/secrets/siteHealth.php @@ -122,6 +122,53 @@ public function test_key_source_test_is_critical_when_the_key_does_not_match_the $this->assertStringContainsString( 'Restoring the previous value', $result['description'] ); } + /** + * Secrets were stored, then WP_SECRETS_KEY was removed. + * + * The root key is wrapped with a keyring that reads WP_SECRETS_KEY_PREVIOUS, + * because WP_SECRETS_KEY can only be defined once in a process. + * + * @ticket 66380 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_key_source_test_is_critical_when_the_key_was_removed_after_secrets_were_stored(): void { + define( 'WP_SECRETS_KEY_PREVIOUS', base64_encode( str_repeat( 'P', 32 ) ) ); + + $previous = new WP_Secrets_Config_Key_Provider( true ); + update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, $previous->wrap( random_bytes( 32 ) ) ); + + $result = wp_secrets_site_health_test_key_source(); + + $this->assertSame( 'critical', $result['status'] ); + $this->assertStringContainsString( 'WP_SECRETS_KEY was changed or removed', $result['description'] ); + } + + /** + * A keyring from a secrets.php drop-in fails for its own reasons, such as an + * unreachable key service. The result reports its error, and does not send + * the site owner to wp-config.php. + * + * @ticket 66380 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_key_source_test_reports_the_error_of_a_dropin_keyring_that_cannot_unwrap(): void { + $keyring = new Mock_Keyring(); + update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, $keyring->wrap( random_bytes( 32 ) ) ); + $keyring->configure_fail_unwrap(); + + $GLOBALS['wp_secrets_keyring'] = $keyring; + + $result = wp_secrets_site_health_test_key_source(); + + $this->assertSame( 'critical', $result['status'] ); + $this->assertStringContainsString( 'unwrap() configured to fail', $result['description'] ); + $this->assertStringNotContainsString( 'WP_SECRETS_KEY', $result['description'] ); + } + /** * @runInSeparateProcess * @preserveGlobalState disabled @@ -169,10 +216,6 @@ static function ( $translation, $text ) { $this->assertSame( 'critical', $result['status'] ); } - /** - * @runInSeparateProcess - * @preserveGlobalState disabled - */ /** * A provider from a secrets.php drop-in does not use the default keyring. * @@ -308,6 +351,28 @@ public function test_debug_info_reports_expected_fields(): void { $this->assertSame( '1', $fields['site_secret_count']['value'] ); } + /** + * A provider from a secrets.php drop-in does not use the default keyring, so + * the Info tab does not describe it. + * + * @ticket 66380 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_debug_info_does_not_describe_the_keyring_when_a_dropin_replaces_the_provider(): void { + $provider = $this->createMock( WP_Secrets_Provider::class ); + $provider->method( 'get_label' )->willReturn( 'Example Vault' ); + $provider->method( 'list_secrets' )->willReturn( array() ); + + $GLOBALS['wp_secrets_provider'] = $provider; + + $fields = $this->debug_fields( wp_secrets_site_health_debug_info( array() ) ); + + $this->assertSame( 'Not used by the provider', $fields['keyring_class']['value'] ); + $this->assertSame( 'Not used by the provider', $fields['key_source']['value'] ); + } + public function test_debug_info_reports_no_dropin_by_default(): void { $info = wp_secrets_site_health_debug_info( array() ); From 78107b267a212344177d8d4df3e08209e28c14fd Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Fri, 6 Nov 2026 08:46:47 -0800 Subject: [PATCH 06/11] 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 07/11] 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 08/11] 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 09/11] 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. * From cbdcfc187b41a1829a65eabf21c76321cf3fa24a Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Tue, 10 Nov 2026 06:23:13 -0800 Subject: [PATCH 10/11] Secrets API: Mark up constants and file names in the Site Health results. Wraps WP_SECRETS_KEY and secrets.php in , as the other Site Health results do and as PR 15258 now does for the strings it adds. The provider label and the keyring's error message are escaped on their own. From review on PR 15258 by peterwilsoncc. --- src/wp-admin/includes/secrets-site-health.php | 45 +++++++++++++------ tests/phpunit/tests/secrets/siteHealth.php | 2 +- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/src/wp-admin/includes/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php index 92cbdc3fa568e..bbf97da012a1f 100644 --- a/src/wp-admin/includes/secrets-site-health.php +++ b/src/wp-admin/includes/secrets-site-health.php @@ -106,12 +106,12 @@ function wp_secrets_site_health_test_key_source(): array { 'good', sprintf( '

%s

', - esc_html( - sprintf( - /* translators: %s: Name of the active secrets provider. */ - __( 'Secrets are protected by the provider of the secrets.php drop-in: %s. WP_SECRETS_KEY is not used.' ), - $provider->get_label() - ) + sprintf( + /* translators: 1: secrets.php, 2: Name of the active secrets provider, 3: WP_SECRETS_KEY */ + __( 'Secrets are protected by the provider of the %1$s drop-in: %2$s. %3$s is not used.' ), + 'secrets.php', + esc_html( $provider->get_label() ), + 'WP_SECRETS_KEY' ) ) ); @@ -151,11 +151,16 @@ function wp_secrets_site_health_test_key_source(): array { * its error is the useful part. */ $cause = $keyring instanceof WP_Secrets_Config_Key_Provider - ? __( 'This happens when WP_SECRETS_KEY was changed or removed after secrets were stored. Restoring the previous value fixes it.' ) + ? sprintf( + /* translators: %s: WP_SECRETS_KEY */ + __( 'This happens when %s was changed or removed after secrets were stored. Restoring the previous value fixes it.' ), + 'WP_SECRETS_KEY' + ) : sprintf( - /* translators: %s: Error message from the keyring. */ - __( 'The keyring of the secrets.php drop-in reported: %s' ), - $root_key->get_error_message() + /* translators: 1: secrets.php, 2: Error message from the keyring. */ + __( 'The keyring of the %1$s drop-in reported: %2$s' ), + 'secrets.php', + esc_html( $root_key->get_error_message() ) ); return wp_secrets_site_health_result( @@ -165,7 +170,7 @@ function wp_secrets_site_health_test_key_source(): array { sprintf( '

%s

%s

', esc_html__( 'The configured key cannot decrypt the root key that protects the stored secrets, so no secret can be read or saved.' ), - esc_html( $cause ) + $cause ) ); } @@ -181,8 +186,16 @@ function wp_secrets_site_health_test_key_source(): array { sprintf( '

%s

', $has_root_key - ? esc_html__( 'WP_SECRETS_KEY is defined, but not as a base64-encoded random 32-byte value. Keep its current value: secrets are already encrypted with it, and changing it makes them unreadable.' ) - : esc_html__( 'WP_SECRETS_KEY is defined, but not as a base64-encoded random 32-byte value. No secrets are stored yet, so it can still be replaced with such a value.' ) + ? sprintf( + /* translators: %s: WP_SECRETS_KEY */ + __( '%s is defined, but not as a base64-encoded random 32-byte value. Keep its current value: secrets are already encrypted with it, and changing it makes them unreadable.' ), + 'WP_SECRETS_KEY' + ) + : sprintf( + /* translators: %s: WP_SECRETS_KEY */ + __( '%s is defined, but not as a base64-encoded random 32-byte value. No secrets are stored yet, so it can still be replaced with such a value.' ), + 'WP_SECRETS_KEY' + ) ) ); } @@ -303,7 +316,11 @@ function wp_secrets_site_health_test_undecryptable(): array { '

%s

    %s

%s

', esc_html__( 'The following secrets exist but could not be decrypted with the current key.' ), $items, - esc_html__( 'This can happen after WP_SECRETS_KEY was changed or removed, after restoring a database backup without its matching key, or after a failed key rotation. Restoring the previous key makes the secrets readable again. Otherwise, each credential must be entered again.' ) + sprintf( + /* translators: %s: WP_SECRETS_KEY */ + __( 'This can happen after %s was changed or removed, after restoring a database backup without its matching key, or after a failed key rotation. Restoring the previous key makes the secrets readable again. Otherwise, each credential must be entered again.' ), + 'WP_SECRETS_KEY' + ) ) ); } diff --git a/tests/phpunit/tests/secrets/siteHealth.php b/tests/phpunit/tests/secrets/siteHealth.php index 9c1161e92dc18..bf55ca408d6a3 100644 --- a/tests/phpunit/tests/secrets/siteHealth.php +++ b/tests/phpunit/tests/secrets/siteHealth.php @@ -212,7 +212,7 @@ public function test_key_source_test_is_critical_when_the_key_was_removed_after_ $result = wp_secrets_site_health_test_key_source(); $this->assertSame( 'critical', $result['status'], 'A removed WP_SECRETS_KEY with stored secrets should produce a critical status.' ); - $this->assertStringContainsString( 'WP_SECRETS_KEY was changed or removed', $result['description'], 'The description should say that WP_SECRETS_KEY was changed or removed.' ); + $this->assertStringContainsString( 'WP_SECRETS_KEY was changed or removed', $result['description'], 'The description should say that WP_SECRETS_KEY was changed or removed.' ); } /** From 504a0d0d667819bf541c172e65a1d417e3daf523 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Tue, 10 Nov 2026 06:41:57 -0800 Subject: [PATCH 11/11] Secrets API: Move the Site Health checks into `WP_Site_Health` and `WP_Debug_Data`. The Secrets API's Site Health checks lived in their own file, secrets-site-health.php, as functions hooked onto site_status_tests and debug_information. That was how the feature plugin added them. Core's own checks are methods of WP_Site_Health, registered in get_tests(), and core's debug information comes from WP_Debug_Data. - The three checks are now WP_Site_Health::get_test_secrets_api_key_source(), get_test_secrets_api_undecryptable() and get_test_secrets_api_needs_rotation(), registered as direct tests. Their helpers are private methods. - The debug information is WP_Debug_Data::get_wp_secrets_api(), under the wp-secrets-api key, as core's other sections are prefixed wp-. - The results use the blue Security badge and the actions key, like the other checks. - secrets-site-health.php and its wp_secrets_site_health_*() functions are removed. None of them has been in a release. Follow-up to r64337. See #66187. --- src/wp-admin/includes/class-wp-debug-data.php | 75 +++ .../includes/class-wp-site-health.php | 416 +++++++++++++- src/wp-admin/includes/secrets-site-health.php | 520 ------------------ tests/phpunit/tests/secrets/siteHealth.php | 181 +++--- 4 files changed, 583 insertions(+), 609 deletions(-) delete mode 100644 src/wp-admin/includes/secrets-site-health.php diff --git a/src/wp-admin/includes/class-wp-debug-data.php b/src/wp-admin/includes/class-wp-debug-data.php index ca85f0ebece3d..e21817915188e 100644 --- a/src/wp-admin/includes/class-wp-debug-data.php +++ b/src/wp-admin/includes/class-wp-debug-data.php @@ -57,6 +57,7 @@ public static function debug_data() { 'wp-server' => self::get_wp_server(), 'wp-database' => self::get_wp_database(), 'wp-constants' => self::get_wp_constants(), + 'wp-secrets-api' => self::get_wp_secrets_api(), 'wp-filesystem' => self::get_wp_filesystem(), ); @@ -335,6 +336,80 @@ private static function get_wp_dropins(): array { ); } + /** + * Gets the Secrets API section of the debug data. + * + * Counts and class names only – no secret values, and no fingerprints. Network + * scope figures are included only for a super admin on a multisite install. + * + * @since 7.2.0 + * + * @return array The Secrets API debug data. + */ + private static function get_wp_secrets_api(): array { + $key_manager = _wp_secrets_get_key_manager(); + $provider = _wp_secrets_get_provider(); + + // A provider from a secrets.php drop-in protects secrets with its own keys, not the keyring. + $uses_keyring = $provider instanceof WP_Secrets_Libsodium_Provider || $provider instanceof WP_Secrets_Broken_Provider; + + $fields = array( + 'dropin_active' => array( + 'label' => __( 'Drop-in active' ), + 'value' => wp_using_secrets_dropin() ? __( 'Yes' ) : __( 'No' ), + ), + 'provider_class' => array( + 'label' => __( 'Provider class' ), + 'value' => get_class( $provider ), + ), + 'protected_by' => array( + 'label' => __( 'Secrets protected by' ), + 'value' => $provider->get_label(), + ), + 'protection_at' => array( + 'label' => __( 'Encryption boundary' ), + 'value' => WP_Secrets_Provider::BOUNDARY_WORDPRESS === $provider->get_protection_boundary() + ? __( 'WordPress' ) + : __( 'The provider (outside WordPress)' ), + ), + 'writable' => array( + 'label' => __( 'Accepts writes' ), + 'value' => $provider->is_writable() ? __( 'Yes' ) : __( 'No' ), + ), + 'keyring_class' => array( + 'label' => __( 'Keyring class' ), + 'value' => $uses_keyring ? get_class( $key_manager->get_keyring() ) : __( 'Not used by the provider' ), + ), + 'key_source' => array( + 'label' => __( 'Key source' ), + 'value' => $uses_keyring ? $key_manager->get_keyring()->get_key_source() : __( 'Not used by the provider' ), + ), + 'record_version' => array( + 'label' => __( 'Record format version' ), + 'value' => (string) WP_SECRETS_RECORD_VERSION, + ), + ); + + $site_secrets = wp_list_secrets(); + $fields['site_secret_count'] = array( + 'label' => __( 'Site secrets' ), + 'value' => is_wp_error( $site_secrets ) ? __( 'Unavailable' ) : (string) count( $site_secrets ), + ); + + if ( is_multisite() && is_super_admin() ) { + $network_secrets = wp_list_network_secrets(); + $fields['network_secret_count'] = array( + 'label' => __( 'Network secrets' ), + 'value' => is_wp_error( $network_secrets ) ? __( 'Unavailable' ) : (string) count( $network_secrets ), + ); + } + + return array( + 'label' => __( 'Secrets API' ), + 'fields' => $fields, + ); + } + /** * Gets the WordPress server section of the debug data. * diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 17a4a190cdbbb..4c4ec2e96fe47 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -7,8 +7,6 @@ * @since 5.2.0 */ -require_once __DIR__ . '/secrets-site-health.php'; - #[AllowDynamicProperties] class WP_Site_Health { private static $instance = null; @@ -2837,6 +2835,408 @@ public function get_test_opcode_cache(): array { return $result; } + /** + * Tests whether the Secrets API has a usable key. + * + * There is no settings screen for secrets, so this is how an administrator notices + * a missing or broken key, or a key that no longer unlocks the stored secrets. + * + * @since 7.2.0 + * + * @return array The test result. + * + * @phpstan-return array{label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string, actions: string, test: string} + */ + public function get_test_secrets_api_key_source(): array { + $provider = _wp_secrets_get_provider(); + + if ( ! $this->secrets_api_uses_keyring( $provider ) ) { + return $this->get_secrets_api_test_result( + 'secrets_api_key_source', + __( 'Secrets API keys are managed by the provider' ), + 'good', + sprintf( + '

%s

', + sprintf( + /* translators: 1: secrets.php, 2: Name of the active secrets provider, 3: WP_SECRETS_KEY */ + __( 'Secrets are protected by the provider of the %1$s drop-in: %2$s. %3$s is not used.' ), + 'secrets.php', + esc_html( $provider->get_label() ), + 'WP_SECRETS_KEY' + ) + ) + ); + } + + $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; + + // The broken keyring's description is translated, so check its class rather than its text. + if ( $keyring instanceof WP_Secrets_Broken_Keyring ) { + return $this->get_secrets_api_test_result( + 'secrets_api_key_source', + __( 'Secrets API key source is broken' ), + 'critical', + sprintf( + '

%s

', + esc_html__( 'The active keyring did not load correctly. No secret can be encrypted or decrypted until this is fixed.' ) + ) + ); + } + + /* + * Once a root key exists, it is wrapped with the current site key, and core has no way + * to re-wrap it under another one. Changing the key source then locks every stored + * secret, and blocks saving new ones, until the change is reverted. + */ + $has_root_key = false !== get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ); + + if ( $has_root_key ) { + $root_key = _wp_secrets_get_key_manager()->get_root_key(); + + if ( is_wp_error( $root_key ) ) { + /* + * Only the default keyring reads wp-config.php. A keyring from a secrets.php + * drop-in fails for its own reasons, such as an unreachable key service, and + * its error is the useful part. + */ + $cause = $keyring instanceof WP_Secrets_Config_Key_Provider + ? sprintf( + /* translators: %s: WP_SECRETS_KEY */ + __( 'This happens when %s was changed or removed after secrets were stored. Restoring the previous value fixes it.' ), + 'WP_SECRETS_KEY' + ) + : sprintf( + /* translators: 1: secrets.php, 2: Error message from the keyring. */ + __( 'The keyring of the %1$s drop-in reported: %2$s' ), + 'secrets.php', + esc_html( $root_key->get_error_message() ) + ); + + return $this->get_secrets_api_test_result( + 'secrets_api_key_source', + __( 'Secrets API cannot unlock the stored secrets' ), + 'critical', + sprintf( + '

%s

%s

', + esc_html__( 'The configured key cannot decrypt the root key that protects the stored secrets, so no secret can be read or saved.' ), + $cause + ) + ); + } + + wp_secrets_memzero( $root_key ); + } + + if ( WP_Secrets_Config_Key_Provider::KEY_STATUS_LEGACY === $key_status ) { + return $this->get_secrets_api_test_result( + 'secrets_api_key_source', + __( 'Secrets API key is not in the recommended form' ), + 'recommended', + sprintf( + '

%s

', + $has_root_key + ? sprintf( + /* translators: %s: WP_SECRETS_KEY */ + __( '%s is defined, but not as a base64-encoded random 32-byte value. Keep its current value: secrets are already encrypted with it, and changing it makes them unreadable.' ), + 'WP_SECRETS_KEY' + ) + : sprintf( + /* translators: %s: WP_SECRETS_KEY */ + __( '%s is defined, but not as a base64-encoded random 32-byte value. No secrets are stored yet, so it can still be replaced with such a value.' ), + 'WP_SECRETS_KEY' + ) + ) + ); + } + + 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 $this->get_secrets_api_test_result( + 'secrets_api_key_source', + __( 'Secrets API has no encryption key' ), + 'recommended', + sprintf( + '

%s

%s

%s

%s

', + $is_defined + ? 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 + ? 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.' ) + ) + ); + } + + return $this->get_secrets_api_test_result( + 'secrets_api_key_source', + __( 'Secrets API is using a dedicated key' ), + 'good', + sprintf( '

%s

', esc_html( $key_source ) ) + ); + } + + /** + * Tests whether every stored secret still decrypts. + * + * Network secrets are included only for a super admin on a multisite install – + * never shown to a site administrator who is not one. + * + * @since 7.2.0 + * + * @return array The test result. + * + * @phpstan-return array{label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string, actions: string, test: string} + */ + public function get_test_secrets_api_undecryptable(): array { + $broken = $this->get_undecryptable_secrets( false ); + + if ( ! is_wp_error( $broken ) && is_multisite() && is_super_admin() ) { + $network_broken = $this->get_undecryptable_secrets( true ); + $broken = is_wp_error( $network_broken ) ? $network_broken : array_merge( $broken, $network_broken ); + } + + // Secrets that cannot be listed cannot be checked, which must not read as all of them decrypting. + if ( is_wp_error( $broken ) ) { + return $this->get_secrets_api_test_result( + 'secrets_api_undecryptable', + __( 'Secrets could not be checked' ), + 'critical', + sprintf( + '

%s

%s

', + esc_html__( 'The stored secrets could not be listed, so none of them could be checked. Secrets probably cannot be read or written either until this is fixed.' ), + esc_html( $broken->get_error_message() ) + ) + ); + } + + if ( empty( $broken ) ) { + return $this->get_secrets_api_test_result( + 'secrets_api_undecryptable', + __( 'All secrets can be decrypted' ), + 'good', + sprintf( '

%s

', esc_html__( 'Every stored secret decrypted successfully.' ) ) + ); + } + + $items = ''; + + foreach ( $broken as $entry ) { + $items .= sprintf( + '
  • %s (%s)
  • ', + esc_html( $entry['name'] ), + sprintf( + /* translators: %s: A secret's last known fingerprint. */ + esc_html__( 'fingerprint: %s' ), + esc_html( $entry['fingerprint'] ) + ) + ); + } + + return $this->get_secrets_api_test_result( + 'secrets_api_undecryptable', + __( 'Some secrets cannot be decrypted' ), + 'critical', + sprintf( + '

    %s

      %s

    %s

    ', + esc_html__( 'The following secrets exist but could not be decrypted with the current key.' ), + $items, + sprintf( + /* translators: %s: WP_SECRETS_KEY */ + __( 'This can happen after %s was changed or removed, after restoring a database backup without its matching key, or after a failed key rotation. Restoring the previous key makes the secrets readable again. Otherwise, each credential must be entered again.' ), + 'WP_SECRETS_KEY' + ) + ) + ); + } + + /** + * Tests whether any secrets are flagged as needing rotation. + * + * @since 7.2.0 + * + * @return array The test result. + * + * @phpstan-return array{label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string, actions: string, test: string} + */ + public function get_test_secrets_api_needs_rotation(): array { + $count = $this->count_secrets_needing_rotation( false ); + + if ( ! is_wp_error( $count ) && is_multisite() && is_super_admin() ) { + $network_count = $this->count_secrets_needing_rotation( true ); + $count = is_wp_error( $network_count ) ? $network_count : $count + $network_count; + } + + if ( is_wp_error( $count ) ) { + return $this->get_secrets_api_test_result( + 'secrets_api_needs_rotation', + __( 'Secrets could not be checked for pending rotation' ), + 'recommended', + sprintf( '

    %s

    ', esc_html( $count->get_error_message() ) ) + ); + } + + if ( 0 === $count ) { + return $this->get_secrets_api_test_result( + 'secrets_api_needs_rotation', + __( 'No secrets are pending rotation' ), + 'good', + sprintf( '

    %s

    ', esc_html__( 'No secret is flagged as needing rotation.' ) ) + ); + } + + return $this->get_secrets_api_test_result( + 'secrets_api_needs_rotation', + __( 'Some secrets are pending rotation' ), + 'recommended', + sprintf( + '

    %s

    ', + esc_html( + sprintf( + /* translators: %d: Number of secrets flagged for rotation. */ + _n( + '%d secret was imported from an existing option and is flagged for rotation. A credential that sat in a plain option has already been through whatever backups and replication paths that option went through; re-entering it with a new value is recommended.', + '%d secrets were imported from existing options and are flagged for rotation. A credential that sat in a plain option has already been through whatever backups and replication paths that option went through; re-entering each with a new value is recommended.', + $count + ), + $count + ) + ) + ) + ); + } + + /** + * Builds the result of a Secrets API test. + * + * @since 7.2.0 + * + * @param string $test The test's identifier, as registered in get_tests(). + * @param string $label Short label for the test result. + * @param string $status One of 'good', 'recommended', 'critical'. + * @param string $description HTML description, normally one or more

    elements. + * @return array The test result. + * + * @phpstan-param 'good'|'recommended'|'critical' $status + * @phpstan-return array{label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string, actions: string, test: string} + */ + private function get_secrets_api_test_result( string $test, string $label, string $status, string $description ): array { + return array( + 'label' => $label, + 'status' => $status, + 'badge' => array( + 'label' => __( 'Security' ), + 'color' => 'blue', + ), + 'description' => $description, + 'actions' => '', + 'test' => $test, + ); + } + + /** + * Determines whether the active secrets provider protects secrets with the keyring. + * + * A secrets.php drop-in can replace the whole provider, which then protects secrets + * with its own keys. The default keyring, and WP_SECRETS_KEY, are not used at all. + * + * @since 7.2.0 + * + * @param WP_Secrets_Provider $provider The active provider. + * @return bool + */ + private function secrets_api_uses_keyring( WP_Secrets_Provider $provider ): bool { + return $provider instanceof WP_Secrets_Libsodium_Provider || $provider instanceof WP_Secrets_Broken_Provider; + } + + /** + * Finds the stored secrets that fail to decrypt, for one scope. + * + * @since 7.2.0 + * + * @param bool $network Whether to check network-scope secrets. + * @return array|WP_Error List of array( 'name' => ..., 'fingerprint' => ... ), + * or WP_Error when the secrets cannot be listed. + * + * @phpstan-return list|WP_Error + */ + private function get_undecryptable_secrets( bool $network ) { + $entries = $network ? wp_list_network_secrets() : wp_list_secrets(); + + if ( is_wp_error( $entries ) ) { + return $entries; + } + + $broken = array(); + + foreach ( $entries as $entry ) { + $secret = $network ? wp_get_network_secret( $entry['name'] ) : wp_get_secret( $entry['name'] ); + + if ( is_wp_error( $secret ) ) { + $broken[] = array( + 'name' => $entry['name'], + 'fingerprint' => $entry['fingerprint'], + ); + } + } + + return $broken; + } + + /** + * Counts the stored secrets flagged as needing rotation, for one scope. + * + * @since 7.2.0 + * + * @param bool $network Whether to check network-scope secrets. + * @return int|WP_Error The number of secrets, or WP_Error when the secrets cannot be listed. + * + * @phpstan-return int<0, max>|WP_Error + */ + private function count_secrets_needing_rotation( bool $network ) { + $entries = $network ? wp_list_network_secrets() : wp_list_secrets(); + + if ( is_wp_error( $entries ) ) { + return $entries; + } + + $count = 0; + + foreach ( $entries as $entry ) { + if ( ! empty( $entry['needs_rotation'] ) ) { + ++$count; + } + } + + return $count; + } + /** * Returns a set of tests that belong to the site status page. * @@ -2937,6 +3337,18 @@ public static function get_tests() { 'label' => __( 'Opcode cache' ), 'test' => 'opcode_cache', ), + 'secrets_api_key_source' => array( + 'label' => __( 'Secrets API key source' ), + 'test' => 'secrets_api_key_source', + ), + 'secrets_api_undecryptable' => array( + 'label' => __( 'Secrets API: undecryptable secrets' ), + 'test' => 'secrets_api_undecryptable', + ), + 'secrets_api_needs_rotation' => array( + 'label' => __( 'Secrets API: credentials pending rotation' ), + 'test' => 'secrets_api_needs_rotation', + ), ), 'async' => array( 'dotorg_communication' => array( diff --git a/src/wp-admin/includes/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php deleted file mode 100644 index bbf97da012a1f..0000000000000 --- a/src/wp-admin/includes/secrets-site-health.php +++ /dev/null @@ -1,520 +0,0 @@ ->> $tests Existing Site Health tests. - * @return array>> - */ -function wp_secrets_site_health_tests( $tests ) { - $tests['direct']['secrets_api_key_source'] = array( - 'label' => __( 'Secrets API key source' ), - 'test' => 'wp_secrets_site_health_test_key_source', - ); - - $tests['direct']['secrets_api_undecryptable'] = array( - 'label' => __( 'Secrets API: undecryptable secrets' ), - 'test' => 'wp_secrets_site_health_test_undecryptable', - ); - - $tests['direct']['secrets_api_needs_rotation'] = array( - 'label' => __( 'Secrets API: credentials pending rotation' ), - 'test' => 'wp_secrets_site_health_test_needs_rotation', - ); - - return $tests; -} -add_filter( 'site_status_tests', 'wp_secrets_site_health_tests' ); - -/** - * Builds the standard shape a Site Health test callback returns. - * - * @since 7.2.0 - * - * @param string $test The test's own identifier, matching the key registered - * in wp_secrets_site_health_tests(). - * @param string $label Short label for the test result. - * @param string $status One of 'good', 'recommended', 'critical'. - * @param string $description HTML description, normally one or more

    elements. - * @return array - * - * @phpstan-return array{test: string, label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string} - * @phpstan-param 'good'|'recommended'|'critical' $status - */ -function wp_secrets_site_health_result( string $test, string $label, string $status, string $description ): array { - $colors = array( - 'good' => 'green', - 'recommended' => 'orange', - 'critical' => 'red', - ); - - return array( - 'test' => $test, - 'label' => $label, - 'status' => $status, - 'badge' => array( - 'label' => __( 'Security' ), - 'color' => $colors[ $status ] ?? 'blue', - ), - 'description' => $description, - ); -} - -/** - * Whether the active provider protects secrets with the keyring. - * - * A secrets.php drop-in can replace the whole provider, which then protects secrets - * with its own keys. The default keyring, and WP_SECRETS_KEY, are not used at all. - * - * @since 7.2.0 - * - * @param WP_Secrets_Provider $provider The active provider. - * @return bool - */ -function wp_secrets_site_health_uses_keyring( WP_Secrets_Provider $provider ): bool { - return $provider instanceof WP_Secrets_Libsodium_Provider || $provider instanceof WP_Secrets_Broken_Provider; -} - -/** - * Tests whether the site key is defined, and in the recommended form. - * - * @since 7.2.0 - * - * @return array - * - * @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 { - $provider = _wp_secrets_get_provider(); - - if ( ! wp_secrets_site_health_uses_keyring( $provider ) ) { - return wp_secrets_site_health_result( - 'secrets_api_key_source', - __( 'Secrets API keys are managed by the provider' ), - 'good', - sprintf( - '

    %s

    ', - sprintf( - /* translators: 1: secrets.php, 2: Name of the active secrets provider, 3: WP_SECRETS_KEY */ - __( 'Secrets are protected by the provider of the %1$s drop-in: %2$s. %3$s is not used.' ), - 'secrets.php', - esc_html( $provider->get_label() ), - 'WP_SECRETS_KEY' - ) - ) - ); - } - - $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; - - // The broken keyring's description is translated, so check its class rather than its text. - if ( $keyring instanceof WP_Secrets_Broken_Keyring ) { - return wp_secrets_site_health_result( - 'secrets_api_key_source', - __( 'Secrets API key source is broken' ), - 'critical', - sprintf( - '

    %s

    ', - esc_html__( 'The active keyring did not load correctly. No secret can be encrypted or decrypted until this is fixed.' ) - ) - ); - } - - /* - * Once a root key exists, it is wrapped with the current site key, and core has no way - * to re-wrap it under another one. Changing the key source then locks every stored - * secret, and blocks saving new ones, until the change is reverted. - */ - $has_root_key = false !== get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION ); - - if ( $has_root_key ) { - $root_key = _wp_secrets_get_key_manager()->get_root_key(); - - if ( is_wp_error( $root_key ) ) { - /* - * Only the default keyring reads wp-config.php. A keyring from a secrets.php - * drop-in fails for its own reasons, such as an unreachable key service, and - * its error is the useful part. - */ - $cause = $keyring instanceof WP_Secrets_Config_Key_Provider - ? sprintf( - /* translators: %s: WP_SECRETS_KEY */ - __( 'This happens when %s was changed or removed after secrets were stored. Restoring the previous value fixes it.' ), - 'WP_SECRETS_KEY' - ) - : sprintf( - /* translators: 1: secrets.php, 2: Error message from the keyring. */ - __( 'The keyring of the %1$s drop-in reported: %2$s' ), - 'secrets.php', - esc_html( $root_key->get_error_message() ) - ); - - return wp_secrets_site_health_result( - 'secrets_api_key_source', - __( 'Secrets API cannot unlock the stored secrets' ), - 'critical', - sprintf( - '

    %s

    %s

    ', - esc_html__( 'The configured key cannot decrypt the root key that protects the stored secrets, so no secret can be read or saved.' ), - $cause - ) - ); - } - - wp_secrets_memzero( $root_key ); - } - - if ( WP_Secrets_Config_Key_Provider::KEY_STATUS_LEGACY === $key_status ) { - return wp_secrets_site_health_result( - 'secrets_api_key_source', - __( 'Secrets API key is not in the recommended form' ), - 'recommended', - sprintf( - '

    %s

    ', - $has_root_key - ? sprintf( - /* translators: %s: WP_SECRETS_KEY */ - __( '%s is defined, but not as a base64-encoded random 32-byte value. Keep its current value: secrets are already encrypted with it, and changing it makes them unreadable.' ), - 'WP_SECRETS_KEY' - ) - : sprintf( - /* translators: %s: WP_SECRETS_KEY */ - __( '%s is defined, but not as a base64-encoded random 32-byte value. No secrets are stored yet, so it can still be replaced with such a value.' ), - 'WP_SECRETS_KEY' - ) - ) - ); - } - - 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( - 'secrets_api_key_source', - __( 'Secrets API has no encryption key' ), - 'recommended', - sprintf( - '

    %s

    %s

    %s

    %s

    ', - $is_defined - ? 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 - ? 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.' ) - ) - ); - } - - return wp_secrets_site_health_result( - 'secrets_api_key_source', - __( 'Secrets API is using a dedicated key' ), - 'good', - sprintf( '

    %s

    ', esc_html( $key_source ) ) - ); -} - -/** - * Tests whether every stored secret still decrypts. - * - * Network secrets are included only for a super admin on a multisite install – - * never shown to a site administrator who is not one. - * - * @since 7.2.0 - * - * @return array - * - * @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_undecryptable(): array { - $broken = wp_secrets_site_health_find_undecryptable( false ); - - if ( ! is_wp_error( $broken ) && is_multisite() && is_super_admin() ) { - $network_broken = wp_secrets_site_health_find_undecryptable( true ); - $broken = is_wp_error( $network_broken ) ? $network_broken : array_merge( $broken, $network_broken ); - } - - // Secrets that cannot be listed cannot be checked, which must not read as all of them decrypting. - if ( is_wp_error( $broken ) ) { - return wp_secrets_site_health_result( - 'secrets_api_undecryptable', - __( 'Secrets could not be checked' ), - 'critical', - sprintf( - '

    %s

    %s

    ', - esc_html__( 'The stored secrets could not be listed, so none of them could be checked. Secrets probably cannot be read or written either until this is fixed.' ), - esc_html( $broken->get_error_message() ) - ) - ); - } - - if ( empty( $broken ) ) { - return wp_secrets_site_health_result( - 'secrets_api_undecryptable', - __( 'All secrets can be decrypted' ), - 'good', - sprintf( '

    %s

    ', esc_html__( 'Every stored secret decrypted successfully.' ) ) - ); - } - - $items = ''; - - foreach ( $broken as $entry ) { - $items .= sprintf( - '
  • %s (%s)
  • ', - esc_html( $entry['name'] ), - sprintf( - /* translators: %s: A secret's last known fingerprint. */ - esc_html__( 'fingerprint: %s' ), - esc_html( $entry['fingerprint'] ) - ) - ); - } - - return wp_secrets_site_health_result( - 'secrets_api_undecryptable', - __( 'Some secrets cannot be decrypted' ), - 'critical', - sprintf( - '

    %s

      %s

    %s

    ', - esc_html__( 'The following secrets exist but could not be decrypted with the current key.' ), - $items, - sprintf( - /* translators: %s: WP_SECRETS_KEY */ - __( 'This can happen after %s was changed or removed, after restoring a database backup without its matching key, or after a failed key rotation. Restoring the previous key makes the secrets readable again. Otherwise, each credential must be entered again.' ), - 'WP_SECRETS_KEY' - ) - ) - ); -} - -/** - * Finds secrets that fail to decrypt, for one scope. - * - * @since 7.2.0 - * - * @param bool $network Whether to check network-scope secrets. - * @return array|WP_Error List of array( 'name' => ..., 'fingerprint' => ... ), - * or WP_Error when the secrets cannot be listed. - * - * @phpstan-return list|WP_Error - */ -function wp_secrets_site_health_find_undecryptable( bool $network ) { - $entries = $network ? wp_list_network_secrets() : wp_list_secrets(); - - if ( is_wp_error( $entries ) ) { - return $entries; - } - - $broken = array(); - - foreach ( $entries as $entry ) { - $secret = $network ? wp_get_network_secret( $entry['name'] ) : wp_get_secret( $entry['name'] ); - - if ( is_wp_error( $secret ) ) { - $broken[] = array( - 'name' => $entry['name'], - 'fingerprint' => $entry['fingerprint'], - ); - } - } - - return $broken; -} - -/** - * Tests whether any secrets are flagged as needing rotation. - * - * @since 7.2.0 - * - * @return array - * - * @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_needs_rotation(): array { - $count = wp_secrets_site_health_count_needing_rotation( false ); - - if ( ! is_wp_error( $count ) && is_multisite() && is_super_admin() ) { - $network_count = wp_secrets_site_health_count_needing_rotation( true ); - $count = is_wp_error( $network_count ) ? $network_count : $count + $network_count; - } - - if ( is_wp_error( $count ) ) { - return wp_secrets_site_health_result( - 'secrets_api_needs_rotation', - __( 'Secrets could not be checked for pending rotation' ), - 'recommended', - sprintf( '

    %s

    ', esc_html( $count->get_error_message() ) ) - ); - } - - if ( 0 === $count ) { - return wp_secrets_site_health_result( - 'secrets_api_needs_rotation', - __( 'No secrets are pending rotation' ), - 'good', - sprintf( '

    %s

    ', esc_html__( 'No secret is flagged as needing rotation.' ) ) - ); - } - - return wp_secrets_site_health_result( - 'secrets_api_needs_rotation', - __( 'Some secrets are pending rotation' ), - 'recommended', - sprintf( - '

    %s

    ', - esc_html( - sprintf( - /* translators: %d: Number of secrets flagged for rotation. */ - _n( - '%d secret was imported from an existing option and is flagged for rotation. A credential that sat in a plain option has already been through whatever backups and replication paths that option went through; re-entering it with a new value is recommended.', - '%d secrets were imported from existing options and are flagged for rotation. A credential that sat in a plain option has already been through whatever backups and replication paths that option went through; re-entering each with a new value is recommended.', - $count - ), - $count - ) - ) - ) - ); -} - -/** - * Counts secrets flagged needs_rotation, for one scope. - * - * @since 7.2.0 - * - * @param bool $network Whether to check network-scope secrets. - * @return int|WP_Error The number of secrets, or WP_Error when the secrets cannot be listed. - * - * @phpstan-return int<0, max>|WP_Error - */ -function wp_secrets_site_health_count_needing_rotation( bool $network ) { - $entries = $network ? wp_list_network_secrets() : wp_list_secrets(); - - if ( is_wp_error( $entries ) ) { - return $entries; - } - - $count = 0; - - foreach ( $entries as $entry ) { - if ( ! empty( $entry['needs_rotation'] ) ) { - ++$count; - } - } - - return $count; -} - -/** - * Adds a Secrets API section to Site Health's debug information. - * - * Counts and class names only – no secret values, and no fingerprints. Network - * scope figures are included only for a super admin on a multisite install. - * - * @since 7.2.0 - * - * @param array> $info Existing debug information sections. - * @return array> - */ -function wp_secrets_site_health_debug_info( $info ) { - $key_manager = _wp_secrets_get_key_manager(); - $provider = _wp_secrets_get_provider(); - $uses_keyring = wp_secrets_site_health_uses_keyring( $provider ); - - $fields = array( - 'dropin_active' => array( - 'label' => __( 'Drop-in active' ), - 'value' => wp_using_secrets_dropin() ? __( 'Yes' ) : __( 'No' ), - ), - 'provider_class' => array( - 'label' => __( 'Provider class' ), - 'value' => get_class( $provider ), - ), - 'protected_by' => array( - 'label' => __( 'Secrets protected by' ), - 'value' => $provider->get_label(), - ), - 'protection_at' => array( - 'label' => __( 'Encryption boundary' ), - 'value' => WP_Secrets_Provider::BOUNDARY_WORDPRESS === $provider->get_protection_boundary() - ? __( 'WordPress' ) - : __( 'The provider (outside WordPress)' ), - ), - 'writable' => array( - 'label' => __( 'Accepts writes' ), - 'value' => $provider->is_writable() ? __( 'Yes' ) : __( 'No' ), - ), - 'keyring_class' => array( - 'label' => __( 'Keyring class' ), - 'value' => $uses_keyring ? get_class( $key_manager->get_keyring() ) : __( 'Not used by the provider' ), - ), - 'key_source' => array( - 'label' => __( 'Key source' ), - 'value' => $uses_keyring ? $key_manager->get_keyring()->get_key_source() : __( 'Not used by the provider' ), - ), - 'record_version' => array( - 'label' => __( 'Record format version' ), - 'value' => (string) WP_SECRETS_RECORD_VERSION, - ), - ); - - $site_secrets = wp_list_secrets(); - $fields['site_secret_count'] = array( - 'label' => __( 'Site secrets' ), - 'value' => is_wp_error( $site_secrets ) ? __( 'Unavailable' ) : (string) count( $site_secrets ), - ); - - if ( is_multisite() && is_super_admin() ) { - $network_secrets = wp_list_network_secrets(); - $fields['network_secret_count'] = array( - 'label' => __( 'Network secrets' ), - 'value' => is_wp_error( $network_secrets ) ? __( 'Unavailable' ) : (string) count( $network_secrets ), - ); - } - - $info['secrets-api'] = array( - 'label' => __( 'Secrets API' ), - 'fields' => $fields, - ); - - return $info; -} -add_filter( 'debug_information', 'wp_secrets_site_health_debug_info' ); diff --git a/tests/phpunit/tests/secrets/siteHealth.php b/tests/phpunit/tests/secrets/siteHealth.php index bf55ca408d6a3..1b5ae7a9be5cf 100644 --- a/tests/phpunit/tests/secrets/siteHealth.php +++ b/tests/phpunit/tests/secrets/siteHealth.php @@ -1,28 +1,51 @@ The section. + */ + private function debug_section(): array { + $method = new ReflectionMethod( 'WP_Debug_Data', 'get_wp_secrets_api' ); + + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + return $method->invoke( null ); + } + /** * Returns the Secrets API section's fields from Site Health debug information, * having checked that the section and each of its fields has the expected shape. * - * @param array> $info Debug information sections. + * @param array $section The Secrets API section of the debug information. * @return array> The section's fields, keyed by field name. */ - private function debug_fields( array $info ): array { - $this->assertArrayHasKey( 'secrets-api', $info, 'The debug information should include a secrets-api section.' ); - $this->assertArrayHasKey( 'fields', $info['secrets-api'], 'The secrets-api section should include a fields entry.' ); - $this->assertIsArray( $info['secrets-api']['fields'], 'The secrets-api fields entry should be an array.' ); + private function debug_fields( array $section ): array { + $this->assertArrayHasKey( 'fields', $section, 'The Secrets API section should include a fields entry.' ); + $this->assertIsArray( $section['fields'], 'The Secrets API fields entry should be an array.' ); $fields = array(); - foreach ( $info['secrets-api']['fields'] as $key => $field ) { + foreach ( $section['fields'] as $key => $field ) { $this->assertIsString( $key, 'Each debug field key should be a string.' ); $this->assertIsArray( $field, 'Each debug field should be an array.' ); @@ -33,34 +56,18 @@ private function debug_fields( array $info ): array { } /** - * Ensure that the three Secrets API checks are registered as direct tests with existing callbacks. + * Ensure that the three Secrets API checks are registered as direct tests with existing methods. * - * @covers ::wp_secrets_site_health_tests + * @covers WP_Site_Health::get_tests */ - public function test_registers_three_tests_under_direct(): void { - $tests = wp_secrets_site_health_tests( array() ); - - $this->assertArrayHasKey( 'direct', $tests, 'The registered tests should include a direct group.' ); - $this->assertCount( 3, $tests['direct'], 'Exactly three Secrets API tests should be registered as direct tests.' ); - $this->assertArrayHasKey( 'secrets_api_key_source', $tests['direct'], 'The key source test should be registered as a direct test.' ); - $this->assertArrayHasKey( 'secrets_api_undecryptable', $tests['direct'], 'The undecryptable test should be registered as a direct test.' ); - $this->assertArrayHasKey( 'secrets_api_needs_rotation', $tests['direct'], 'The needs rotation test should be registered as a direct test.' ); - - foreach ( $tests['direct'] as $key => $test ) { - $this->assertIsString( $test['test'], 'Each registered test callback should be a function name string.' ); - $this->assertTrue( function_exists( $test['test'] ), "Registered test callback \"{$test['test']}\" for \"{$key}\" does not exist." ); - } - } + public function test_registers_three_direct_tests(): void { + $tests = WP_Site_Health::get_tests(); - /** - * Ensure that wp_secrets_site_health_tests() keeps tests that were already registered. - * - * @covers ::wp_secrets_site_health_tests - */ - public function test_preserves_existing_tests(): void { - $tests = wp_secrets_site_health_tests( array( 'direct' => array( 'existing_test' => array( 'label' => 'x' ) ) ) ); - - $this->assertArrayHasKey( 'existing_test', $tests['direct'], 'A previously registered test should be preserved.' ); + foreach ( array( 'secrets_api_key_source', 'secrets_api_undecryptable', 'secrets_api_needs_rotation' ) as $key ) { + $this->assertArrayHasKey( $key, $tests['direct'], "The {$key} test should be registered as a direct test." ); + $this->assertSame( $key, $tests['direct'][ $key ]['test'], "The {$key} test should name its own method." ); + $this->assertTrue( method_exists( 'WP_Site_Health', "get_test_{$key}" ), "WP_Site_Health::get_test_{$key}() should exist." ); + } } /** @@ -68,13 +75,13 @@ public function test_preserves_existing_tests(): void { * * Runs in a separate process, where the test bootstrap does not define WP_SECRETS_KEY. * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @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(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'recommended', $result['status'], 'The key source status should be recommended without WP_SECRETS_KEY.' ); $this->assertStringContainsString( 'Add this line', $result['description'], 'The description should ask for the line to be added.' ); @@ -87,7 +94,7 @@ public function test_key_source_test_offers_a_generated_key_when_none_is_defined * Runs in a separate process because WP_SECRETS_KEY is a constant and cannot be * undefined for later tests. * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -95,7 +102,7 @@ public function test_key_source_test_offers_a_generated_key_when_none_is_defined 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(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_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.' ); @@ -106,7 +113,7 @@ public function test_key_source_test_offers_a_replacement_for_the_placeholder(): * * The key source text is translated, so the check must not depend on it. * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -121,7 +128,7 @@ static function ( $translation, $text ) { 2 ); - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_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.' ); @@ -133,7 +140,7 @@ static function ( $translation, $text ) { * Runs in a separate process because WP_SECRETS_KEY is a constant and cannot be * undefined for later tests. * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -141,7 +148,7 @@ static function ( $translation, $text ) { public function test_key_source_test_recommends_regeneration_for_a_legacy_key(): void { define( 'WP_SECRETS_KEY', 'not-base64-32!!' ); - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'recommended', $result['status'], 'A legacy WP_SECRETS_KEY should produce a recommended status.' ); $this->assertStringContainsString( 'can still be replaced', $result['description'], 'The description should say that the key can still be replaced.' ); @@ -155,7 +162,7 @@ public function test_key_source_test_recommends_regeneration_for_a_legacy_key(): * * @ticket 66380 * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -165,7 +172,7 @@ public function test_key_source_test_warns_to_keep_a_legacy_key_once_secrets_are $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'recommended', $result['status'], 'A legacy WP_SECRETS_KEY with stored secrets should produce a recommended status.' ); $this->assertStringContainsString( 'Keep its current value', $result['description'], 'The description should say to keep the current value.' ); @@ -176,7 +183,7 @@ public function test_key_source_test_warns_to_keep_a_legacy_key_once_secrets_are * * @ticket 66380 * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source */ public function test_key_source_test_is_critical_when_the_key_does_not_match_the_stored_secrets(): void { $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); @@ -184,7 +191,7 @@ public function test_key_source_test_is_critical_when_the_key_does_not_match_the // Stands in for a root key that was wrapped with a different site key. update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, base64_encode( random_bytes( 72 ) ) ); - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'critical', $result['status'], 'A key that cannot unwrap the root key should produce a critical status.' ); $this->assertStringContainsString( 'Restoring the previous value', $result['description'], 'The description should say that restoring the previous value fixes it.' ); @@ -198,7 +205,7 @@ public function test_key_source_test_is_critical_when_the_key_does_not_match_the * * @ticket 66380 * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -209,7 +216,7 @@ public function test_key_source_test_is_critical_when_the_key_was_removed_after_ $previous = new WP_Secrets_Config_Key_Provider( true ); update_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION, $previous->wrap( random_bytes( 32 ) ) ); - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'critical', $result['status'], 'A removed WP_SECRETS_KEY with stored secrets should produce a critical status.' ); $this->assertStringContainsString( 'WP_SECRETS_KEY was changed or removed', $result['description'], 'The description should say that WP_SECRETS_KEY was changed or removed.' ); @@ -224,7 +231,7 @@ public function test_key_source_test_is_critical_when_the_key_was_removed_after_ * * @ticket 66380 * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -236,7 +243,7 @@ public function test_key_source_test_reports_the_error_of_a_dropin_keyring_that_ $GLOBALS['wp_secrets_keyring'] = $keyring; - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'critical', $result['status'], 'A drop-in keyring that cannot unwrap the root key should produce a critical status.' ); $this->assertStringContainsString( 'unwrap() configured to fail', $result['description'], 'The description should contain the keyring\'s own error.' ); @@ -249,7 +256,7 @@ public function test_key_source_test_reports_the_error_of_a_dropin_keyring_that_ * Runs in a separate process because WP_SECRETS_KEY is a constant and cannot be * undefined for later tests. * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -257,7 +264,7 @@ public function test_key_source_test_reports_the_error_of_a_dropin_keyring_that_ public function test_key_source_test_is_good_for_a_dedicated_base64_key(): void { define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'A', 32 ) ) ); - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'good', $result['status'], 'A base64-encoded 32-byte WP_SECRETS_KEY should produce a good status.' ); } @@ -268,7 +275,7 @@ public function test_key_source_test_is_good_for_a_dedicated_base64_key(): void * Runs in a separate process because the key manager is cached in a static variable * the first time it is requested, so the broken flag must be set before then. * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -276,7 +283,7 @@ public function test_key_source_test_is_good_for_a_dedicated_base64_key(): void public function test_key_source_test_is_critical_when_the_keyring_is_broken(): void { $GLOBALS['wp_secrets_dropin_broken'] = true; - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'critical', $result['status'], 'A broken secrets drop-in should produce a critical status.' ); } @@ -288,7 +295,7 @@ public function test_key_source_test_is_critical_when_the_keyring_is_broken(): v * * @ticket 66380 * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -305,7 +312,7 @@ static function ( $translation, $text ) { 2 ); - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'critical', $result['status'], 'A broken secrets drop-in should produce a critical status on a translated site.' ); } @@ -317,7 +324,7 @@ static function ( $translation, $text ) { * * @ticket 66380 * - * @covers ::wp_secrets_site_health_test_key_source + * @covers WP_Site_Health::get_test_secrets_api_key_source * * @runInSeparateProcess * @preserveGlobalState disabled @@ -328,7 +335,7 @@ public function test_key_source_test_is_good_when_a_dropin_replaces_the_provider $GLOBALS['wp_secrets_provider'] = $provider; - $result = wp_secrets_site_health_test_key_source(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_key_source(); $this->assertSame( 'good', $result['status'], 'A provider drop-in should produce a good status.' ); $this->assertStringContainsString( 'Example Vault', $result['description'], 'The description should name the provider.' ); @@ -339,7 +346,7 @@ public function test_key_source_test_is_good_when_a_dropin_replaces_the_provider * * @ticket 66380 * - * @covers ::wp_secrets_site_health_test_undecryptable + * @covers WP_Site_Health::get_test_secrets_api_undecryptable * * @runInSeparateProcess * @preserveGlobalState disabled @@ -347,7 +354,7 @@ public function test_key_source_test_is_good_when_a_dropin_replaces_the_provider public function test_undecryptable_test_is_critical_when_the_secrets_cannot_be_listed(): void { $GLOBALS['wp_secrets_dropin_broken'] = true; - $result = wp_secrets_site_health_test_undecryptable(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_undecryptable(); $this->assertSame( 'critical', $result['status'], 'A provider that cannot list secrets should produce a critical status.' ); } @@ -357,7 +364,7 @@ public function test_undecryptable_test_is_critical_when_the_secrets_cannot_be_l * * @ticket 66380 * - * @covers ::wp_secrets_site_health_test_needs_rotation + * @covers WP_Site_Health::get_test_secrets_api_needs_rotation * * @runInSeparateProcess * @preserveGlobalState disabled @@ -365,7 +372,7 @@ public function test_undecryptable_test_is_critical_when_the_secrets_cannot_be_l public function test_needs_rotation_test_is_recommended_when_the_secrets_cannot_be_listed(): void { $GLOBALS['wp_secrets_dropin_broken'] = true; - $result = wp_secrets_site_health_test_needs_rotation(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_needs_rotation(); $this->assertSame( 'recommended', $result['status'], 'A provider that cannot list secrets should produce a recommended status.' ); } @@ -373,12 +380,12 @@ public function test_needs_rotation_test_is_recommended_when_the_secrets_cannot_ /** * Ensure that the undecryptable check passes when every stored secret decrypts. * - * @covers ::wp_secrets_site_health_test_undecryptable + * @covers WP_Site_Health::get_test_secrets_api_undecryptable */ public function test_undecryptable_test_is_good_when_nothing_is_broken(): void { $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); - $result = wp_secrets_site_health_test_undecryptable(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_undecryptable(); $this->assertSame( 'good', $result['status'], 'The status should be good when every secret decrypts.' ); } @@ -386,10 +393,10 @@ public function test_undecryptable_test_is_good_when_nothing_is_broken(): void { /** * Ensure that the undecryptable check passes when no secrets are stored. * - * @covers ::wp_secrets_site_health_test_undecryptable + * @covers WP_Site_Health::get_test_secrets_api_undecryptable */ public function test_undecryptable_test_is_good_with_no_secrets_at_all(): void { - $result = wp_secrets_site_health_test_undecryptable(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_undecryptable(); $this->assertSame( 'good', $result['status'], 'The status should be good when no secrets are stored.' ); } @@ -399,7 +406,7 @@ public function test_undecryptable_test_is_good_with_no_secrets_at_all(): void { * * The stored ciphertext is overwritten to simulate a record that no longer decrypts. * - * @covers ::wp_secrets_site_health_test_undecryptable + * @covers WP_Site_Health::get_test_secrets_api_undecryptable */ public function test_undecryptable_test_is_critical_and_lists_the_broken_secret(): void { $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); @@ -417,7 +424,7 @@ public function test_undecryptable_test_is_critical_and_lists_the_broken_secret( $record['current']['ct'] = base64_encode( 'not decryptable' ); update_option( '_wp_secret_myplugin/api-key', $record, false ); - $result = wp_secrets_site_health_test_undecryptable(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_undecryptable(); $this->assertSame( 'critical', $result['status'], 'An undecryptable secret should produce a critical status.' ); $this->assertStringContainsString( 'myplugin/api-key', $result['description'], 'The description should name the undecryptable secret.' ); @@ -427,7 +434,7 @@ public function test_undecryptable_test_is_critical_and_lists_the_broken_secret( /** * Ensure that the undecryptable check's description never includes a secret's plaintext value. * - * @covers ::wp_secrets_site_health_test_undecryptable + * @covers WP_Site_Health::get_test_secrets_api_undecryptable */ public function test_undecryptable_test_never_contains_a_plaintext(): void { $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-9f3a' ), 'Setting myplugin/api-key to UNIQUE-PLAINTEXT-CANARY-9f3a should succeed.' ); @@ -441,7 +448,7 @@ public function test_undecryptable_test_never_contains_a_plaintext(): void { $record['current']['ct'] = base64_encode( 'not decryptable' ); update_option( '_wp_secret_myplugin/api-key', $record, false ); - $result = wp_secrets_site_health_test_undecryptable(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_undecryptable(); $this->assertStringNotContainsString( 'UNIQUE-PLAINTEXT-CANARY-9f3a', $result['description'], 'The description should never contain the plaintext value.' ); } @@ -449,12 +456,12 @@ public function test_undecryptable_test_never_contains_a_plaintext(): void { /** * Ensure that the rotation check passes for a secret stored directly with wp_set_secret(). * - * @covers ::wp_secrets_site_health_test_needs_rotation + * @covers WP_Site_Health::get_test_secrets_api_needs_rotation */ public function test_needs_rotation_test_is_good_by_default(): void { $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); - $result = wp_secrets_site_health_test_needs_rotation(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_needs_rotation(); $this->assertSame( 'good', $result['status'], 'A directly stored secret should not need rotation.' ); } @@ -462,13 +469,13 @@ public function test_needs_rotation_test_is_good_by_default(): void { /** * Ensure that the rotation check recommends rotating a secret imported from a plain option. * - * @covers ::wp_secrets_site_health_test_needs_rotation + * @covers WP_Site_Health::get_test_secrets_api_needs_rotation */ public function test_needs_rotation_test_is_recommended_after_an_import(): void { update_option( 'my_plugin_api_key', 'value' ); $this->assertTrue( wp_import_option_as_secret( 'my_plugin_api_key', 'myplugin/api-key' ), 'Importing my_plugin_api_key as myplugin/api-key should succeed.' ); - $result = wp_secrets_site_health_test_needs_rotation(); + $result = ( new WP_Site_Health() )->get_test_secrets_api_needs_rotation(); $this->assertSame( 'recommended', $result['status'], 'An imported secret should produce a recommended status.' ); $this->assertStringContainsString( '1 secret was imported', $result['description'], 'The description should report the number of secrets flagged for rotation.' ); @@ -477,12 +484,12 @@ public function test_needs_rotation_test_is_recommended_after_an_import(): void /** * Ensure that the debug information reports the provider, keyring, record version and secret count. * - * @covers ::wp_secrets_site_health_debug_info + * @covers WP_Debug_Data::get_wp_secrets_api */ public function test_debug_info_reports_expected_fields(): void { $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' ); - $info = wp_secrets_site_health_debug_info( array() ); + $info = $this->debug_section(); $fields = $this->debug_fields( $info ); $this->assertArrayHasKey( 'dropin_active', $fields, 'The debug fields should include dropin_active.' ); @@ -507,7 +514,7 @@ public function test_debug_info_reports_expected_fields(): void { * * @ticket 66380 * - * @covers ::wp_secrets_site_health_debug_info + * @covers WP_Debug_Data::get_wp_secrets_api * * @runInSeparateProcess * @preserveGlobalState disabled @@ -519,7 +526,7 @@ public function test_debug_info_does_not_describe_the_keyring_when_a_dropin_repl $GLOBALS['wp_secrets_provider'] = $provider; - $fields = $this->debug_fields( wp_secrets_site_health_debug_info( array() ) ); + $fields = $this->debug_fields( $this->debug_section() ); $this->assertSame( 'Not used by the provider', $fields['keyring_class']['value'], 'The keyring class should be marked as not used.' ); $this->assertSame( 'Not used by the provider', $fields['key_source']['value'], 'The key source should be marked as not used.' ); @@ -528,10 +535,10 @@ public function test_debug_info_does_not_describe_the_keyring_when_a_dropin_repl /** * Ensure that the debug information reports no active drop-in when none is installed. * - * @covers ::wp_secrets_site_health_debug_info + * @covers WP_Debug_Data::get_wp_secrets_api */ public function test_debug_info_reports_no_dropin_by_default(): void { - $info = wp_secrets_site_health_debug_info( array() ); + $info = $this->debug_section(); $fields = $this->debug_fields( $info ); @@ -542,26 +549,26 @@ public function test_debug_info_reports_no_dropin_by_default(): void { /** * Ensure that the debug information never includes a secret's plaintext value. * - * @covers ::wp_secrets_site_health_debug_info + * @covers WP_Debug_Data::get_wp_secrets_api */ public function test_debug_info_never_contains_a_plaintext(): void { $this->assertTrue( wp_set_secret( 'myplugin/api-key', 'UNIQUE-PLAINTEXT-CANARY-9f3a' ), 'Setting myplugin/api-key to UNIQUE-PLAINTEXT-CANARY-9f3a should succeed.' ); - $info = wp_secrets_site_health_debug_info( array() ); + $info = $this->debug_section(); - $dump = wp_json_encode( $info['secrets-api'] ); + $dump = wp_json_encode( $info ); - $this->assertIsString( $dump, 'The secrets-api section should encode to a JSON string.' ); + $this->assertIsString( $dump, 'The Secrets API section should encode to a JSON string.' ); $this->assertStringNotContainsString( 'UNIQUE-PLAINTEXT-CANARY-9f3a', $dump, 'The debug information should never contain the plaintext value.' ); } /** * Ensure that the debug information omits network secret counts on a single site install. * - * @covers ::wp_secrets_site_health_debug_info + * @covers WP_Debug_Data::get_wp_secrets_api */ public function test_debug_info_omits_network_fields_on_single_site(): void { - $info = wp_secrets_site_health_debug_info( array() ); + $info = $this->debug_section(); $this->assertArrayNotHasKey( 'network_secret_count', $this->debug_fields( $info ), 'Network secret counts should be omitted on a single site install.' ); } @@ -573,7 +580,7 @@ public function test_debug_info_omits_network_fields_on_single_site(): void { * @group multisite * @group ms-required * - * @covers ::wp_secrets_site_health_debug_info + * @covers WP_Debug_Data::get_wp_secrets_api */ public function test_debug_info_includes_network_fields_only_for_a_super_admin(): void { $this->skipWithoutMultisite(); @@ -581,12 +588,12 @@ public function test_debug_info_includes_network_fields_only_for_a_super_admin() $user_id = self::factory()->user->create(); wp_set_current_user( $user_id ); - $info = wp_secrets_site_health_debug_info( array() ); + $info = $this->debug_section(); $this->assertArrayNotHasKey( 'network_secret_count', $this->debug_fields( $info ), 'A non-super-admin should not see network secret counts.' ); grant_super_admin( $user_id ); - $info = wp_secrets_site_health_debug_info( array() ); + $info = $this->debug_section(); $this->assertArrayHasKey( 'network_secret_count', $this->debug_fields( $info ), 'A super admin should see network secret counts.' ); } }