From bdb709b09903f4051a3dfde3a36af8408a79745e Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Fri, 30 Oct 2026 11:46:26 -0700 Subject: [PATCH 1/2] Generate `WP_SECRETS_KEY` in `wp config create` WordPress 7.2 needs WP_SECRETS_KEY for the Secrets API. `wp config create` now writes it as a base64-encoded random 32-byte value, after the keys and salts group. `--skip-secrets-key` leaves it out, and a key defined in `--extra-php` is kept. `wp config shuffle-salts WP_SECRETS_KEY` now refuses, since changing the key makes every stored secret unreadable. Fixes #234. --- README.md | 7 ++-- features/config-create.feature | 46 +++++++++++++++++++++++++++ features/config-shuffle-salts.feature | 21 ++++++++++++ src/Config_Command.php | 32 ++++++++++++++++++- templates/wp-config.mustache | 12 +++++++ 5 files changed, 115 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d617ee78..6c6ca597 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ wp config delete [--type=] [--config-file=] Generates a wp-config.php file. ~~~ -wp config create [--dbname=] [--dbuser=] [--dbpass|db_password=] [--dbhost=] [--dbprefix=] [--dbcharset=] [--dbcollate=] [--locale=] [--extra-php] [--skip-salts] [--skip-check] [--force] [--config-file=] [--insecure] [--ssl] +wp config create [--dbname=] [--dbuser=] [--dbpass|db_password=] [--dbhost=] [--dbprefix=] [--dbcharset=] [--dbcollate=] [--locale=] [--extra-php] [--skip-salts] [--skip-secrets-key] [--skip-check] [--force] [--config-file=] [--insecure] [--ssl] ~~~ Creates a new wp-config.php with database constants, and verifies that @@ -257,6 +257,9 @@ the database constants are correct. [--skip-salts] If set, keys and salts won't be generated, but should instead be passed via `--extra-php`. + [--skip-secrets-key] + If set, `WP_SECRETS_KEY` won't be generated. WordPress 7.2 and later need it to store secrets with the Secrets API, so pass it via `--extra-php` instead. + [--skip-check] If set, the database connection is not checked. @@ -576,7 +579,7 @@ wp config shuffle-salts [...] [--force] [--config-file=] [--insecure **OPTIONS** [...] - One ore more keys to shuffle. If none are provided, this falls back to the default WordPress Core salt keys. + One ore more keys to shuffle. If none are provided, this falls back to the default WordPress Core salt keys. `WP_SECRETS_KEY` is not a salt and cannot be shuffled: changing it makes every stored secret unreadable. [--force] If an unknown key is requested to be shuffled, add it instead of throwing a warning. diff --git a/features/config-create.feature b/features/config-create.feature index 0dffcb36..f8c18bac 100644 --- a/features/config-create.feature +++ b/features/config-create.feature @@ -302,6 +302,52 @@ Feature: Create a wp-config file define( 'AUTH_SALT', """ + @require-php-7.0 + Scenario: Configure with a Secrets API key generated + Given an empty directory + And WP files + + When I run `wp config create {CORE_CONFIG_SETTINGS} --skip-check` + Then the wp-config.php file should contain: + """ + define( 'WP_SECRETS_KEY', + """ + + When I run `wp config get WP_SECRETS_KEY` + Then STDOUT should match /^[A-Za-z0-9+\/]{43}=$/ + + Scenario: Configure without a Secrets API key + Given an empty directory + And WP files + + When I run `wp config create {CORE_CONFIG_SETTINGS} --skip-check --skip-secrets-key` + Then the wp-config.php file should not contain: + """ + WP_SECRETS_KEY + """ + + Scenario: Configure with a Secrets API key passed via --extra-php + Given an empty directory + And WP files + + When I run `wp config create {CORE_CONFIG_SETTINGS} --skip-check --extra-php="define( 'WP_SECRETS_KEY', 'my-own-key' );"` + Then the wp-config.php file should contain: + """ + define( 'WP_SECRETS_KEY', 'my-own-key' ); + """ + + When I run `wp config get WP_SECRETS_KEY` + Then STDOUT should be: + """ + my-own-key + """ + + When I run `grep -c WP_SECRETS_KEY wp-config.php` + Then STDOUT should be: + """ + 1 + """ + Scenario: Values are properly escaped to avoid creating invalid config files Given an empty directory And WP files diff --git a/features/config-shuffle-salts.feature b/features/config-shuffle-salts.feature index 3acfcc81..3b555395 100644 --- a/features/config-shuffle-salts.feature +++ b/features/config-shuffle-salts.feature @@ -265,3 +265,24 @@ Feature: Refresh the salts in the wp-config.php file """ {NEW_KEY_ORIG} """ + + Scenario: The Secrets API key is not a salt + Given an empty directory + And WP files + And I run `wp config create {CORE_CONFIG_SETTINGS} --skip-check` + And I run `wp config get WP_SECRETS_KEY` + And save STDOUT as {SECRETS_KEY} + + When I run `wp config shuffle-salts` + And I run `wp config get WP_SECRETS_KEY` + Then STDOUT should be: + """ + {SECRETS_KEY} + """ + + When I try `wp config shuffle-salts WP_SECRETS_KEY --force` + Then STDERR should contain: + """ + WP_SECRETS_KEY is not a salt and cannot be shuffled + """ + And the return code should be 1 diff --git a/src/Config_Command.php b/src/Config_Command.php index 6b6ec671..8887eb6c 100644 --- a/src/Config_Command.php +++ b/src/Config_Command.php @@ -165,6 +165,9 @@ private static function get_initial_locale() { * [--skip-salts] * : If set, keys and salts won't be generated, but should instead be passed via `--extra-php`. * + * [--skip-secrets-key] + * : If set, `WP_SECRETS_KEY` won't be generated. WordPress 7.2 and later need it to store secrets with the Secrets API, so pass it via `--extra-php` instead. + * * [--skip-check] * : If set, the database connection is not checked. * @@ -311,6 +314,7 @@ public function create( $_, $assoc_args ) { [ 'keys-and-salts' => false, 'keys-and-salts-alt' => '', + 'secrets-key' => false, 'extra-php' => '', ] ); @@ -337,6 +341,23 @@ public function create( $_, $assoc_args ) { $template_args['extra-php'] = $extra_php; } + /* + * WordPress 7.2 and later need WP_SECRETS_KEY to store secrets. It is a base64-encoded + * random 32-byte value, unlike the salts, and it is left alone if --extra-php defines it. + */ + $extra_php_defines_secrets_key = is_string( $template_args['extra-php'] ) + && preg_match( '/define\s*\(\s*[\'"]WP_SECRETS_KEY[\'"]/', $template_args['extra-php'] ); + + if ( ! Utils\get_flag_value( $assoc_args, 'skip-secrets-key' ) && ! $extra_php_defines_secrets_key ) { + try { + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- The Secrets API reads the key as base64. + $assoc_args['secrets-key'] = base64_encode( random_bytes( 32 ) ); + $template_args['secrets-key'] = true; + } catch ( Exception $e ) { + WP_CLI::warning( 'Could not generate WP_SECRETS_KEY: ' . $e->getMessage() . ' Add it to the wp-config.php file to use the Secrets API.' ); + } + } + $command_root = Path::phar_safe( dirname( __DIR__ ) ); $out = Utils\mustache_render( "{$command_root}/templates/wp-config.mustache", $template_args ); @@ -441,6 +462,10 @@ public function create( $_, $assoc_args ) { $config_transformer->update( 'constant', $name, $assoc_args[ $arg_name ], [ 'add' => true ] ); } } + + if ( ! empty( $template_args['secrets-key'] ) ) { + $config_transformer->update( 'constant', 'WP_SECRETS_KEY', $assoc_args['secrets-key'], [ 'add' => true ] ); + } } catch ( Throwable $exception ) { $cleanup_error = ''; if ( $created_config_file && file_exists( $assoc_args['config-file'] ) ) { @@ -1246,7 +1271,7 @@ public function has( $args, $assoc_args ) { * ## OPTIONS * * [...] - * : One ore more keys to shuffle. If none are provided, this falls back to the default WordPress Core salt keys. + * : One ore more keys to shuffle. If none are provided, this falls back to the default WordPress Core salt keys. `WP_SECRETS_KEY` is not a salt and cannot be shuffled: changing it makes every stored secret unreadable. * * [--force] * : If an unknown key is requested to be shuffled, add it instead of throwing a warning. @@ -1275,6 +1300,11 @@ public function shuffle_salts( $args, $assoc_args ) { $keys = $args; $force = Utils\get_flag_value( $assoc_args, 'force', false ); + // Secrets stored with the Secrets API cannot be read once WP_SECRETS_KEY changes. + if ( in_array( 'WP_SECRETS_KEY', $keys, true ) ) { + WP_CLI::error( 'WP_SECRETS_KEY is not a salt and cannot be shuffled: changing it makes every secret stored with the Secrets API unreadable.' ); + } + $has_keys = ( ! empty( $keys ) ) ? true : false; if ( empty( $keys ) ) { diff --git a/templates/wp-config.mustache b/templates/wp-config.mustache index 9bcc9e69..da1bbadc 100644 --- a/templates/wp-config.mustache +++ b/templates/wp-config.mustache @@ -63,6 +63,18 @@ define( 'WP_CACHE_KEY_SALT', '{{wp-cache-key-salt}}' ); {{keys-and-salts-alt}} /**#@-*/ +{{#secrets-key}} + +/** + * Encryption key for the Secrets API. + * + * Secrets stored by WordPress and plugins are encrypted with this key. + * Unlike the keys and salts above, changing it makes every stored secret unreadable. + * + * @since 7.2.0 + */ +define( 'WP_SECRETS_KEY', '' ); +{{/secrets-key}} /** * WordPress database table prefix. From eebc57373f7ddf214474ebf96c99be78fcdf0f08 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Fri, 30 Oct 2026 12:36:49 -0700 Subject: [PATCH 2/2] Match `define` in any case when --extra-php sets the key PHP function names are case-insensitive, so `DEFINE( 'WP_SECRETS_KEY', ... )` in --extra-php was missed and an empty define came first. The constant name still matches exactly. Also tests that `shuffle-salts WP_SECRETS_KEY` is refused without --force. --- features/config-create.feature | 17 +++++++++++++++++ features/config-shuffle-salts.feature | 7 +++++++ src/Config_Command.php | 4 +++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/features/config-create.feature b/features/config-create.feature index f8c18bac..46419c3e 100644 --- a/features/config-create.feature +++ b/features/config-create.feature @@ -348,6 +348,23 @@ Feature: Create a wp-config file 1 """ + Scenario: Configure with a Secrets API key passed via --extra-php in upper case + Given an empty directory + And WP files + + When I run `wp config create {CORE_CONFIG_SETTINGS} --skip-check --extra-php="DEFINE( 'WP_SECRETS_KEY', 'my-own-key' );"` + And I run `grep -c WP_SECRETS_KEY wp-config.php` + Then STDOUT should be: + """ + 1 + """ + + When I run `wp config get WP_SECRETS_KEY` + Then STDOUT should be: + """ + my-own-key + """ + Scenario: Values are properly escaped to avoid creating invalid config files Given an empty directory And WP files diff --git a/features/config-shuffle-salts.feature b/features/config-shuffle-salts.feature index 3b555395..61b4e62c 100644 --- a/features/config-shuffle-salts.feature +++ b/features/config-shuffle-salts.feature @@ -280,6 +280,13 @@ Feature: Refresh the salts in the wp-config.php file {SECRETS_KEY} """ + When I try `wp config shuffle-salts WP_SECRETS_KEY` + Then STDERR should contain: + """ + WP_SECRETS_KEY is not a salt and cannot be shuffled + """ + And the return code should be 1 + When I try `wp config shuffle-salts WP_SECRETS_KEY --force` Then STDERR should contain: """ diff --git a/src/Config_Command.php b/src/Config_Command.php index 8887eb6c..8a802e4a 100644 --- a/src/Config_Command.php +++ b/src/Config_Command.php @@ -344,9 +344,11 @@ public function create( $_, $assoc_args ) { /* * WordPress 7.2 and later need WP_SECRETS_KEY to store secrets. It is a base64-encoded * random 32-byte value, unlike the salts, and it is left alone if --extra-php defines it. + * PHP function names are case-insensitive and constant names are not, so only `define` + * is matched in any case. */ $extra_php_defines_secrets_key = is_string( $template_args['extra-php'] ) - && preg_match( '/define\s*\(\s*[\'"]WP_SECRETS_KEY[\'"]/', $template_args['extra-php'] ); + && preg_match( '/(?i:define)\s*\(\s*[\'"]WP_SECRETS_KEY[\'"]/', $template_args['extra-php'] ); if ( ! Utils\get_flag_value( $assoc_args, 'skip-secrets-key' ) && ! $extra_php_defines_secrets_key ) { try {