[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

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

   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          $has_current  = is_array( $existing ) && isset( $existing['current'] ) && is_array( $existing['current'] );
 325          $is_unchanged = false;
 326  
 327          if ( $has_current ) {
 328              $current_value = $cipher->decrypt_value( $master_key, $scope, $site_id, $name, WP_Secret_Version::CURRENT, $existing['current'] );
 329              $is_unchanged  = is_string( $current_value ) && hash_equals( $current_value, $value );
 330  
 331              if ( is_string( $current_value ) ) {
 332                  wp_secrets_memzero( $current_value );
 333              }
 334          }
 335  
 336          /*
 337           * A write that would change nothing stored is skipped, and reports nothing
 338           * to wp_secret_changed: a settings form that saves an unchanged value on
 339           * every submit has not changed the secret. Rewriting the record would also
 340           * reset the current slot's 'created' time and clear a 'needs_rotation' flag
 341           * on a value that was never rotated.
 342           *
 343           * The exception is a write that raises 'needs_rotation', such as importing
 344           * an option that holds the value already stored. That value has still sat
 345           * in plaintext, so the flag is recorded, and handled as a rewrite below.
 346           */
 347          if ( $is_unchanged && ( ! $needs_rotation || ! empty( $existing['current']['needs_rotation'] ) ) ) {
 348              wp_secrets_memzero( $master_key );
 349  
 350              return true;
 351          }
 352  
 353          $new_slot = $cipher->encrypt_value( $master_key, $scope, $site_id, $name, WP_Secret_Version::CURRENT, $value );
 354  
 355          if ( is_wp_error( $new_slot ) ) {
 356              wp_secrets_memzero( $master_key );
 357  
 358              return $new_slot;
 359          }
 360  
 361          $new_slot['created']        = time();
 362          $new_slot['needs_rotation'] = $needs_rotation;
 363  
 364          $record = array(
 365              'v'       => WP_SECRETS_RECORD_VERSION,
 366              'current' => $new_slot,
 367          );
 368  
 369          /*
 370           * Demotion: the outgoing current slot becomes the new previous slot. Only
 371           * one previous slot is ever kept – whatever was already in
 372           * $existing['previous'] is never carried forward, which is what makes a
 373           * third write discard the oldest value rather than accumulating history.
 374           *
 375           * This cannot be a plain array copy. A slot's AAD binds its ciphertext to
 376           * the exact position it occupies – current or previous – so a slot moved
 377           * verbatim would still carry an authentication tag computed for 'current'
 378           * while every future read asks for it under 'previous', and would fail to
 379           * decrypt forever. Demoting requires decrypting under the outgoing binding
 380           * and re-encrypting under the incoming one.
 381           *
 382           * If the outgoing current slot cannot be decrypted at all – already
 383           * corrupted, or orphaned by a botched key rotation – it is dropped rather
 384           * than failing this write. The value was already unreadable before this
 385           * call; refusing to let an operator set a new one over it would repeat the
 386           * corrupted-record-blocks-everything failure this API is built to avoid.
 387           *
 388           * Saving the current value again, as a settings form does on every save, is
 389           * not a rotation: demoting it would replace the previous value that a
 390           * rotation in progress still depends on. The existing previous slot is kept
 391           * instead, and can be carried over as stored because it is already bound to
 392           * WP_Secret_Version::PREVIOUS.
 393           */
 394          if ( $has_current ) {
 395              if ( $is_unchanged ) {
 396                  if ( isset( $existing['previous'] ) && is_array( $existing['previous'] ) ) {
 397                      $record['previous'] = $existing['previous'];
 398                  }
 399              } else {
 400                  $demoted = $this->demote_slot( $cipher, $master_key, $scope, $site_id, $name, $existing['current'] );
 401  
 402                  if ( ! is_wp_error( $demoted ) ) {
 403                      $record['previous'] = $demoted;
 404                  }
 405              }
 406          }
 407  
 408          wp_secrets_memzero( $master_key );
 409  
 410          $result = $store->set( $name, $record, $network );
 411  
 412          if ( is_wp_error( $result ) ) {
 413              return $result;
 414          }
 415  
 416          $is_update       = is_array( $existing );
 417          $old_fingerprint = $this->stored_fingerprint( $existing );
 418  
 419          /**
 420           * Fires whenever a secret is created, updated, deleted, imported, or retired.
 421           *
 422           * The only hook in the core-bound Secrets API code, and it does not fire on the
 423           * retrieval path. Fingerprints are readable; values are never passed.
 424           *
 425           * @since 7.2.0
 426           *
 427           * @param string $name            The secret's namespaced name.
 428           * @param string $action          One of 'created', 'updated', 'deleted',
 429           *                                'imported', 'retired'.
 430           * @param int    $actor_id        The current user id, or 0.
 431           * @param int    $timestamp       Unix timestamp of the change.
 432           * @param string $old_fingerprint The previous fingerprint, or '' if none.
 433           * @param string $new_fingerprint The new fingerprint, or '' if the secret was deleted.
 434           * @param bool   $network         Whether the secret is network-scope. A site-scope
 435           *                                secret belongs to the current site.
 436           */
 437          do_action(
 438              'wp_secret_changed',
 439              $name,
 440              $action ?? ( $is_update ? 'updated' : 'created' ),
 441              get_current_user_id(),
 442              $new_slot['created'],
 443              $old_fingerprint,
 444              $new_slot['fingerprint'],
 445              $network
 446          );
 447  
 448          return true;
 449      }
 450  
 451      /**
 452       * Shared implementation behind wp_get_secret() and wp_get_network_secret().
 453       *
 454       * @since 7.2.0
 455       *
 456       * @param string $name    The secret's namespaced name.
 457       * @param string $version A WP_Secret_Version constant.
 458       * @param bool   $network Whether this is a network-scope secret.
 459       * @return WP_Secret|null|WP_Error
 460       *
 461       * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $version
 462       */
 463  	public function get( string $name, string $version, bool $network = false ) {
 464  
 465          $name_check = wp_secrets_validate_name( $name );
 466  
 467          if ( is_wp_error( $name_check ) ) {
 468              return $name_check;
 469          }
 470  
 471          $record = $this->store->get( $name, $network );
 472  
 473          if ( is_wp_error( $record ) ) {
 474              return $record;
 475          }
 476  
 477          if ( null === $record ) {
 478              return null;
 479          }
 480  
 481          $shape_check = $this->validate_record_shape( $record );
 482  
 483          if ( is_wp_error( $shape_check ) ) {
 484              return $shape_check;
 485          }
 486  
 487          if ( ! isset( $record[ $version ] ) || ! is_array( $record[ $version ] ) ) {
 488              // The secret exists, but this slot does not. Absence, not an error –
 489              // this matters most for PREVIOUS on a secret that has never been rotated.
 490              return null;
 491          }
 492  
 493          $scope   = $network ? 'network' : 'site';
 494          $site_id = $network ? 0 : get_current_blog_id();
 495  
 496          $master_key = $this->key_manager->get_master_key( $scope, $network ? null : $site_id );
 497  
 498          if ( is_wp_error( $master_key ) ) {
 499              return $master_key;
 500          }
 501  
 502          $cipher    = new WP_Secrets_Cipher();
 503          $plaintext = $cipher->decrypt_value( $master_key, $scope, $site_id, $name, $version, $record[ $version ] );
 504  
 505          if ( is_wp_error( $plaintext ) ) {
 506              wp_secrets_memzero( $master_key );
 507  
 508              return $plaintext;
 509          }
 510  
 511          /*
 512           * Recomputed from the plaintext just decrypted, never read back from the record.
 513           * The stored 'fingerprint' field sits outside the AAD and so is not
 514           * authenticated: anyone who can write to the store can set it to anything
 515           * without disturbing the ciphertext. Trusting it would make
 516           * WP_Secret::fingerprint() attacker-controlled – which matters most where a
 517           * fingerprint comparison gates something irreversible, such as a migration
 518           * verifying a value before deleting its source. The stored copy still exists,
 519           * but only so wp_list_secrets() has something to show without decrypting.
 520           */
 521          $fingerprint = $cipher->fingerprint( $master_key, $plaintext );
 522  
 523          wp_secrets_memzero( $master_key );
 524  
 525          if ( is_wp_error( $fingerprint ) ) {
 526              wp_secrets_memzero( $plaintext );
 527  
 528              return $fingerprint;
 529          }
 530  
 531          $secret = new WP_Secret( $name, $plaintext, $fingerprint );
 532  
 533          wp_secrets_memzero( $plaintext );
 534  
 535          return $secret;
 536      }
 537  
 538      /**
 539       * Shared implementation behind wp_delete_secret() and wp_delete_network_secret().
 540       *
 541       * @since 7.2.0
 542       *
 543       * @param string $name    The secret's namespaced name.
 544       * @param bool   $network Whether this is a network-scope secret.
 545       * @return true|WP_Error
 546       */
 547  	public function delete( string $name, bool $network = false ) {
 548          $name_check = wp_secrets_validate_name( $name );
 549  
 550          if ( is_wp_error( $name_check ) ) {
 551              return $name_check;
 552          }
 553  
 554          $store = $this->store;
 555  
 556          $existing = $this->read_prior_record( $store, $name, $network );
 557  
 558          if ( is_wp_error( $existing ) ) {
 559              return $existing;
 560          }
 561  
 562          $result = $store->delete( $name, $network );
 563  
 564          if ( is_wp_error( $result ) ) {
 565              return $result;
 566          }
 567  
 568          if ( is_array( $existing ) ) {
 569              /** This action is documented in wp-includes/secrets/class-wp-secrets-libsodium-provider.php */
 570              do_action(
 571                  'wp_secret_changed',
 572                  $name,
 573                  'deleted',
 574                  get_current_user_id(),
 575                  time(),
 576                  $this->stored_fingerprint( $existing ),
 577                  '',
 578                  $network
 579              );
 580          }
 581  
 582          return true;
 583      }
 584  
 585      /**
 586       * Shared implementation behind the two version-retirement functions.
 587       *
 588       * Backs wp_retire_secret_version() and wp_retire_network_secret_version().
 589       *
 590       * Beyond the API surface the proposal names. It states that retirement is "an
 591       * explicit operator action" but names no function; this is this implementation's
 592       * name for it, pending confirmation in the comments thread.
 593       *
 594       * @since 7.2.0
 595       *
 596       * @param string $name    The secret's namespaced name.
 597       * @param bool   $network Whether this is a network-scope secret.
 598       * @return true|WP_Error
 599       */
 600  	public function retire_previous( string $name, bool $network = false ) {
 601          $name_check = wp_secrets_validate_name( $name );
 602  
 603          if ( is_wp_error( $name_check ) ) {
 604              return $name_check;
 605          }
 606  
 607          $store  = $this->store;
 608          $record = $store->get( $name, $network );
 609  
 610          if ( is_wp_error( $record ) ) {
 611              return $record;
 612          }
 613  
 614          if ( null === $record || ! isset( $record['previous'] ) ) {
 615              /*
 616               * The goal of retirement – "there is no previous slot" – is already
 617               * true, whether because the secret doesn't exist or because it was
 618               * never rotated. A successful no-op, not an error, and deliberately
 619               * checked before the write-support check below: a store that can't
 620               * accept writes can still report a correct "nothing to do" without
 621               * that being mistaken for the store refusing the operation.
 622               */
 623              return true;
 624          }
 625  
 626          $retired_fingerprint = $this->slot_fingerprint( $record['previous'] );
 627  
 628          unset( $record['previous'] );
 629  
 630          $result = $store->set( $name, $record, $network );
 631  
 632          if ( is_wp_error( $result ) ) {
 633              return $result;
 634          }
 635  
 636          /** This action is documented in wp-includes/secrets/class-wp-secrets-libsodium-provider.php */
 637          do_action(
 638              'wp_secret_changed',
 639              $name,
 640              'retired',
 641              get_current_user_id(),
 642              time(),
 643              $retired_fingerprint,
 644              '',
 645              $network
 646          );
 647  
 648          return true;
 649      }
 650  
 651      /**
 652       * Shared implementation behind wp_list_secrets() and wp_list_network_secrets().
 653       *
 654       * Beyond the API surface the proposal names, and justified by its statement that
 655       * "the hooks and accessors an admin screen would need are in scope now; the screen
 656       * itself is not."
 657       *
 658       * Fingerprints returned here come directly from the stored record field, not
 659       * recomputed by decrypting each secret. That is a deliberate difference from
 660       * WP_Secret::fingerprint(), which always recomputes – recomputing here would mean
 661       * decrypting every matching secret just to list them, defeating the point of a
 662       * lightweight listing call. This is safe specifically because a list entry is
 663       * documented as informational only and
 664       * is never used to gate anything; nothing in this codebase performs a security
 665       * decision based on a fingerprint returned from this function.
 666       *
 667       * @since 7.2.0
 668       *
 669       * @param string $name_prefix Only secrets whose name starts with "{$name_prefix}/"
 670       *                            are returned. Default '' returns every secret in this
 671       *                            scope. Named to match the public function's own
 672       *                            $namespace parameter, without using the reserved word
 673       *                            'namespace' in an internal signature.
 674       * @param bool   $network     Whether to list network-scope secrets.
 675       * @return array|WP_Error Array of associative arrays, each with keys 'name',
 676       *                        'fingerprint', 'created', 'has_previous', and
 677       *                        'needs_rotation'. Never a value. WP_Error on failure.
 678       *
 679       * @phpstan-return list<array{name: string, fingerprint: string, created: int, has_previous: bool, needs_rotation: bool}>|WP_Error
 680       */
 681  	public function list_secrets( string $name_prefix = '', bool $network = false ) {
 682  
 683          $store = $this->store;
 684  
 685          $names = $store->list_names( $network );
 686  
 687          if ( is_wp_error( $names ) ) {
 688              return $names;
 689          }
 690  
 691          $entries = array();
 692  
 693          foreach ( $names as $name ) {
 694              if ( '' !== $name_prefix && ! str_starts_with( $name, $name_prefix . '/' ) ) {
 695                  continue;
 696              }
 697  
 698              $record = $store->get( $name, $network );
 699  
 700              if ( ! is_wp_error( $record ) && null !== $record && true === $this->validate_record_shape( $record ) && is_array( $record['current'] ) ) {
 701                  $entries[] = array(
 702                      'name'           => $name,
 703                      'fingerprint'    => $this->stored_fingerprint( $record ),
 704                      'created'        => isset( $record['current']['created'] ) && is_int( $record['current']['created'] ) ? $record['current']['created'] : 0,
 705                      'has_previous'   => isset( $record['previous'] ) && is_array( $record['previous'] ),
 706                      'needs_rotation' => ! empty( $record['current']['needs_rotation'] ),
 707                  );
 708  
 709                  continue;
 710              }
 711  
 712              /*
 713               * A corrupted or unreadable record is still listed, with whatever
 714               * metadata could not be salvaged left blank, rather than silently
 715               * omitted. Site Health's "undecryptable secrets" check depends on
 716               * exactly this: a secret that has gone bad must remain visible.
 717               */
 718              $entries[] = array(
 719                  'name'           => $name,
 720                  'fingerprint'    => '',
 721                  'created'        => 0,
 722                  'has_previous'   => false,
 723                  'needs_rotation' => false,
 724              );
 725          }
 726  
 727          return $entries;
 728      }
 729  }


Generated : Tue Nov 3 08:20:36 2026 Cross-referenced by PHPXref