Commit 8fa9c2c7fd for wordpress.org

commit 8fa9c2c7fd99edb8d337d85c34874ada95c2f092
Author: whyisjake <whyisjake@git.wordpress.org>
Date:   Tue Nov 10 15:24:58 2026 +0000

    Security: Require `WP_SECRETS_KEY` for the Secrets API.

    Without `WP_SECRETS_KEY`, the default keyring derived its key from `LOGGED_IN_KEY . LOGGED_IN_SALT`. Rotating the salts, which is routine advice, then locked every stored secret. The salts were also read directly rather than through `wp_salt()`, so an unfilled or translated placeholder from `wp-config-sample.php` could become the key.

    This removes the fallback. Without a usable `WP_SECRETS_KEY`, every call that needs the key returns `secret_key_unavailable`. A key is not usable when it is empty or only whitespace, when it is the `wp-config-sample.php` placeholder, or when it equals any of the authentication keys and salts.

    Site Health reports a missing or unusable key as recommended and shows a newly generated `define()` line to add. It reads the key's state from the new `WP_Secrets_Config_Key_Provider::get_key_status()` instead of parsing the translated key source.

    This also introduces `wp_is_secrets_api_available()`, so a caller can check before moving a credential into the Secrets API. It returns `false` when the `secrets.php` drop-in failed to load, when the default keyring has no usable key, or when the key cannot unwrap the stored root key.

    A site that stored secrets under the fallback keeps them. They can be read again once `WP_SECRETS_KEY` is set to a new key, `WP_SECRETS_KEY_PREVIOUS` is set to the old `LOGGED_IN_KEY . LOGGED_IN_SALT`, and the root key is re-wrapped with `WP_Secrets_Key_Manager::rotate_site_key()`.

    Follow-up to r64337.

    Props ericmann, peterwilsoncc, Clorith, dhrupo, wildworks, johnbillion, jorbin, digitalpritam.
    See #66187.

    Built from https://develop.svn.wordpress.org/trunk@65385


    git-svn-id: http://core.svn.wordpress.org/trunk@64524 1a063a9b-81f0-0310-95a4-ce76da25c4cd

diff --git a/wp-admin/includes/secrets-site-health.php b/wp-admin/includes/secrets-site-health.php
index 646bc4a918..b2a4c097f3 100644
--- a/wp-admin/includes/secrets-site-health.php
+++ b/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(
-				'<p>%s</p>',
-				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`.' )
+				'<p>%s</p><p>%s</p><p><code>%s</code></p><p>%s</p>',
+				$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.' ),
+						'<code>WP_SECRETS_KEY</code>',
+						'<code>wp-config.php</code>',
+						'<code>wp-config-sample.php</code>'
+					)
+					: 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.' ),
+						'<code>WP_SECRETS_KEY</code>',
+						'<code>wp-config.php</code>'
+					),
+				$is_defined
+					? sprintf(
+						/* translators: 1: WP_SECRETS_KEY, 2: wp-config.php */
+						__( 'Replace the %1$s line in %2$s with this one:' ),
+						'<code>WP_SECRETS_KEY</code>',
+						'<code>wp-config.php</code>'
+					)
+					: 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:' ),
+						'<code>wp-config.php</code>',
+						'<code>/* ' . __( 'That&#8217;s all, stop editing! Happy publishing.' ) . ' */</code>'
+					),
+				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/wp-includes/secrets.php b/wp-includes/secrets.php
index 32b3a4b02e..0a8163c3e8 100644
--- a/wp-includes/secrets.php
+++ b/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/wp-includes/secrets/class-wp-secrets-config-key-provider.php b/wp-includes/secrets/class-wp-secrets-config-key-provider.php
index 819814f488..64f253fd73 100644
--- a/wp-includes/secrets/class-wp-secrets-config-key-provider.php
+++ b/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 @@ final class WP_Secrets_Config_Key_Provider implements WP_Secrets_Keyring {
 		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 @@ final class WP_Secrets_Config_Key_Provider implements WP_Secrets_Keyring {
 	 * @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 @@ final class WP_Secrets_Config_Key_Provider implements WP_Secrets_Keyring {
 	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/wp-includes/version.php b/wp-includes/version.php
index 0ad187fa64..0e6651d151 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
  *
  * @global string $wp_version
  */
-$wp_version = '7.2-beta4-65384';
+$wp_version = '7.2-beta4-65385';

 /**
  * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.