Make WordPress Core


Ignore:
Timestamp:
10/13/2026 07:54:18 PM (7 hours 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.

File:
1 edited

Legend:

Unmodified
Added
Removed
  • 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 *
Note: See TracChangeset for help on using the changeset viewer.