Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 182 additions & 28 deletions src/wp-admin/includes/secrets-site-health.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,22 @@ function wp_secrets_site_health_result( string $test, string $label, string $sta
}

/**
* Tests whether the site key comes from a dedicated constant or a weaker fallback.
* Whether the active provider protects secrets with the keyring.
*
* A secrets.php drop-in can replace the whole provider, which then protects secrets
* with its own keys. The default keyring, and WP_SECRETS_KEY, are not used at all.
*
* @since 7.2.0
*
* @param WP_Secrets_Provider $provider The active provider.
* @return bool
*/
function wp_secrets_site_health_uses_keyring( WP_Secrets_Provider $provider ): bool {
return $provider instanceof WP_Secrets_Libsodium_Provider || $provider instanceof WP_Secrets_Broken_Provider;
}

/**
* Tests whether the site key is defined, and in the recommended form.
*
* @since 7.2.0
*
Expand All @@ -82,9 +97,32 @@ function wp_secrets_site_health_result( string $test, string $label, string $sta
* @phpstan-return array{test: string, label: string, status: 'good'|'recommended'|'critical', badge: array{label: string, color: string}, description: string}
*/
function wp_secrets_site_health_test_key_source(): array {
$key_source = _wp_secrets_get_key_manager()->get_keyring()->get_key_source();
$provider = _wp_secrets_get_provider();

if ( str_contains( $key_source, 'broken' ) ) {
if ( ! wp_secrets_site_health_uses_keyring( $provider ) ) {
return wp_secrets_site_health_result(
'secrets_api_key_source',
__( 'Secrets API keys are managed by the provider' ),
'good',
sprintf(
'<p>%s</p>',
sprintf(
/* translators: 1: secrets.php, 2: Name of the active secrets provider, 3: WP_SECRETS_KEY */
__( 'Secrets are protected by the provider of the %1$s drop-in: %2$s. %3$s is not used.' ),
'<code>secrets.php</code>',
esc_html( $provider->get_label() ),
'<code>WP_SECRETS_KEY</code>'
)
)
);
}

$keyring = _wp_secrets_get_key_manager()->get_keyring();
$key_source = $keyring->get_key_source();
$key_status = $keyring instanceof WP_Secrets_Config_Key_Provider ? $keyring->get_key_status() : null;

// The broken keyring's description is translated, so check its class rather than its text.
if ( $keyring instanceof WP_Secrets_Broken_Keyring ) {
return wp_secrets_site_health_result(
'secrets_api_key_source',
__( 'Secrets API key source is broken' ),
Expand All @@ -96,26 +134,111 @@ function wp_secrets_site_health_test_key_source(): array {
);
}

if ( str_contains( $key_source, 'legacy interpretation' ) ) {
/*
* Once a root key exists, it is wrapped with the current site key, and core has no way
* to re-wrap it under another one. Changing the key source then locks every stored
* secret, and blocks saving new ones, until the change is reverted.
*/
$has_root_key = false !== get_site_option( WP_Secrets_Key_Manager::ROOT_KEY_OPTION );

if ( $has_root_key ) {
$root_key = _wp_secrets_get_key_manager()->get_root_key();

if ( is_wp_error( $root_key ) ) {
/*
* Only the default keyring reads wp-config.php. A keyring from a secrets.php
* drop-in fails for its own reasons, such as an unreachable key service, and
* its error is the useful part.
*/
$cause = $keyring instanceof WP_Secrets_Config_Key_Provider
? sprintf(
/* translators: %s: WP_SECRETS_KEY */
__( 'This happens when %s was changed or removed after secrets were stored. Restoring the previous value fixes it.' ),
'<code>WP_SECRETS_KEY</code>'
)
: sprintf(
/* translators: 1: secrets.php, 2: Error message from the keyring. */
__( 'The keyring of the %1$s drop-in reported: %2$s' ),
'<code>secrets.php</code>',
esc_html( $root_key->get_error_message() )
);

return wp_secrets_site_health_result(
'secrets_api_key_source',
__( 'Secrets API cannot unlock the stored secrets' ),
'critical',
sprintf(
'<p>%s</p><p>%s</p>',
esc_html__( 'The configured key cannot decrypt the root key that protects the stored secrets, so no secret can be read or saved.' ),
$cause
)
);
}

wp_secrets_memzero( $root_key );
}

if ( WP_Secrets_Config_Key_Provider::KEY_STATUS_LEGACY === $key_status ) {
return wp_secrets_site_health_result(
'secrets_api_key_source',
__( 'Secrets API key should be regenerated' ),
__( 'Secrets API key is not in the recommended form' ),
'recommended',
sprintf(
'<p>%s</p>',
esc_html__( 'WP_SECRETS_KEY is defined, but not in the recommended base64-encoded 32-byte form. Regenerate it with `wp secret generate-key`.' )
$has_root_key
? sprintf(
/* translators: %s: WP_SECRETS_KEY */
__( '%s is defined, but not as a base64-encoded random 32-byte value. Keep its current value: secrets are already encrypted with it, and changing it makes them unreadable.' ),
'<code>WP_SECRETS_KEY</code>'
)
: sprintf(
/* translators: %s: WP_SECRETS_KEY */
__( '%s is defined, but not as a base64-encoded random 32-byte value. No secrets are stored yet, so it can still be replaced with such a value.' ),
'<code>WP_SECRETS_KEY</code>'
)
)
);
}

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.' )
)
);
}
Expand Down Expand Up @@ -143,8 +266,23 @@ function wp_secrets_site_health_test_key_source(): array {
function wp_secrets_site_health_test_undecryptable(): array {
$broken = wp_secrets_site_health_find_undecryptable( false );

if ( is_multisite() && is_super_admin() ) {
$broken = array_merge( $broken, wp_secrets_site_health_find_undecryptable( true ) );
if ( ! is_wp_error( $broken ) && is_multisite() && is_super_admin() ) {
$network_broken = wp_secrets_site_health_find_undecryptable( true );
$broken = is_wp_error( $network_broken ) ? $network_broken : array_merge( $broken, $network_broken );
}

// Secrets that cannot be listed cannot be checked, which must not read as all of them decrypting.
if ( is_wp_error( $broken ) ) {
return wp_secrets_site_health_result(
'secrets_api_undecryptable',
__( 'Secrets could not be checked' ),
'critical',
sprintf(
'<p>%s</p><p>%s</p>',
esc_html__( 'The stored secrets could not be listed, so none of them could be checked. Secrets probably cannot be read or written either until this is fixed.' ),
esc_html( $broken->get_error_message() )
)
);
}

if ( empty( $broken ) ) {
Expand Down Expand Up @@ -176,9 +314,13 @@ function wp_secrets_site_health_test_undecryptable(): array {
'critical',
sprintf(
'<p>%s</p><ul>%s</ul><p>%s</p>',
esc_html__( 'The following secrets exist but could not be decrypted with the current key. There is no way to recover the original value. The credential must be re-entered.' ),
esc_html__( 'The following secrets exist but could not be decrypted with the current key.' ),
$items,
esc_html__( 'This can happen after losing WP_SECRETS_KEY, restoring a database backup without its matching key, or a failed key rotation.' )
sprintf(
/* translators: %s: WP_SECRETS_KEY */
__( 'This can happen after %s was changed or removed, after restoring a database backup without its matching key, or after a failed key rotation. Restoring the previous key makes the secrets readable again. Otherwise, each credential must be entered again.' ),
'<code>WP_SECRETS_KEY</code>'
)
)
);
}
Expand All @@ -189,15 +331,16 @@ function wp_secrets_site_health_test_undecryptable(): array {
* @since 7.2.0
*
* @param bool $network Whether to check network-scope secrets.
* @return array List of array( 'name' => ..., 'fingerprint' => ... ).
* @return array|WP_Error List of array( 'name' => ..., 'fingerprint' => ... ),
* or WP_Error when the secrets cannot be listed.
*
* @phpstan-return list<array{name: string, fingerprint: string}>
* @phpstan-return list<array{name: string, fingerprint: string}>|WP_Error
*/
function wp_secrets_site_health_find_undecryptable( bool $network ): array {
function wp_secrets_site_health_find_undecryptable( bool $network ) {
$entries = $network ? wp_list_network_secrets() : wp_list_secrets();

if ( is_wp_error( $entries ) ) {
return array();
return $entries;
}

$broken = array();
Expand Down Expand Up @@ -228,8 +371,18 @@ function wp_secrets_site_health_find_undecryptable( bool $network ): array {
function wp_secrets_site_health_test_needs_rotation(): array {
$count = wp_secrets_site_health_count_needing_rotation( false );

if ( is_multisite() && is_super_admin() ) {
$count += wp_secrets_site_health_count_needing_rotation( true );
if ( ! is_wp_error( $count ) && is_multisite() && is_super_admin() ) {
$network_count = wp_secrets_site_health_count_needing_rotation( true );
$count = is_wp_error( $network_count ) ? $network_count : $count + $network_count;
}

if ( is_wp_error( $count ) ) {
return wp_secrets_site_health_result(
'secrets_api_needs_rotation',
__( 'Secrets could not be checked for pending rotation' ),
'recommended',
sprintf( '<p>%s</p>', esc_html( $count->get_error_message() ) )
);
}

if ( 0 === $count ) {
Expand Down Expand Up @@ -268,15 +421,15 @@ function wp_secrets_site_health_test_needs_rotation(): array {
* @since 7.2.0
*
* @param bool $network Whether to check network-scope secrets.
* @return int
* @return int|WP_Error The number of secrets, or WP_Error when the secrets cannot be listed.
*
* @phpstan-return int<0, max>
* @phpstan-return int<0, max>|WP_Error
*/
function wp_secrets_site_health_count_needing_rotation( bool $network ): int {
function wp_secrets_site_health_count_needing_rotation( bool $network ) {
$entries = $network ? wp_list_network_secrets() : wp_list_secrets();

if ( is_wp_error( $entries ) ) {
return 0;
return $entries;
}

$count = 0;
Expand All @@ -302,8 +455,9 @@ function wp_secrets_site_health_count_needing_rotation( bool $network ): int {
* @return array<string, array<string, mixed>>
*/
function wp_secrets_site_health_debug_info( $info ) {
$key_manager = _wp_secrets_get_key_manager();
$provider = _wp_secrets_get_provider();
$key_manager = _wp_secrets_get_key_manager();
$provider = _wp_secrets_get_provider();
$uses_keyring = wp_secrets_site_health_uses_keyring( $provider );

$fields = array(
'dropin_active' => array(
Expand All @@ -330,11 +484,11 @@ function wp_secrets_site_health_debug_info( $info ) {
),
'keyring_class' => array(
'label' => __( 'Keyring class' ),
'value' => get_class( $key_manager->get_keyring() ),
'value' => $uses_keyring ? get_class( $key_manager->get_keyring() ) : __( 'Not used by the provider' ),
),
'key_source' => array(
'label' => __( 'Key source' ),
'value' => $key_manager->get_keyring()->get_key_source(),
'value' => $uses_keyring ? $key_manager->get_keyring()->get_key_source() : __( 'Not used by the provider' ),
),
'record_version' => array(
'label' => __( 'Record format version' ),
Expand Down
71 changes: 71 additions & 0 deletions src/wp-includes/secrets.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Loading
Loading