Make WordPress Core

Changeset 64337


Ignore:
Timestamp:
10/13/2026 07:54:18 PM (less than one hour ago)
Author:
whyisjake
Message:

Security: Introduce the Secrets API.

Credentials such as API keys and tokens are usually stored as plaintext options, so anyone with a database dump or backup can read them. The Secrets API gives plugins and core a place to store them encrypted instead, using wp_set_secret(), wp_get_secret(), wp_delete_secret() and related functions. Each secret keeps its previous version, so a credential can be rotated without breaking the code that reads it.

By default, secrets are encrypted with libsodium and stored in the options table. The encryption key never goes in the database: it comes from a WP_SECRETS_KEY constant in wp-config.php, or is derived from the LOGGED_IN_KEY and LOGGED_IN_SALT constants when that is not defined.

Hosts can use a different method by adding a wp-content/secrets.php drop-in, which loads just before must-use plugins, in the same way object-cache.php replaces the object cache. A drop-in can hand all storage to an external secrets manager, or replace only where secrets are stored or only where the encryption key comes from, such as a key management service. If a drop-in is broken, every Secrets API call returns a WP_Error rather than falling back to the default method, so a misconfigured host shows up as an error rather than as secrets that appear not to exist. Conformance test suites are included for drop-in authors.

Also adds the manage_secrets capability, granted to administrators through populate_roles_720() and upgrade_720(), the manage_network_secrets meta capability for super admins, and Site Health tests for the Secrets API.

This provides the encrypted storage requested in #64789, so the Connectors screen's API keys can move off plaintext options in a follow-up. The admin screen is deferred to 7.3, and the WP-CLI commands stay in the feature plugin.

Props ericmann, westonruter, mindctrl, audrasjb, whyisjake.
Fixes #66187. See #64789.

Location:
trunk
Files:
48 added
10 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-admin/includes/class-wp-site-health.php

    r63443 r64337  
    77 * @since 5.2.0
    88 */
     9
     10
     11require_once __DIR__ . '/secrets-site-health.php';
    912
    1013#[AllowDynamicProperties]
  • trunk/src/wp-admin/includes/schema.php

    r62334 r64337  
    733733        populate_roles_280();
    734734        populate_roles_300();
     735        populate_roles_720();
    735736
    736737        // Save the updated roles to the database.
    … …  
    969970}
    970971
     972/**
     973 * Create and modify WordPress roles for WordPress 7.2.
     974 *
     975 * @since 7.2.0
     976 */
     977function populate_roles_720(): void {
     978        $role = get_role( 'administrator' );
     979
     980        if ( ! empty( $role ) ) {
     981                $role->add_cap( 'manage_secrets' );
     982        }
     983}
     984
    971985if ( ! function_exists( 'install_network' ) ) :
    972986        /**
  • trunk/src/wp-admin/includes/upgrade.php

    r64163 r64337  
    893893        }
    894894
     895        if ( $wp_current_db_version < 64337 ) {
     896                upgrade_720();
     897        }
     898
    895899        maybe_disable_link_manager();
    896900
    … …  
    25042508
    25052509/**
     2510 * Executes changes made in WordPress 7.2.
     2511 *
     2512 * @ignore
     2513 * @since 7.2.0
     2514 *
     2515 * @global int $wp_current_db_version The old (current) database version.
     2516 */
     2517function upgrade_720(): void {
     2518        global $wp_current_db_version;
     2519
     2520        // Grant the Secrets API's site-level capability to administrators.
     2521        if ( $wp_current_db_version < 64337 ) {
     2522                populate_roles_720();
     2523        }
     2524}
     2525
     2526/**
    25062527 * Executes network-level upgrade routines.
    25072528 *
  • trunk/src/wp-includes/capabilities.php

    r64336 r64337  
    768768                case 'manage_network_themes':
    769769                case 'manage_network_options':
     770                case 'manage_network_secrets':
    770771                case 'upgrade_network':
    771772                        $caps[] = $cap;
  • trunk/src/wp-includes/load.php

    r64305 r64337  
    826826
    827827/**
     828 * Loads the secrets.php drop-in, if one exists, and records whether it left the
     829 * Secrets API's provider, store, and keyring overrides in a usable state.
     830 *
     831 * Runs once, before must-use plugins load, so that every interface a drop-in's
     832 * classes implement already exists and every plugin sees the drop-in's provider.
     833 *
     834 * Sets `$GLOBALS['wp_secrets_dropin_loaded']` as soon as the file is found, whether
     835 * or not it then loads cleanly: wp_using_secrets_dropin() reports presence, not
     836 * health.
     837 *
     838 * Sets `$GLOBALS['wp_secrets_dropin_broken']` when the require throws, or when any of
     839 * `$GLOBALS['wp_secrets_provider']`, `$GLOBALS['wp_secrets_store']`, or
     840 * `$GLOBALS['wp_secrets_keyring']` is set to something that does not implement its
     841 * interface. A global left unset is fine: a drop-in that overrides only the keyring
     842 * leaves the other two alone. Once the flag is set, every Secrets API operation
     843 * returns WP_Error for the rest of the request rather than falling back to the
     844 * default provider, so a broken drop-in reads as unreachable, never as absent.
     845 *
     846 * PHP treats a class that implements an interface but omits a required method as an
     847 * uncatchable fatal error, even inside the try/catch around the require. That case
     848 * cannot be intercepted from PHP code.
     849 *
     850 * @since 7.2.0
     851 * @access private
     852 */
     853function wp_load_secrets_dropin(): void {
     854        static $loaded = false;
     855
     856        if ( $loaded ) {
     857                return;
     858        }
     859
     860        $loaded = true;
     861
     862        $dropin_path = WP_CONTENT_DIR . '/secrets.php';
     863
     864        if ( ! file_exists( $dropin_path ) ) {
     865                return;
     866        }
     867
     868        $GLOBALS['wp_secrets_dropin_loaded'] = true;
     869
     870        try {
     871                require $dropin_path;
     872        } catch ( Throwable $e ) {
     873                $GLOBALS['wp_secrets_dropin_broken'] = true;
     874
     875                return;
     876        }
     877
     878        if ( isset( $GLOBALS['wp_secrets_store'] ) && ! ( $GLOBALS['wp_secrets_store'] instanceof WP_Secrets_Store ) ) {
     879                $GLOBALS['wp_secrets_dropin_broken'] = true;
     880        }
     881
     882        if ( isset( $GLOBALS['wp_secrets_keyring'] ) && ! ( $GLOBALS['wp_secrets_keyring'] instanceof WP_Secrets_Keyring ) ) {
     883                $GLOBALS['wp_secrets_dropin_broken'] = true;
     884        }
     885
     886        if ( isset( $GLOBALS['wp_secrets_provider'] ) && ! ( $GLOBALS['wp_secrets_provider'] instanceof WP_Secrets_Provider ) ) {
     887                $GLOBALS['wp_secrets_dropin_broken'] = true;
     888        }
     889}
     890
     891/**
    828892 * Starts the WordPress object cache.
    829893 *
  • trunk/src/wp-includes/version.php

    r63167 r64337  
    2424 * @global int $wp_db_version
    2525 */
    26 $wp_db_version = 61833;
     26$wp_db_version = 64337;
    2727
    2828/**
  • trunk/src/wp-settings.php

    r64327 r64337  
    312312require ABSPATH . WPINC . '/admin-bar.php';
    313313require ABSPATH . WPINC . '/class-wp-application-passwords.php';
     314require ABSPATH . WPINC . '/secrets.php';
     315require ABSPATH . WPINC . '/secrets/class-wp-secret-version.php';
     316require ABSPATH . WPINC . '/secrets/class-wp-secret.php';
     317require ABSPATH . WPINC . '/secrets/interface-wp-secrets-provider.php';
     318require ABSPATH . WPINC . '/secrets/interface-wp-secrets-keyring.php';
     319require ABSPATH . WPINC . '/secrets/interface-wp-secrets-store.php';
     320require ABSPATH . WPINC . '/secrets/class-wp-secrets-config-key-provider.php';
     321require ABSPATH . WPINC . '/secrets/class-wp-secrets-broken-keyring.php';
     322require ABSPATH . WPINC . '/secrets/class-wp-secrets-cipher.php';
     323require ABSPATH . WPINC . '/secrets/class-wp-secrets-key-manager.php';
     324require ABSPATH . WPINC . '/secrets/class-wp-secrets-option-store.php';
     325require ABSPATH . WPINC . '/secrets/class-wp-secrets-broken-store.php';
     326require ABSPATH . WPINC . '/secrets/class-wp-secrets-libsodium-provider.php';
     327require ABSPATH . WPINC . '/secrets/class-wp-secrets-broken-provider.php';
    314328require ABSPATH . WPINC . '/abilities-api/class-wp-ability-category.php';
    315329require ABSPATH . WPINC . '/abilities-api/class-wp-ability-categories-registry.php';
    … …  
    509523require_once ABSPATH . 'wp-admin/includes/plugin.php';
    510524
     525// Load the secrets.php drop-in, which may replace the Secrets API's provider, store, or keyring.
     526wp_load_secrets_dropin();
     527
    511528// Load must-use plugins.
    512529foreach ( wp_get_mu_plugins() as $mu_plugin ) {
  • trunk/tests/phpstan/base.neon

    r64237 r64337  
    133133                - HEADER_IMAGE_WIDTH
    134134                - HEADER_TEXTCOLOR
     135                - LOGGED_IN_KEY
     136                - LOGGED_IN_SALT
    135137                - MEDIA_TRASH
    136138                - MULTISITE
  • trunk/tests/phpunit/includes/bootstrap.php

    r63300 r64337  
    2929
    3030require_once $config_file_path;
     31
     32/*
     33 * The Secrets API will not derive a key from the placeholder salts that
     34 * wp-tests-config-sample.php ships with, so give the suite a fixed test key.
     35 *
     36 * Not in a process-isolated test: PHPUnit sets $GLOBALS['__PHPUNIT_BOOTSTRAP']
     37 * while it loads this file in a child process, and the Secrets API tests that
     38 * run isolated do so precisely to define WP_SECRETS_KEY themselves, or to
     39 * leave it undefined.
     40 */
     41if ( ! defined( 'WP_SECRETS_KEY' ) && ! isset( $GLOBALS['__PHPUNIT_BOOTSTRAP'] )
     42        && defined( 'LOGGED_IN_KEY' ) && 'put your unique phrase here' === LOGGED_IN_KEY
     43) {
     44        define( 'WP_SECRETS_KEY', base64_encode( str_repeat( 'k', 32 ) ) );
     45}
    3146require_once __DIR__ . '/functions.php';
    3247
    … …  
    334349require __DIR__ . '/class-wp-sitemaps-empty-test-provider.php';
    335350require __DIR__ . '/class-wp-sitemaps-large-test-provider.php';
     351require __DIR__ . '/secrets/trait-wp-secrets-assertions.php';
     352require __DIR__ . '/secrets/class-mock-store.php';
     353require __DIR__ . '/secrets/class-mock-keyring.php';
     354require __DIR__ . '/secrets/class-wp-secrets-provider-conformance.php';
     355require __DIR__ . '/secrets/class-wp-secrets-keyring-conformance.php';
    336356
    337357/**
  • trunk/tests/phpunit/tests/user/capabilities.php

    r64065 r64337  
    127127                        'list_users'              => array( 'administrator' ),
    128128                        'manage_options'          => array( 'administrator' ),
     129                        'manage_secrets'          => array( 'administrator' ),
    129130                        'promote_users'           => array( 'administrator' ),
    130131                        'remove_users'            => array( 'administrator' ),
    … …  
    210211                        'list_users'              => array( 'administrator' ),
    211212                        'manage_options'          => array( 'administrator' ),
     213                        'manage_secrets'          => array( 'administrator' ),
    212214                        'promote_users'           => array( 'administrator' ),
    213215                        'remove_users'            => array( 'administrator' ),
    … …  
    276278                        'manage_network_themes'       => array(),
    277279                        'manage_network_options'      => array(),
     280                        'manage_network_secrets'      => array(),
    278281                        'delete_site'                 => array(),
    279282                        'upgrade_network'             => array(),
    … …  
    315318                        'manage_network_themes'       => array(),
    316319                        'manage_network_options'      => array(),
     320                        'manage_network_secrets'      => array(),
    317321                        'setup_network'               => array(),
    318322                        'upload_plugins'              => array(),
Note: See TracChangeset for help on using the changeset viewer.