[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

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

   1  <?php
   2  /**
   3   * Secrets API: WP_Secrets_Config_Key_Provider class
   4   *
   5   * @package WordPress
   6   * @subpackage Secrets
   7   * @since 7.2.0
   8   */
   9  
  10  /**
  11   * The default keyring.
  12   *
  13   * Derives a site key from wp-config.php and uses it to wrap and unwrap the root key
  14   * with authenticated encryption.
  15   *
  16   * Site key derivation, in priority order:
  17   *
  18   * 1. WP_SECRETS_KEY is defined and base64-decodes (strictly) to exactly 32 bytes:
  19   *    those decoded bytes are used raw. This is the documented, recommended form;
  20   *    `wp secret generate-key` emits it.
  21   * 2. WP_SECRETS_KEY is defined in any other shape: the literal constant string is
  22   *    hashed with a keyed BLAKE2b to 32 bytes. This is the legacy interpretation –
  23   *    sites arriving from a prior plugin with a constant of arbitrary shape are not
  24   *    locked out of their own credentials by a hard failure here.
  25   * 3. WP_SECRETS_KEY is undefined: LOGGED_IN_KEY . LOGGED_IN_SALT is hashed the same
  26   *    way. Deliberately byte-identical to the legacy interpretation's hashing, not a
  27   *    coincidence – it is what makes salt-fallback sites migrate with zero
  28   *    credential re-entry.
  29   *
  30   * WP_SECRETS_KEY_PREVIOUS follows the same three rules and exists only so a site-key
  31   * rotation can unwrap under the old key before wrapping under the new one.
  32   *
  33   * @since 7.2.0
  34   */
  35  final class WP_Secrets_Config_Key_Provider implements WP_Secrets_Keyring {
  36  
  37      /**
  38       * AAD binding the root key's wrapped form to this purpose.
  39       *
  40       * Prevents the wrapped root key from being replayed as some other wrapped value.
  41       *
  42       * @since 7.2.0
  43       * @var string
  44       */
  45      const AAD = 'wp-secrets-root-key-v1';
  46  
  47      /**
  48       * The exact text wp-config-sample.php ships for every auth and salt constant.
  49       *
  50       * A site running with this literal value never ran the secret-key generator.
  51       * Treating it as usable would derive a key that any attacker can also derive.
  52       *
  53       * @since 7.2.0
  54       * @var string
  55       */
  56      const KNOWN_PLACEHOLDER = 'put your unique phrase here';
  57  
  58      /**
  59       * Whether to derive from WP_SECRETS_KEY_PREVIOUS instead of WP_SECRETS_KEY.
  60       *
  61       * @since 7.2.0
  62       * @var bool
  63       */
  64      private $use_previous_key;
  65  
  66      /**
  67       * Constructor.
  68       *
  69       * @since 7.2.0
  70       *
  71       * @param bool $use_previous_key Derive from WP_SECRETS_KEY_PREVIOUS rather than
  72       *                               WP_SECRETS_KEY. Used only during a site-key
  73       *                               rotation, to unwrap under the outgoing key.
  74       */
  75  	public function __construct( bool $use_previous_key = false ) {
  76          $this->use_previous_key = $use_previous_key;
  77      }
  78  
  79      /**
  80       * Wraps raw key material under the derived site key.
  81       *
  82       * @since 7.2.0
  83       *
  84       * @param string $key_material Raw key material to protect.
  85       * @return string|WP_Error
  86       */
  87  	public function wrap( string $key_material ) {
  88          if ( '' === $key_material ) {
  89              return new WP_Error(
  90                  WP_SECRETS_ERROR_INVALID_VALUE,
  91                  __( 'Key material to wrap must not be empty.' )
  92              );
  93          }
  94  
  95          if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt' ) ) {
  96              return new WP_Error(
  97                  WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE,
  98                  __( 'No libsodium implementation is available.' )
  99              );
 100          }
 101  
 102          $site_key = $this->get_site_key();
 103  
 104          if ( is_wp_error( $site_key ) ) {
 105              return $site_key;
 106          }
 107  
 108          $nonce      = random_bytes( SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES );
 109          $ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt( $key_material, self::AAD, $nonce, $site_key );
 110  
 111          wp_secrets_memzero( $site_key );
 112  
 113          return base64_encode( $nonce . $ciphertext );
 114      }
 115  
 116      /**
 117       * Unwraps key material previously wrapped by wrap().
 118       *
 119       * @since 7.2.0
 120       *
 121       * @param string $wrapped An opaque value previously returned by wrap().
 122       * @return string|WP_Error
 123       */
 124  	public function unwrap( string $wrapped ) {
 125          if ( '' === $wrapped ) {
 126              return new WP_Error(
 127                  WP_SECRETS_ERROR_KEY_UNAVAILABLE,
 128                  __( 'Nothing to unwrap.' )
 129              );
 130          }
 131  
 132          if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_decrypt' ) ) {
 133              return new WP_Error(
 134                  WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE,
 135                  __( 'No libsodium implementation is available.' )
 136              );
 137          }
 138  
 139          $raw          = base64_decode( $wrapped, true );
 140          $nonce_length = SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES;
 141  
 142          if ( false === $raw || strlen( $raw ) <= $nonce_length ) {
 143              return new WP_Error(
 144                  WP_SECRETS_ERROR_KEY_UNAVAILABLE,
 145                  __( 'The wrapped key material is malformed.' )
 146              );
 147          }
 148  
 149          $nonce      = substr( $raw, 0, $nonce_length );
 150          $ciphertext = substr( $raw, $nonce_length );
 151  
 152          $site_key = $this->get_site_key();
 153  
 154          if ( is_wp_error( $site_key ) ) {
 155              return $site_key;
 156          }
 157  
 158          $key_material = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( $ciphertext, self::AAD, $nonce, $site_key );
 159  
 160          wp_secrets_memzero( $site_key );
 161  
 162          if ( ! is_string( $key_material ) ) {
 163              return new WP_Error(
 164                  WP_SECRETS_ERROR_KEY_UNAVAILABLE,
 165                  __( 'The wrapped key material could not be decrypted with the configured site key.' )
 166              );
 167          }
 168  
 169          return $key_material;
 170      }
 171  
 172      /**
 173       * Describes the active key source, for Site Health.
 174       *
 175       * @since 7.2.0
 176       *
 177       * @return string
 178       */
 179  	public function get_key_source(): string {
 180          if ( defined( 'WP_SECRETS_KEY' ) ) {
 181              return $this->is_canonical_base64_32( WP_SECRETS_KEY )
 182                  ? 'WP_SECRETS_KEY (base64-encoded 32 bytes)'
 183                  : 'WP_SECRETS_KEY (legacy interpretation, hashed as an opaque string)';
 184          }
 185  
 186          return 'derived from LOGGED_IN_KEY and LOGGED_IN_SALT';
 187      }
 188  
 189      /**
 190       * Derives the 32-byte site key per the priority rules documented on this class.
 191       *
 192       * @since 7.2.0
 193       *
 194       * @return string|WP_Error
 195       */
 196  	private function get_site_key() {
 197          $constant_name = $this->use_previous_key ? 'WP_SECRETS_KEY_PREVIOUS' : 'WP_SECRETS_KEY';
 198  
 199          if ( defined( $constant_name ) ) {
 200              $raw_constant = constant( $constant_name );
 201  
 202              if ( ! is_string( $raw_constant ) || '' === $raw_constant ) {
 203                  return new WP_Error(
 204                      WP_SECRETS_ERROR_KEY_UNAVAILABLE,
 205                      sprintf(
 206                          /* translators: %s: PHP constant name. */
 207                          __( '%s is defined but is not a usable string.' ),
 208                          $constant_name
 209                      )
 210                  );
 211              }
 212  
 213              $decoded = $this->decode_canonical_base64_32( $raw_constant );
 214  
 215              if ( null !== $decoded ) {
 216                  return $decoded;
 217              }
 218  
 219              return sodium_crypto_generichash( $raw_constant, '', SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES );
 220          }
 221  
 222          if ( $this->use_previous_key ) {
 223              return new WP_Error(
 224                  WP_SECRETS_ERROR_KEY_UNAVAILABLE,
 225                  __( 'WP_SECRETS_KEY_PREVIOUS is not defined.' )
 226              );
 227          }
 228  
 229          $logged_in_key  = defined( 'LOGGED_IN_KEY' ) ? LOGGED_IN_KEY : null;
 230          $logged_in_salt = defined( 'LOGGED_IN_SALT' ) ? LOGGED_IN_SALT : null;
 231  
 232          if ( ! $this->are_usable_salt_values( $logged_in_key, $logged_in_salt ) ) {
 233              return new WP_Error(
 234                  WP_SECRETS_ERROR_KEY_UNAVAILABLE,
 235                  __( 'WP_SECRETS_KEY is not defined, and LOGGED_IN_KEY/LOGGED_IN_SALT are not usable (undefined, empty, or left at the wp-config-sample.php placeholder).' )
 236              );
 237          }
 238  
 239          return sodium_crypto_generichash( $logged_in_key . $logged_in_salt, '', SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES );
 240      }
 241  
 242      /**
 243       * Whether a pair of candidate salt values are usable as key material.
 244       *
 245       * Both must be non-empty strings, and neither may be the literal
 246       * wp-config-sample.php placeholder – which every unconfigured install shares,
 247       * making it a publicly known value rather than a secret.
 248       *
 249       * Takes explicit arguments rather than reading LOGGED_IN_KEY/LOGGED_IN_SALT
 250       * directly so the decision logic is testable without touching real constants,
 251       * which – once WordPress has bootstrapped once in a process – can never be
 252       * redefined to a different value for a test.
 253       *
 254       * @since 7.2.0
 255       *
 256       * @param mixed $logged_in_key  Candidate value of LOGGED_IN_KEY, or null if undefined.
 257       * @param mixed $logged_in_salt Candidate value of LOGGED_IN_SALT, or null if undefined.
 258       * @return bool
 259       *
 260       * @phpstan-assert-if-true non-empty-string $logged_in_key
 261       * @phpstan-assert-if-true non-empty-string $logged_in_salt
 262       */
 263  	private function are_usable_salt_values( $logged_in_key, $logged_in_salt ): bool {
 264          foreach ( array( $logged_in_key, $logged_in_salt ) as $value ) {
 265              if ( ! is_string( $value ) || '' === $value || self::KNOWN_PLACEHOLDER === $value ) {
 266                  return false;
 267              }
 268          }
 269  
 270          return true;
 271      }
 272  
 273      /**
 274       * Whether $value is the canonical base64 encoding of exactly 32 bytes.
 275       *
 276       * Uses strict-mode decoding plus a re-encode comparison, rather than a bare
 277       * base64_decode(), so that a malformed or non-canonically-padded constant falls
 278       * through to the legacy (hashed) interpretation instead of being silently
 279       * accepted as 32 bytes of something else.
 280       *
 281       * @since 7.2.0
 282       *
 283       * @param mixed $value Candidate value.
 284       * @return bool
 285       */
 286  	private function is_canonical_base64_32( $value ): bool {
 287          return null !== $this->decode_canonical_base64_32( $value );
 288      }
 289  
 290      /**
 291       * Decodes $value if it is the canonical base64 encoding of exactly 32 bytes.
 292       *
 293       * @since 7.2.0
 294       *
 295       * @param mixed $value Candidate value.
 296       * @return string|null The 32 decoded bytes, or null if $value is anything else.
 297       */
 298  	private function decode_canonical_base64_32( $value ): ?string {
 299          if ( ! is_string( $value ) ) {
 300              return null;
 301          }
 302  
 303          $decoded = base64_decode( $value, true );
 304  
 305          if ( false === $decoded || SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES !== strlen( $decoded ) ) {
 306              return null;
 307          }
 308  
 309          return base64_encode( $decoded ) === $value ? $decoded : null;
 310      }
 311  }


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