[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

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

   1  <?php
   2  /**
   3   * Secrets API: WP_Secrets_Cipher class
   4   *
   5   * @package WordPress
   6   * @subpackage Secrets
   7   * @since 7.2.0
   8   */
   9  
  10  /**
  11   * Encrypts and decrypts a single record slot.
  12   *
  13   * A slot holds a per-secret data key wrapped by a master key, plus a value encrypted
  14   * under that data key.
  15   *
  16   * This class does not know where a master key comes from (that is
  17   * WP_Secrets_Key_Manager's job), does not know about the 'v'/current/previous record
  18   * envelope (that is assembled by the functions in secrets.php), and does not set
  19   * 'created' or 'needs_rotation' (those are facts about a write, not about the
  20   * cryptography). It knows exactly one thing: given a 32-byte master key and a single
  21   * slot's worth of material, encrypt or decrypt it correctly.
  22   *
  23   * @since 7.2.0
  24   */
  25  final class WP_Secrets_Cipher {
  26  
  27      /**
  28       * AAD purpose tag for a wrapped data key.
  29       *
  30       * @since 7.2.0
  31       * @var string
  32       */
  33      const AAD_DATA_KEY = 'wp-secrets-data-key-v1';
  34  
  35      /**
  36       * AAD purpose tag for an encrypted value.
  37       *
  38       * @since 7.2.0
  39       * @var string
  40       */
  41      const AAD_VALUE = 'wp-secrets-value-v1';
  42  
  43      /**
  44       * KDF context used to derive the fingerprint key from a master key.
  45       *
  46       * Exactly 8 bytes, as sodium_crypto_kdf_derive_from_key() requires.
  47       *
  48       * @since 7.2.0
  49       * @var string
  50       */
  51      const FINGERPRINT_KDF_CONTEXT = 'wpsecfpr';
  52  
  53      /**
  54       * Encrypts a plaintext into a single record slot.
  55       *
  56       * @since 7.2.0
  57       *
  58       * @param string $master_key 32-byte master key for this scope.
  59       * @param string $scope      'site' or 'network'.
  60       * @param int    $site_id    Binds the AAD to a specific blog for site scope; use
  61       *                           0 for network scope, which is not bound to any one blog.
  62       * @param string $name       The secret's namespaced name.
  63       * @param string $slot       A WP_Secret_Version constant.
  64       * @param string $plaintext  The value to encrypt.
  65       * @return array|WP_Error Slot array with keys 'dk', 'dk_nonce', 'ct', 'nonce',
  66       *                        'fingerprint' on success. WP_Error on failure.
  67       *
  68       * @phpstan-return array{dk: string, dk_nonce: string, ct: string, nonce: string, fingerprint: string}|WP_Error
  69       * @phpstan-param 'site'|'network' $scope
  70       * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $slot
  71       */
  72  	public function encrypt_value( string $master_key, string $scope, int $site_id, string $name, string $slot, $plaintext ) {
  73          $check = $this->validate_common( $master_key, $scope, $site_id, $name, $slot );
  74  
  75          if ( is_wp_error( $check ) ) {
  76              return $check;
  77          }
  78  
  79          if ( ! is_string( $plaintext ) ) {
  80              return new WP_Error(
  81                  WP_SECRETS_ERROR_INVALID_VALUE,
  82                  __( 'Secret values must be strings.' )
  83              );
  84          }
  85  
  86          if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_encrypt' ) ) {
  87              return new WP_Error(
  88                  WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE,
  89                  __( 'No libsodium implementation is available.' )
  90              );
  91          }
  92  
  93          $fingerprint = $this->fingerprint( $master_key, $plaintext );
  94  
  95          if ( is_wp_error( $fingerprint ) ) {
  96              return $fingerprint;
  97          }
  98  
  99          $data_key = random_bytes( SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES );
 100  
 101          $dk_nonce   = random_bytes( SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES );
 102          $wrapped_dk = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(
 103              $data_key,
 104              $this->build_aad( self::AAD_DATA_KEY, $scope, $site_id, $name, $slot ),
 105              $dk_nonce,
 106              $master_key
 107          );
 108  
 109          $value_nonce = random_bytes( SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES );
 110          $ciphertext  = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(
 111              $plaintext,
 112              $this->build_aad( self::AAD_VALUE, $scope, $site_id, $name, $slot ),
 113              $value_nonce,
 114              $data_key
 115          );
 116  
 117          wp_secrets_memzero( $data_key );
 118  
 119          return array(
 120              'dk'          => base64_encode( $wrapped_dk ),
 121              'dk_nonce'    => base64_encode( $dk_nonce ),
 122              'ct'          => base64_encode( $ciphertext ),
 123              'nonce'       => base64_encode( $value_nonce ),
 124              'fingerprint' => $fingerprint,
 125          );
 126      }
 127  
 128      /**
 129       * Decrypts a single record slot back to its plaintext.
 130       *
 131       * @since 7.2.0
 132       *
 133       * @param string $master_key 32-byte master key for this scope.
 134       * @param string $scope      'site' or 'network'. Must match what encrypt_value()
 135       *                           was called with, or decryption fails.
 136       * @param int    $site_id    Must match what encrypt_value() was called with.
 137       * @param string $name       Must match what encrypt_value() was called with.
 138       * @param string $slot       Must match what encrypt_value() was called with.
 139       * @param mixed  $record     The slot array previously returned by encrypt_value().
 140       * @return string|WP_Error Plaintext on success. WP_Error on failure.
 141       *
 142       * @phpstan-param 'site'|'network' $scope
 143       * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $slot
 144       */
 145  	public function decrypt_value( string $master_key, string $scope, int $site_id, string $name, string $slot, $record ) {
 146          $check = $this->validate_common( $master_key, $scope, $site_id, $name, $slot );
 147  
 148          if ( is_wp_error( $check ) ) {
 149              return $check;
 150          }
 151  
 152          $decoded = $this->decode_record_fields( $record );
 153  
 154          if ( is_wp_error( $decoded ) ) {
 155              return $decoded;
 156          }
 157  
 158          if ( ! function_exists( 'sodium_crypto_aead_xchacha20poly1305_ietf_decrypt' ) ) {
 159              return new WP_Error(
 160                  WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE,
 161                  __( 'No libsodium implementation is available.' )
 162              );
 163          }
 164  
 165          $data_key = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
 166              $decoded['dk'],
 167              $this->build_aad( self::AAD_DATA_KEY, $scope, $site_id, $name, $slot ),
 168              $decoded['dk_nonce'],
 169              $master_key
 170          );
 171  
 172          if ( ! is_string( $data_key ) ) {
 173              return new WP_Error(
 174                  WP_SECRETS_ERROR_DECRYPTION_FAILED,
 175                  __( "The secret's data key could not be decrypted." )
 176              );
 177          }
 178  
 179          $plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
 180              $decoded['ct'],
 181              $this->build_aad( self::AAD_VALUE, $scope, $site_id, $name, $slot ),
 182              $decoded['nonce'],
 183              $data_key
 184          );
 185  
 186          wp_secrets_memzero( $data_key );
 187  
 188          if ( ! is_string( $plaintext ) ) {
 189              return new WP_Error(
 190                  WP_SECRETS_ERROR_DECRYPTION_FAILED,
 191                  __( 'The secret value could not be decrypted.' )
 192              );
 193          }
 194  
 195          return $plaintext;
 196      }
 197  
 198      /**
 199       * Computes the keyed fingerprint of a plaintext under a master key.
 200       *
 201       * Keyed so a fingerprint is not a cross-site rainbow-table oracle: the same
 202       * plaintext fingerprints differently under a different master key. Callers
 203       * verifying a value against a previously stored fingerprint (for example, a
 204       * migration's verify-before-delete step) must recompute this from freshly
 205       * decrypted plaintext and compare – never trust a fingerprint read back from a
 206       * record, which sits outside the AAD and is not authenticated.
 207       *
 208       * @since 7.2.0
 209       *
 210       * @param string $master_key 32-byte master key.
 211       * @param string $plaintext  Value to fingerprint.
 212       * @return string|WP_Error 32-character hex string on success. WP_Error on failure.
 213       */
 214  	public function fingerprint( string $master_key, $plaintext ) {
 215          if ( ! $this->is_valid_master_key( $master_key ) ) {
 216              return new WP_Error(
 217                  WP_SECRETS_ERROR_KEY_UNAVAILABLE,
 218                  __( 'A 32-byte master key is required.' )
 219              );
 220          }
 221  
 222          if ( ! is_string( $plaintext ) ) {
 223              return new WP_Error(
 224                  WP_SECRETS_ERROR_INVALID_VALUE,
 225                  __( 'Secret values must be strings.' )
 226              );
 227          }
 228  
 229          if ( ! function_exists( 'sodium_crypto_kdf_derive_from_key' ) || ! function_exists( 'sodium_crypto_generichash' ) ) {
 230              return new WP_Error(
 231                  WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE,
 232                  __( 'No libsodium implementation is available.' )
 233              );
 234          }
 235  
 236          $fingerprint_key = sodium_crypto_kdf_derive_from_key( 32, 1, self::FINGERPRINT_KDF_CONTEXT, $master_key );
 237          $fingerprint     = sodium_crypto_generichash( $plaintext, $fingerprint_key, 16 );
 238  
 239          wp_secrets_memzero( $fingerprint_key );
 240  
 241          return bin2hex( $fingerprint );
 242      }
 243  
 244      /**
 245       * Builds the AAD that binds a ciphertext to its full context.
 246       *
 247       * Binding covers purpose, scope, site, name, and slot, so that a record can never
 248       * be replayed under any of them.
 249       *
 250       * Centralized in one place rather than inlined at each call site, and only ever
 251       * called after validate_common() has confirmed $name passes
 252       * wp_secrets_validate_name() – the validated character set contains no '|', which
 253       * is what makes this delimiter safe to join on.
 254       *
 255       * @since 7.2.0
 256       *
 257       * @param string $purpose One of the AAD_* class constants.
 258       * @param string $scope   'site' or 'network'.
 259       * @param int    $site_id Blog id for site scope, 0 for network scope.
 260       * @param string $name    Validated secret name.
 261       * @param string $slot    A WP_Secret_Version constant.
 262       * @return string
 263       *
 264       * @phpstan-param 'site'|'network' $scope
 265       * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $slot
 266       */
 267  	private function build_aad( string $purpose, string $scope, int $site_id, string $name, string $slot ): string {
 268          return sprintf( '%s|%s|%d|%s|%s', $purpose, $scope, $site_id, $name, $slot );
 269      }
 270  
 271      /**
 272       * Validates the parameters shared by encrypt_value() and decrypt_value().
 273       *
 274       * $scope, $site_id, and $slot are never influenced by external input – every
 275       * call site in this API supplies them internally – so a bad value here means
 276       * the calling code is wrong, not that a runtime condition failed. That still
 277       * reports as a WP_Error rather than an exception: WordPress functions return
 278       * WP_Error or false, they do not throw. The _doing_it_wrong() notice is what
 279       * makes the distinction visible during development. $name and $master_key
 280       * legitimately vary at runtime (a plugin author's typo, an unavailable key
 281       * backend) and have always reported the same way.
 282       *
 283       * @since 7.2.0
 284       *
 285       * @param string $master_key Candidate master key.
 286       * @param string $scope      Candidate scope.
 287       * @param int    $site_id    Candidate site id.
 288       * @param string $name       Candidate secret name.
 289       * @param string $slot       Candidate slot.
 290       * @return true|WP_Error
 291       *
 292       * @phpstan-param 'site'|'network' $scope
 293       * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $slot
 294       */
 295  	private function validate_common( string $master_key, string $scope, int $site_id, string $name, string $slot ) {
 296          if ( ! in_array( $scope, array( 'site', 'network' ), true ) ) {
 297              return $this->invalid_argument( __( 'The scope must be "site" or "network".' ) );
 298          }
 299  
 300          if ( $site_id < 0 ) {
 301              return $this->invalid_argument( __( 'The site id must be a non-negative integer.' ) );
 302          }
 303  
 304          if ( ! in_array( $slot, array( WP_Secret_Version::CURRENT, WP_Secret_Version::PREVIOUS ), true ) ) {
 305              return $this->invalid_argument( __( 'The slot must be a WP_Secret_Version constant.' ) );
 306          }
 307  
 308          if ( ! $this->is_valid_master_key( $master_key ) ) {
 309              return new WP_Error(
 310                  WP_SECRETS_ERROR_KEY_UNAVAILABLE,
 311                  __( 'A 32-byte master key is required.' )
 312              );
 313          }
 314  
 315          $name_check = wp_secrets_validate_name( $name );
 316  
 317          if ( is_wp_error( $name_check ) ) {
 318              return $name_check;
 319          }
 320  
 321          return true;
 322      }
 323  
 324      /**
 325       * Reports a caller error the way WordPress does.
 326       *
 327       * Emits a _doing_it_wrong() notice for the developer, and returns a WP_Error the
 328       * caller can act on.
 329       *
 330       * @since 7.2.0
 331       *
 332       * @param string $message What the caller got wrong.
 333       * @return WP_Error
 334       */
 335  	private function invalid_argument( string $message ): WP_Error {
 336          _doing_it_wrong( __CLASS__ . '::encrypt_value()/decrypt_value()', $message, '7.2.0' );
 337  
 338          return new WP_Error( WP_SECRETS_ERROR_INVALID_ARGUMENT, $message );
 339      }
 340  
 341      /**
 342       * Whether a candidate value is usable as a 32-byte master key.
 343       *
 344       * @since 7.2.0
 345       *
 346       * @param string $master_key Candidate master key.
 347       * @return bool
 348       */
 349  	private function is_valid_master_key( string $master_key ): bool {
 350          return SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES === strlen( $master_key );
 351      }
 352  
 353      /**
 354       * Validates and base64-decodes the four crypto fields of a record slot.
 355       *
 356       * @since 7.2.0
 357       *
 358       * @param mixed $record Candidate slot array.
 359       * @return array<string, string>|WP_Error Decoded ('dk', 'dk_nonce', 'ct', 'nonce') on success.
 360       */
 361  	private function decode_record_fields( $record ) {
 362          if ( ! is_array( $record ) ) {
 363              return new WP_Error(
 364                  WP_SECRETS_ERROR_RECORD_MALFORMED,
 365                  __( 'Secret record slot must be an array.' )
 366              );
 367          }
 368  
 369          $decoded = array();
 370  
 371          foreach ( array( 'dk', 'dk_nonce', 'ct', 'nonce' ) as $field ) {
 372              if ( ! isset( $record[ $field ] ) || ! is_string( $record[ $field ] ) ) {
 373                  return new WP_Error(
 374                      WP_SECRETS_ERROR_RECORD_MALFORMED,
 375                      sprintf(
 376                          /* translators: %s: Record field name. */
 377                          __( 'Secret record slot is missing the "%s" field.' ),
 378                          $field
 379                      )
 380                  );
 381              }
 382  
 383              $value = base64_decode( $record[ $field ], true );
 384  
 385              if ( false === $value ) {
 386                  return new WP_Error(
 387                      WP_SECRETS_ERROR_RECORD_MALFORMED,
 388                      sprintf(
 389                          /* translators: %s: Record field name. */
 390                          __( 'Secret record slot field "%s" is not valid base64.' ),
 391                          $field
 392                      )
 393                  );
 394              }
 395  
 396              $decoded[ $field ] = $value;
 397          }
 398  
 399          if ( SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES !== strlen( $decoded['dk_nonce'] )
 400              || SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES !== strlen( $decoded['nonce'] ) ) {
 401              return new WP_Error(
 402                  WP_SECRETS_ERROR_RECORD_MALFORMED,
 403                  __( 'Secret record slot has a malformed nonce.' )
 404              );
 405          }
 406  
 407          return $decoded;
 408      }
 409  }


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