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’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.