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 + * @return array */ - public static function data_are_usable_salt_values(): array { + public static function data_is_usable_key_value(): 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 ), + 'empty when trimmed' => array( ' ', array(), false ), + 'null (undefined)' => array( null, array(), false ), + 'not a string' => array( 12345, array(), false ), ); } + /** + * 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 + */ + 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, '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->assertSame( WP_Secrets_Config_Key_Provider::KEY_STATUS_UNUSABLE, $provider->get_key_status(), 'The key status should be unusable for the placeholder.' ); + } + + /** + * Ensure that the value of an authentication salt is not usable as WP_SECRETS_KEY. + * + * @covers WP_Secrets_Config_Key_Provider::get_site_key + * + * @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, 'The value of LOGGED_IN_SALT should return a WP_Error.' ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $result->get_error_code(), 'The value of LOGGED_IN_SALT should return the key unavailable error code.' ); + } + /** * Ensure that a canonical base64 WP_SECRETS_KEY is used as the decoded raw bytes. * @@ -315,34 +392,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.' ); } @@ -364,8 +445,7 @@ 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. + * Ensure that defining only the previous-key constant does not give the current-key provider a key. * * @covers WP_Secrets_Config_Key_Provider::get_site_key * @@ -373,22 +453,13 @@ public function test_use_previous_key_reads_the_previous_constant(): void { * @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, 'The current-key provider should derive a site key from the salts.' ); - $this->assertNotSame( str_repeat( 'C', 32 ), $key, 'The current-key provider should not use WP_SECRETS_KEY_PREVIOUS.' ); - $this->assertSame( - sodium_crypto_generichash( LOGGED_IN_KEY . LOGGED_IN_SALT, '', SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES ), - $key, - 'The current-key provider should use the key derived from LOGGED_IN_KEY and LOGGED_IN_SALT.' - ); + $this->assertWPError( $key, 'The current-key provider should have no key without WP_SECRETS_KEY.' ); + $this->assertSame( WP_SECRETS_ERROR_KEY_UNAVAILABLE, $key->get_error_code(), 'The current-key provider should return the key unavailable error code.' ); } /**