[ 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          $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  }


Generated : Wed Oct 14 08:20:33 2026 Cross-referenced by PHPXref