diff --git a/src/wp-admin/includes/secrets-site-health.php b/src/wp-admin/includes/secrets-site-health.php index 646bc4a918bf5..b2a4c097f392f 100644 --- a/src/wp-admin/includes/secrets-site-health.php +++ b/src/wp-admin/includes/secrets-site-health.php @@ -73,7 +73,7 @@ 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. + * Tests whether the site key is defined, and in the recommended form. * * @since 7.2.0 * @@ -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,14 +110,45 @@ function wp_secrets_site_health_test_key_source(): array { ); } - 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.' )
)
);
}
diff --git a/src/wp-includes/secrets.php b/src/wp-includes/secrets.php
index 32b3a4b02e741..0a8163c3e86b4 100644
--- a/src/wp-includes/secrets.php
+++ b/src/wp-includes/secrets.php
@@ -693,6 +693,77 @@ 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, 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().
+ *
+ * @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 ) {
+ $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;
+}
+
/**
* A human-readable description of what is protecting this site's secrets.
*
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 819814f4886bc..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
@@ -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,55 @@ 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.
+ *
+ * @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.
*
@@ -172,6 +227,28 @@ public function unwrap( string $wrapped ) {
return $key_material;
}
+ /**
+ * 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 One of the KEY_STATUS_* constants.
+ */
+ public function get_key_status(): string {
+ if ( ! defined( 'WP_SECRETS_KEY' ) ) {
+ return self::KEY_STATUS_UNDEFINED;
+ }
+
+ if ( ! $this->is_usable_key_value( WP_SECRETS_KEY, $this->get_auth_constant_values() ) ) {
+ return self::KEY_STATUS_UNUSABLE;
+ }
+
+ 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.
*
@@ -180,17 +257,27 @@ 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)';
+ 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' );
}
-
- return 'derived from LOGGED_IN_KEY and LOGGED_IN_SALT';
}
/**
- * 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
*
@@ -199,75 +286,83 @@ 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 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 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 is_usable_key_value( $value, array $auth_values ): bool {
+ return is_string( $value )
+ && '' !== trim( $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 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 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 e29e44bfb0e5b..7e97dd55bc2b7 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 cde45645bd347..ace85df9982c2 100644
--- a/tests/phpunit/tests/secrets/siteHealth.php
+++ b/tests/phpunit/tests/secrets/siteHealth.php
@@ -64,19 +64,67 @@ public function test_preserves_existing_tests(): void {
}
/**
- * Ensure that the key source check recommends defining WP_SECRETS_KEY when it is not set.
+ * Ensure that the key source check offers a generated define line when WP_SECRETS_KEY is not defined.
+ *
+ * Runs in a separate process, where the test bootstrap does not define WP_SECRETS_KEY.
*
* @covers ::wp_secrets_site_health_test_key_source
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
*/
- 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.' );
- }
-
+ 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'], 'The key source status should be recommended without WP_SECRETS_KEY.' );
- $this->assertStringContainsString( 'WP_SECRETS_KEY', $result['description'], 'The description should recommend defining WP_SECRETS_KEY.' );
+ $this->assertStringContainsString( 'Add this line', $result['description'], 'The description should ask for the line to be added.' );
+ $this->assertMatchesRegularExpression( '/define\( 'WP_SECRETS_KEY', '([A-Za-z0-9+\/]{43}=)' \);/', $result['description'], 'The description should contain a define line with a base64-encoded 32-byte key.' );
+ }
+
+ /**
+ * Ensure that the key source check offers a replacement line when WP_SECRETS_KEY is the placeholder.
+ *
+ * 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
+ *
+ * @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'], '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.' );
+ }
+
+ /**
+ * 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.' );
}
/**
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