| [ Index ] |
PHP Cross Reference of WordPress Trunk (Updated Daily) |
[Summary view] [Print] [Text view]
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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated : Wed Oct 14 08:20:33 2026 | Cross-referenced by PHPXref |