[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

/wp-includes/ -> secrets.php (source)

   1  <?php
   2  /**
   3   * Secrets API
   4   *
   5   * @package WordPress
   6   * @subpackage Secrets
   7   * @since 7.2.0
   8   */
   9  
  10  /**
  11   * The store or key backend is misbehaving.
  12   *
  13   * The secret exists and was decrypted, but the backend it came from failed in a way
  14   * none of the more specific codes below describe.
  15   *
  16   * @since 7.2.0
  17   */
  18  define( 'WP_SECRETS_ERROR_DECRYPTION_FAILED', 'secret_decryption_failed' );
  19  
  20  /**
  21   * The key needed to encrypt or decrypt a secret could not be obtained.
  22   *
  23   * The keyring is unreachable, misconfigured, or refusing to answer.
  24   *
  25   * @since 7.2.0
  26   */
  27  define( 'WP_SECRETS_ERROR_KEY_UNAVAILABLE', 'secret_key_unavailable' );
  28  
  29  /**
  30   * The storage back end could not be reached at all.
  31   *
  32   * @since 7.2.0
  33   */
  34  define( 'WP_SECRETS_ERROR_STORE_UNAVAILABLE', 'secret_store_unavailable' );
  35  
  36  /**
  37   * A secret name failed wp_secrets_validate_name().
  38   *
  39   * @since 7.2.0
  40   */
  41  define( 'WP_SECRETS_ERROR_INVALID_NAME', 'secret_invalid_name' );
  42  
  43  /**
  44   * A secret value was not a string.
  45   *
  46   * @since 7.2.0
  47   */
  48  define( 'WP_SECRETS_ERROR_INVALID_VALUE', 'secret_invalid_value' );
  49  
  50  /**
  51   * A caller passed an argument this API cannot act on.
  52   *
  53   * An unrecognized version constant, or an invalid scope, slot, or site id.
  54   *
  55   * Distinct from the codes above in cause rather than in severity: those describe
  56   * a runtime condition a correct caller can still hit (a name that failed
  57   * validation, an unavailable key), while this one only ever means the calling
  58   * code is wrong. Every site that returns it also calls _doing_it_wrong(), so the
  59   * mistake is visible in development rather than only in a return value the caller
  60   * may not be checking.
  61   *
  62   * @since 7.2.0
  63   */
  64  define( 'WP_SECRETS_ERROR_INVALID_ARGUMENT', 'secret_invalid_argument' );
  65  
  66  /**
  67   * The cipher this API depends on is unavailable.
  68   *
  69   * Neither libsodium nor its sodium_compat fallback is present in this environment.
  70   *
  71   * @since 7.2.0
  72   */
  73  define( 'WP_SECRETS_ERROR_CRYPTO_UNAVAILABLE', 'secret_crypto_unavailable' );
  74  
  75  /**
  76   * A stored record could not be parsed as a secret record at all.
  77   *
  78   * Distinct from decrypting incorrectly, which is WP_SECRETS_ERROR_DECRYPTION_FAILED.
  79   *
  80   * @since 7.2.0
  81   */
  82  define( 'WP_SECRETS_ERROR_RECORD_MALFORMED', 'secret_record_malformed' );
  83  
  84  /**
  85   * A stored record parses, but its format version is not one this API understands.
  86   *
  87   * Distinct from WP_SECRETS_ERROR_RECORD_MALFORMED so that an operator, or Site
  88   * Health, can tell a corrupt record apart from one written by a newer version of
  89   * this plugin than is currently active. See
  90   * docs/decisions/0006-record-format-v2-not-read-compatible.md. The upgrade path for a future v2 is not
  91   * designed, so an unrecognized version is rejected outright rather than guessed at.
  92   *
  93   * @since 7.2.0
  94   */
  95  define( 'WP_SECRETS_ERROR_RECORD_UNSUPPORTED_VERSION', 'secret_record_unsupported_version' );
  96  
  97  /**
  98   * The active provider does not accept writes.
  99   *
 100   * Its credentials are managed by host tooling, a control panel, or a key policy
 101   * outside WordPress. Distinct from WP_SECRETS_ERROR_STORE_UNAVAILABLE, which means
 102   * the write could have succeeded and did not. This one means it was never possible.
 103   *
 104   * @since 7.2.0
 105   */
 106  define( 'WP_SECRETS_ERROR_PROVIDER_READ_ONLY', 'secret_provider_read_only' );
 107  
 108  /**
 109   * The current record format version.
 110   *
 111   * Bumped only if the record shape in WP_Secrets_Cipher's slot arrays ever changes
 112   * incompatibly.
 113   *
 114   * @since 7.2.0
 115   */
 116  define( 'WP_SECRETS_RECORD_VERSION', 1 );
 117  
 118  /**
 119   * The longest a secret name may be.
 120   *
 121   * The wp_options.option_name column allows 191 characters. The longest prefix this
 122   * API stores under is '_wp_network_secret_' (19 characters), so 191 - 19 = 172
 123   * is the longest name any store built on core's options tables can accept
 124   * without truncation, and every store is held to the same limit for
 125   * consistency regardless of what a specific backend could technically fit.
 126   *
 127   * @since 7.2.0
 128   */
 129  define( 'WP_SECRETS_MAX_NAME_LENGTH', 172 );
 130  
 131  /**
 132   * Capability required to manage site-scope secrets at the operator boundary.
 133   *
 134   * Checked by WP-CLI and by a future admin screen, never by the function-level API
 135   * itself. See wp_set_secret() for why.
 136   *
 137   * @since 7.2.0
 138   */
 139  define( 'WP_SECRETS_CAP_MANAGE', 'manage_secrets' );
 140  
 141  /**
 142   * Capability required at the operator boundary to manage network-scope secrets.
 143   *
 144   * @since 7.2.0
 145   */
 146  define( 'WP_SECRETS_CAP_MANAGE_NETWORK', 'manage_network_secrets' );
 147  
 148  /**
 149   * Best-effort clearing of a plaintext value from memory.
 150   *
 151   * This is hygiene, not a guarantee. PHP strings are reference-counted and often
 152   * shared by copy-on-write; sodium_memzero() can only scrub the bytes of a string it
 153   * exclusively owns; if another reference to the same value exists elsewhere,
 154   * PHP forces a private copy before zeroing rather than corrupting the shared buffer,
 155   * and that other reference is left completely untouched. Call this on every plaintext
 156   * local as soon as it is no longer needed, and do not keep incidental copies around.
 157   *
 158   * Under sodium_compat, core's documented fallback when the libsodium extension is
 159   * unavailable, this scrubs nothing. A userland polyfill cannot reach a PHP string's
 160   * underlying memory at all. Overwriting the local binding is the only thing available
 161   * in that case, and it removes the live reference from this scope without touching
 162   * the memory the string used to occupy.
 163   *
 164   * @since 7.2.0
 165   *
 166   * @param string $value The value to clear, by reference.
 167   */
 168  function wp_secrets_memzero( &$value ): void {
 169      if ( ! is_string( $value ) ) {
 170          return;
 171      }
 172  
 173      if ( function_exists( 'sodium_memzero' ) ) {
 174          try {
 175              /*
 176               * sodium_memzero() is called through a true reference alias, not
 177               * directly on $value, so that a static analyzer's stub for
 178               * sodium_memzero() (whose own by-ref parameter is documented as
 179               * becoming null) is evaluated against a local variable rather than
 180               * against this function's own by-ref parameter. The alias is not a
 181               * copy: `=&` shares the identical underlying storage, so this still
 182               * zeroes the real buffer the caller holds.
 183               */
 184              $alias =& $value;
 185              sodium_memzero( $alias );
 186          } catch ( \Throwable $e ) {
 187              // A value this function doesn't exclusively own (a shared,
 188              // copy-on-write buffer) can legitimately refuse. The unconditional
 189              // reassignment below still runs regardless of what happened here.
 190              unset( $e );
 191          }
 192      }
 193  
 194      // Reached on every path, including sodium_memzero() success (it leaves the
 195      // variable null, not an empty string) so callers see one consistent contract.
 196      $value = '';
 197  }
 198  
 199  /**
 200   * Validates a secret's name.
 201   *
 202   * Names take the form 'plugin-slug/secret-name': lowercase alphanumerics,
 203   * hyphens, and underscores in each segment, exactly one '/' separating them,
 204   * and no segment starting or ending with a hyphen or underscore.
 205   *
 206   * Namespacing is a matter of organization, not security. It groups secrets by owner
 207   * so that listings and admin screens can be sensible. It confers no isolation: any
 208   * plugin that can run PHP can read any secret, namespaced or not.
 209   *
 210   * An unnamespaced name ('secret-name', no '/') is accepted, but reports through
 211   * _doing_it_wrong(). It exists for one reason: code written against the earlier
 212   * prototype used a flat keyspace, and refusing those names outright would mean
 213   * every such call site has to be rewritten before it can be ported at all.
 214   * Accepting them keeps that migration incremental. Nothing else should use one.
 215   * Two plugins that both choose 'api-key' collide silently, which is a real problem
 216   * even though it is not a security one.
 217   *
 218   * @since 7.2.0
 219   *
 220   * @param string $name Candidate secret name.
 221   * @return true|WP_Error True if $name is usable, including the unnamespaced form.
 222   *                       Otherwise WP_Error with code WP_SECRETS_ERROR_INVALID_NAME.
 223   */
 224  function wp_secrets_validate_name( string $name ) {
 225      if ( '' === $name ) {
 226          return new WP_Error(
 227              WP_SECRETS_ERROR_INVALID_NAME,
 228              __( 'Secret names must not be empty.' )
 229          );
 230      }
 231  
 232      if ( strlen( $name ) > WP_SECRETS_MAX_NAME_LENGTH ) {
 233          return new WP_Error(
 234              WP_SECRETS_ERROR_INVALID_NAME,
 235              sprintf(
 236                  /* translators: %d: Maximum allowed length, in characters. */
 237                  __( 'Secret names must be %d characters or fewer.' ),
 238                  WP_SECRETS_MAX_NAME_LENGTH
 239              )
 240          );
 241      }
 242  
 243      $slashes = substr_count( $name, '/' );
 244  
 245      if ( $slashes > 1 ) {
 246          return new WP_Error(
 247              WP_SECRETS_ERROR_INVALID_NAME,
 248              __( 'Secret names may contain at most one "/", separating a namespace from a key.' )
 249          );
 250      }
 251  
 252      $segment_pattern = '/^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/';
 253  
 254      if ( 0 === $slashes ) {
 255          if ( ! preg_match( $segment_pattern, $name ) ) {
 256              return new WP_Error(
 257                  WP_SECRETS_ERROR_INVALID_NAME,
 258                  __( 'Secret names may contain only lowercase letters, numbers, hyphens, and underscores, and must not start or end with a hyphen or underscore.' )
 259              );
 260          }
 261  
 262          /*
 263           * Reported here rather than at each public entry point: this is the one
 264           * place that decides what a name is, so a caller cannot reach the store
 265           * with an unnamespaced name without passing through it.
 266           */
 267          _doing_it_wrong(
 268              __FUNCTION__,
 269              sprintf(
 270                  /* translators: %s: The unnamespaced secret name. */
 271                  __( 'The secret name "%s" has no namespace. Namespaced names ("plugin-slug/secret-name") group secrets by owner so that listings and admin screens can be organized; unnamespaced names are supported only so that code written against the Displace prototype can be ported incrementally.' ),
 272                  $name
 273              ),
 274              '7.2.0'
 275          );
 276  
 277          return true;
 278      }
 279  
 280      $segments  = explode( '/', $name );
 281      $namespace = $segments[0];
 282      $key       = $segments[1];
 283  
 284      if ( '' === $namespace || '' === $key ) {
 285          return new WP_Error(
 286              WP_SECRETS_ERROR_INVALID_NAME,
 287              __( 'Both segments of a secret name must be non-empty.' )
 288          );
 289      }
 290  
 291      if ( ! preg_match( $segment_pattern, $namespace ) || ! preg_match( $segment_pattern, $key ) ) {
 292          return new WP_Error(
 293              WP_SECRETS_ERROR_INVALID_NAME,
 294              __( 'Secret name segments may contain only lowercase letters, numbers, hyphens, and underscores, and must not start or end with a hyphen or underscore.' )
 295          );
 296      }
 297  
 298      return true;
 299  }
 300  
 301  /**
 302   * Returns the active secret store.
 303   *
 304   * No filter is applied here, or anywhere on the retrieval path. See
 305   * docs/spec/extension-points.md. A secrets.php drop-in overrides the store by setting
 306   * $GLOBALS['wp_secrets_store'] to an instance before this is first called, and
 307   * that global is checked here directly, not through a hook.
 308   *
 309   * If the global was set but is not a valid WP_Secrets_Store, or if the drop-in
 310   * itself failed to load cleanly, this returns WP_Secrets_Broken_Store rather than
 311   * silently using the default: the drop-in's presence signals the operator wants
 312   * storage other than local options, and falling back to local options anyway would
 313   * be the exact silent downgrade this API refuses to make.
 314   *
 315   * @since 7.2.0
 316   *
 317   * @return WP_Secrets_Store
 318   */
 319  function _wp_secrets_get_store(): WP_Secrets_Store {
 320      static $store = null;
 321  
 322      if ( $store instanceof WP_Secrets_Store ) {
 323          return $store;
 324      }
 325  
 326      if ( ! empty( $GLOBALS['wp_secrets_dropin_broken'] ) ) {
 327          $store = new WP_Secrets_Broken_Store();
 328  
 329          return $store;
 330      }
 331  
 332      if ( isset( $GLOBALS['wp_secrets_store'] ) && $GLOBALS['wp_secrets_store'] instanceof WP_Secrets_Store ) {
 333          $store = $GLOBALS['wp_secrets_store'];
 334  
 335          return $store;
 336      }
 337  
 338      $store = new WP_Secrets_Option_Store();
 339  
 340      return $store;
 341  }
 342  
 343  /**
 344   * Returns the active key manager.
 345   *
 346   * A secrets.php drop-in overrides the keyring by setting
 347   * $GLOBALS['wp_secrets_keyring'] to an instance before this is first called. See
 348   * _wp_secrets_get_store() for why an invalid override or a failed drop-in load
 349   * fails closed (WP_Secrets_Broken_Keyring) rather than falling back to the default.
 350   *
 351   * @since 7.2.0
 352   *
 353   * @return WP_Secrets_Key_Manager
 354   */
 355  function _wp_secrets_get_key_manager(): WP_Secrets_Key_Manager {
 356      static $key_manager = null;
 357  
 358      if ( $key_manager instanceof WP_Secrets_Key_Manager ) {
 359          return $key_manager;
 360      }
 361  
 362      if ( ! empty( $GLOBALS['wp_secrets_dropin_broken'] ) ) {
 363          $key_manager = new WP_Secrets_Key_Manager( new WP_Secrets_Broken_Keyring() );
 364  
 365          return $key_manager;
 366      }
 367  
 368      $keyring = null;
 369  
 370      if ( isset( $GLOBALS['wp_secrets_keyring'] ) && $GLOBALS['wp_secrets_keyring'] instanceof WP_Secrets_Keyring ) {
 371          $keyring = $GLOBALS['wp_secrets_keyring'];
 372      }
 373  
 374      $key_manager = new WP_Secrets_Key_Manager( $keyring );
 375  
 376      return $key_manager;
 377  }
 378  
 379  /**
 380   * Whether a secrets.php drop-in is present and was loaded.
 381   *
 382   * True whether or not the drop-in successfully provided a store or keyring
 383   * override. It reports presence rather than health; Site Health reports separately
 384   * on whether a loaded drop-in is actually working.
 385   *
 386   * @since 7.2.0
 387   *
 388   * @return bool
 389   */
 390  function wp_using_secrets_dropin(): bool {
 391      return ! empty( $GLOBALS['wp_secrets_dropin_loaded'] );
 392  }
 393  
 394  
 395  
 396  
 397  
 398  
 399  
 400  
 401  
 402  
 403  
 404  
 405  
 406  
 407  
 408  
 409  
 410  
 411  
 412  /**
 413   * Encrypts and stores a secret.
 414   *
 415   * Encryption is unconditional: there is no plaintext mode and no constant to
 416   * disable it. No capability check is applied here, because this must be callable from
 417   * cron, REST, and front-end requests where no user is logged in. Enforce
 418   * capabilities at the operator boundary (CLI, an admin screen) instead.
 419   *
 420   * @since 7.2.0
 421   *
 422   * @param string $name  The secret's namespaced name ('plugin-slug/secret-name').
 423   * @param string $value The plaintext value to store.
 424   * @return true|WP_Error
 425   */
 426  function wp_set_secret( string $name, $value ) {
 427      return _wp_secrets_set( $name, $value, false );
 428  }
 429  
 430  /**
 431   * Imports an existing option's value as a secret.
 432   *
 433   * For an explicit migration, "on their own explicit upgrade schedule,"
 434   * in the proposal's words, because "core cannot reliably tell which options are
 435   * credentials, and guessing would break sites." The source option is left
 436   * untouched: this reads it, it does not move or delete it.
 437   *
 438   * The imported secret is flagged needs_rotation, unconditionally. A credential that
 439   * sat in a plain option has already been through however many backups and
 440   * replication paths that option went through; re-encrypting it here does not undo
 441   * that, and the flag exists so an operator (or a future admin screen) knows to
 442   * actually rotate the value rather than considering the migration finished.
 443   *
 444   * @since 7.2.0
 445   *
 446   * @param string $option The existing option's name.
 447   * @param string $name   The secret's namespaced name to store it under.
 448   * @return true|WP_Error
 449   */
 450  function wp_import_option_as_secret( string $option, string $name ) {
 451      if ( '' === $option ) {
 452          return new WP_Error(
 453              WP_SECRETS_ERROR_INVALID_VALUE,
 454              __( 'The option name must not be empty.' )
 455          );
 456      }
 457  
 458      $value = get_option( $option, null );
 459  
 460      if ( null === $value ) {
 461          return new WP_Error(
 462              WP_SECRETS_ERROR_INVALID_VALUE,
 463              __( 'The option does not exist.' )
 464          );
 465      }
 466  
 467      if ( ! is_string( $value ) ) {
 468          return new WP_Error(
 469              WP_SECRETS_ERROR_INVALID_VALUE,
 470              __( 'The option value is not a string and cannot be imported as a secret.' )
 471          );
 472      }
 473  
 474      return _wp_secrets_set( $name, $value, false, true, 'imported' );
 475  }
 476  
 477  
 478  
 479  /**
 480   * Retrieves a secret.
 481   *
 482   * Three states, never collapsed: a WP_Secret if it exists and decrypts, null if it
 483   * does not exist, WP_Error if it exists but could not be retrieved. No capability
 484   * check is applied here. See wp_set_secret().
 485   *
 486   * @since 7.2.0
 487   *
 488   * @param string $name    The secret's namespaced name.
 489   * @param string $version A WP_Secret_Version constant. Default WP_Secret_Version::CURRENT.
 490   * @return WP_Secret|null|WP_Error
 491   *
 492   * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $version
 493   */
 494  function wp_get_secret( string $name, string $version = WP_Secret_Version::CURRENT ) {
 495      return _wp_secrets_get( $name, $version, false );
 496  }
 497  
 498  /**
 499   * Deletes a secret.
 500   *
 501   * @since 7.2.0
 502   *
 503   * @param string $name The secret's namespaced name.
 504   * @return true|WP_Error
 505   */
 506  function wp_delete_secret( string $name ) {
 507      return _wp_secrets_delete( $name, false );
 508  }
 509  
 510  /**
 511   * Clears a secret's previous version, retiring it for good.
 512   *
 513   * An explicit operator action, with no timers and no cron. Calling this on a secret
 514   * that has never been rotated, or that does not exist, is a successful no-op:
 515   * the previous slot is already absent either way.
 516   *
 517   * Beyond the API surface the proposal names.
 518   *
 519   * @since 7.2.0
 520   *
 521   * @param string $name The secret's namespaced name.
 522   * @return true|WP_Error
 523   */
 524  function wp_retire_secret_version( string $name ) {
 525      return _wp_secrets_retire( $name, false );
 526  }
 527  
 528  /**
 529   * Lists secrets by name and metadata. Never a value.
 530   *
 531   * Beyond the API surface the proposal names, and justified by its statement that
 532   * the hooks and accessors a future admin screen needs are in scope now, even though
 533   * the screen itself is not.
 534   *
 535   * @since 7.2.0
 536   *
 537   * @param string $namespace Only secrets whose name starts with "{$namespace}/" are
 538   *                          returned. Default '' returns every secret.
 539   * @return array|WP_Error Array of associative arrays, each with keys 'name',
 540   *                        'fingerprint', 'created', 'has_previous', and
 541   *                        'needs_rotation'.
 542   *
 543   * @phpstan-return list<array{name: string, fingerprint: string, created: int, has_previous: bool, needs_rotation: bool}>|WP_Error
 544   */
 545  function wp_list_secrets( string $namespace = '' ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.namespaceFound -- $namespace is the parameter name the proposal specifies.
 546      return _wp_secrets_list( $namespace, false );
 547  }
 548  
 549  /**
 550   * Encrypts and stores a network-scope secret.
 551   *
 552   * Site secrets and network secrets are separate functions with separate
 553   * capabilities and separate storage prefixes, with no implicit fallback from one
 554   * scope to the other. The proposal describes network scope but does not name these
 555   * functions; these are this implementation's names for them.
 556   *
 557   * @since 7.2.0
 558   *
 559   * @param string $name  The secret's namespaced name.
 560   * @param string $value The plaintext value to store.
 561   * @return true|WP_Error
 562   */
 563  function wp_set_network_secret( string $name, $value ) {
 564      return _wp_secrets_set( $name, $value, true );
 565  }
 566  
 567  /**
 568   * Retrieves a network-scope secret.
 569   *
 570   * @since 7.2.0
 571   *
 572   * @param string $name    The secret's namespaced name.
 573   * @param string $version A WP_Secret_Version constant. Default WP_Secret_Version::CURRENT.
 574   * @return WP_Secret|null|WP_Error
 575   *
 576   * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $version
 577   */
 578  function wp_get_network_secret( string $name, string $version = WP_Secret_Version::CURRENT ) {
 579      return _wp_secrets_get( $name, $version, true );
 580  }
 581  
 582  /**
 583   * Deletes a network-scope secret.
 584   *
 585   * @since 7.2.0
 586   *
 587   * @param string $name The secret's namespaced name.
 588   * @return true|WP_Error
 589   */
 590  function wp_delete_network_secret( string $name ) {
 591      return _wp_secrets_delete( $name, true );
 592  }
 593  
 594  /**
 595   * Clears a network-scope secret's previous version.
 596   *
 597   * @since 7.2.0
 598   *
 599   * @param string $name The secret's namespaced name.
 600   * @return true|WP_Error
 601   */
 602  function wp_retire_network_secret_version( string $name ) {
 603      return _wp_secrets_retire( $name, true );
 604  }
 605  
 606  /**
 607   * Lists network-scope secrets by name and metadata. Never a value.
 608   *
 609   * @since 7.2.0
 610   *
 611   * @param string $namespace Only secrets whose name starts with "{$namespace}/" are
 612   *                          returned. Default '' returns every network-scope secret.
 613   * @return array|WP_Error
 614   *
 615   * @phpstan-return list<array{name: string, fingerprint: string, created: int, has_previous: bool, needs_rotation: bool}>|WP_Error
 616   */
 617  function wp_list_network_secrets( string $namespace = '' ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.namespaceFound -- matches wp_list_secrets()'s own signature.
 618      return _wp_secrets_list( $namespace, true );
 619  }
 620  
 621  /**
 622   * Returns the active provider.
 623   *
 624   * The provider is whatever is responsible for holding, protecting, and answering for
 625   * this site's secrets.
 626   *
 627   * A secrets.php drop-in overrides it by setting $GLOBALS['wp_secrets_provider'] to
 628   * an instance before this is first called. Absent that, the provider WordPress
 629   * ships is assembled from the active store and keyring, which a drop-in can also
 630   * replace individually, so wanting host-managed key custody with default storage needs
 631   * no provider at all.
 632   *
 633   * No filter is applied here, or anywhere on the retrieval path. Substitution is by
 634   * replacement, never by interception: a filter that can observe which provider
 635   * answers is one step from a filter that can answer instead.
 636   *
 637   * Cached for the request. A failed drop-in fails closed, returning WP_Error from
 638   * every operation, rather than silently reverting to the default, because a
 639   * misconfigured credential backend must never look like a working one that happens
 640   * to be empty.
 641   *
 642   * @since 7.2.0
 643   *
 644   * @return WP_Secrets_Provider
 645   */
 646  function _wp_secrets_get_provider(): WP_Secrets_Provider {
 647      static $provider = null;
 648  
 649      if ( $provider instanceof WP_Secrets_Provider ) {
 650          return $provider;
 651      }
 652  
 653      if ( ! empty( $GLOBALS['wp_secrets_dropin_broken'] ) ) {
 654          $provider = new WP_Secrets_Broken_Provider();
 655  
 656          return $provider;
 657      }
 658  
 659      if ( isset( $GLOBALS['wp_secrets_provider'] ) ) {
 660          /*
 661           * Set, but not a provider. Failing closed here rather than falling through to
 662           * the default is the whole point: a platform drop-in that names its class
 663           * wrongly would otherwise have every secret served from wp_options, where none
 664           * of them exist, and a site whose credentials are held elsewhere would report
 665           * them absent rather than unreachable.
 666           */
 667          if ( ! ( $GLOBALS['wp_secrets_provider'] instanceof WP_Secrets_Provider ) ) {
 668              $provider = new WP_Secrets_Broken_Provider();
 669  
 670              return $provider;
 671          }
 672  
 673          $provider = $GLOBALS['wp_secrets_provider'];
 674  
 675          return $provider;
 676      }
 677  
 678      $provider = new WP_Secrets_Libsodium_Provider(
 679          _wp_secrets_get_store(),
 680          _wp_secrets_get_key_manager()
 681      );
 682  
 683      return $provider;
 684  }
 685  
 686  /**
 687   * Whether the active provider accepts writes.
 688   *
 689   * Exists so a settings screen can disable its own save control before an operator
 690   * types a credential into a field that will only reject it. A provider whose
 691   * credentials are managed by host tooling or a control panel reports false.
 692   *
 693   * @since 7.2.0
 694   *
 695   * @return bool
 696   */
 697  function wp_secrets_provider_is_writable(): bool {
 698      return _wp_secrets_get_provider()->is_writable();
 699  }
 700  
 701  /**
 702   * A human-readable description of what is protecting this site's secrets.
 703   *
 704   * For Site Health and a future admin screen. Never key material, never a value.
 705   *
 706   * @since 7.2.0
 707   *
 708   * @return string
 709   */
 710  function wp_secrets_provider_label(): string {
 711      return _wp_secrets_get_provider()->get_label();
 712  }
 713  
 714  /**
 715   * Shared implementation behind wp_set_secret() and wp_set_network_secret().
 716   *
 717   * @since 7.2.0
 718   *
 719   * @param string      $name            The secret's name.
 720   * @param string      $value           The plaintext value.
 721   * @param bool        $network         Whether this is a network-scope secret.
 722   * @param bool        $needs_rotation  Flag the stored secret as needing rotation.
 723   * @param string|null $action_override Overrides the $action reported to
 724   *                                     wp_secret_changed.
 725   * @return true|WP_Error
 726   */
 727  function _wp_secrets_set( string $name, $value, bool $network, bool $needs_rotation = false, ?string $action_override = null ) {
 728      return _wp_secrets_get_provider()->set( $name, $value, $network, $needs_rotation, $action_override );
 729  }
 730  
 731  /**
 732   * Shared implementation behind wp_get_secret() and wp_get_network_secret().
 733   *
 734   * @since 7.2.0
 735   *
 736   * @param string $name    The secret's name.
 737   * @param string $version A WP_Secret_Version constant.
 738   * @param bool   $network Whether this is a network-scope secret.
 739   * @return WP_Secret|null|WP_Error
 740   *
 741   * @phpstan-param WP_Secret_Version::CURRENT|WP_Secret_Version::PREVIOUS $version
 742   */
 743  function _wp_secrets_get( string $name, string $version, bool $network ) {
 744      /*
 745       * Checked here rather than inside the provider: this is a contract between
 746       * the API and its caller, and asking every provider to re-validate a core
 747       * constant would be duplicated work that a third-party provider could
 748       * silently skip.
 749       */
 750      if ( ! in_array( $version, array( WP_Secret_Version::CURRENT, WP_Secret_Version::PREVIOUS ), true ) ) {
 751          _doing_it_wrong(
 752              __FUNCTION__,
 753              __( 'The version must be WP_Secret_Version::CURRENT or WP_Secret_Version::PREVIOUS.' ),
 754              '7.2.0'
 755          );
 756  
 757          return new WP_Error(
 758              WP_SECRETS_ERROR_INVALID_ARGUMENT,
 759              __( 'The version must be WP_Secret_Version::CURRENT or WP_Secret_Version::PREVIOUS.' )
 760          );
 761      }
 762  
 763      return _wp_secrets_get_provider()->get( $name, $version, $network );
 764  }
 765  
 766  /**
 767   * Shared implementation behind wp_delete_secret() and wp_delete_network_secret().
 768   *
 769   * @since 7.2.0
 770   *
 771   * @param string $name    The secret's name.
 772   * @param bool   $network Whether this is a network-scope secret.
 773   * @return true|WP_Error
 774   */
 775  function _wp_secrets_delete( string $name, bool $network ) {
 776      return _wp_secrets_get_provider()->delete( $name, $network );
 777  }
 778  
 779  /**
 780   * Shared implementation behind wp_retire_secret_version() and its network twin.
 781   *
 782   * @since 7.2.0
 783   *
 784   * @param string $name    The secret's name.
 785   * @param bool   $network Whether this is a network-scope secret.
 786   * @return true|WP_Error
 787   */
 788  function _wp_secrets_retire( string $name, bool $network ) {
 789      return _wp_secrets_get_provider()->retire_previous( $name, $network );
 790  }
 791  
 792  /**
 793   * Shared implementation behind wp_list_secrets() and wp_list_network_secrets().
 794   *
 795   * @since 7.2.0
 796   *
 797   * @param string $name_prefix Restrict to names beginning with this prefix.
 798   * @param bool   $network     Whether to list network-scope secrets.
 799   * @return array|WP_Error
 800   *
 801   * @phpstan-return list<array{name: string, fingerprint: string, created: int, has_previous: bool, needs_rotation: bool}>|WP_Error
 802   */
 803  function _wp_secrets_list( string $name_prefix, bool $network ) {
 804      return _wp_secrets_get_provider()->list_secrets( $name_prefix, $network );
 805  }


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