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