[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

/wp-includes/secrets/ -> class-wp-secrets-key-manager.php (source)

   1  <?php
   2  /**
   3   * Secrets API: WP_Secrets_Key_Manager class
   4   *
   5   * @package WordPress
   6   * @subpackage Secrets
   7   * @since 7.2.0
   8   */
   9  
  10  /**
  11   * Manages the root key and derives per-scope master keys from it.
  12   *
  13   * Owns the root key's generation, storage, and rotation.
  14   *
  15   * There is exactly one root key per install: 32 random bytes, generated once, wrapped
  16   * by the active WP_Secrets_Keyring, and stored via update_site_option() (which is a
  17   * plain option on single-site, and network-wide on multisite). It is the only value a
  18   * site-key rotation ever re-wraps, on a single site or on a 500-site network.
  19   *
  20   * Master keys are derived from the root key on demand and never stored:
  21   *
  22   * - Site scope, blog N: sodium_crypto_kdf_derive_from_key( 32, N, 'wpsecsit', $root ).
  23   *   Distinct per blog, which shared network-wide salts otherwise cannot provide.
  24   * - Network scope: sodium_crypto_kdf_derive_from_key( 32, 0, 'wpsecnet', $root ).
  25   *   Subkey id 0 is reserved for this (blog ids start at 1) and the context string
  26   *   differs from the site path, so there is no collision between the two. Identical
  27   *   on every blog, so a network secret written on one blog reads on every other.
  28   *
  29   * One unwrapped copy of the root key lives in this object for the rest of the
  30   * request, in memory only, never in the object cache. It is replaced whenever the
  31   * stored wrapped value changes – a rotation, a re-wrap, a restore – so it is never
  32   * stale. Callers of get_root_key() still receive a copy and must zero it themselves;
  33   * this object's own copy is not theirs to zero. The practical effect: a remote
  34   * keyring (a KMS or HSM call) is invoked once per request, not once per secret.
  35   *
  36   * @since 7.2.0
  37   */
  38  final class WP_Secrets_Key_Manager {
  39  
  40      /**
  41       * Option name the wrapped root key is stored under.
  42       *
  43       * Always accessed through the *_site_option() functions directly, never through a
  44       * WP_Secrets_Store. Swapping the pluggable secret store therefore never relocates
  45       * the root key, and swapping the keyring never affects where ciphertext lives.
  46       *
  47       * @since 7.2.0
  48       * @var string
  49       */
  50      const ROOT_KEY_OPTION = '_wp_secrets_root_key';
  51  
  52      /**
  53       * KDF context for deriving a site-scope master key. Exactly 8 bytes.
  54       *
  55       * @since 7.2.0
  56       * @var string
  57       */
  58      const SITE_KDF_CONTEXT = 'wpsecsit';
  59  
  60      /**
  61       * KDF context for deriving the network-scope master key. Exactly 8 bytes.
  62       *
  63       * @since 7.2.0
  64       * @var string
  65       */
  66      const NETWORK_KDF_CONTEXT = 'wpsecnet';
  67  
  68      /**
  69       * Reserved KDF subkey id for network scope.
  70       *
  71       * Blog ids start at 1, so this can never collide with a site-scope subkey id.
  72       *
  73       * @since 7.2.0
  74       * @var int
  75       */
  76      const NETWORK_SUBKEY_ID = 0;
  77  
  78      /**
  79       * The keyring used to wrap and unwrap the root key.
  80       *
  81       * @since 7.2.0
  82       * @var WP_Secrets_Keyring
  83       */
  84      private $keyring;
  85  
  86      /**
  87       * The unwrapped root key currently cached for this request, or null if nothing
  88       * has been unwrapped yet.
  89       *
  90       * @since 7.2.0
  91       * @var string|null
  92       */
  93      private $cached_root_key = null;
  94  
  95      /**
  96       * The wrapped value $cached_root_key was unwrapped from, or null if nothing has
  97       * been unwrapped yet. Used to detect that the stored wrapped value changed
  98       * underneath this object (a rotation, a re-wrap, a restore) so the cache is not
  99       * served stale.
 100       *
 101       * @since 7.2.0
 102       * @var string|null
 103       */
 104      private $cached_wrapped = null;
 105  
 106      /**
 107       * Constructor.
 108       *
 109       * @since 7.2.0
 110       *
 111       * @param WP_Secrets_Keyring|null $keyring Keyring to use. Defaults to the
 112       *                                         built-in config-based provider.
 113       */
 114  	public function __construct( ?WP_Secrets_Keyring $keyring = null ) {
 115          $this->keyring = $keyring ? $keyring : new WP_Secrets_Config_Key_Provider();
 116      }
 117  
 118      /**
 119       * Returns the keyring in use.
 120       *
 121       * Used by Site Health and by `wp secret dropin` and `wp secret health`, which all
 122       * need to describe the active keyring without duplicating the logic that resolves
 123       * it.
 124       *
 125       * @since 7.2.0
 126       *
 127       * @return WP_Secrets_Keyring
 128       */
 129  	public function get_keyring(): WP_Secrets_Keyring {
 130          return $this->keyring;
 131      }
 132  
 133      /**
 134       * Derives a scope's master key from the root key.
 135       *
 136       * @since 7.2.0
 137       *
 138       * @param string   $scope   'site' or 'network'.
 139       * @param int|null $site_id Blog id for site scope. Defaults to the current blog.
 140       *                          Ignored for network scope.
 141       * @return string|WP_Error 32-byte master key on success. WP_Error on failure,
 142       *                         including when a caller passes an invalid scope or
 143       *                         site id – see WP_Secrets_Cipher::validate_common()
 144       *                         for why that is a WP_Error and not an exception.
 145       *
 146       * @phpstan-param 'site'|'network' $scope
 147       */
 148  	public function get_master_key( string $scope, ?int $site_id = null ) {
 149          if ( ! in_array( $scope, array( 'site', 'network' ), true ) ) {
 150              $message = __( 'The scope must be "site" or "network".' );
 151  
 152              _doing_it_wrong( __METHOD__, $message, '7.2.0' );
 153  
 154              return new WP_Error( WP_SECRETS_ERROR_INVALID_ARGUMENT, $message );
 155          }
 156  
 157          if ( 'network' === $scope ) {
 158              $subkey_id = self::NETWORK_SUBKEY_ID;
 159              $context   = self::NETWORK_KDF_CONTEXT;
 160          } else {
 161              $subkey_id = null === $site_id ? get_current_blog_id() : $site_id;
 162  
 163              if ( $subkey_id < 1 ) {
 164                  $message = __( 'The site id must be a positive integer.' );
 165  
 166                  _doing_it_wrong( __METHOD__, $message, '7.2.0' );
 167  
 168                  return new WP_Error( WP_SECRETS_ERROR_INVALID_ARGUMENT, $message );
 169              }
 170  
 171              $context = self::SITE_KDF_CONTEXT;
 172          }
 173  
 174          $root_key = $this->get_root_key();
 175  
 176          if ( is_wp_error( $root_key ) ) {
 177              return $root_key;
 178          }
 179  
 180          if ( ! function_exists( 'sodium_crypto_kdf_derive_from_key' ) ) {
 181              wp_secrets_memzero( $root_key );
 182  
 183              return new WP_Error(
 184                  WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE,
 185                  __( 'No libsodium implementation is available.' )
 186              );
 187          }
 188  
 189          $master_key = sodium_crypto_kdf_derive_from_key( 32, $subkey_id, $context, $root_key );
 190  
 191          wp_secrets_memzero( $root_key );
 192  
 193          return $master_key;
 194      }
 195  
 196      /**
 197       * Returns the root key, generating and persisting one on first use.
 198       *
 199       * @since 7.2.0
 200       *
 201       * @return string|WP_Error 32 raw bytes on success. WP_Error on failure.
 202       */
 203  	public function get_root_key() {
 204          $wrapped = $this->get_wrapped_root_key();
 205  
 206          if ( false === $wrapped ) {
 207              return $this->generate_root_key();
 208          }
 209  
 210          if ( ! is_string( $wrapped ) ) {
 211              return new WP_Error(
 212                  WP_SECRETS_ERROR_RECORD_MALFORMED,
 213                  __( 'The stored root key is not a string.' )
 214              );
 215          }
 216  
 217          if ( null !== $this->cached_root_key && $wrapped === $this->cached_wrapped ) {
 218              return $this->cached_root_key;
 219          }
 220  
 221          $root_key = $this->keyring->unwrap( $wrapped );
 222  
 223          if ( is_string( $root_key ) ) {
 224              $this->cached_wrapped  = $wrapped;
 225              $this->cached_root_key = $root_key;
 226          }
 227  
 228          return $root_key;
 229      }
 230  
 231      /**
 232       * Re-wraps the root key under a new keyring, without touching any stored secret.
 233       *
 234       * Every derived master key is unchanged by this: they come from the root key's
 235       * raw bytes, which rotation never alters, only its wrapping. No secret value is
 236       * ever re-encrypted as a result of a site-key rotation.
 237       *
 238       * @since 7.2.0
 239       *
 240       * @param WP_Secrets_Keyring $old_keyring Unwraps the current wrapped root key.
 241       * @param WP_Secrets_Keyring $new_keyring Wraps it again for storage.
 242       * @return true|WP_Error
 243       */
 244  	public function rotate_site_key( WP_Secrets_Keyring $old_keyring, WP_Secrets_Keyring $new_keyring ) {
 245          $wrapped = $this->get_wrapped_root_key();
 246  
 247          if ( ! is_string( $wrapped ) ) {
 248              return new WP_Error(
 249                  WP_SECRETS_ERROR_STORE_UNAVAILABLE,
 250                  __( 'No root key exists to rotate.' )
 251              );
 252          }
 253  
 254          if ( $old_keyring === $this->keyring && null !== $this->cached_root_key && $wrapped === $this->cached_wrapped ) {
 255              $root_key = $this->cached_root_key;
 256          } else {
 257              $root_key = $old_keyring->unwrap( $wrapped );
 258          }
 259  
 260          if ( is_wp_error( $root_key ) ) {
 261              return $root_key;
 262          }
 263  
 264          $rewrapped = $new_keyring->wrap( $root_key );
 265  
 266          if ( is_wp_error( $rewrapped ) ) {
 267              wp_secrets_memzero( $root_key );
 268  
 269              return $rewrapped;
 270          }
 271  
 272          /*
 273           * update_site_option() returns false both on genuine failure and when the
 274           * new value equals the old one – a documented ambiguity shared with
 275           * update_option(). In practice this never collides here: wrap() draws a
 276           * fresh random nonce every call, so the re-wrapped value is only equal to
 277           * the old one if this call somehow re-wrapped under the exact same nonce,
 278           * astronomically unlikely.
 279           */
 280          if ( ! update_site_option( self::ROOT_KEY_OPTION, $rewrapped ) ) {
 281              wp_secrets_memzero( $root_key );
 282  
 283              return new WP_Error(
 284                  WP_SECRETS_ERROR_STORE_UNAVAILABLE,
 285                  __( 'Could not store the re-wrapped root key.' )
 286              );
 287          }
 288  
 289          $this->cached_wrapped  = $rewrapped;
 290          $this->cached_root_key = $root_key;
 291  
 292          wp_secrets_memzero( $root_key );
 293  
 294          return true;
 295      }
 296  
 297      /**
 298       * Returns the wrapped root key, adopting a pre-conversion copy if needed.
 299       *
 300       * A single site converted to multisite keeps its wrapped root key in its own
 301       * (now main site's) options row: `update_site_option()` only starts writing to
 302       * sitemeta once `is_multisite()` is true, and the conversion process does not
 303       * move existing option rows. Without this, a network's first read would find no
 304       * sitemeta row and mint a brand new root key, stranding every secret written
 305       * before conversion.
 306       *
 307       * @since 7.2.0
 308       *
 309       * @return mixed The wrapped root key, or false if none exists anywhere. Anything
 310       *               but a string or false is a corrupt row, which the caller reports.
 311       */
 312  	private function get_wrapped_root_key() {
 313          $wrapped = get_site_option( self::ROOT_KEY_OPTION );
 314  
 315          if ( false !== $wrapped || ! is_multisite() ) {
 316              return $wrapped;
 317          }
 318  
 319          $main_site_id = get_main_site_id();
 320          $legacy       = get_blog_option( $main_site_id, self::ROOT_KEY_OPTION );
 321  
 322          if ( ! is_string( $legacy ) ) {
 323              return false;
 324          }
 325  
 326          if ( add_site_option( self::ROOT_KEY_OPTION, $legacy ) ) {
 327              delete_blog_option( $main_site_id, self::ROOT_KEY_OPTION );
 328          }
 329  
 330          // Re-read: handles a concurrent request that already adopted it.
 331          return get_site_option( self::ROOT_KEY_OPTION );
 332      }
 333  
 334      /**
 335       * Generates a new root key and persists it.
 336       *
 337       * Handles the race where two requests both find no root key at the same time.
 338       *
 339       * @since 7.2.0
 340       *
 341       * @return string|WP_Error
 342       */
 343  	private function generate_root_key() {
 344          $candidate = random_bytes( 32 );
 345  
 346          $wrapped = $this->keyring->wrap( $candidate );
 347  
 348          if ( is_wp_error( $wrapped ) ) {
 349              wp_secrets_memzero( $candidate );
 350  
 351              return $wrapped;
 352          }
 353  
 354          if ( add_site_option( self::ROOT_KEY_OPTION, $wrapped ) ) {
 355              $this->cached_wrapped  = $wrapped;
 356              $this->cached_root_key = $candidate;
 357  
 358              return $candidate;
 359          }
 360  
 361          // Lost the race: another request created one first. Use theirs, not ours.
 362          wp_secrets_memzero( $candidate );
 363  
 364          $existing = get_site_option( self::ROOT_KEY_OPTION );
 365  
 366          if ( ! is_string( $existing ) ) {
 367              return new WP_Error(
 368                  WP_SECRETS_ERROR_STORE_UNAVAILABLE,
 369                  __( 'Could not read the root key after losing the creation race.' )
 370              );
 371          }
 372  
 373          $root_key = $this->keyring->unwrap( $existing );
 374  
 375          if ( is_string( $root_key ) ) {
 376              $this->cached_wrapped  = $existing;
 377              $this->cached_root_key = $root_key;
 378          }
 379  
 380          return $root_key;
 381      }
 382  }


Generated : Wed Oct 14 08:20:33 2026 Cross-referenced by PHPXref