| [ Index ] |
PHP Cross Reference of WordPress Trunk (Updated Daily) |
[Summary view] [Print] [Text view]
1 <?php 2 /** 3 * Secrets API: WP_Secrets_Libsodium_Provider class 4 * 5 * @package WordPress 6 * @subpackage Secrets 7 * @since 7.2.0 8 */ 9 10 /** 11 * The provider WordPress ships. 12 * 13 * Uses libsodium envelope encryption and stores ciphertext in the options tables. 14 * 15 * This is the default rather than a privileged case. Everything it does is expressed 16 * through WP_Secrets_Provider, the same interface a platform implements, so a KMS- 17 * or HSM-backed provider is a peer rather than an exception carved into the side of 18 * this one. 19 * 20 * It is assembled from two smaller pieces, and that is deliberate: a host who wants 21 * their own key custody but is happy with WordPress's storage swaps only the 22 * keyring and keeps everything else, and the inverse works too. Coupling them would 23 * force an all-or-nothing decision most sites cannot make. 24 * 25 * - WP_Secrets_Store – where the ciphertext records live. 26 * - WP_Secrets_Keyring – what wraps the root key everything else derives from. 27 * 28 * Neither is handed a plaintext, because for this provider the encryption boundary 29 * genuinely is inside WordPress. That is a property of *this* implementation rather 30 * than a rule imposed on every provider; see WP_Secrets_Provider for why the 31 * distinction matters. 32 * 33 * @since 7.2.0 34 */ 35 final class WP_Secrets_Libsodium_Provider implements WP_Secrets_Provider { 36 37 /** 38 * Where ciphertext records live. 39 * 40 * @since 7.2.0 41 * @var WP_Secrets_Store 42 */ 43 private $store; 44 45 /** 46 * Derives the per-scope master keys this provider encrypts under. 47 * 48 * @since 7.2.0 49 * @var WP_Secrets_Key_Manager 50 */ 51 private $key_manager; 52 53 /** 54 * Composes the provider from its two replaceable halves. 55 * 56 * @since 7.2.0 57 * 58 * @param WP_Secrets_Store $store Where ciphertext records live. 59 * @param WP_Secrets_Key_Manager $key_manager Derives per-scope master keys. 60 */ 61 public function __construct( WP_Secrets_Store $store, WP_Secrets_Key_Manager $key_manager ) { 62 $this->store = $store; 63 $this->key_manager = $key_manager; 64 } 65 66 /** 67 * A description of what is protecting these secrets, for Site Health. 68 * 69 * Reports the keyring's own description rather than a fixed string: "libsodium" 70 * is only half the answer, and which key source is in use is the half an 71 * operator cannot otherwise see. 72 * 73 * @since 7.2.0 74 * 75 * @return string 76 */ 77 public function get_label(): string { 78 $keyring = $this->key_manager->get_keyring(); 79 80 return sprintf( 81 /* translators: %s: Human-readable description of the key source. */ 82 __( 'WordPress (libsodium), key source: %s' ), 83 $keyring->get_key_source() 84 ); 85 } 86 87 /** 88 * Protection happens inside WordPress for this provider. 89 * 90 * @since 7.2.0 91 * 92 * @return string 93 * 94 * @phpstan-return self::BOUNDARY_* 95 */ 96 public function get_protection_boundary(): string { 97 return self::BOUNDARY_WORDPRESS; 98 } 99 100 /** 101 * Always true. 102 * 103 * A store that cannot accept a write reports that from set() itself. There is no 104 * separate capability flag to consult, and the shipped store accepts writes 105 * unconditionally. 106 * 107 * @since 7.2.0 108 * 109 * @return bool 110 */ 111 public function is_writable(): bool { 112 return true; 113 } 114 /** 115 * Validates the shape of a record read back from a store. 116 * 117 * Runs before any decryption is attempted. 118 * 119 * @since 7.2.0 120 * 121 * @param mixed $record Candidate record. 122 * @return true|WP_Error 123 */ 124 private function validate_record_shape( $record ) { 125 if ( ! is_array( $record ) || ! array_key_exists( 'v', $record ) || ! array_key_exists( 'current', $record ) ) { 126 return new WP_Error( 127 WP_SECRETS_ERROR_RECORD_MALFORMED, 128 __( 'The stored secret record is missing required fields.' ) 129 ); 130 } 131 132 if ( WP_SECRETS_RECORD_VERSION !== $record['v'] ) { 133 return new WP_Error( 134 WP_SECRETS_ERROR_RECORD_UNSUPPORTED_VERSION, 135 sprintf( 136 /* translators: %s: Unrecognized record format version. */ 137 __( 'Secret record format version "%s" is not supported.' ), 138 is_scalar( $record['v'] ) ? $record['v'] : gettype( $record['v'] ) 139 ) 140 ); 141 } 142 143 if ( ! is_array( $record['current'] ) ) { 144 return new WP_Error( 145 WP_SECRETS_ERROR_RECORD_MALFORMED, 146 __( 'The stored secret record is missing required fields.' ) 147 ); 148 } 149 150 return true; 151 } 152 153 /** 154 * Reads the record a write or delete is about to replace, tolerating corruption. 155 * 156 * A write path needs the prior record only to report the old fingerprint on the 157 * change hook. That is not worth refusing the operation over: if the stored record 158 * is unreadable, overwriting or deleting it is the only repair an operator has 159 * through this API, and returning WP_Error here would leave a corrupted secret 160 * permanently stuck – neither readable, nor fixable, nor removable without editing 161 * the database by hand. 162 * 163 * An unreachable *store* is different, and still aborts: that is an infrastructure 164 * failure, not a data problem, and continuing would mean writing blind. 165 * 166 * @since 7.2.0 167 * 168 * @param WP_Secrets_Store $store The active store. 169 * @param string $name The secret's namespaced name. 170 * @param bool $network Whether this is a network-scope secret. 171 * @return array<mixed>|null|WP_Error The prior record, null if absent or unreadable, 172 * or WP_Error only if the store itself is 173 * unavailable. 174 */ 175 private function read_prior_record( WP_Secrets_Store $store, string $name, bool $network ) { 176 $existing = $store->get( $name, $network ); 177 178 if ( is_wp_error( $existing ) ) { 179 if ( WP_SECRETS_ERROR_STORE_UNAVAILABLE === $existing->get_error_code() ) { 180 return $existing; 181 } 182 183 return null; 184 } 185 186 return $existing; 187 } 188 189 /** 190 * Extracts the current slot's stored fingerprint from a record, if it has one. 191 * 192 * @since 7.2.0 193 * 194 * @param mixed $record A record previously read from the store, or null. 195 * @return string The fingerprint, or '' if there is not a usable one. 196 */ 197 private function stored_fingerprint( $record ): string { 198 if ( ! is_array( $record ) || ! isset( $record['current'] ) || ! is_array( $record['current'] ) ) { 199 return ''; 200 } 201 202 return $this->slot_fingerprint( $record['current'] ); 203 } 204 205 /** 206 * Extracts a single slot's stored fingerprint, if it has one. 207 * 208 * @since 7.2.0 209 * 210 * @param mixed $slot A slot previously read from the store. 211 * @return string The fingerprint, or '' if there is not a usable one. 212 */ 213 private function slot_fingerprint( $slot ): string { 214 if ( ! is_array( $slot ) || ! isset( $slot['fingerprint'] ) || ! is_string( $slot['fingerprint'] ) ) { 215 return ''; 216 } 217 218 return $slot['fingerprint']; 219 } 220 221 /** 222 * Re-encrypts an outgoing current slot so it can be stored as the previous slot. 223 * 224 * A slot's ciphertext is bound, via AAD, to the exact position it was written to. 225 * Copying its stored bytes as-is into the other slot would leave it permanently 226 * undecryptable there: the authentication tag was computed for the slot it came 227 * from, and every future read asks for it under the other one. Demoting a slot 228 * means decrypting it under its old binding and re-encrypting the same plaintext 229 * under the new one. 230 * 231 * @since 7.2.0 232 * 233 * @param WP_Secrets_Cipher $cipher A cipher instance. 234 * @param string $master_key 32-byte master key. 235 * @param string $scope 'site' or 'network'. 236 * @param int $site_id Blog id for site scope, 0 for network scope. 237 * @param string $name The secret's namespaced name. 238 * @param array<mixed> $current_slot The outgoing current slot, as stored. 239 * @return array|WP_Error The same value, re-encrypted and bound to 240 * WP_Secret_Version::PREVIOUS. 'created' and 241 * 'needs_rotation' carry over unchanged; 'fingerprint' is 242 * recomputed but identical, since the plaintext and master 243 * key are unchanged. 244 * 245 * @phpstan-return array{dk: string, dk_nonce: string, ct: string, nonce: string, fingerprint: string, created: int, needs_rotation: bool}|WP_Error 246 * @phpstan-param 'site'|'network' $scope 247 */ 248 private function demote_slot( WP_Secrets_Cipher $cipher, string $master_key, string $scope, int $site_id, string $name, array $current_slot ) { 249 $plaintext = $cipher->decrypt_value( $master_key, $scope, $site_id, $name, WP_Secret_Version::CURRENT, $current_slot ); 250 251 if ( is_wp_error( $plaintext ) ) { 252 return $plaintext; 253 } 254 255 $demoted = $cipher->encrypt_value( $master_key, $scope, $site_id, $name, WP_Secret_Version::PREVIOUS, $plaintext ); 256 257 wp_secrets_memzero( $plaintext ); 258 259 if ( is_wp_error( $demoted ) ) { 260 return $demoted; 261 } 262 263 $demoted['created'] = isset( $current_slot['created'] ) && is_int( $current_slot['created'] ) ? $current_slot['created'] : time(); 264 $demoted['needs_rotation'] = ! empty( $current_slot['needs_rotation'] ); 265 266 return $demoted; 267 } 268 269 /** 270 * Shared implementation behind the three secret-writing functions. 271 * 272 * Backs wp_set_secret(), wp_set_network_secret(), and wp_import_option_as_secret(), 273 * which select their behavior through the last two parameters. 274 * 275 * @since 7.2.0 276 * 277 * @param string $name The secret's namespaced name. 278 * @param string $value The plaintext value. 279 * @param bool $network Whether this is a network-scope secret. 280 * @param bool $needs_rotation Value for the new current slot's 281 * 'needs_rotation' flag. False for an ordinary 282 * write; wp_import_option_as_secret() passes true, 283 * since a credential that sat in an option is 284 * already in backups and re-encrypting does not 285 * fix that. 286 * @param string|null $action When given, used as the $action reported to 287 * the wp_secret_changed hook instead of the 288 * usual 'created'/'updated' detection – 289 * wp_import_option_as_secret() passes 'imported'. 290 * @return true|WP_Error 291 */ 292 public function set( string $name, $value, bool $network = false, bool $needs_rotation = false, ?string $action = null ) { 293 $name_check = wp_secrets_validate_name( $name ); 294 295 if ( is_wp_error( $name_check ) ) { 296 return $name_check; 297 } 298 299 if ( ! is_string( $value ) ) { 300 return new WP_Error( 301 WP_SECRETS_ERROR_INVALID_VALUE, 302 __( 'Secret values must be strings.' ) 303 ); 304 } 305 306 $store = $this->store; 307 308 $scope = $network ? 'network' : 'site'; 309 $site_id = $network ? 0 : get_current_blog_id(); 310 311 $existing = $this->read_prior_record( $store, $name, $network ); 312 313 if ( is_wp_error( $existing ) ) { 314 return $existing; 315 } 316 317 $master_key = $this->key_manager->get_master_key( $scope, $network ? null : $site_id ); 318 319 if ( is_wp_error( $master_key ) ) { 320 return $master_key; 321 } 322 323 $cipher = new WP_Secrets_Cipher(); 324 $new_slot = $cipher->encrypt_value( $master_key, $scope, $site_id, $name, WP_Secret_Version::CURRENT, $value ); 325 326 if ( is_wp_error( $new_slot ) ) { 327 wp_secrets_memzero( $master_key ); 328 329 return $new_slot; 330 } 331 332 $new_slot['created'] = time(); 333 $new_slot['needs_rotation'] = $needs_rotation; 334 335 $record = array( 336 'v' => WP_SECRETS_RECORD_VERSION, 337 'current' => $new_slot, 338 ); 339 340 /* 341 * Demotion: the outgoing current slot becomes the new previous slot. Only 342 * one previous slot is ever kept – whatever was already in 343 * $existing['previous'] is never carried forward, which is what makes a 344 * third write discard the oldest value rather than accumulating history. 345 * 346 * This cannot be a plain array copy. A slot's AAD binds its ciphertext to 347 * the exact position it occupies – current or previous – so a slot moved 348 * verbatim would still carry an authentication tag computed for 'current' 349 * while every future read asks for it under 'previous', and would fail to 350 * decrypt forever. Demoting requires decrypting under the outgoing binding 351 * and re-encrypting under the incoming one. 352 * 353 * If the outgoing current slot cannot be decrypted at all – already 354 * corrupted, or orphaned by a botched key rotation – it is dropped rather 355 * than failing this write. The value was already unreadable before this 356 * call; refusing to let an operator set a new one over it would repeat the 357 * corrupted-record-blocks-everything failure this API is built to avoid. 358 */ 359 if ( is_array( $existing ) && isset( $existing['current'] ) && is_array( $existing['current'] ) ) { 360 $demoted = $this->demote_slot( $cipher, $master_key, $scope, $site_id, $name, $existing['current'] ); 361 362 if ( ! is_wp_error( $demoted ) ) { 363 $record['previous'] = $demoted; 364 } 365 } 366 367 wp_secrets_memzero( $master_key ); 368 369 $result = $store->set( $name, $record, $network ); 370 371 if ( is_wp_error( $result ) ) { 372 return $result; 373 } 374 375 $is_update = is_array( $existing ); 376 $old_fingerprint = $this->stored_fingerprint( $existing ); 377 378 /** 379 * Fires whenever a secret is created, updated, deleted, imported, or retired. 380 * 381 * The only hook in the core-bound Secrets API code, and it does not fire on the 382 * retrieval path. Fingerprints are readable; values are never passed. 383 * 384 * @since 7.2.0 385 * 386 * @param string $name The secret's namespaced name. 387 * @param string $action One of 'created', 'updated', 'deleted', 388 * 'imported', 'retired'. 389 * @param int $actor_id The current user id, or 0. 390 * @param int $timestamp Unix timestamp of the change. 391 * @param string $old_fingerprint The previous fingerprint, or '' if none. 392 * @param string $new_fingerprint The new fingerprint, or '' if the secret was deleted. 393 * @param bool $network Whether the secret is network-scope. A site-scope 394 * secret belongs to the current site. 395 */ 396 do_action( 397 'wp_secret_changed', 398 $name, 399 null !== $action ? $action : ( $is_update ? 'updated' : 'created' ), 400 get_current_user_id(), 401 $new_slot['created'], 402 $old_fingerprint, 403 $new_slot['fingerprint'], 404 $network 405 ); 406 407 return true; 408 } 409 410 /** 411 * Shared implementation behind wp_get_secret() and wp_get_network_secret(). 412 * 413 * @since 7.2.0 414 * 415 * @param string $name The secret's namespaced name. 416 * @param string $version A WP_Secret_Version constant. 417 * @param bool $network Whether this is a network-scope secret. 418 * @return WP_Secret|null|WP_Error 419 * 420 * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $version 421 */ 422 public function get( string $name, string $version, bool $network = false ) { 423 424 $name_check = wp_secrets_validate_name( $name ); 425 426 if ( is_wp_error( $name_check ) ) { 427 return $name_check; 428 } 429 430 $record = $this->store->get( $name, $network ); 431 432 if ( is_wp_error( $record ) ) { 433 return $record; 434 } 435 436 if ( null === $record ) { 437 return null; 438 } 439 440 $shape_check = $this->validate_record_shape( $record ); 441 442 if ( is_wp_error( $shape_check ) ) { 443 return $shape_check; 444 } 445 446 if ( ! isset( $record[ $version ] ) || ! is_array( $record[ $version ] ) ) { 447 // The secret exists, but this slot does not. Absence, not an error – 448 // this matters most for PREVIOUS on a secret that has never been rotated. 449 return null; 450 } 451 452 $scope = $network ? 'network' : 'site'; 453 $site_id = $network ? 0 : get_current_blog_id(); 454 455 $master_key = $this->key_manager->get_master_key( $scope, $network ? null : $site_id ); 456 457 if ( is_wp_error( $master_key ) ) { 458 return $master_key; 459 } 460 461 $cipher = new WP_Secrets_Cipher(); 462 $plaintext = $cipher->decrypt_value( $master_key, $scope, $site_id, $name, $version, $record[ $version ] ); 463 464 if ( is_wp_error( $plaintext ) ) { 465 wp_secrets_memzero( $master_key ); 466 467 return $plaintext; 468 } 469 470 /* 471 * Recomputed from the plaintext just decrypted, never read back from the record. 472 * The stored 'fingerprint' field sits outside the AAD and so is not 473 * authenticated: anyone who can write to the store can set it to anything 474 * without disturbing the ciphertext. Trusting it would make 475 * WP_Secret::fingerprint() attacker-controlled – which matters most where a 476 * fingerprint comparison gates something irreversible, such as a migration 477 * verifying a value before deleting its source. The stored copy still exists, 478 * but only so wp_list_secrets() has something to show without decrypting. 479 */ 480 $fingerprint = $cipher->fingerprint( $master_key, $plaintext ); 481 482 wp_secrets_memzero( $master_key ); 483 484 if ( is_wp_error( $fingerprint ) ) { 485 wp_secrets_memzero( $plaintext ); 486 487 return $fingerprint; 488 } 489 490 $secret = new WP_Secret( $name, $plaintext, $fingerprint ); 491 492 wp_secrets_memzero( $plaintext ); 493 494 return $secret; 495 } 496 497 /** 498 * Shared implementation behind wp_delete_secret() and wp_delete_network_secret(). 499 * 500 * @since 7.2.0 501 * 502 * @param string $name The secret's namespaced name. 503 * @param bool $network Whether this is a network-scope secret. 504 * @return true|WP_Error 505 */ 506 public function delete( string $name, bool $network = false ) { 507 $name_check = wp_secrets_validate_name( $name ); 508 509 if ( is_wp_error( $name_check ) ) { 510 return $name_check; 511 } 512 513 $store = $this->store; 514 515 $existing = $this->read_prior_record( $store, $name, $network ); 516 517 if ( is_wp_error( $existing ) ) { 518 return $existing; 519 } 520 521 $result = $store->delete( $name, $network ); 522 523 if ( is_wp_error( $result ) ) { 524 return $result; 525 } 526 527 if ( is_array( $existing ) ) { 528 /** This action is documented in wp-includes/secrets/class-wp-secrets-libsodium-provider.php */ 529 do_action( 530 'wp_secret_changed', 531 $name, 532 'deleted', 533 get_current_user_id(), 534 time(), 535 $this->stored_fingerprint( $existing ), 536 '', 537 $network 538 ); 539 } 540 541 return true; 542 } 543 544 /** 545 * Shared implementation behind the two version-retirement functions. 546 * 547 * Backs wp_retire_secret_version() and wp_retire_network_secret_version(). 548 * 549 * Beyond the API surface the proposal names. It states that retirement is "an 550 * explicit operator action" but names no function; this is this implementation's 551 * name for it, pending confirmation in the comments thread. 552 * 553 * @since 7.2.0 554 * 555 * @param string $name The secret's namespaced name. 556 * @param bool $network Whether this is a network-scope secret. 557 * @return true|WP_Error 558 */ 559 public function retire_previous( string $name, bool $network = false ) { 560 $name_check = wp_secrets_validate_name( $name ); 561 562 if ( is_wp_error( $name_check ) ) { 563 return $name_check; 564 } 565 566 $store = $this->store; 567 $record = $store->get( $name, $network ); 568 569 if ( is_wp_error( $record ) ) { 570 return $record; 571 } 572 573 if ( null === $record || ! isset( $record['previous'] ) ) { 574 /* 575 * The goal of retirement – "there is no previous slot" – is already 576 * true, whether because the secret doesn't exist or because it was 577 * never rotated. A successful no-op, not an error, and deliberately 578 * checked before the write-support check below: a store that can't 579 * accept writes can still report a correct "nothing to do" without 580 * that being mistaken for the store refusing the operation. 581 */ 582 return true; 583 } 584 585 $retired_fingerprint = $this->slot_fingerprint( $record['previous'] ); 586 587 unset( $record['previous'] ); 588 589 $result = $store->set( $name, $record, $network ); 590 591 if ( is_wp_error( $result ) ) { 592 return $result; 593 } 594 595 /** This action is documented in wp-includes/secrets/class-wp-secrets-libsodium-provider.php */ 596 do_action( 597 'wp_secret_changed', 598 $name, 599 'retired', 600 get_current_user_id(), 601 time(), 602 $retired_fingerprint, 603 '', 604 $network 605 ); 606 607 return true; 608 } 609 610 /** 611 * Shared implementation behind wp_list_secrets() and wp_list_network_secrets(). 612 * 613 * Beyond the API surface the proposal names, and justified by its statement that 614 * "the hooks and accessors an admin screen would need are in scope now; the screen 615 * itself is not." 616 * 617 * Fingerprints returned here come directly from the stored record field, not 618 * recomputed by decrypting each secret. That is a deliberate difference from 619 * WP_Secret::fingerprint(), which always recomputes – recomputing here would mean 620 * decrypting every matching secret just to list them, defeating the point of a 621 * lightweight listing call. This is safe specifically because a list entry is 622 * documented as informational only and 623 * is never used to gate anything; nothing in this codebase performs a security 624 * decision based on a fingerprint returned from this function. 625 * 626 * @since 7.2.0 627 * 628 * @param string $name_prefix Only secrets whose name starts with "{$name_prefix}/" 629 * are returned. Default '' returns every secret in this 630 * scope. Named to match the public function's own 631 * $namespace parameter, without using the reserved word 632 * 'namespace' in an internal signature. 633 * @param bool $network Whether to list network-scope secrets. 634 * @return array|WP_Error Array of associative arrays, each with keys 'name', 635 * 'fingerprint', 'created', 'has_previous', and 636 * 'needs_rotation'. Never a value. WP_Error on failure. 637 * 638 * @phpstan-return list<array{name: string, fingerprint: string, created: int, has_previous: bool, needs_rotation: bool}>|WP_Error 639 */ 640 public function list_secrets( string $name_prefix = '', bool $network = false ) { 641 642 $store = $this->store; 643 644 $names = $store->list_names( $network ); 645 646 if ( is_wp_error( $names ) ) { 647 return $names; 648 } 649 650 $entries = array(); 651 652 foreach ( $names as $name ) { 653 if ( '' !== $name_prefix && 0 !== strpos( $name, $name_prefix . '/' ) ) { 654 continue; 655 } 656 657 $record = $store->get( $name, $network ); 658 659 if ( ! is_wp_error( $record ) && null !== $record && true === $this->validate_record_shape( $record ) && is_array( $record['current'] ) ) { 660 $entries[] = array( 661 'name' => $name, 662 'fingerprint' => $this->stored_fingerprint( $record ), 663 'created' => isset( $record['current']['created'] ) && is_int( $record['current']['created'] ) ? $record['current']['created'] : 0, 664 'has_previous' => isset( $record['previous'] ) && is_array( $record['previous'] ), 665 'needs_rotation' => ! empty( $record['current']['needs_rotation'] ), 666 ); 667 668 continue; 669 } 670 671 /* 672 * A corrupted or unreadable record is still listed, with whatever 673 * metadata could not be salvaged left blank, rather than silently 674 * omitted. Site Health's "undecryptable secrets" check depends on 675 * exactly this: a secret that has gone bad must remain visible. 676 */ 677 $entries[] = array( 678 'name' => $name, 679 'fingerprint' => '', 680 'created' => 0, 681 'has_previous' => false, 682 'needs_rotation' => false, 683 ); 684 } 685 686 return $entries; 687 } 688 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated : Wed Oct 14 08:20:33 2026 | Cross-referenced by PHPXref |