diff --git a/src/wp-admin/includes/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php index 646bc4a918bf5..bbf97da012a1f 100644 --- a/src/wp-admin/includes/secrets-site-health.php +++ b/src/wp-admin/includes/secrets-site-health.php @@ -73,7 +73,22 @@ function wp_secrets_site_health_result( string $test, string $label, string $sta } /** - * Tests whether the site key comes from a dedicated constant or a weaker fallback. + * 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 * @@ -82,9 +97,32 @@ 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(); + $provider = _wp_secrets_get_provider(); - if ( str_contains( $key_source, 'broken' ) ) { + 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' ),
@@ -96,26 +134,111 @@ function wp_secrets_site_health_test_key_source(): array {
);
}
- if ( str_contains( $key_source, 'legacy interpretation' ) ) {
+ /*
+ * 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 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 + ? 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 ( str_contains( $key_source, 'LOGGED_IN_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 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 + ? 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.' )
)
);
}
@@ -143,8 +266,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 ) ) { @@ -176,9 +314,13 @@ function wp_secrets_site_health_test_undecryptable(): array { 'critical', sprintf( '%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.' ) + 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'
+ )
)
);
}
@@ -189,15 +331,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%s
', esc_html( $count->get_error_message() ) ) + ); } if ( 0 === $count ) { @@ -268,15 +421,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; @@ -302,8 +455,9 @@ function wp_secrets_site_health_count_needing_rotation( bool $network ): int { * @return arrayWP_SECRETS_KEY line', $result['description'], 'The description should ask for the line to be replaced.' );
}
/**
- * Ensure that the key source check recommends regenerating a WP_SECRETS_KEY not in base64 form.
+ * 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 says a legacy-form WP_SECRETS_KEY can still be replaced before secrets exist.
*
* Runs in a separate process because WP_SECRETS_KEY is a constant and cannot be
* undefined for later tests.
@@ -96,7 +144,103 @@ 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'], 'A legacy WP_SECRETS_KEY should produce a recommended status.' );
- $this->assertStringContainsString( 'generate-key', $result['description'], 'The description should mention generate-key for regenerating the key.' );
+ $this->assertStringContainsString( 'can still be replaced', $result['description'], 'The description should say that the key can still be replaced.' );
+ $this->assertStringNotContainsString( 'generate-key', $result['description'], 'The description should not mention generate-key, which is not a core or WP-CLI command.' );
+ }
+
+ /**
+ * Ensure that the key source check says to keep a legacy-form WP_SECRETS_KEY once secrets are stored.
+ *
+ * Replacing the key then would make the stored secrets unreadable.
+ *
+ * @ticket 66380
+ *
+ * @covers ::wp_secrets_site_health_test_key_source
+ *
+ * @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' ), 'Setting myplugin/api-key to value should succeed.' );
+
+ $result = wp_secrets_site_health_test_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.' );
+ }
+
+ /**
+ * Ensure that the key source check is critical when the key cannot unwrap the stored root key.
+ *
+ * @ticket 66380
+ *
+ * @covers ::wp_secrets_site_health_test_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.' );
+
+ // 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'], '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.' );
+ }
+
+ /**
+ * Ensure that the key source check is critical when WP_SECRETS_KEY was removed after secrets were stored.
+ *
+ * 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
+ *
+ * @covers ::wp_secrets_site_health_test_key_source
+ *
+ * @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'], '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.' );
+ }
+
+ /**
+ * Ensure that the key source check reports the error of a drop-in keyring that cannot unwrap the root key.
+ *
+ * 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
+ *
+ * @covers ::wp_secrets_site_health_test_key_source
+ *
+ * @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'], '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.' );
+ $this->assertStringNotContainsString( 'WP_SECRETS_KEY', $result['description'], 'The description should not mention WP_SECRETS_KEY for a drop-in keyring.' );
}
/**
@@ -137,6 +281,95 @@ public function test_key_source_test_is_critical_when_the_keyring_is_broken(): v
$this->assertSame( 'critical', $result['status'], 'A broken secrets drop-in should produce a critical status.' );
}
+ /**
+ * Ensure that the key source check is critical for a broken drop-in on a translated site.
+ *
+ * The broken keyring describes itself with a translated string.
+ *
+ * @ticket 66380
+ *
+ * @covers ::wp_secrets_site_health_test_key_source
+ *
+ * @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'], 'A broken secrets drop-in should produce a critical status on a translated site.' );
+ }
+
+ /**
+ * Ensure that the key source check passes when a drop-in replaces the provider.
+ *
+ * A provider from a secrets.php drop-in does not use the default keyring.
+ *
+ * @ticket 66380
+ *
+ * @covers ::wp_secrets_site_health_test_key_source
+ *
+ * @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'], 'A provider drop-in should produce a good status.' );
+ $this->assertStringContainsString( 'Example Vault', $result['description'], 'The description should name the provider.' );
+ }
+
+ /**
+ * Ensure that the undecryptable check is critical when the secrets cannot be listed.
+ *
+ * @ticket 66380
+ *
+ * @covers ::wp_secrets_site_health_test_undecryptable
+ *
+ * @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'], 'A provider that cannot list secrets should produce a critical status.' );
+ }
+
+ /**
+ * Ensure that the rotation check is recommended when the secrets cannot be listed.
+ *
+ * @ticket 66380
+ *
+ * @covers ::wp_secrets_site_health_test_needs_rotation
+ *
+ * @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'], 'A provider that cannot list secrets should produce a recommended status.' );
+ }
+
/**
* Ensure that the undecryptable check passes when every stored secret decrypts.
*
@@ -267,6 +500,31 @@ public function test_debug_info_reports_expected_fields(): void {
$this->assertSame( '1', $fields['site_secret_count']['value'], 'The site secret count should be 1.' );
}
+ /**
+ * Ensure that the debug information does not describe the keyring when a drop-in replaces the provider.
+ *
+ * A provider from a secrets.php drop-in does not use the default keyring.
+ *
+ * @ticket 66380
+ *
+ * @covers ::wp_secrets_site_health_debug_info
+ *
+ * @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'], '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.' );
+ }
+
/**
* Ensure that the debug information reports no active drop-in when none is installed.
*
diff --git a/tests/phpunit/tests/secrets/threeStateContract.php b/tests/phpunit/tests/secrets/threeStateContract.php
index 8fc1adadac3db..14e02368aa730 100644
--- a/tests/phpunit/tests/secrets/threeStateContract.php
+++ b/tests/phpunit/tests/secrets/threeStateContract.php
@@ -124,6 +124,8 @@ 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 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.
*
* @covers ::wp_get_secret
*
@@ -131,13 +133,11 @@ public function test_aad_mismatch_from_a_copied_record_is_wp_error_not_null(): v
* @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 ) )
);
$this->assertTrue( $provider->set( 'myplugin/api-key', 'value' ), 'Setting myplugin/api-key to value should succeed.' );
@@ -184,7 +184,7 @@ public function test_a_different_valid_key_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/wpIsSecretsApiAvailable.php b/tests/phpunit/tests/secrets/wpIsSecretsApiAvailable.php
new file mode 100644
index 0000000000000..39b45e829fd54
--- /dev/null
+++ b/tests/phpunit/tests/secrets/wpIsSecretsApiAvailable.php
@@ -0,0 +1,158 @@
+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 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.
+ *
+ * @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.' );
+ }
+}
diff --git a/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php b/tests/phpunit/tests/secrets/wpSecretsConfigKeyProvider.php
index bbc96a8f4a99c..ea8de58bada24 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 in a separate process. 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 in a separate process. The test bootstrap defines
+ * WP_SECRETS_KEY only outside separate processes.
*
* @ticket 66187
*
@@ -150,22 +148,59 @@ public function test_unwrap_rejects_a_tampered_ciphertext(): void {
}
/**
- * Ensure that the key source names LOGGED_IN_KEY when WP_SECRETS_KEY is not defined.
+ * 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_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.' );
- }
+ public function test_get_key_status_reports_an_undefined_key(): void {
+ $provider = new WP_Secrets_Config_Key_Provider();
- if ( defined( 'WP_SECRETS_KEY' ) ) {
- $this->markTestSkipped( 'Needs WP_SECRETS_KEY to be undefined; it is defined in this process.' );
- }
+ $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->assertStringContainsString( 'LOGGED_IN_KEY', $provider->get_key_source(), 'The key source should name LOGGED_IN_KEY when WP_SECRETS_KEY is unset.' );
+ $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.
+ *
+ * @covers WP_Secrets_Config_Key_Provider::get_site_key
+ *
+ * @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->assertWPError( $result, 'An undefined WP_SECRETS_KEY should return a WP_Error.' );
+ $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code(), 'An undefined WP_SECRETS_KEY should return the key unavailable error code.' );
+ $this->assertStringContainsString( 'WP_SECRETS_KEY', $result->get_error_message(), 'The error message should name WP_SECRETS_KEY.' );
}
/**
@@ -200,36 +235,78 @@ public static function data_is_canonical_base64_32(): array {
}
/**
- * Ensure that salt values are rejected when missing, empty, non-string or the sample placeholder.
+ * Ensure that a key value is rejected when empty, non-string, the sample placeholder or equal to a key or salt.
*
- * @covers WP_Secrets_Config_Key_Provider::are_usable_salt_values
+ * @covers WP_Secrets_Config_Key_Provider::is_usable_key_value
*
- * @dataProvider data_are_usable_salt_values
+ * @dataProvider data_is_usable_key_value
*
- * @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 ) ), 'are_usable_salt_values() should return the expected result for this pair.' );
+ $this->assertSame( $expected, $this->invoke_private( $provider, 'is_usable_key_value', array( $value, $auth_values ) ), 'is_usable_key_value() should return the expected result for this value.' );
}
/**
- * @return array