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